oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3// The iOS zoom-on-tap check, in a real Chromium through chromedp. Not
4// part of `make ci`, because it needs a Chromium (on PATH, or via
5// RASTRILLO_CHROME; the Makefile finds the Playwright cache's on a Mac):
6//
7// make browser
8//
9// `make vet` vets this package with the tag, so it cannot stop compiling
10// unnoticed.
11package memoriatest
12
13import (
14 "bytes"
15 "context"
16 "fmt"
17 "io"
18 "log/slog"
19 "mime/multipart"
20 "net/http"
21 "path/filepath"
22 "regexp"
23 "strings"
24 "testing"
25 "time"
26
27 "amadan.net/rastrillo/rastrillo/db"
28 "amadan.net/rastrillo/rastrillo/harness"
29 "github.com/chromedp/cdproto/emulation"
30 "github.com/chromedp/chromedp"
31
32 memoria "amadan.net/oficina/memoria/internal/memoria"
33)
34
35// textControls is every control a tap puts a caret in. A checkbox, a
36// radio, a file picker or a button raises no keyboard, so iOS never
37// zooms for them.
38const 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`
39
40// TestNoZoomOnTap: iOS Safari zooms the whole page into any text control
41// whose type is under 16px, and rastrillo's base type is 14px. Its
42// tokens.css brings text controls up to 16px where the pointer is coarse
43// ("Touch: the two zooms nobody asked for"); an app on a tokens.css
44// without that block, or with a rule of its own that sets a control
45// smaller, zooms on the first tap of every field.
46//
47// Emulated as pointer: coarse, which is how the CSS decides (touch
48// emulation and a reload; the coarse check proves it took). Every page
49// with a field is measured: the door (signed out, through the production
50// App), the search box on the index and on results, Add content, a
51// document, the connectors and the Notion connector's token.
52func TestNoZoomOnTap(t *testing.T) {
53 t.Run("signed out", func(t *testing.T) {
54 rig := harness.New(t, func(origin string) http.Handler {
55 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
56 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
57 if err != nil {
58 t.Fatalf("db.Open: %v", err)
59 }
60 t.Cleanup(func() { d.Close() })
61 mux, _, err := memoria.App(d, origin, testDEK(t), logger)
62 if err != nil {
63 t.Fatalf("App: %v", err)
64 }
65 return mux
66 })
67 var coarse bool
68 var small []string
69 ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
70 defer cancel()
71 if err := chromedp.Run(ctx,
72 emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5),
73 chromedp.Navigate(rig.Origin+"/signin"),
74 chromedp.Reload(),
75 chromedp.WaitVisible(`input[name="address"]`, chromedp.ByQuery),
76 chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
77 chromedp.Evaluate(smallControls(textControls), &small),
78 ); err != nil {
79 t.Fatalf("/signin: %v", err)
80 }
81 if !coarse {
82 t.Fatal("/signin: the touch-screen emulation did not apply")
83 }
84 if len(small) > 0 {
85 t.Errorf("/signin: iOS zooms when these are tapped (under 16px on a touch screen): %s", strings.Join(small, "; "))
86 }
87 })
88 t.Run("signed in", testNoZoomSignedIn)
89}
90
91func testNoZoomSignedIn(t *testing.T) {
92 rig := harness.New(t, func(origin string) http.Handler {
93 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
94 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
95 if err != nil {
96 t.Fatalf("db.Open: %v", err)
97 }
98 t.Cleanup(func() { d.Close() })
99 mux, _, err := memoria.AppForTest(d, origin, testDEK(t), logger)
100 if err != nil {
101 t.Fatalf("AppForTest: %v", err)
102 }
103 return mux
104 })
105
106 run := func(what string, actions ...chromedp.Action) {
107 t.Helper()
108 ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
109 defer cancel()
110 if err := chromedp.Run(ctx, actions...); err != nil {
111 var at string
112 _ = chromedp.Run(rig.Context(), chromedp.Location(&at))
113 t.Fatalf("%s: %v (the browser is at %s)", what, err, at)
114 }
115 }
116 measure := func(path, ready string) {
117 t.Helper()
118 var coarse bool
119 var small []string
120 run(path,
121 chromedp.Navigate(rig.Origin+path),
122 chromedp.Reload(),
123 chromedp.WaitVisible(ready, chromedp.ByQuery),
124 chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
125 chromedp.Evaluate(smallControls(textControls), &small),
126 )
127 if !coarse {
128 t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", path)
129 }
130 if len(small) > 0 {
131 t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", path, strings.Join(small, "; "))
132 }
133 }
134
135 run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5))
136
137 // A document, posted as the Add content form posts one.
138 var buf bytes.Buffer
139 mw := multipart.NewWriter(&buf)
140 _ = mw.WriteField("title", "Office handbook")
141 _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.")
142 mw.Close()
143 req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/documents", &buf)
144 req.Header.Set("Content-Type", mw.FormDataContentType())
145 req.Header.Set("Origin", rig.Origin)
146 res, err := http.DefaultClient.Do(req)
147 if err != nil {
148 t.Fatalf("store a document: %v", err)
149 }
150 res.Body.Close()
151 var at string
152 run("find the document",
153 chromedp.Navigate(rig.Origin+"/"),
154 chromedp.WaitVisible(`a[href^="/documents/"]`, chromedp.ByQuery),
155 chromedp.AttributeValue(`a[href^="/documents/"]`, "href", &at, nil, chromedp.ByQuery),
156 )
157 doc := regexp.MustCompile(`^/documents/\d+$`).FindString(at)
158 if doc == "" {
159 t.Fatalf("the index links no document (got %q)", at)
160 }
161
162 measure("/", `main`)
163 measure("/?q=Lisbon", `main`)
164 measure("/add", `textarea[name="body"]`)
165 measure(doc, `main`)
166 measure(doc+"/delete", `main`)
167 measure("/connectors", `main`)
168 measure("/connectors/notion", `input[name="token"]`)
169}
170
171// smallControls is a script listing each visible control matching sel
172// whose computed font-size is under 16px, described well enough to find.
173func smallControls(sel string) string {
174 return fmt.Sprintf(`[...document.querySelectorAll(%q)]
175 .filter(e => e.getClientRects().length > 0)
176 .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16)
177 .map(e => e.tagName.toLowerCase() + (e.name ? '[name=' + e.name + ']' : '') + (e.id ? '#' + e.id : '') + ' ' + getComputedStyle(e).fontSize)`, sel)
178}
179