| 1 | //go:build browser |
| 2 | |
| 3 | // The browser checks: Docs in a real Chromium. Not part of the plain |
| 4 | // suite or the gate, because they need a Chromium (on PATH, via |
| 5 | // RASTRILLO_CHROME, or in a Playwright cache): |
| 6 | // |
| 7 | // CGO_ENABLED=0 go test -tags browser -run OnTap ./internal/docs/ |
| 8 | // |
| 9 | // The gate vets this file with the tag (.amadan/ci.d/10-gate), so it |
| 10 | // cannot stop compiling unnoticed. |
| 11 | package docs_test |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "fmt" |
| 16 | "net/http" |
| 17 | "net/http/httptest" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | |
| 22 | "amadan.net/rastrillo/rastrillo/harness" |
| 23 | "github.com/chromedp/cdproto/emulation" |
| 24 | "github.com/chromedp/cdproto/network" |
| 25 | "github.com/chromedp/chromedp" |
| 26 | |
| 27 | "github.com/carlosframework/docs/internal/docs" |
| 28 | ) |
| 29 | |
| 30 | // textControls is every control a tap puts a caret in. A checkbox, a |
| 31 | // radio, a file picker or a button raises no keyboard, so iOS never |
| 32 | // zooms for them. The editor's own text (contenteditable) is not here: |
| 33 | // it is the document, set at the document's own sizes, and the person |
| 34 | // chose them. |
| 35 | 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` |
| 36 | |
| 37 | // TestNoZoomOnTap: iOS Safari zooms the whole page into any text control |
| 38 | // whose type is under 16px, and rastrillo's base type is 14px. Its |
| 39 | // tokens.css brings text controls up to 16px where the pointer is coarse |
| 40 | // ("Touch: the two zooms nobody asked for"); an app on a tokens.css |
| 41 | // without that block, or with a rule of its own that outweighs it, zooms |
| 42 | // on the first tap of every field. |
| 43 | // |
| 44 | // Emulated as pointer: coarse, which is how the CSS decides. The |
| 45 | // jump-to palette is measured open: it is hidden until Control-K, and a |
| 46 | // hidden field measures as nothing. |
| 47 | func TestNoZoomOnTap(t *testing.T) { |
| 48 | var app *docs.App |
| 49 | rig := harness.New(t, func(origin string) http.Handler { |
| 50 | var err error |
| 51 | app, err = docs.NewForTestWith(t, docs.Config{Origin: origin}) |
| 52 | if err != nil { |
| 53 | t.Fatalf("NewForTestWith: %v", err) |
| 54 | } |
| 55 | return app.Handler() |
| 56 | }) |
| 57 | cookies := signInCookies(t, app, "alice@example.com") |
| 58 | // Same-origin POSTs: this app's origin is the rig's, not DefaultOrigin. |
| 59 | sameOrigin := func(path, body string) *httptest.ResponseRecorder { |
| 60 | req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) |
| 61 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 62 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 63 | for _, c := range cookies { |
| 64 | req.AddCookie(c) |
| 65 | } |
| 66 | rec := httptest.NewRecorder() |
| 67 | app.Handler().ServeHTTP(rec, req) |
| 68 | return rec |
| 69 | } |
| 70 | created := sameOrigin("/documents", "title=Zoom") |
| 71 | if created.Code != http.StatusSeeOther { |
| 72 | t.Fatalf("POST /documents: %d", created.Code) |
| 73 | } |
| 74 | sqid := strings.TrimPrefix(created.Header().Get("Location"), "/d/") |
| 75 | |
| 76 | run := func(what string, actions ...chromedp.Action) { |
| 77 | t.Helper() |
| 78 | ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| 79 | defer cancel() |
| 80 | if err := chromedp.Run(ctx, actions...); err != nil { |
| 81 | var at string |
| 82 | _ = chromedp.Run(rig.Context(), chromedp.Location(&at)) |
| 83 | t.Fatalf("%s: %v (the browser is at %s)", what, err, at) |
| 84 | } |
| 85 | } |
| 86 | // Touch emulation, then a reload on every page so it re-evaluates |
| 87 | // its media queries. SetEmulatedMedia's "pointer" feature is not |
| 88 | // emulated by this Chromium; the coarse check below is what proves |
| 89 | // the emulation took. |
| 90 | run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5)) |
| 91 | |
| 92 | once := false |
| 93 | measure := func(path, ready string, open ...chromedp.Action) { |
| 94 | t.Helper() |
| 95 | var coarse bool |
| 96 | var small []string |
| 97 | actions := []chromedp.Action{chromedp.Navigate(rig.Origin + path)} |
| 98 | if !once { |
| 99 | actions = append(actions, chromedp.Reload()) |
| 100 | } |
| 101 | actions = append(actions, |
| 102 | chromedp.WaitVisible(ready, chromedp.ByQuery), |
| 103 | chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), |
| 104 | ) |
| 105 | actions = append(actions, open...) |
| 106 | actions = append(actions, chromedp.Evaluate(smallControls(textControls), &small)) |
| 107 | run(path, actions...) |
| 108 | if !coarse { |
| 109 | t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", path) |
| 110 | } |
| 111 | if len(small) > 0 { |
| 112 | t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", path, strings.Join(small, "; ")) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // Signed out first: the door is the field every visit starts on. |
| 117 | measure("/signin", `input[name="address"]`) |
| 118 | |
| 119 | var set []chromedp.Action |
| 120 | for _, c := range cookies { |
| 121 | set = append(set, network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/"). |
| 122 | WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax)) |
| 123 | } |
| 124 | run("sign in", set...) |
| 125 | |
| 126 | measure("/", `input[name="title"]`) |
| 127 | // The toolbar's three selects, once select.js has enhanced them, and |
| 128 | // the title field in the bar. |
| 129 | measure("/d/"+sqid, `[contenteditable="true"]`, |
| 130 | chromedp.WaitVisible(`[estilo-bar-title-input], [docs-toolbar] input, [docs-toolbar] select`, chromedp.ByQuery)) |
| 131 | // The share page, then published: the link field shows only in the |
| 132 | // one render that follows the publish. |
| 133 | measure("/d/"+sqid+"/share", `input[name="address"]`) |
| 134 | if rec := sameOrigin("/d/"+sqid+"/share/link", "on=1"); rec.Code != http.StatusSeeOther { |
| 135 | t.Fatalf("publishing: %d", rec.Code) |
| 136 | } |
| 137 | // Once only: the reload measure makes would render the page without it. |
| 138 | once = true |
| 139 | measure("/d/"+sqid+"/share", `[docs-share-url-input]`) |
| 140 | once = false |
| 141 | measure("/", `input[name="title"]`, |
| 142 | chromedp.Evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key: 'k', ctrlKey: true, bubbles: true}))`, nil), |
| 143 | chromedp.WaitVisible(`[docs-jumpto-input]`, chromedp.ByQuery)) |
| 144 | } |
| 145 | |
| 146 | // smallControls is a script listing each visible, tappable control matching sel |
| 147 | // whose computed font-size is under 16px, described well enough to find. |
| 148 | func smallControls(sel string) string { |
| 149 | return fmt.Sprintf(`[...document.querySelectorAll(%q)] |
| 150 | .filter(e => e.getClientRects().length > 0) |
| 151 | // select.js hides the native select it enhances (aria-hidden, |
| 152 | // clipped, out of the tab order); its combobox is what is tapped. |
| 153 | .filter(e => !e.closest('[aria-hidden=true]')) |
| 154 | .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16) |
| 155 | .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) |
| 156 | } |
| 157 | |