//go:build browser // The iOS zoom-on-tap check, in a real Chromium through chromedp. Not // part of `make ci`, because it needs a Chromium (on PATH, or via // RASTRILLO_CHROME; the Makefile finds the Playwright cache's on a Mac): // // make browser // // `make vet` vets this package with the tag, so it cannot stop compiling // unnoticed. package memoriatest import ( "bytes" "context" "fmt" "io" "log/slog" "mime/multipart" "net/http" "path/filepath" "regexp" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/harness" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/chromedp" memoria "amadan.net/oficina/memoria/internal/memoria" ) // 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` // 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. // // Emulated as pointer: coarse, which is how the CSS decides (touch // emulation and a reload; the coarse check proves it took). Every page // with a field is measured: the door (signed out, through the production // App), the search box on the index and on results, Add content, a // document, the connectors and the Notion connector's token. func TestNoZoomOnTap(t *testing.T) { t.Run("signed out", func(t *testing.T) { rig := harness.New(t, func(origin string) http.Handler { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, _, err := memoria.App(d, origin, testDEK(t), logger) if err != nil { t.Fatalf("App: %v", err) } return mux }) var coarse bool var small []string ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) defer cancel() if err := chromedp.Run(ctx, emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5), chromedp.Navigate(rig.Origin+"/signin"), chromedp.Reload(), chromedp.WaitVisible(`input[name="address"]`, chromedp.ByQuery), chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), chromedp.Evaluate(smallControls(textControls), &small), ); err != nil { t.Fatalf("/signin: %v", err) } if !coarse { t.Fatal("/signin: the touch-screen emulation did not apply") } if len(small) > 0 { t.Errorf("/signin: iOS zooms when these are tapped (under 16px on a touch screen): %s", strings.Join(small, "; ")) } }) t.Run("signed in", testNoZoomSignedIn) } func testNoZoomSignedIn(t *testing.T) { rig := harness.New(t, func(origin string) http.Handler { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, _, err := memoria.AppForTest(d, origin, testDEK(t), logger) if err != nil { t.Fatalf("AppForTest: %v", err) } return mux }) 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: %v (the browser is at %s)", what, err, at) } } measure := func(path, ready string) { t.Helper() var coarse bool var small []string run(path, chromedp.Navigate(rig.Origin+path), chromedp.Reload(), chromedp.WaitVisible(ready, chromedp.ByQuery), chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), chromedp.Evaluate(smallControls(textControls), &small), ) if !coarse { t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", path) } if len(small) > 0 { t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", path, strings.Join(small, "; ")) } } run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5)) // A document, posted as the Add content form posts one. var buf bytes.Buffer mw := multipart.NewWriter(&buf) _ = mw.WriteField("title", "Office handbook") _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.") mw.Close() req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/documents", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Origin", rig.Origin) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("store a document: %v", err) } res.Body.Close() var at string run("find the document", chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`a[href^="/documents/"]`, chromedp.ByQuery), chromedp.AttributeValue(`a[href^="/documents/"]`, "href", &at, nil, chromedp.ByQuery), ) doc := regexp.MustCompile(`^/documents/\d+$`).FindString(at) if doc == "" { t.Fatalf("the index links no document (got %q)", at) } measure("/", `main`) measure("/?q=Lisbon", `main`) measure("/add", `textarea[name="body"]`) measure(doc, `main`) measure(doc+"/delete", `main`) measure("/connectors", `main`) measure("/connectors/notion", `input[name="token"]`) } // 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 + ']' : '') + (e.id ? '#' + e.id : '') + ' ' + getComputedStyle(e).fontSize)`, sel) }