rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fiesta_node runs fiesta.js against a stub page in plain Node, one
2// fresh context per scenario, and reports what the scheme switch did:
3// what a submit in the [rst-scheme] menu applied, stored and
4// dispatched, with and without <html data-rst-scheme-store="local">,
5// with a storage that throws, and what a stored choice does on load.
6// One scenario evaluates the script twice and counts listeners.
7//
8// check/scripts_test.go is the only caller. It reads back one JSON
9// object, a scenario per key.
10//
11// The page is the least DOM fiesta.js touches on the scheme path: a
12// root with attributes, a document with listeners, one scheme menu of
13// three forms, and a storage. Everything else the file reaches for on
14// load (getAnimations, matchMedia) is absent and guarded.
15import { readFileSync } from "node:fs";
16import vm from "node:vm";
17
18const src = readFileSync(new URL("../../js/fiesta.js", import.meta.url), "utf8");
19
20// An element with attributes and nothing else.
21function element() {
22 const attrs = new Map();
23 return {
24 getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null),
25 setAttribute: (k, v) => { attrs.set(k, String(v)); },
26 removeAttribute: (k) => { attrs.delete(k); },
27 hasAttribute: (k) => attrs.has(k),
28 };
29}
30
31// A storage over a Map that counts its calls, or one whose every
32// method throws, as a browser's does with storage switched off.
33function storage(broken) {
34 const m = new Map();
35 const n = { calls: 0 };
36 const call = (fn) => (...args) => {
37 n.calls++;
38 if (broken) throw new Error("storage is off");
39 return fn(...args);
40 };
41 return {
42 n, m,
43 getItem: call((k) => (m.has(k) ? m.get(k) : null)),
44 setItem: call((k, v) => { m.set(k, String(v)); }),
45 removeItem: call((k) => { m.delete(k); }),
46 };
47}
48
49// page builds the stub, evaluates fiesta.js in it, and hands back the
50// hooks a scenario drives. store sets the ownership attribute; stored
51// seeds the storage; broken makes it throw; twice evaluates the script
52// a second time in the same context.
53function page({ store = false, stored = null, broken = false, twice = false } = {}) {
54 const root = element();
55 if (store) root.setAttribute("data-rst-scheme-store", "local");
56 const local = storage(broken);
57 if (stored !== null) local.m.set("fiesta-scheme", stored);
58
59 const on = {};
60 let listeners = 0;
61 const events = [];
62 const dispatchEvent = (e) => { events.push(e); return true; };
63 const forms = ["light", "dark", "system"].map((v) => {
64 const form = { elements: { scheme: { value: v } }, dispatchEvent };
65 const button = Object.assign(element(), { form });
66 if (v === "system") button.setAttribute("aria-current", "true");
67 return { form, button };
68 });
69 const menu = {
70 open: true,
71 dispatchEvent,
72 querySelectorAll: (sel) => (sel === "button" ? forms.map((f) => f.button) : []),
73 };
74 for (const f of forms) f.form.closest = (sel) => (sel.includes("rst-scheme") ? menu : null);
75
76 let cookies = 0;
77 const document = {
78 readyState: "loading",
79 documentElement: root,
80 set cookie(v) { cookies++; },
81 get cookie() { return ""; },
82 querySelectorAll: (sel) => (sel.includes("rst-scheme") ? [menu] : []),
83 addEventListener: (type, fn) => { listeners++; (on[type] = on[type] || []).push(fn); },
84 dispatchEvent,
85 };
86 const window = { localStorage: local, addEventListener: () => { listeners++; } };
87 const context = {
88 window, document, localStorage: local, CustomEvent, setTimeout, clearTimeout,
89 location: { protocol: "http:" },
90 };
91 vm.runInNewContext(src, context);
92 const once = listeners;
93 if (twice) vm.runInNewContext(src, context);
94
95 return {
96 once, twice: listeners,
97 ready() { for (const fn of on.DOMContentLoaded || []) fn({}); },
98 submit(v) {
99 const f = forms.find((x) => x.form.elements.scheme.value === v).form;
100 const e = { type: "submit", target: f, prevented: false, preventDefault() { this.prevented = true; } };
101 let error = "";
102 try { for (const fn of on.submit || []) fn(e); } catch (err) { error = String(err); }
103 return { prevented: e.prevented, error };
104 },
105 state(extra = {}) {
106 const cur = forms.find((f) => f.button.getAttribute("aria-current") === "true");
107 const stored = local.m.get("fiesta-scheme");
108 return {
109 theme: root.getAttribute("data-theme") || "",
110 stored: stored === undefined ? "" : stored,
111 keys: local.m.size,
112 storageCalls: local.n.calls,
113 cookies,
114 events: events.filter((e) => e.type === "rst-scheme").map((e) => (e.detail && e.detail.scheme) || ""),
115 current: cur ? cur.form.elements.scheme.value : "",
116 open: menu.open,
117 prevented: false,
118 error: "",
119 ran: true,
120 ...extra,
121 };
122 },
123 };
124}
125
126// run opens a page, submits one scheme, and reports.
127function run(opts, scheme) {
128 const p = page(opts);
129 p.ready();
130 const { prevented, error } = p.submit(scheme);
131 return p.state({ prevented, error });
132}
133
134const out = {};
135
136{
137 const p = page({ twice: true });
138 out.idempotent = { once: p.once, twice: p.twice };
139}
140
141out.standalone = run({ store: true }, "dark");
142out.system = run({ store: true, stored: "dark" }, "system");
143out.broken = run({ store: true, broken: true }, "dark");
144out.server = run({ store: false }, "dark");
145
146{
147 const p = page({ store: true, stored: "dark" });
148 const before = p.state();
149 p.ready();
150 out.storedOnLoad = { ...p.state(), theme: before.theme };
151}
152
153{
154 const p = page({ store: false, stored: "dark" });
155 p.ready();
156 out.serverIgnoresStored = p.state();
157}
158
159process.stdout.write(JSON.stringify(out));
160