| new file mode 100644 |
| index 0000000..2c0eb94 |
| --- /dev/null |
| +++ b/internal/docs/zoom_browser_test.go |
| @@ -0,0 +1,156 @@ |
| +//go:build browser |
| + |
| +// The browser checks: Docs in a real Chromium. Not part of the plain |
| +// suite or the gate, because they need a Chromium (on PATH, via |
| +// RASTRILLO_CHROME, or in a Playwright cache): |
| +// |
| +// CGO_ENABLED=0 go test -tags browser -run OnTap ./internal/docs/ |
| +// |
| +// The gate vets this file with the tag (.amadan/ci.d/10-gate), so it |
| +// cannot stop compiling unnoticed. |
| +package docs_test |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/harness" |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/cdproto/network" |
| + "github.com/chromedp/chromedp" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// textControls is every control a tap puts a caret in. A checkbox, a |
| +// radio, a file picker or a button raises no keyboard, so iOS never |
| +// zooms for them. The editor's own text (contenteditable) is not here: |
| +// it is the document, set at the document's own sizes, and the person |
| +// chose them. |
| +const textControls = `input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=file]):not([type=submit]):not([type=button]):not([type=color]):not([type=range]), select, textarea` |
| + |
| +// TestNoZoomOnTap: iOS Safari zooms the whole page into any text control |
| +// whose type is under 16px, and rastrillo's base type is 14px. Its |
| +// tokens.css brings text controls up to 16px where the pointer is coarse |
| +// ("Touch: the two zooms nobody asked for"); an app on a tokens.css |
| +// without that block, or with a rule of its own that outweighs it, zooms |
| +// on the first tap of every field. |
| +// |
| +// Emulated as pointer: coarse, which is how the CSS decides. The |
| +// jump-to palette is measured open: it is hidden until Control-K, and a |
| +// hidden field measures as nothing. |
| +func TestNoZoomOnTap(t *testing.T) { |
| + var app *docs.App |
| + rig := harness.New(t, func(origin string) http.Handler { |
| + var err error |
| + app, err = docs.NewForTestWith(t, docs.Config{Origin: origin}) |
| + if err != nil { |
| + t.Fatalf("NewForTestWith: %v", err) |
| + } |
| + return app.Handler() |
| + }) |
| + cookies := signInCookies(t, app, "alice@example.com") |
| + // Same-origin POSTs: this app's origin is the rig's, not DefaultOrigin. |
| + sameOrigin := func(path, body string) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + return rec |
| + } |
| + created := sameOrigin("/documents", "title=Zoom") |
| + if created.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /documents: %d", created.Code) |
| + } |
| + sqid := strings.TrimPrefix(created.Header().Get("Location"), "/d/") |
| + |
| + run := func(what string, actions ...chromedp.Action) { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| + defer cancel() |
| + if err := chromedp.Run(ctx, actions...); err != nil { |
| + var at string |
| + _ = chromedp.Run(rig.Context(), chromedp.Location(&at)) |
| + t.Fatalf("%s: %v (the browser is at %s)", what, err, at) |
| + } |
| + } |
| + // Touch emulation, then a reload on every page so it re-evaluates |
| + // its media queries. SetEmulatedMedia's "pointer" feature is not |
| + // emulated by this Chromium; the coarse check below is what proves |
| + // the emulation took. |
| + run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5)) |
| + |
| + once := false |
| + measure := func(path, ready string, open ...chromedp.Action) { |
| + t.Helper() |
| + var coarse bool |
| + var small []string |
| + actions := []chromedp.Action{chromedp.Navigate(rig.Origin + path)} |
| + if !once { |
| + actions = append(actions, chromedp.Reload()) |
| + } |
| + actions = append(actions, |
| + chromedp.WaitVisible(ready, chromedp.ByQuery), |
| + chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), |
| + ) |
| + actions = append(actions, open...) |
| + actions = append(actions, chromedp.Evaluate(smallControls(textControls), &small)) |
| + run(path, actions...) |
| + if !coarse { |
| + t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", path) |
| + } |
| + if len(small) > 0 { |
| + t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", path, strings.Join(small, "; ")) |
| + } |
| + } |
| + |
| + // Signed out first: the door is the field every visit starts on. |
| + measure("/signin", `input[name="address"]`) |
| + |
| + var set []chromedp.Action |
| + for _, c := range cookies { |
| + set = append(set, network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/"). |
| + WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax)) |
| + } |
| + run("sign in", set...) |
| + |
| + measure("/", `input[name="title"]`) |
| + // The toolbar's three selects, once select.js has enhanced them, and |
| + // the title field in the bar. |
| + measure("/d/"+sqid, `[contenteditable="true"]`, |
| + chromedp.WaitVisible(`[estilo-bar-title-input], [docs-toolbar] input, [docs-toolbar] select`, chromedp.ByQuery)) |
| + // The share page, then published: the link field shows only in the |
| + // one render that follows the publish. |
| + measure("/d/"+sqid+"/share", `input[name="address"]`) |
| + if rec := sameOrigin("/d/"+sqid+"/share/link", "on=1"); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("publishing: %d", rec.Code) |
| + } |
| + // Once only: the reload measure makes would render the page without it. |
| + once = true |
| + measure("/d/"+sqid+"/share", `[docs-share-url-input]`) |
| + once = false |
| + measure("/", `input[name="title"]`, |
| + chromedp.Evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key: 'k', ctrlKey: true, bubbles: true}))`, nil), |
| + chromedp.WaitVisible(`[docs-jumpto-input]`, chromedp.ByQuery)) |
| +} |
| + |
| +// smallControls is a script listing each visible, tappable control matching sel |
| +// whose computed font-size is under 16px, described well enough to find. |
| +func smallControls(sel string) string { |
| + return fmt.Sprintf(`[...document.querySelectorAll(%q)] |
| + .filter(e => e.getClientRects().length > 0) |
| + // select.js hides the native select it enhances (aria-hidden, |
| + // clipped, out of the tab order); its combobox is what is tapped. |
| + .filter(e => !e.closest('[aria-hidden=true]')) |
| + .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16) |
| + .map(e => e.tagName.toLowerCase() + [...e.attributes].filter(a => a.name !== 'class' && a.name !== 'style' && a.name !== 'value' && a.name !== 'placeholder').map(a => '[' + a.name + (a.value && a.value.length < 24 ? '=' + a.value : '') + ']').join('') + ' ' + getComputedStyle(e).fontSize)`, sel) |
| +} |