//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 := ``
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)
}
}