//go:build browser package check // Browser drives for datetime.js's native state: a field the page // cannot change is a box that cannot change it, and a form reset puts // the box back to what the field holds. Each drive runs its steps // inside one page script and reads the result back as JSON, like the // select drives, since what is fenced is the field's value after each // way of changing it was tried. import ( "context" "encoding/json" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/chromedp" ) // datePages is one page holding four armed date fields in a form: // one readonly, one disabled, one plain, and one inside a fieldset, // each with a value, so a change shows and a reset has somewhere to // go back to. sizingDoc links calendar.js, so the pick button opens // the grid. func datePages(t *testing.T) map[string]string { t.Helper() field := func(id, extra string) string { return `
` + `
` } body := `
` + field("ro", " readonly") + field("dis", " disabled") + field("plain", "") + `
` + field("inset", "") + `
` + `
` return map[string]string{"/": sizingDoc("datetime", body)} } // driveDate opens datePages on a rig of its own and runs one page // script, decoding its result. func driveDate(t *testing.T, script string, out any) { t.Helper() rig := sizingRig(t, false, datePages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() var raw string if err := chromedp.Run(ctx, emulation.SetFocusEmulationEnabled(true), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`#plain-combo`, chromedp.ByQuery), chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+script+`})()))()`, &raw, awaitPromise), ); err != nil { t.Fatalf("drive failed: %v", err) } if err := json.Unmarshal([]byte(raw), out); err != nil { t.Fatalf("decode %q: %v", raw, err) } } // dateMoves is the page-side helper the drives share: every way of // changing a field through its box. Words and Enter commit the text; // the suggestions list, if it opened, has a row to press; the pick // button opens the grid, whose first day cell is picked; and leaving // the box commits whatever it holds. const dateMoves = ` const frame = () => ` + afterFrame + `; const tick = () => new Promise((r) => setTimeout(r, 0)); const form = document.querySelector('form'); const posted = (name) => new FormData(form).get(name); const field = (id) => ({ native: document.querySelector('#' + id), input: document.querySelector('#' + id + '-combo'), list: document.querySelector('#' + id + '-combo-listbox') || document.querySelector('#' + id + '-listbox'), pick: document.querySelector('#' + id).closest('[rst-dtp]').querySelector('[rst-dtp-pick]'), }); const tryAll = async (f) => { const tried = {}; f.input.focus(); f.input.click(); await frame(); tried.listOpen = !!(f.list && !f.list.hidden); f.input.value = '25 Dec 2026'; f.input.dispatchEvent(new Event('input', { bubbles: true })); f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); await frame(); tried.afterEnter = f.native.value; // A quick pick's row, if the list built any. const row = f.list && f.list.querySelector('[rst-dtp-quick]'); if (row) row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await frame(); tried.afterRow = f.native.value; // The calendar: press the button, pick the first day shown. f.pick.click(); await frame(); // This field's own grid, and only while it is shown: a grid // opened and closed earlier stays in the page, hidden. const dialog = f.native.closest('[rst-dtp]').querySelector('[role="dialog"]'); tried.calendarOpen = !!dialog && !dialog.hidden && dialog.getClientRects().length > 0; const day = tried.calendarOpen ? dialog.querySelector('[role="gridcell"] button, [role="gridcell"]') : null; if (day) { day.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); day.click(); } await frame(); tried.afterCalendar = f.native.value; f.input.value = '25 Dec 2026'; f.input.blur(); document.body.focus(); await tick(); await frame(); tried.afterBlur = f.native.value; tried.shown = f.input.value; return tried; }; ` // A readonly date field shows its value and refuses every way of // changing it: the list does not open, words and Enter, a quick pick, // the grid and leaving the box all leave the field as it was, and the // form posts the value it loaded with. Made writable, the same moves // change it, so the refusals were the state's. A disabled field is // disabled throughout, box and button, and a field in a disabled // fieldset, which sets no attribute, is refused at the commit. func TestAReadonlyOrDisabledDateFieldCannotBeChangedThroughTheBox(t *testing.T) { t.Parallel() type tried struct { ListOpen bool `json:"listOpen"` AfterEnter string `json:"afterEnter"` AfterRow string `json:"afterRow"` CalendarOpen bool `json:"calendarOpen"` AfterCalendar string `json:"afterCalendar"` AfterBlur string `json:"afterBlur"` Shown string `json:"shown"` } var got struct { RoBox bool `json:"roBox"` RoPick bool `json:"roPick"` Ro tried `json:"ro"` RoPosted string `json:"roPosted"` RoShownLoad string `json:"roShownLoad"` Writable tried `json:"writable"` DisBox bool `json:"disBox"` DisPick bool `json:"disPick"` Dis tried `json:"dis"` DisEnabled string `json:"disEnabled"` InSet bool `json:"inSet"` Set tried `json:"set"` SetPosted string `json:"setPosted"` } driveDate(t, dateMoves+` const out = {}; const ro = field('ro'); out.roShownLoad = ro.input.value; out.roBox = ro.input.readOnly; out.roPick = ro.pick.disabled; out.ro = await tryAll(ro); out.roPosted = posted('ro'); ro.native.readOnly = false; await frame(); out.writable = await tryAll(ro); const dis = field('dis'); out.disBox = dis.input.disabled; out.disPick = dis.pick.disabled; out.dis = await tryAll(dis); dis.native.disabled = false; await frame(); out.disEnabled = dis.input.disabled ? 'still disabled' : 'enabled'; const fs = document.querySelector('#fs'); fs.disabled = true; await frame(); const inset = field('inset'); out.inSet = inset.input.matches(':disabled'); out.set = await tryAll(inset); fs.disabled = false; await frame(); out.setPosted = posted('inset'); return out; `, &got) unchanged := func(name string, tr tried) { t.Helper() if tr.ListOpen || tr.CalendarOpen { t.Errorf("%s: a popup opened (list %v, calendar %v) on a field that cannot change", name, tr.ListOpen, tr.CalendarOpen) } for step, v := range map[string]string{"Enter": tr.AfterEnter, "a row": tr.AfterRow, "the calendar": tr.AfterCalendar, "leaving the box": tr.AfterBlur} { if v != "2026-10-20" { t.Errorf("%s: after %s the field is %q, want 2026-10-20", name, step, v) } } } if !got.RoBox || !got.RoPick { t.Errorf("readonly: box readOnly %v, pick disabled %v; want both", got.RoBox, got.RoPick) } unchanged("readonly", got.Ro) if got.Ro.Shown != got.RoShownLoad || got.RoShownLoad == "" { t.Errorf("readonly: the box shows %q after the tries, want what it showed on load, %q", got.Ro.Shown, got.RoShownLoad) } if got.RoPosted != "2026-10-20" { t.Errorf("readonly: the form posts %q, want 2026-10-20", got.RoPosted) } if got.Writable.AfterEnter != "2026-12-25" { 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) } if !got.DisBox || !got.DisPick { t.Errorf("disabled: box disabled %v, pick disabled %v; want both", got.DisBox, got.DisPick) } unchanged("disabled", got.Dis) if got.DisEnabled != "enabled" { t.Error("the field was enabled again and its box stayed disabled") } if !got.InSet { t.Fatal("inside a disabled fieldset, the box does not read as :disabled; the fieldset leg is not set up") } unchanged("in a disabled fieldset", got.Set) if got.SetPosted != "2026-10-20" { t.Errorf("in a disabled fieldset: once enabled the form posts %q, want 2026-10-20", got.SetPosted) } } // A form reset puts the field back to its initial value without a // change event, and the box, whose own default is empty, resets to // blank: the page showed nothing while the form posted a date. After // an uncancelled reset, through form.reset() or a reset button, the // box shows the field's value again and an open popup closes; a reset // a listener cancelled leaves the new date in place. func TestAFormResetRepaintsTheDateBox(t *testing.T) { t.Parallel() var got struct { ShownLoad string `json:"shownLoad"` Changed string `json:"changed"` ShownChanged string `json:"shownChanged"` ListReset bool `json:"listReset"` ShownReset string `json:"shownReset"` PostedReset string `json:"postedReset"` ShownButton string `json:"shownButton"` PostedButton string `json:"postedButton"` ShownKept string `json:"shownKept"` PostedKept string `json:"postedKept"` } driveDate(t, dateMoves+` const out = {}; const f = field('plain'); out.shownLoad = f.input.value; const type = (words) => { f.input.focus(); f.input.value = words; f.input.dispatchEvent(new Event('input', { bubbles: true })); f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); }; type('25 Dec 2026'); await frame(); out.changed = f.native.value; out.shownChanged = f.input.value; // Open the list, then reset through the API. f.input.click(); await frame(); form.reset(); await tick(); await frame(); out.listReset = !!(f.list && !f.list.hidden); out.shownReset = f.input.value; out.postedReset = posted('plain'); f.input.blur(); // Again through a reset button. type('25 Dec 2026'); await frame(); const button = document.createElement('button'); button.type = 'reset'; form.append(button); button.click(); await tick(); await frame(); out.shownButton = f.input.value; out.postedButton = posted('plain'); // A cancelled reset changes nothing. type('25 Dec 2026'); await frame(); form.addEventListener('reset', (e) => e.preventDefault(), { once: true }); form.reset(); await tick(); await frame(); out.shownKept = f.input.value; out.postedKept = posted('plain'); return out; `, &got) if got.ShownLoad == "" || got.Changed != "2026-12-25" || got.ShownChanged == got.ShownLoad { 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) } if got.ListReset || got.ShownReset != got.ShownLoad || got.PostedReset != "2026-10-20" { 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) } if got.ShownButton != got.ShownLoad || got.PostedButton != "2026-10-20" { t.Errorf("after a reset button: box shows %q, form posts %q; want %q, 2026-10-20", got.ShownButton, got.PostedButton, got.ShownLoad) } if got.ShownKept != got.ShownChanged || got.PostedKept != "2026-12-25" { t.Errorf("after a cancelled reset: box shows %q, form posts %q; want %q, 2026-12-25", got.ShownKept, got.PostedKept, got.ShownChanged) } } // A reset that lands in the tick between leaving the box and the // commit that leaving queues: the reset blanks the box and restores // the field, and the queued commit then committed that blank, emptying // the field the reset had just restored. Now the pending commit yields // to an uncancelled reset, and still runs after a cancelled one. The // focus leaves the box by blur() and by moving to a button, since // either way is a real focusout. func TestAResetInTheTickAfterABlurKeepsTheDefault(t *testing.T) { t.Parallel() var got struct { ShownLoad string `json:"shownLoad"` Changed string `json:"changed"` NativeBlur string `json:"nativeBlur"` ShownBlur string `json:"shownBlur"` NativeButton string `json:"nativeButton"` ShownButton string `json:"shownButton"` NativeKept string `json:"nativeKept"` ShownKept string `json:"shownKept"` } driveDate(t, dateMoves+` const out = {}; const f = field('plain'); out.shownLoad = f.input.value; const change = () => { f.input.focus(); f.input.value = '25 Dec 2026'; f.input.dispatchEvent(new Event('input', { bubbles: true })); f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); f.input.focus(); }; change(); await frame(); out.changed = f.native.value; // Leave by blur(), then reset in the same task. f.input.blur(); form.reset(); await tick(); await tick(); await frame(); out.nativeBlur = f.native.value; out.shownBlur = f.input.value; // Leave by moving focus to the button, then reset in the same task. change(); await frame(); document.querySelector('button[type="submit"]').focus(); form.reset(); await tick(); await tick(); await frame(); out.nativeButton = f.native.value; out.shownButton = f.input.value; // A cancelled reset in that tick still lets the commit through. change(); await frame(); f.input.value = '31 Dec 2026'; f.input.dispatchEvent(new Event('input', { bubbles: true })); f.input.blur(); form.addEventListener('reset', (e) => e.preventDefault(), { once: true }); form.reset(); await tick(); await tick(); await frame(); out.nativeKept = f.native.value; out.shownKept = f.input.value; return out; `, &got) if got.ShownLoad == "" || got.Changed != "2026-12-25" { 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) } if got.NativeBlur != "2026-10-20" || got.ShownBlur != got.ShownLoad { 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) } if got.NativeButton != "2026-10-20" || got.ShownButton != got.ShownLoad { 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) } if got.NativeKept != "2026-12-31" { t.Errorf("a cancelled reset in that tick: the field is %q, want 2026-12-31, the text the blur was committing", got.NativeKept) } } // The English vocabulary must be there before the first scan: a // deferred script runs once the document has parsed and scans at // once, and a script injected after load does the same at complete. // Before, the words were assigned after that scan, so every field such // a script enhanced captured no vocabulary and "tomorrow" was not a // date. Two legs: the page's own deferred script (sizingDoc's), and a // page with no datetime.js at all that injects it after load. func TestTomorrowParsesWhateverTheScriptsTiming(t *testing.T) { t.Parallel() pages := datePages(t) const tag = `` without := strings.Replace(pages["/"], tag, "", 1) if without == pages["/"] { t.Fatalf("the fixture page does not link %s; the injected leg has nothing to leave out", tag) } pages["/late"] = without rig := sizingRig(t, false, pages) ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() const tomorrow = ` const f = document.querySelector('#plain'); const input = document.querySelector('#plain-combo'); const d = new Date(); d.setDate(d.getDate() + 1); const pad = (n) => String(n).padStart(2, '0'); const want = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); input.focus(); input.value = 'tomorrow'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0))); return { ready: readyWhenRun, value: f.value, want }; ` type leg struct { Ready string `json:"ready"` Value string `json:"value"` Want string `json:"want"` } run := func(path, setup string) leg { t.Helper() var raw string if err := chromedp.Run(ctx, emulation.SetFocusEmulationEnabled(true), chromedp.Navigate(rig.Origin+path), chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+setup+tomorrow+`})()))()`, &raw, awaitPromise), ); err != nil { t.Fatalf("%s: drive failed: %v", path, err) } var got leg if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("%s: decode %q: %v", path, raw, err) } return got } // The deferred script has run by the time the page script does; // the readyState it saw is what a deferred script sees. deferred := run("/", ` await new Promise((r) => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); }); if (!document.querySelector('#plain-combo')) throw new Error('the deferred datetime.js did not enhance the field'); const readyWhenRun = 'interactive (deferred)'; `) if deferred.Value != deferred.Want { t.Errorf("deferred: the field is %q after \"tomorrow\", want %q (%s)", deferred.Value, deferred.Want, deferred.Ready) } // No datetime.js on the page; it is injected once the page is complete. late := run("/late", ` await new Promise((r) => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); }); if (document.querySelector('#plain-combo')) throw new Error('/late already has an enhanced field; the injected leg is not set up'); const readyWhenRun = document.readyState; await new Promise((r, x) => { const s = document.createElement('script'); s.src = '/datetime.js'; s.onload = r; s.onerror = x; document.head.append(s); }); if (!document.querySelector('#plain-combo')) throw new Error('the injected datetime.js did not enhance the field'); `) if late.Ready != "complete" { t.Errorf("the injected leg ran at readyState %q, want complete", late.Ready) } if late.Value != late.Want { t.Errorf("injected at complete: the field is %q after \"tomorrow\", want %q", late.Value, late.Want) } }