oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
11package docs_test
12
13import (
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.
35const 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.
47func 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.
148func 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