rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5// Browser drives for datetime.js's native state: a field the page
6// cannot change is a box that cannot change it, and a form reset puts
7// the box back to what the field holds. Each drive runs its steps
8// inside one page script and reads the result back as JSON, like the
9// select drives, since what is fenced is the field's value after each
10// way of changing it was tried.
11
12import (
13 "context"
14 "encoding/json"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/chromedp/cdproto/emulation"
20 "github.com/chromedp/chromedp"
21)
22
23// datePages is one page holding four armed date fields in a form:
24// one readonly, one disabled, one plain, and one inside a fieldset,
25// each with a value, so a change shows and a reset has somewhere to
26// go back to. sizingDoc links calendar.js, so the pick button opens
27// the grid.
28func datePages(t *testing.T) map[string]string {
29 t.Helper()
30 field := func(id, extra string) string {
31 return `<div rst-field><label rst-field-label for="` + id + `">` + id + `</label>` +
32 `<input rst-input id="` + id + `" name="` + id + `" type="date" value="2026-10-20" data-rst-date` + extra + `></div>`
33 }
34 body := `<form method="post" action="/submit">` +
35 field("ro", " readonly") + field("dis", " disabled") + field("plain", "") +
36 `<fieldset id="fs">` + field("inset", "") + `</fieldset>` +
37 `<button type="submit">Save</button></form>`
38 return map[string]string{"/": sizingDoc("datetime", body)}
39}
40
41// driveDate opens datePages on a rig of its own and runs one page
42// script, decoding its result.
43func driveDate(t *testing.T, script string, out any) {
44 t.Helper()
45 rig := sizingRig(t, false, datePages(t))
46 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
47 defer cancel()
48 var raw string
49 if err := chromedp.Run(ctx,
50 emulation.SetFocusEmulationEnabled(true),
51 chromedp.Navigate(rig.Origin+"/"),
52 chromedp.WaitVisible(`#plain-combo`, chromedp.ByQuery),
53 chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+script+`})()))()`, &raw, awaitPromise),
54 ); err != nil {
55 t.Fatalf("drive failed: %v", err)
56 }
57 if err := json.Unmarshal([]byte(raw), out); err != nil {
58 t.Fatalf("decode %q: %v", raw, err)
59 }
60}
61
62// dateMoves is the page-side helper the drives share: every way of
63// changing a field through its box. Words and Enter commit the text;
64// the suggestions list, if it opened, has a row to press; the pick
65// button opens the grid, whose first day cell is picked; and leaving
66// the box commits whatever it holds.
67const dateMoves = `
68 const frame = () => ` + afterFrame + `;
69 const tick = () => new Promise((r) => setTimeout(r, 0));
70 const form = document.querySelector('form');
71 const posted = (name) => new FormData(form).get(name);
72 const field = (id) => ({
73 native: document.querySelector('#' + id),
74 input: document.querySelector('#' + id + '-combo'),
75 list: document.querySelector('#' + id + '-combo-listbox') || document.querySelector('#' + id + '-listbox'),
76 pick: document.querySelector('#' + id).closest('[rst-dtp]').querySelector('[rst-dtp-pick]'),
77 });
78 const tryAll = async (f) => {
79 const tried = {};
80 f.input.focus();
81 f.input.click();
82 await frame();
83 tried.listOpen = !!(f.list && !f.list.hidden);
84 f.input.value = '25 Dec 2026';
85 f.input.dispatchEvent(new Event('input', { bubbles: true }));
86 f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
87 await frame();
88 tried.afterEnter = f.native.value;
89 // A quick pick's row, if the list built any.
90 const row = f.list && f.list.querySelector('[rst-dtp-quick]');
91 if (row) row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
92 await frame();
93 tried.afterRow = f.native.value;
94 // The calendar: press the button, pick the first day shown.
95 f.pick.click();
96 await frame();
97 // This field's own grid, and only while it is shown: a grid
98 // opened and closed earlier stays in the page, hidden.
99 const dialog = f.native.closest('[rst-dtp]').querySelector('[role="dialog"]');
100 tried.calendarOpen = !!dialog && !dialog.hidden && dialog.getClientRects().length > 0;
101 const day = tried.calendarOpen ? dialog.querySelector('[role="gridcell"] button, [role="gridcell"]') : null;
102 if (day) { day.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); day.click(); }
103 await frame();
104 tried.afterCalendar = f.native.value;
105 f.input.value = '25 Dec 2026';
106 f.input.blur();
107 document.body.focus();
108 await tick();
109 await frame();
110 tried.afterBlur = f.native.value;
111 tried.shown = f.input.value;
112 return tried;
113 };
114`
115
116// A readonly date field shows its value and refuses every way of
117// changing it: the list does not open, words and Enter, a quick pick,
118// the grid and leaving the box all leave the field as it was, and the
119// form posts the value it loaded with. Made writable, the same moves
120// change it, so the refusals were the state's. A disabled field is
121// disabled throughout, box and button, and a field in a disabled
122// fieldset, which sets no attribute, is refused at the commit.
123func TestAReadonlyOrDisabledDateFieldCannotBeChangedThroughTheBox(t *testing.T) {
124 t.Parallel()
125 type tried struct {
126 ListOpen bool `json:"listOpen"`
127 AfterEnter string `json:"afterEnter"`
128 AfterRow string `json:"afterRow"`
129 CalendarOpen bool `json:"calendarOpen"`
130 AfterCalendar string `json:"afterCalendar"`
131 AfterBlur string `json:"afterBlur"`
132 Shown string `json:"shown"`
133 }
134 var got struct {
135 RoBox bool `json:"roBox"`
136 RoPick bool `json:"roPick"`
137 Ro tried `json:"ro"`
138 RoPosted string `json:"roPosted"`
139 RoShownLoad string `json:"roShownLoad"`
140 Writable tried `json:"writable"`
141 DisBox bool `json:"disBox"`
142 DisPick bool `json:"disPick"`
143 Dis tried `json:"dis"`
144 DisEnabled string `json:"disEnabled"`
145 InSet bool `json:"inSet"`
146 Set tried `json:"set"`
147 SetPosted string `json:"setPosted"`
148 }
149 driveDate(t, dateMoves+`
150 const out = {};
151 const ro = field('ro');
152 out.roShownLoad = ro.input.value;
153 out.roBox = ro.input.readOnly;
154 out.roPick = ro.pick.disabled;
155 out.ro = await tryAll(ro);
156 out.roPosted = posted('ro');
157 ro.native.readOnly = false;
158 await frame();
159 out.writable = await tryAll(ro);
160 const dis = field('dis');
161 out.disBox = dis.input.disabled;
162 out.disPick = dis.pick.disabled;
163 out.dis = await tryAll(dis);
164 dis.native.disabled = false;
165 await frame();
166 out.disEnabled = dis.input.disabled ? 'still disabled' : 'enabled';
167 const fs = document.querySelector('#fs');
168 fs.disabled = true;
169 await frame();
170 const inset = field('inset');
171 out.inSet = inset.input.matches(':disabled');
172 out.set = await tryAll(inset);
173 fs.disabled = false;
174 await frame();
175 out.setPosted = posted('inset');
176 return out;
177 `, &got)
178 unchanged := func(name string, tr tried) {
179 t.Helper()
180 if tr.ListOpen || tr.CalendarOpen {
181 t.Errorf("%s: a popup opened (list %v, calendar %v) on a field that cannot change", name, tr.ListOpen, tr.CalendarOpen)
182 }
183 for step, v := range map[string]string{"Enter": tr.AfterEnter, "a row": tr.AfterRow, "the calendar": tr.AfterCalendar, "leaving the box": tr.AfterBlur} {
184 if v != "2026-10-20" {
185 t.Errorf("%s: after %s the field is %q, want 2026-10-20", name, step, v)
186 }
187 }
188 }
189 if !got.RoBox || !got.RoPick {
190 t.Errorf("readonly: box readOnly %v, pick disabled %v; want both", got.RoBox, got.RoPick)
191 }
192 unchanged("readonly", got.Ro)
193 if got.Ro.Shown != got.RoShownLoad || got.RoShownLoad == "" {
194 t.Errorf("readonly: the box shows %q after the tries, want what it showed on load, %q", got.Ro.Shown, got.RoShownLoad)
195 }
196 if got.RoPosted != "2026-10-20" {
197 t.Errorf("readonly: the form posts %q, want 2026-10-20", got.RoPosted)
198 }
199 if got.Writable.AfterEnter != "2026-12-25" {
200 t.Errorf("made writable, Enter left the field at %q, want 2026-12-25: the readonly refusals were not the state's", got.Writable.AfterEnter)
201 }
202 if !got.DisBox || !got.DisPick {
203 t.Errorf("disabled: box disabled %v, pick disabled %v; want both", got.DisBox, got.DisPick)
204 }
205 unchanged("disabled", got.Dis)
206 if got.DisEnabled != "enabled" {
207 t.Error("the field was enabled again and its box stayed disabled")
208 }
209 if !got.InSet {
210 t.Fatal("inside a disabled fieldset, the box does not read as :disabled; the fieldset leg is not set up")
211 }
212 unchanged("in a disabled fieldset", got.Set)
213 if got.SetPosted != "2026-10-20" {
214 t.Errorf("in a disabled fieldset: once enabled the form posts %q, want 2026-10-20", got.SetPosted)
215 }
216}
217
218// A form reset puts the field back to its initial value without a
219// change event, and the box, whose own default is empty, resets to
220// blank: the page showed nothing while the form posted a date. After
221// an uncancelled reset, through form.reset() or a reset button, the
222// box shows the field's value again and an open popup closes; a reset
223// a listener cancelled leaves the new date in place.
224func TestAFormResetRepaintsTheDateBox(t *testing.T) {
225 t.Parallel()
226 var got struct {
227 ShownLoad string `json:"shownLoad"`
228 Changed string `json:"changed"`
229 ShownChanged string `json:"shownChanged"`
230 ListReset bool `json:"listReset"`
231 ShownReset string `json:"shownReset"`
232 PostedReset string `json:"postedReset"`
233 ShownButton string `json:"shownButton"`
234 PostedButton string `json:"postedButton"`
235 ShownKept string `json:"shownKept"`
236 PostedKept string `json:"postedKept"`
237 }
238 driveDate(t, dateMoves+`
239 const out = {};
240 const f = field('plain');
241 out.shownLoad = f.input.value;
242 const type = (words) => {
243 f.input.focus();
244 f.input.value = words;
245 f.input.dispatchEvent(new Event('input', { bubbles: true }));
246 f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
247 };
248 type('25 Dec 2026');
249 await frame();
250 out.changed = f.native.value;
251 out.shownChanged = f.input.value;
252 // Open the list, then reset through the API.
253 f.input.click();
254 await frame();
255 form.reset();
256 await tick();
257 await frame();
258 out.listReset = !!(f.list && !f.list.hidden);
259 out.shownReset = f.input.value;
260 out.postedReset = posted('plain');
261 f.input.blur();
262 // Again through a reset button.
263 type('25 Dec 2026');
264 await frame();
265 const button = document.createElement('button');
266 button.type = 'reset';
267 form.append(button);
268 button.click();
269 await tick();
270 await frame();
271 out.shownButton = f.input.value;
272 out.postedButton = posted('plain');
273 // A cancelled reset changes nothing.
274 type('25 Dec 2026');
275 await frame();
276 form.addEventListener('reset', (e) => e.preventDefault(), { once: true });
277 form.reset();
278 await tick();
279 await frame();
280 out.shownKept = f.input.value;
281 out.postedKept = posted('plain');
282 return out;
283 `, &got)
284 if got.ShownLoad == "" || got.Changed != "2026-12-25" || got.ShownChanged == got.ShownLoad {
285 t.Fatalf("setup: the box showed %q on load, then %q after typing 25 Dec 2026, and the field is %q; the drive's setup did not take", got.ShownLoad, got.ShownChanged, got.Changed)
286 }
287 if got.ListReset || got.ShownReset != got.ShownLoad || got.PostedReset != "2026-10-20" {
288 t.Errorf("after form.reset(): list open %v, box shows %q, form posts %q; want closed, %q, 2026-10-20", got.ListReset, got.ShownReset, got.PostedReset, got.ShownLoad)
289 }
290 if got.ShownButton != got.ShownLoad || got.PostedButton != "2026-10-20" {
291 t.Errorf("after a reset button: box shows %q, form posts %q; want %q, 2026-10-20", got.ShownButton, got.PostedButton, got.ShownLoad)
292 }
293 if got.ShownKept != got.ShownChanged || got.PostedKept != "2026-12-25" {
294 t.Errorf("after a cancelled reset: box shows %q, form posts %q; want %q, 2026-12-25", got.ShownKept, got.PostedKept, got.ShownChanged)
295 }
296}
297
298// A reset that lands in the tick between leaving the box and the
299// commit that leaving queues: the reset blanks the box and restores
300// the field, and the queued commit then committed that blank, emptying
301// the field the reset had just restored. Now the pending commit yields
302// to an uncancelled reset, and still runs after a cancelled one. The
303// focus leaves the box by blur() and by moving to a button, since
304// either way is a real focusout.
305func TestAResetInTheTickAfterABlurKeepsTheDefault(t *testing.T) {
306 t.Parallel()
307 var got struct {
308 ShownLoad string `json:"shownLoad"`
309 Changed string `json:"changed"`
310 NativeBlur string `json:"nativeBlur"`
311 ShownBlur string `json:"shownBlur"`
312 NativeButton string `json:"nativeButton"`
313 ShownButton string `json:"shownButton"`
314 NativeKept string `json:"nativeKept"`
315 ShownKept string `json:"shownKept"`
316 }
317 driveDate(t, dateMoves+`
318 const out = {};
319 const f = field('plain');
320 out.shownLoad = f.input.value;
321 const change = () => {
322 f.input.focus();
323 f.input.value = '25 Dec 2026';
324 f.input.dispatchEvent(new Event('input', { bubbles: true }));
325 f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
326 f.input.focus();
327 };
328 change();
329 await frame();
330 out.changed = f.native.value;
331 // Leave by blur(), then reset in the same task.
332 f.input.blur();
333 form.reset();
334 await tick();
335 await tick();
336 await frame();
337 out.nativeBlur = f.native.value;
338 out.shownBlur = f.input.value;
339 // Leave by moving focus to the button, then reset in the same task.
340 change();
341 await frame();
342 document.querySelector('button[type="submit"]').focus();
343 form.reset();
344 await tick();
345 await tick();
346 await frame();
347 out.nativeButton = f.native.value;
348 out.shownButton = f.input.value;
349 // A cancelled reset in that tick still lets the commit through.
350 change();
351 await frame();
352 f.input.value = '31 Dec 2026';
353 f.input.dispatchEvent(new Event('input', { bubbles: true }));
354 f.input.blur();
355 form.addEventListener('reset', (e) => e.preventDefault(), { once: true });
356 form.reset();
357 await tick();
358 await tick();
359 await frame();
360 out.nativeKept = f.native.value;
361 out.shownKept = f.input.value;
362 return out;
363 `, &got)
364 if got.ShownLoad == "" || got.Changed != "2026-12-25" {
365 t.Fatalf("setup: the box showed %q on load and the field is %q after typing 25 Dec 2026; the drive's setup did not take", got.ShownLoad, got.Changed)
366 }
367 if got.NativeBlur != "2026-10-20" || got.ShownBlur != got.ShownLoad {
368 t.Errorf("blur() then reset in the same task: the field is %q and the box shows %q; want 2026-10-20 and %q", got.NativeBlur, got.ShownBlur, got.ShownLoad)
369 }
370 if got.NativeButton != "2026-10-20" || got.ShownButton != got.ShownLoad {
371 t.Errorf("focus to a button then reset in the same task: the field is %q and the box shows %q; want 2026-10-20 and %q", got.NativeButton, got.ShownButton, got.ShownLoad)
372 }
373 if got.NativeKept != "2026-12-31" {
374 t.Errorf("a cancelled reset in that tick: the field is %q, want 2026-12-31, the text the blur was committing", got.NativeKept)
375 }
376}
377
378// The English vocabulary must be there before the first scan: a
379// deferred script runs once the document has parsed and scans at
380// once, and a script injected after load does the same at complete.
381// Before, the words were assigned after that scan, so every field such
382// a script enhanced captured no vocabulary and "tomorrow" was not a
383// date. Two legs: the page's own deferred script (sizingDoc's), and a
384// page with no datetime.js at all that injects it after load.
385func TestTomorrowParsesWhateverTheScriptsTiming(t *testing.T) {
386 t.Parallel()
387 pages := datePages(t)
388 const tag = `<script defer src="/datetime.js"></script>`
389 without := strings.Replace(pages["/"], tag, "", 1)
390 if without == pages["/"] {
391 t.Fatalf("the fixture page does not link %s; the injected leg has nothing to leave out", tag)
392 }
393 pages["/late"] = without
394 rig := sizingRig(t, false, pages)
395 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
396 defer cancel()
397 const tomorrow = `
398 const f = document.querySelector('#plain');
399 const input = document.querySelector('#plain-combo');
400 const d = new Date();
401 d.setDate(d.getDate() + 1);
402 const pad = (n) => String(n).padStart(2, '0');
403 const want = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
404 input.focus();
405 input.value = 'tomorrow';
406 input.dispatchEvent(new Event('input', { bubbles: true }));
407 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
408 await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
409 return { ready: readyWhenRun, value: f.value, want };
410 `
411 type leg struct {
412 Ready string `json:"ready"`
413 Value string `json:"value"`
414 Want string `json:"want"`
415 }
416 run := func(path, setup string) leg {
417 t.Helper()
418 var raw string
419 if err := chromedp.Run(ctx,
420 emulation.SetFocusEmulationEnabled(true),
421 chromedp.Navigate(rig.Origin+path),
422 chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+setup+tomorrow+`})()))()`, &raw, awaitPromise),
423 ); err != nil {
424 t.Fatalf("%s: drive failed: %v", path, err)
425 }
426 var got leg
427 if err := json.Unmarshal([]byte(raw), &got); err != nil {
428 t.Fatalf("%s: decode %q: %v", path, raw, err)
429 }
430 return got
431 }
432 // The deferred script has run by the time the page script does;
433 // the readyState it saw is what a deferred script sees.
434 deferred := run("/", `
435 await new Promise((r) => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); });
436 if (!document.querySelector('#plain-combo')) throw new Error('the deferred datetime.js did not enhance the field');
437 const readyWhenRun = 'interactive (deferred)';
438 `)
439 if deferred.Value != deferred.Want {
440 t.Errorf("deferred: the field is %q after \"tomorrow\", want %q (%s)", deferred.Value, deferred.Want, deferred.Ready)
441 }
442 // No datetime.js on the page; it is injected once the page is complete.
443 late := run("/late", `
444 await new Promise((r) => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); });
445 if (document.querySelector('#plain-combo')) throw new Error('/late already has an enhanced field; the injected leg is not set up');
446 const readyWhenRun = document.readyState;
447 await new Promise((r, x) => { const s = document.createElement('script'); s.src = '/datetime.js'; s.onload = r; s.onerror = x; document.head.append(s); });
448 if (!document.querySelector('#plain-combo')) throw new Error('the injected datetime.js did not enhance the field');
449 `)
450 if late.Ready != "complete" {
451 t.Errorf("the injected leg ran at readyState %q, want complete", late.Ready)
452 }
453 if late.Value != late.Want {
454 t.Errorf("injected at complete: the field is %q after \"tomorrow\", want %q", late.Value, late.Want)
455 }
456}
457