| 1 | // reset_node drives the race between a date field's blur commit and a |
| 2 | // form reset, in plain Node on a stub page with queued timers, so each |
| 3 | // ordering of blur, accepted reset and cancelled reset within one task |
| 4 | // can be laid out exactly and the timers run afterwards in the order |
| 5 | // a browser would run them. |
| 6 | // |
| 7 | // node node/reset_node.mjs |
| 8 | // |
| 9 | // check/datetime_test.go is the only caller. It reads back one JSON |
| 10 | // object with the field's wire value and the box's text after each |
| 11 | // scenario, plus "shownLoad", what the box showed before anything |
| 12 | // happened, which is what it must show again after an accepted reset: |
| 13 | // |
| 14 | // single blur, then an accepted reset |
| 15 | // cancelled text typed, blur, then a cancelled reset |
| 16 | // blurAcceptedCancelled blur, an accepted reset, a cancelled reset |
| 17 | // cancelledBlurAccepted a cancelled reset, blur, an accepted reset |
| 18 | // |
| 19 | // The page is the least DOM enhance() touches: elements with |
| 20 | // attributes, children, a class list, listeners and a form; a document |
| 21 | // that creates them, holds the field, and dispatches a reset to the |
| 22 | // listeners the script put on it; and setTimeout that queues. A reset |
| 23 | // here does what a browser's does: the event is dispatched, and if no |
| 24 | // listener cancelled it every control goes back to its default, the |
| 25 | // field to its value attribute and the box to the empty string. |
| 26 | import { readFileSync } from "node:fs"; |
| 27 | import vm from "node:vm"; |
| 28 | |
| 29 | const src = readFileSync(new URL("../../js/datetime.js", import.meta.url), "utf8"); |
| 30 | |
| 31 | // page builds a fresh context holding one armed date field with a |
| 32 | // default of 2026-10-20 inside a form, loads the script (which scans at |
| 33 | // once, the document being complete), and returns the handles the |
| 34 | // scenarios drive. |
| 35 | function page() { |
| 36 | const timers = []; |
| 37 | const listenersOf = new WeakMap(); |
| 38 | const element = (tag) => { |
| 39 | const attrs = {}; |
| 40 | const classes = new Set(); |
| 41 | const el = { |
| 42 | tagName: tag.toUpperCase(), |
| 43 | children: [], |
| 44 | parentNode: null, |
| 45 | dataset: {}, |
| 46 | hidden: false, |
| 47 | value: "", |
| 48 | id: "", |
| 49 | type: "", |
| 50 | disabled: false, |
| 51 | readOnly: false, |
| 52 | form: null, |
| 53 | textContent: "", |
| 54 | innerHTML: "", |
| 55 | getAttribute: (k) => (k in attrs ? attrs[k] : null), |
| 56 | hasAttribute: (k) => k in attrs, |
| 57 | setAttribute(k, v) { attrs[k] = String(v); if (k === "id") el.id = String(v); }, |
| 58 | removeAttribute(k) { delete attrs[k]; }, |
| 59 | toggleAttribute(k, force) { const on = force === undefined ? !(k in attrs) : !!force; if (on) attrs[k] = ""; else delete attrs[k]; return on; }, |
| 60 | classList: { add: (...c) => c.forEach((x) => classes.add(x)), remove: (...c) => c.forEach((x) => classes.delete(x)), toggle: (c, f) => (f ? classes.add(c) : classes.delete(c), !!f), contains: (c) => classes.has(c) }, |
| 61 | appendChild(c) { if (c.parentNode) c.remove(); el.children.push(c); c.parentNode = el; return c; }, |
| 62 | append(...cs) { cs.forEach((c) => el.appendChild(c)); }, |
| 63 | insertBefore(c, ref) { const i = el.children.indexOf(ref); el.children.splice(i < 0 ? el.children.length : i, 0, c); c.parentNode = el; return c; }, |
| 64 | remove() { if (el.parentNode) { const p = el.parentNode; p.children.splice(p.children.indexOf(el), 1); el.parentNode = null; } }, |
| 65 | contains(other) { for (let n = other; n; n = n.parentNode) if (n === el) return true; return false; }, |
| 66 | closest(sel) { for (let n = el; n; n = n.parentNode) if (n.matches && n.matches(sel)) return n; return null; }, |
| 67 | matches(sel) { |
| 68 | if (sel === ":disabled") { for (let n = el; n; n = n.parentNode) if (n.disabled && (n === el || n.tagName === "FIELDSET")) return true; return false; } |
| 69 | if (sel.startsWith("[") && sel.endsWith("]")) return sel.slice(1, -1) in attrs; |
| 70 | return false; |
| 71 | }, |
| 72 | querySelector: () => null, |
| 73 | querySelectorAll: () => [], |
| 74 | getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }), |
| 75 | focus() { document.activeElement = el; }, |
| 76 | blur() { if (document.activeElement === el) document.activeElement = document.body; }, |
| 77 | select() {}, |
| 78 | addEventListener(type, fn) { const m = listenersOf.get(el) || {}; (m[type] = m[type] || []).push(fn); listenersOf.set(el, m); }, |
| 79 | removeEventListener() {}, |
| 80 | // A real Event's target is read-only, so the script's own |
| 81 | // new Event("change") is wrapped rather than written to. |
| 82 | dispatchEvent(ev) { |
| 83 | const e = ev instanceof Event ? { type: ev.type, bubbles: ev.bubbles, target: el, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } } : Object.assign({ target: el }, ev); |
| 84 | for (let n = el; n; n = n.parentNode) { |
| 85 | const fns = (listenersOf.get(n) || {})[e.type] || []; |
| 86 | for (const fn of fns) fn.call(n, e); |
| 87 | if (!e.bubbles) break; |
| 88 | } |
| 89 | return !e.defaultPrevented; |
| 90 | }, |
| 91 | get offsetTop() { return 0; }, |
| 92 | get clientHeight() { return 0; }, |
| 93 | scrollTop: 0, |
| 94 | }; |
| 95 | return el; |
| 96 | }; |
| 97 | |
| 98 | const form = element("form"); |
| 99 | const native = element("input"); |
| 100 | native.type = "date"; |
| 101 | native.setAttribute("type", "date"); |
| 102 | native.setAttribute("id", "d"); |
| 103 | native.setAttribute("name", "d"); |
| 104 | native.setAttribute("value", "2026-10-20"); |
| 105 | native.setAttribute("data-rst-date", ""); |
| 106 | native.value = "2026-10-20"; |
| 107 | native.form = form; |
| 108 | const field = element("div"); |
| 109 | field.appendChild(native); |
| 110 | form.appendChild(field); |
| 111 | |
| 112 | const body = element("body"); |
| 113 | body.appendChild(form); |
| 114 | const root = element("html"); |
| 115 | root.setAttribute("lang", "en"); |
| 116 | root.lang = "en"; |
| 117 | root.appendChild(body); |
| 118 | const document = Object.assign(element("#document"), { |
| 119 | readyState: "complete", |
| 120 | documentElement: root, |
| 121 | body, |
| 122 | activeElement: body, |
| 123 | createElement: (tag) => element(tag), |
| 124 | querySelector: () => null, |
| 125 | querySelectorAll: (sel) => (sel.includes("data-rst-date") ? [native] : []), |
| 126 | }); |
| 127 | root.parentNode = document; |
| 128 | |
| 129 | const window = { innerHeight: 600 }; |
| 130 | const context = { |
| 131 | window, self: window, document, |
| 132 | Event, CustomEvent, Intl, console, |
| 133 | MutationObserver: class { observe() {} disconnect() {} }, |
| 134 | setTimeout: (fn) => { timers.push(fn); return timers.length; }, |
| 135 | clearTimeout() {}, |
| 136 | }; |
| 137 | vm.runInNewContext(src, context, { filename: "datetime.js" }); |
| 138 | |
| 139 | const input = field.children.find((c) => c.tagName === "DIV").children.find((c) => c.tagName === "INPUT" && c !== native); |
| 140 | if (!input) throw new Error("enhance did not put a box beside the field"); |
| 141 | const fire = (el, type, extra) => el.dispatchEvent(Object.assign({ type, bubbles: true, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }, extra || {})); |
| 142 | |
| 143 | return { |
| 144 | native, input, |
| 145 | // Types text into the box and commits it with Enter. |
| 146 | commit(text) { input.focus(); input.value = text; fire(input, "input"); fire(input, "keydown", { key: "Enter" }); }, |
| 147 | // Types text into the box without committing it. |
| 148 | type(text) { input.focus(); input.value = text; fire(input, "input"); }, |
| 149 | // Focus leaves the box: focus goes to the body, and focusout bubbles |
| 150 | // from the box through the wrap the script listens on. |
| 151 | blur() { document.activeElement = body; fire(input, "focusout", { bubbles: true }); }, |
| 152 | // A form reset, accepted or cancelled by a listener registered for the purpose. |
| 153 | reset(cancel) { |
| 154 | if (cancel) form.addEventListener("reset", function once(e) { e.preventDefault(); }); |
| 155 | const ev = { type: "reset", bubbles: true, target: form, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; |
| 156 | // The form's own listeners (the cancelling one), then the document's, as a bubbling event reaches them. |
| 157 | for (const fn of (listenersOf.get(form) || {}).reset || []) fn.call(form, ev); |
| 158 | for (const fn of (listenersOf.get(document) || {}).reset || []) fn.call(document, ev); |
| 159 | listenersOf.set(form, {}); |
| 160 | if (!ev.defaultPrevented) { native.value = native.getAttribute("value"); input.value = ""; } |
| 161 | }, |
| 162 | // Runs every queued timer in order, including ones queued while running. |
| 163 | run() { while (timers.length) timers.shift()(); }, |
| 164 | state() { return { native: native.value, shown: input.value }; }, |
| 165 | }; |
| 166 | } |
| 167 | |
| 168 | const out = {}; |
| 169 | { |
| 170 | const p = page(); |
| 171 | out.shownLoad = p.input.value; |
| 172 | p.commit("25 Dec 2026"); |
| 173 | p.run(); |
| 174 | out.changed = p.native.value; |
| 175 | p.blur(); |
| 176 | p.reset(false); |
| 177 | p.run(); |
| 178 | out.single = p.state(); |
| 179 | } |
| 180 | { |
| 181 | const p = page(); |
| 182 | p.commit("25 Dec 2026"); |
| 183 | p.run(); |
| 184 | p.type("31 Dec 2026"); |
| 185 | p.blur(); |
| 186 | p.reset(true); |
| 187 | p.run(); |
| 188 | out.cancelled = p.state(); |
| 189 | } |
| 190 | { |
| 191 | const p = page(); |
| 192 | p.commit("25 Dec 2026"); |
| 193 | p.run(); |
| 194 | p.blur(); |
| 195 | p.reset(false); |
| 196 | p.reset(true); |
| 197 | p.run(); |
| 198 | out.blurAcceptedCancelled = p.state(); |
| 199 | } |
| 200 | { |
| 201 | const p = page(); |
| 202 | p.commit("25 Dec 2026"); |
| 203 | p.run(); |
| 204 | p.reset(true); |
| 205 | p.blur(); |
| 206 | p.reset(false); |
| 207 | p.run(); |
| 208 | out.cancelledBlurAccepted = p.state(); |
| 209 | } |
| 210 | process.stdout.write(JSON.stringify(out)); |
| 211 | |