| new file mode 100644 |
| index 0000000..39c7a1e |
| --- /dev/null |
| +++ b/internal/calendartest/zoom_browser_test.go |
| @@ -0,0 +1,205 @@ |
| +//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" |
| + |
| + "github.com/carlosframework/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 <details> 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) |
| +} |