| 1 | //go:build browser |
| 2 | |
| 3 | // The browser checks: Home in a real Chromium. Not part of the plain |
| 4 | // suite, because they need a Chromium (on PATH, via RASTRILLO_CHROME, or |
| 5 | // in a Playwright cache): |
| 6 | // |
| 7 | // make test-browser |
| 8 | // |
| 9 | // `make test-vet` vets this file with the tag, so it cannot stop |
| 10 | // compiling unnoticed. |
| 11 | package home |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "fmt" |
| 16 | "net/http" |
| 17 | "os" |
| 18 | "os/exec" |
| 19 | "strings" |
| 20 | "testing" |
| 21 | "time" |
| 22 | |
| 23 | "amadan.net/rastrillo/rastrillo/harness" |
| 24 | "github.com/chromedp/cdproto/emulation" |
| 25 | "github.com/chromedp/cdproto/network" |
| 26 | "github.com/chromedp/chromedp" |
| 27 | ) |
| 28 | |
| 29 | // textControls is every control a tap puts a caret in. A checkbox, a |
| 30 | // radio, a file picker or a button raises no keyboard, so iOS never |
| 31 | // zooms for them. |
| 32 | 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` |
| 33 | |
| 34 | // useChrome points rastrillo's harness at a Chromium it would not find by |
| 35 | // itself: when RASTRILLO_CHROME is unset and no Chromium is on PATH, the |
| 36 | // newest one in a Playwright cache, the macOS layouts included |
| 37 | // (playwrightChrome, chromefinder_test.go). Call it before harness.New. |
| 38 | func useChrome(t *testing.T) { |
| 39 | t.Helper() |
| 40 | if os.Getenv("RASTRILLO_CHROME") != "" { |
| 41 | return |
| 42 | } |
| 43 | for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable"} { |
| 44 | if _, err := exec.LookPath(name); err == nil { |
| 45 | return |
| 46 | } |
| 47 | } |
| 48 | home, err := os.UserHomeDir() |
| 49 | if err != nil { |
| 50 | return |
| 51 | } |
| 52 | if p := playwrightChrome(home); p != "" { |
| 53 | t.Setenv("RASTRILLO_CHROME", p) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // asHost serves h as if every request had arrived for host. The rig's |
| 58 | // browser talks to http://localhost:<port>, and Home answers only its two |
| 59 | // configured hosts (serveKnownHost); this is the browser-side half of the |
| 60 | // production TLS front, which routes by Host. |
| 61 | func asHost(host string, h http.Handler) func(string) http.Handler { |
| 62 | return func(string) http.Handler { |
| 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 64 | r.Host = host |
| 65 | h.ServeHTTP(w, r) |
| 66 | }) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // TestNoZoomOnTap: iOS Safari zooms the whole page into any text control |
| 71 | // whose type is under 16px, and rastrillo's base type is 14px. Its |
| 72 | // tokens.css brings text controls up to 16px where the pointer is coarse |
| 73 | // ("Touch: the two zooms nobody asked for"); an app on a tokens.css |
| 74 | // without that block, or with a rule of its own that sets a control |
| 75 | // smaller, zooms on the first tap of every field. |
| 76 | // |
| 77 | // Every app's sign-in goes through id.*'s field, so that is the one that |
| 78 | // matters most. The team-home pages (the directory, the team chooser, |
| 79 | // signed out, Open source) are measured too: they carry no text field |
| 80 | // today, and this keeps any they gain honest. |
| 81 | // |
| 82 | // Emulated as pointer: coarse, which is how the CSS decides. |
| 83 | func TestNoZoomOnTap(t *testing.T) { |
| 84 | f := newMountedChooser(t) |
| 85 | cookie := f.session(t, chooserPerson) |
| 86 | // Two teams with Docs, so /apps/docs renders the chooser rather than |
| 87 | // redirecting to the one team. |
| 88 | f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| 89 | f.team(t, "B2", "beta", "Beta", chooserPerson) |
| 90 | |
| 91 | type page struct{ path, ready string } |
| 92 | hosts := []struct { |
| 93 | name string |
| 94 | handler func(string) http.Handler |
| 95 | signIn *http.Cookie |
| 96 | pages []page |
| 97 | }{ |
| 98 | {"id.*", asHost(credentialHost, f.p.credential.Router()), nil, []page{ |
| 99 | {"/signin", `input[name="address"]`}, |
| 100 | {"/passkey/confirm", `input[name="code"]`}, |
| 101 | }}, |
| 102 | {"directory, signed out", asHost(directoryHost, f.p.directory.Router()), nil, []page{ |
| 103 | {"/signed-out", `main`}, |
| 104 | {"/help/open-source", `main`}, |
| 105 | }}, |
| 106 | {"directory, signed in", asHost(directoryHost, f.p.directory.Router()), cookie, []page{ |
| 107 | {"/", `[home-apps]`}, |
| 108 | {docsEntryPath, `button[name="team"]`}, |
| 109 | }}, |
| 110 | } |
| 111 | useChrome(t) |
| 112 | for _, host := range hosts { |
| 113 | rig := harness.New(t, host.handler) |
| 114 | run := func(what string, actions ...chromedp.Action) { |
| 115 | t.Helper() |
| 116 | ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| 117 | defer cancel() |
| 118 | if err := chromedp.Run(ctx, actions...); err != nil { |
| 119 | var at string |
| 120 | _ = chromedp.Run(rig.Context(), chromedp.Location(&at)) |
| 121 | t.Fatalf("%s %s: %v (the browser is at %s)", host.name, what, err, at) |
| 122 | } |
| 123 | } |
| 124 | // Touch emulation, then a reload on each page so it re-evaluates |
| 125 | // its media queries. SetEmulatedMedia's "pointer" feature is not |
| 126 | // emulated by this Chromium; the coarse check below is what |
| 127 | // proves the emulation took. |
| 128 | run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5)) |
| 129 | if c := host.signIn; c != nil { |
| 130 | run("sign in", network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/"). |
| 131 | WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax)) |
| 132 | } |
| 133 | for _, p := range host.pages { |
| 134 | var coarse bool |
| 135 | var small []string |
| 136 | run(p.path, |
| 137 | chromedp.Navigate(rig.Origin+p.path), |
| 138 | chromedp.Reload(), |
| 139 | chromedp.WaitVisible(p.ready, chromedp.ByQuery), |
| 140 | chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), |
| 141 | chromedp.Evaluate(smallControls(textControls), &small), |
| 142 | ) |
| 143 | if !coarse { |
| 144 | t.Fatalf("%s %s: the touch-screen emulation did not apply; this would measure desktop styles", host.name, p.path) |
| 145 | } |
| 146 | if len(small) > 0 { |
| 147 | t.Errorf("%s %s: iOS zooms when these are tapped (under 16px on a touch screen): %s", host.name, p.path, strings.Join(small, "; ")) |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // smallControls is a script listing each visible control matching sel |
| 154 | // whose computed font-size is under 16px, described well enough to find. |
| 155 | func smallControls(sel string) string { |
| 156 | return fmt.Sprintf(`[...document.querySelectorAll(%q)] |
| 157 | .filter(e => e.getClientRects().length > 0) |
| 158 | .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16) |
| 159 | .map(e => e.tagName.toLowerCase() + (e.name ? '[name=' + e.name + ']' : '') + ' ' + getComputedStyle(e).fontSize)`, sel) |
| 160 | } |
| 161 | |