| 1 | //go:build browser |
| 2 | |
| 3 | package 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 | |
| 12 | import ( |
| 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. |
| 28 | func 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. |
| 43 | func 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. |
| 67 | const 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. |
| 123 | func 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. |
| 224 | func 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. |
| 305 | func 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. |
| 385 | func 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 | |