oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

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.
11package home
12
13import (
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.
32const 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.
38func 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.
61func 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.
83func 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.
155func 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