| 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. |
| 15 | import { readFileSync } from "node:fs"; |
| 16 | import vm from "node:vm"; |
| 17 | |
| 18 | const src = readFileSync(new URL("../../js/fiesta.js", import.meta.url), "utf8"); |
| 19 | |
| 20 | // An element with attributes and nothing else. |
| 21 | function 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. |
| 33 | function 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. |
| 53 | function 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. |
| 127 | function 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 | |
| 134 | const out = {}; |
| 135 | |
| 136 | { |
| 137 | const p = page({ twice: true }); |
| 138 | out.idempotent = { once: p.once, twice: p.twice }; |
| 139 | } |
| 140 | |
| 141 | out.standalone = run({ store: true }, "dark"); |
| 142 | out.system = run({ store: true, stored: "dark" }, "system"); |
| 143 | out.broken = run({ store: true, broken: true }, "dark"); |
| 144 | out.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 | |
| 159 | process.stdout.write(JSON.stringify(out)); |
| 160 | |