| new file mode 100644 |
| index 0000000..9fb14ad |
| --- /dev/null |
| +++ b/internal/home/zoom_browser_test.go |
| @@ -0,0 +1,134 @@ |
| +//go:build browser |
| + |
| +// The browser checks: Home in a real Chromium. Not part of the plain |
| +// suite, because they need a Chromium (on PATH, via RASTRILLO_CHROME, or |
| +// in a Playwright cache): |
| +// |
| +// make test-browser |
| +// |
| +// `make test-vet` vets this file with the tag, so it cannot stop |
| +// compiling unnoticed. |
| +package home |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/harness" |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/cdproto/network" |
| + "github.com/chromedp/chromedp" |
| +) |
| + |
| +// 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. |
| +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` |
| + |
| +// asHost serves h as if every request had arrived for host. The rig's |
| +// browser talks to http://localhost:<port>, and Home answers only its two |
| +// configured hosts (serveKnownHost); this is the browser-side half of the |
| +// production TLS front, which routes by Host. |
| +func asHost(host string, h http.Handler) func(string) http.Handler { |
| + return func(string) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + r.Host = host |
| + h.ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
| + |
| +// 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 sets a control |
| +// smaller, zooms on the first tap of every field. |
| +// |
| +// Every app's sign-in goes through id.*'s field, so that is the one that |
| +// matters most. The team-home pages (the directory, the team chooser, |
| +// signed out, Open source) are measured too: they carry no text field |
| +// today, and this keeps any they gain honest. |
| +// |
| +// Emulated as pointer: coarse, which is how the CSS decides. |
| +func TestNoZoomOnTap(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + // Two teams with Docs, so /apps/docs renders the chooser rather than |
| + // redirecting to the one team. |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + f.team(t, "B2", "beta", "Beta", chooserPerson) |
| + |
| + type page struct{ path, ready string } |
| + hosts := []struct { |
| + name string |
| + handler func(string) http.Handler |
| + signIn *http.Cookie |
| + pages []page |
| + }{ |
| + {"id.*", asHost(credentialHost, f.p.credential.Router()), nil, []page{ |
| + {"/signin", `input[name="address"]`}, |
| + {"/passkey/confirm", `input[name="code"]`}, |
| + }}, |
| + {"directory, signed out", asHost(directoryHost, f.p.directory.Router()), nil, []page{ |
| + {"/signed-out", `main`}, |
| + {"/help/open-source", `main`}, |
| + }}, |
| + {"directory, signed in", asHost(directoryHost, f.p.directory.Router()), cookie, []page{ |
| + {"/", `[home-apps]`}, |
| + {docsEntryPath, `button[name="team"]`}, |
| + }}, |
| + } |
| + for _, host := range hosts { |
| + rig := harness.New(t, host.handler) |
| + 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 %s: %v (the browser is at %s)", host.name, what, err, at) |
| + } |
| + } |
| + // Touch emulation, then a reload on each 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)) |
| + if c := host.signIn; c != nil { |
| + run("sign in", network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/"). |
| + WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax)) |
| + } |
| + for _, p := range host.pages { |
| + var coarse bool |
| + var small []string |
| + run(p.path, |
| + chromedp.Navigate(rig.Origin+p.path), |
| + chromedp.Reload(), |
| + chromedp.WaitVisible(p.ready, chromedp.ByQuery), |
| + chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), |
| + chromedp.Evaluate(smallControls(textControls), &small), |
| + ) |
| + if !coarse { |
| + t.Fatalf("%s %s: the touch-screen emulation did not apply; this would measure desktop styles", host.name, p.path) |
| + } |
| + if len(small) > 0 { |
| + t.Errorf("%s %s: iOS zooms when these are tapped (under 16px on a touch screen): %s", host.name, p.path, strings.Join(small, "; ")) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// smallControls is a script listing each visible 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) |
| + .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16) |
| + .map(e => e.tagName.toLowerCase() + (e.name ? '[name=' + e.name + ']' : '') + ' ' + getComputedStyle(e).fontSize)`, sel) |
| +} |