oficina / calendar Public

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

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, via
5// RASTRILLO_CHROME, or in a Playwright cache):
6//
7// make browser
8//
9// `make vet` vets this package with the tag, so it cannot stop
10// compiling unnoticed.
11package calendartest
12
13import (
14 "context"
15 "fmt"
16 "io"
17 "log/slog"
18 "net/http"
19 "path/filepath"
20 "regexp"
21 "strings"
22 "testing"
23 "time"
24
25 "amadan.net/rastrillo/rastrillo/harness"
26 "github.com/chromedp/cdproto/emulation"
27 "github.com/chromedp/cdproto/network"
28 "github.com/chromedp/chromedp"
29
30 calendar "amadan.net/oficina/calendar/internal/calendar"
31)
32
33// textControls is every control a tap puts a caret in. A checkbox, a
34// radio, a file picker or a button raises no keyboard, so iOS never
35// zooms for them.
36const 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`
37
38// TestNoZoomOnTap: iOS Safari zooms the whole page into any text control
39// whose type is under 16px, and rastrillo's base type is 14px. Its
40// tokens.css brings text controls up to 16px where the pointer is coarse
41// ("Touch: the two zooms nobody asked for"); an app on a tokens.css
42// without that block, or with a rule of its own that sets a control
43// smaller, zooms on the first tap of every field.
44//
45// Emulated as pointer: coarse, which is how the CSS decides. Every page
46// with a field is measured: the door, the views (and the quick-add box,
47// which only shows once its disclosure is opened), the event form with
48// its enhanced date fields, the event page's reminders, settings,
49// sharing, import, the booking-page form, and a stranger's booking
50// form, which is the one most often opened on a phone.
51func TestNoZoomOnTap(t *testing.T) {
52 var app *calendar.App
53 rig := harness.New(t, func(origin string) http.Handler {
54 a, err := calendar.New(calendar.Config{
55 DBPath: filepath.Join(t.TempDir(), "calendar.db"),
56 Env: "development",
57 Origin: origin,
58 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
59 })
60 if err != nil {
61 t.Fatalf("calendar.New: %v", err)
62 }
63 t.Cleanup(func() { a.Close() })
64 app = a
65 return a.Handler()
66 })
67 session := signInOverHTTP(t, app, rig.Origin, "host@example.com")
68
69 // A published booking page, through the store, so the stranger's
70 // form has something to book.
71 ctx := context.Background()
72 st := app.StoreForTest()
73 host := app.PersonByAddressForTest(t, "host@example.com")
74 if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil {
75 t.Fatal(err)
76 }
77 ws, _ := calendar.ParseAvailability("every day 9-5")
78 page, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
79 Title: "Quick chat", Description: "Half an hour.", Availability: "every day 9-5",
80 DurationMin: 30, HorizonDays: 30}, ws)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
85 t.Fatal(err)
86 }
87 public := "/book/paul/" + page.Code
88
89 run := func(what string, actions ...chromedp.Action) {
90 t.Helper()
91 ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
92 defer cancel()
93 if err := chromedp.Run(ctx, actions...); err != nil {
94 var at string
95 _ = chromedp.Run(rig.Context(), chromedp.Location(&at))
96 t.Fatalf("%s: %v (the browser is at %s)", what, err, at)
97 }
98 }
99 // measure loads path on a touch screen and lists its small controls,
100 // after the optional actions (which open what only interaction shows).
101 measure := func(path, ready string, then ...chromedp.Action) {
102 t.Helper()
103 var coarse bool
104 var small []string
105 actions := []chromedp.Action{
106 chromedp.Navigate(rig.Origin + path),
107 chromedp.Reload(),
108 chromedp.WaitVisible(ready, chromedp.ByQuery),
109 chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
110 }
111 actions = append(actions, then...)
112 actions = append(actions, chromedp.Evaluate(smallControls(textControls), &small))
113 run(path, actions...)
114 if !coarse {
115 t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", path)
116 }
117 if len(small) > 0 {
118 t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", path, strings.Join(small, "; "))
119 }
120 }
121
122 run("touch emulation",
123 // Touch emulation, then a reload per page so it re-evaluates its
124 // media queries. SetEmulatedMedia's "pointer" feature is not
125 // emulated by this Chromium; the coarse check in measure is what
126 // proves the emulation took.
127 emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5),
128 )
129
130 // Signed out first: the door is the field every visit starts on.
131 measure("/signin", `input[name="address"]`)
132
133 // The session the real magic-link flow minted, handed to the browser.
134 run("sign in", network.SetCookie(session.Name, session.Value).WithURL(rig.Origin+"/").
135 WithHTTPOnly(session.HttpOnly).WithSameSite(network.CookieSameSiteLax))
136
137 // An event, made through the form as a person makes one.
138 var at string
139 run("create an event",
140 chromedp.Navigate(rig.Origin+"/events/new?start=2026-09-02T09:30&end=2026-09-02T10:00"),
141 chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery),
142 chromedp.SendKeys(`input[name="title"]`, "Standup", chromedp.ByQuery),
143 chromedp.Click(`form[action="/events"] button[type="submit"]`, chromedp.ByQuery),
144 chromedp.WaitVisible(`form[action$="/reminders"]`, chromedp.ByQuery),
145 chromedp.Location(&at),
146 )
147 event := regexp.MustCompile(`/events/\d+`).FindString(at)
148 if event == "" {
149 t.Fatalf("creating an event landed on %s", at)
150 }
151
152 // The quick-add box is a closed <details> until it is tapped.
153 openQuick := chromedp.Evaluate(`document.querySelectorAll('details[cal-quick]').forEach(d => d.open = true)`, nil)
154 for _, view := range []string{"/day/2026-09-02", "/week/2026-09-02", "/month/2026-09", "/agenda?from=2026-09-02"} {
155 measure(view, `main`, openQuick)
156 }
157 measure("/events/new", `input[name="title"]`)
158 measure(event+"/edit", `input[name="title"]`)
159 measure(event, `form[action$="/reminders"]`)
160 measure("/settings", `main form`)
161 measure("/calendars", `main`)
162 measure("/calendars/1/share", `main`)
163 measure("/import", `main form`)
164 measure("/settings/booking", `main`)
165 measure("/settings/booking/new", `main form`)
166
167 // The stranger's side: the zone picker, then a day, then a slot,
168 // which is where the name, address and note fields are.
169 measure(public, `select[name="zone"]`)
170 var day string
171 run("a booking day",
172 chromedp.Navigate(rig.Origin+public),
173 chromedp.WaitVisible(`select[name="zone"]`, chromedp.ByQuery),
174 chromedp.Evaluate(`(document.querySelector('a[href*="day="]') || {}).href || ''`, &day),
175 )
176 if day == "" {
177 t.Fatal("the booking page offers no day to pick")
178 }
179 measure(strings.TrimPrefix(day, rig.Origin), `[cal-book-slot]`)
180 run("a booking slot",
181 chromedp.Click(`[cal-book-slot]`, chromedp.ByQuery),
182 chromedp.WaitVisible(`[cal-book-details] input[name="name"]`, chromedp.ByQuery),
183 )
184 var coarse bool
185 var small []string
186 run("the booker's details",
187 chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
188 chromedp.Evaluate(smallControls(textControls), &small),
189 )
190 if !coarse {
191 t.Fatal("the booker's details: the touch-screen emulation did not apply")
192 }
193 if len(small) > 0 {
194 t.Errorf("the booker's details: iOS zooms when these are tapped (under 16px on a touch screen): %s", strings.Join(small, "; "))
195 }
196}
197
198// smallControls is a script listing each visible control matching sel
199// whose computed font-size is under 16px, described well enough to find.
200func smallControls(sel string) string {
201 return fmt.Sprintf(`[...document.querySelectorAll(%q)]
202 .filter(e => e.getClientRects().length > 0)
203 .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16)
204 .map(e => e.tagName.toLowerCase() + (e.name ? '[name=' + e.name + ']' : '') + (e.id ? '#' + e.id : '') + ' ' + getComputedStyle(e).fontSize)`, sel)
205}
206