rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
26import { readFileSync } from "node:fs";
27import vm from "node:vm";
28
29const 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.
35function 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
168const 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}
210process.stdout.write(JSON.stringify(out));
211