Test: no text control zooms iOS on tap (red: rastrillo v0.23 has no touch rule)
TestNoZoomOnTap emulates a touch screen (SetTouchEmulationEnabled plus a reload; the coarse-pointer check proves it took) and lists every visible text control under 16px on the door, the grid, the cell editor, share, comments and the table view. On this commit every one of them is 14px or smaller, because the ui import is frozen on github.com/carlosframework/ rastrillo v0.23.0, whose tokens.css predates the touch rule. Behind the browser tag, with make test-browser; make test-vet vets it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+150
−13
Makefile+12 −1go.mod+9 −0go.sum+4 −12internal/sheets/zoom_browser_test.go+125 −0
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... CGO_ENABLED=0 go vet -tags browser ./internal/sheets/ go: downloading github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 go: downloading github.com/chromedp/chromedp v0.14.2 go: downloading github.com/gobwas/ws v1.4.0 go: downloading github.com/chromedp/sysutil v1.1.0 go: downloading github.com/gobwas/pool v0.2.1 go: downloading github.com/gobwas/httphead v0.1.0 === fmt === === build === CGO_ENABLED=0 go build ./... === migrations === rastrillo migration check rastrillo migration check: models and migrations agree === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/sheets/cmd/sheets [no test files] ok amadan.net/oficina/sheets/internal/guardstest 0.086s ok amadan.net/oficina/sheets/internal/sheets 11.446s ok amadan.net/oficina/sheets/internal/sheets/calc 0.009s ok amadan.net/oficina/sheets/internal/sheets/format 0.002s ok amadan.net/oficina/sheets/internal/sheets/live 0.053s ok amadan.net/oficina/sheets/internal/sheets/seal 0.002s ok amadan.net/oficina/sheets/internal/sheets/sealtest 0.002s ok amadan.net/oficina/sheets/internal/sheets/store 2.994s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.307s ? amadan.net/oficina/sheets/tools/gencorpus [no test files] === race === go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/... ok amadan.net/oficina/sheets/internal/sheets/store 46.946s ok amadan.net/oficina/sheets/internal/sheets/live 1.062s
diff --git a/Makefile b/Makefile| index d02bb33..27cf795 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -1,7 +1,7 @@ |
| comma := , |
| export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(comma))amadan.net/oficina/estilo.git |
| -.PHONY: build release test test-vet test-fmt test-build test-migrations test-go test-race |
| +.PHONY: build release test test-vet test-fmt test-build test-migrations test-go test-race test-browser |
| # One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set |
| # per-target below rather than exported globally, because test-race is |
| @@ -60,8 +60,11 @@ release: |
| # package) comes next, and -race — by far the slowest step — runs last. |
| test: test-vet test-fmt test-build test-migrations test-go test-race |
| +# Also vets the browser-tagged tests: they are outside `make test`, and a |
| +# test behind a tag that nothing compiles rots silently. |
| test-vet: |
| CGO_ENABLED=0 go vet ./... |
| + CGO_ENABLED=0 go vet -tags browser ./internal/sheets/ |
| # Fails on unformatted files rather than rewriting them. CI must never |
| # silently "fix" a diff out from under a branch — a red test-fmt tells |
| @@ -113,3 +116,11 @@ test-go: |
| # gets deployed. |
| test-race: |
| go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/... |
| + |
| +# The browser checks (internal/sheets/*_browser_test.go), in a real |
| +# Chromium: on PATH, via RASTRILLO_CHROME, or in a Playwright cache. |
| +# Deliberately NOT part of `test`: the gate CI runs on every push must |
| +# not need a browser. Run it before shipping a change to the CSS or to |
| +# rastrillo's pin — TestNoZoomOnTap is the iOS zoom-on-tap check. |
| +test-browser: |
| + CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/sheets/ |
diff --git a/go.mod b/go.mod| index 8c52105..e9c82eb 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -8,12 +8,21 @@ require ( |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
| + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 |
| + github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| github.com/xuri/excelize/v2 v2.11.0 |
| golang.org/x/crypto v0.55.0 |
| gorm.io/gorm v1.31.2 |
| ) |
| +require ( |
| + github.com/chromedp/sysutil v1.1.0 // indirect |
| + github.com/gobwas/httphead v0.1.0 // indirect |
| + github.com/gobwas/pool v0.2.1 // indirect |
| + github.com/gobwas/ws v1.4.0 // indirect |
| +) |
| + |
| require ( |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 |
| github.com/dustin/go-humanize v1.0.1 // indirect |
diff --git a/go.sum b/go.sum| index 5683e5c..815b694 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,15 +1,3 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwLjPbMihf0f7aqz4CLSe0/NseQsHcJUYQ= |
| -amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZClOLqvppw+cCTXsn3b9NmRQ8CV5ZlXY= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjPb6m94Ynf7moyFYeBXr08rdOr3nmNqvg= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b h1:UOxYBKsOSRHcHHYNKt0i6cB1Z4yT2soywOTWURYvBWo= |
| -amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22 h1:RR9qP85FU1cZdn788v1zSLgZbKMGBmSk6Q7Rezj5JYI= |
| amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| @@ -56,12 +44,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= |
| +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= |
| +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= |
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
diff --git a/internal/sheets/zoom_browser_test.go b/internal/sheets/zoom_browser_test.go| new file mode 100644 |
| index 0000000..749cd36 |
| --- /dev/null |
| +++ b/internal/sheets/zoom_browser_test.go |
| @@ -0,0 +1,125 @@ |
| +//go:build browser |
| + |
| +// The browser checks: Sheets in a real Chromium. Not part of the plain |
| +// suite, because it needs 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 sheets |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/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` |
| + |
| +// 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. The grid's |
| +// cell editor is measured too: it only exists once a cell is being |
| +// edited, and it is the field someone taps most. |
| +func TestNoZoomOnTap(t *testing.T) { |
| + app, mail := appWithRecorder(t) |
| + cookies := sessionFor(t, app, mail) |
| + sheet, _ := newSheet(t, app, cookies, "Zoom") |
| + |
| + rig := harness.New(t, func(string) http.Handler { return app.Router() }) |
| + // Signed out first: the door is the field every visit starts on. |
| + pages := []struct{ path, ready string }{ |
| + {"/signin", `input[name="address"]`}, |
| + {"", ""}, // sign in, below |
| + {"/sheets", `main`}, |
| + {fmt.Sprintf("/sheets/%d", sheet.ID), `td[data-x="0"][data-y="0"]`}, |
| + {fmt.Sprintf("/sheets/%d/share", sheet.ID), `input[type=email], input[name=address]`}, |
| + {fmt.Sprintf("/sheets/%d/comments", sheet.ID), `textarea, input[type=text]`}, |
| + {fmt.Sprintf("/sheets/%d/table", sheet.ID), `main`}, |
| + } |
| + 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) |
| + } |
| + } |
| + run("touch emulation", |
| + // Touch emulation, then a reload so the page 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. |
| + emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5), |
| + ) |
| + for _, page := range pages { |
| + if page.path == "" { |
| + // The session signIn's real magic-link flow minted, handed |
| + // to the browser. |
| + var set []chromedp.Action |
| + for _, c := range cookies { |
| + set = append(set, network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/"). |
| + WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax)) |
| + } |
| + run("sign in", set...) |
| + continue |
| + } |
| + var coarse bool |
| + var small []string |
| + run(page.path, |
| + chromedp.Navigate(rig.Origin+page.path), |
| + chromedp.Reload(), |
| + chromedp.WaitVisible(page.ready), |
| + 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", page.path) |
| + } |
| + if len(small) > 0 { |
| + t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", page.path, strings.Join(small, "; ")) |
| + } |
| + } |
| + |
| + // The cell editor, opened the way a double-tap opens it. |
| + var small []string |
| + run("the cell editor", |
| + chromedp.Navigate(fmt.Sprintf("%s/sheets/%d", rig.Origin, sheet.ID)), |
| + chromedp.WaitVisible(`td[data-x="0"][data-y="0"]`), |
| + chromedp.DoubleClick(`td[data-x="0"][data-y="0"]`), |
| + chromedp.WaitVisible(`td[data-x="0"][data-y="0"] input, td[data-x="0"][data-y="0"] textarea, td[data-x="0"][data-y="0"] [contenteditable]`), |
| + chromedp.Evaluate(smallControls(`td[data-x="0"][data-y="0"] input, td[data-x="0"][data-y="0"] textarea, td[data-x="0"][data-y="0"] [contenteditable]`), &small), |
| + ) |
| + if len(small) > 0 { |
| + t.Errorf("the cell editor zooms when a cell is tapped: %s", 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 + ']' : '') + (e.className ? '.' + String(e.className).trim().split(/\s+/).join('.') : '') + ' ' + getComputedStyle(e).fontSize)`, sel) |
| +} |