//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, via // RASTRILLO_CHROME, or in a Playwright cache): // // make browser // // `make vet` vets this package with the tag, so it cannot stop // compiling unnoticed. package calendartest import ( "context" "fmt" "io" "log/slog" "net/http" "path/filepath" "regexp" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo/harness" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" calendar "amadan.net/oficina/calendar/internal/calendar" ) // 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. Every page // with a field is measured: the door, the views (and the quick-add box, // which only shows once its disclosure is opened), the event form with // its enhanced date fields, the event page's reminders, settings, // sharing, import, the booking-page form, and a stranger's booking // form, which is the one most often opened on a phone. func TestNoZoomOnTap(t *testing.T) { var app *calendar.App rig := harness.New(t, func(origin string) http.Handler { a, err := calendar.New(calendar.Config{ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: origin, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("calendar.New: %v", err) } t.Cleanup(func() { a.Close() }) app = a return a.Handler() }) session := signInOverHTTP(t, app, rig.Origin, "host@example.com") // A published booking page, through the store, so the stranger's // form has something to book. ctx := context.Background() st := app.StoreForTest() host := app.PersonByAddressForTest(t, "host@example.com") if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil { t.Fatal(err) } ws, _ := calendar.ParseAvailability("every day 9-5") page, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{ Title: "Quick chat", Description: "Half an hour.", Availability: "every day 9-5", DurationMin: 30, HorizonDays: 30}, ws) if err != nil { t.Fatal(err) } if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil { t.Fatal(err) } public := "/book/paul/" + page.Code 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 loads path on a touch screen and lists its small controls, // after the optional actions (which open what only interaction shows). measure := func(path, ready string, then ...chromedp.Action) { t.Helper() var coarse bool var small []string actions := []chromedp.Action{ chromedp.Navigate(rig.Origin + path), chromedp.Reload(), chromedp.WaitVisible(ready, chromedp.ByQuery), chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), } actions = append(actions, then...) actions = append(actions, chromedp.Evaluate(smallControls(textControls), &small)) run(path, actions...) 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", // Touch emulation, then a reload per page so it re-evaluates its // media queries. SetEmulatedMedia's "pointer" feature is not // emulated by this Chromium; the coarse check in measure is what // proves the emulation took. emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5), ) // Signed out first: the door is the field every visit starts on. measure("/signin", `input[name="address"]`) // The session the real magic-link flow minted, handed to the browser. run("sign in", network.SetCookie(session.Name, session.Value).WithURL(rig.Origin+"/"). WithHTTPOnly(session.HttpOnly).WithSameSite(network.CookieSameSiteLax)) // An event, made through the form as a person makes one. var at string run("create an event", chromedp.Navigate(rig.Origin+"/events/new?start=2026-09-02T09:30&end=2026-09-02T10:00"), chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery), chromedp.SendKeys(`input[name="title"]`, "Standup", chromedp.ByQuery), chromedp.Click(`form[action="/events"] button[type="submit"]`, chromedp.ByQuery), chromedp.WaitVisible(`form[action$="/reminders"]`, chromedp.ByQuery), chromedp.Location(&at), ) event := regexp.MustCompile(`/events/\d+`).FindString(at) if event == "" { t.Fatalf("creating an event landed on %s", at) } // The quick-add box is a closed
until it is tapped. openQuick := chromedp.Evaluate(`document.querySelectorAll('details[cal-quick]').forEach(d => d.open = true)`, nil) for _, view := range []string{"/day/2026-09-02", "/week/2026-09-02", "/month/2026-09", "/agenda?from=2026-09-02"} { measure(view, `main`, openQuick) } measure("/events/new", `input[name="title"]`) measure(event+"/edit", `input[name="title"]`) measure(event, `form[action$="/reminders"]`) measure("/settings", `main form`) measure("/calendars", `main`) measure("/calendars/1/share", `main`) measure("/import", `main form`) measure("/settings/booking", `main`) measure("/settings/booking/new", `main form`) // The stranger's side: the zone picker, then a day, then a slot, // which is where the name, address and note fields are. measure(public, `select[name="zone"]`) var day string run("a booking day", chromedp.Navigate(rig.Origin+public), chromedp.WaitVisible(`select[name="zone"]`, chromedp.ByQuery), chromedp.Evaluate(`(document.querySelector('a[href*="day="]') || {}).href || ''`, &day), ) if day == "" { t.Fatal("the booking page offers no day to pick") } measure(strings.TrimPrefix(day, rig.Origin), `[cal-book-slot]`) run("a booking slot", chromedp.Click(`[cal-book-slot]`, chromedp.ByQuery), chromedp.WaitVisible(`[cal-book-details] input[name="name"]`, chromedp.ByQuery), ) var coarse bool var small []string run("the booker's details", chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse), chromedp.Evaluate(smallControls(textControls), &small), ) if !coarse { t.Fatal("the booker's details: the touch-screen emulation did not apply") } if len(small) > 0 { t.Errorf("the booker's details: iOS zooms when these are tapped (under 16px on a touch screen): %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.id ? '#' + e.id : '') + ' ' + getComputedStyle(e).fontSize)`, sel) }