| 1 | // Regression coverage for issue #402 ("sandbox apps don't update theme until |
| 2 | // you switch away and then back"): a live "theme" postMessage from the host |
| 3 | // (sandbox.js's pushTheme, fired whenever the member changes theme/appearance) |
| 4 | // must re-tint an already-mounted sandbox frame in place — not just seed the |
| 5 | // frame's INITIAL theme at mount time. Same minimal-DOM-over-a-real- |
| 6 | // MessageChannel harness as sandbox-bridge.test.mjs, with an instrumented |
| 7 | // documentElement.style so applyTheme's effect is observable. |
| 8 | // node test/sandbox-theme.test.mjs |
| 9 | import test from "node:test"; |
| 10 | import assert from "node:assert/strict"; |
| 11 | import { MessageChannel } from "node:worker_threads"; |
| 12 | |
| 13 | const PARENT = { name: "parent-window" }; |
| 14 | function fakeNode() { |
| 15 | const node = { |
| 16 | style: { setProperty() {}, cssText: "", font: "" }, |
| 17 | dataset: {}, |
| 18 | children: [], |
| 19 | textContent: "", |
| 20 | appendChild(c) { |
| 21 | this.children.push(c); |
| 22 | return c; |
| 23 | }, |
| 24 | prepend() {}, |
| 25 | remove() {}, |
| 26 | querySelector: () => null, |
| 27 | setAttribute() {}, |
| 28 | addEventListener() {}, |
| 29 | classList: { add() {}, toggle() {} }, |
| 30 | }; |
| 31 | return node; |
| 32 | } |
| 33 | |
| 34 | // A style object that records every setProperty call and the colorScheme |
| 35 | // assignment, so a test can assert exactly what a "theme" message applied. |
| 36 | function spyStyle() { |
| 37 | const calls = []; |
| 38 | const style = { |
| 39 | setProperty(k, v) { |
| 40 | calls.push([k, v]); |
| 41 | }, |
| 42 | get colorScheme() { |
| 43 | return style._colorScheme || ""; |
| 44 | }, |
| 45 | set colorScheme(v) { |
| 46 | style._colorScheme = v; |
| 47 | }, |
| 48 | }; |
| 49 | return { style, calls }; |
| 50 | } |
| 51 | |
| 52 | let bust = 0; |
| 53 | async function connect({ source, manifest, seed, theme }) { |
| 54 | const rootStyle = spyStyle(); |
| 55 | const body = fakeNode(); |
| 56 | globalThis.document = { |
| 57 | createElement: () => fakeNode(), |
| 58 | body, |
| 59 | head: fakeNode(), |
| 60 | documentElement: { style: rootStyle.style }, |
| 61 | getElementById: () => null, |
| 62 | addEventListener() {}, |
| 63 | }; |
| 64 | const listeners = {}; |
| 65 | globalThis.window = { |
| 66 | parent: PARENT, |
| 67 | addEventListener: (t, fn) => ((listeners[t] ||= []).push(fn)), |
| 68 | removeEventListener: (t, fn) => { |
| 69 | listeners[t] = (listeners[t] || []).filter((f) => f !== fn); |
| 70 | }, |
| 71 | }; |
| 72 | await import(`../web/static/sandbox-frame.js?b=${++bust}`); |
| 73 | |
| 74 | const chan = new MessageChannel(); |
| 75 | const inbox = []; |
| 76 | const waiters = []; |
| 77 | chan.port1.onmessage = (ev) => { |
| 78 | inbox.push(ev.data); |
| 79 | for (const w of waiters.splice(0)) w(); |
| 80 | }; |
| 81 | for (const fn of listeners.message || []) { |
| 82 | fn({ |
| 83 | source: PARENT, |
| 84 | origin: "http://instance.test", |
| 85 | data: { type: "lchat-sandbox-init", source, manifest, seed, me: { uid: "u1", name: "Ana" }, theme: theme || {} }, |
| 86 | ports: [chan.port2], |
| 87 | }); |
| 88 | } |
| 89 | const next = (pred) => |
| 90 | new Promise((resolve) => { |
| 91 | const check = () => { |
| 92 | const i = inbox.findIndex(pred); |
| 93 | if (i >= 0) return resolve(inbox.splice(i, 1)[0]); |
| 94 | waiters.push(check); |
| 95 | }; |
| 96 | check(); |
| 97 | }); |
| 98 | return { |
| 99 | post: (m) => chan.port1.postMessage(m), |
| 100 | next, |
| 101 | rootCalls: rootStyle.calls, |
| 102 | rootStyle: rootStyle.style, |
| 103 | body, |
| 104 | close: () => { |
| 105 | chan.port1.close(); |
| 106 | chan.port2.close(); |
| 107 | }, |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | const HELLO_APP = ` |
| 112 | import { defineApp } from "../apps.js"; |
| 113 | export const app = defineApp({ |
| 114 | name: "hello", title: "Hello", glyph: "H", |
| 115 | initialState: () => ({ count: 0 }), |
| 116 | events: { "hello.tap": (s) => ({ count: s.count + 1 }) }, |
| 117 | render(state, el) { el.textContent = "count " + state.count; }, |
| 118 | }); |
| 119 | `; |
| 120 | const MANIFEST = { v: 1, name: "hello", title: "Hello", glyph: "H", types: ["hello.tap"], chat: false, commands: [] }; |
| 121 | |
| 122 | test("init seeds the mount-time theme onto the frame's root", async () => { |
| 123 | const d = await connect({ |
| 124 | source: HELLO_APP, |
| 125 | manifest: MANIFEST, |
| 126 | seed: { v: null, vUpTo: 0, pend: [], chat: [] }, |
| 127 | theme: { vars: { "--panel": "#111" }, font: "13px sans", dark: true }, |
| 128 | }); |
| 129 | try { |
| 130 | await d.next((m) => m.type === "state"); |
| 131 | assert.deepEqual(d.rootCalls, [["--panel", "#111"]]); |
| 132 | assert.equal(d.rootStyle.colorScheme, "dark"); |
| 133 | assert.equal(d.body.style.font, "13px sans"); |
| 134 | } finally { |
| 135 | d.close(); |
| 136 | } |
| 137 | }); |
| 138 | |
| 139 | test("a live 'theme' message re-tints an already-mounted frame, not just init", async () => { |
| 140 | const d = await connect({ |
| 141 | source: HELLO_APP, |
| 142 | manifest: MANIFEST, |
| 143 | seed: { v: null, vUpTo: 0, pend: [], chat: [] }, |
| 144 | theme: { vars: { "--panel": "#111" }, font: "13px sans", dark: true }, |
| 145 | }); |
| 146 | try { |
| 147 | await d.next((m) => m.type === "state"); |
| 148 | d.rootCalls.length = 0; // only care about what the LIVE push applies, not init |
| 149 | |
| 150 | // The member switched to a light theme with different vars — this is what |
| 151 | // sandbox.js's pushTheme sends to every mounted frame's port (issue #402). |
| 152 | d.post({ type: "theme", vars: { "--panel": "#fff", "--ink": "#111" }, font: "15px sans", dark: false }); |
| 153 | // Messages on one port are delivered in order — a no-op events batch posted |
| 154 | // right after fences on the theme message actually having been handled |
| 155 | // first, without a fixed/racy timeout. |
| 156 | d.post({ type: "events", batch: [] }); |
| 157 | await d.next((m) => m.type === "state"); |
| 158 | |
| 159 | assert.deepEqual( |
| 160 | d.rootCalls.sort(), |
| 161 | [ |
| 162 | ["--ink", "#111"], |
| 163 | ["--panel", "#fff"], |
| 164 | ].sort(), |
| 165 | ); |
| 166 | assert.equal(d.rootStyle.colorScheme, "light"); |
| 167 | assert.equal(d.body.style.font, "15px sans"); |
| 168 | } finally { |
| 169 | d.close(); |
| 170 | } |
| 171 | }); |
| 172 | |
| 173 | test("a live 'theme' message with no vars still flips colorScheme and font", async () => { |
| 174 | const d = await connect({ |
| 175 | source: HELLO_APP, |
| 176 | manifest: MANIFEST, |
| 177 | seed: { v: null, vUpTo: 0, pend: [], chat: [] }, |
| 178 | theme: { vars: { "--panel": "#111" }, font: "13px sans", dark: true }, |
| 179 | }); |
| 180 | try { |
| 181 | await d.next((m) => m.type === "state"); |
| 182 | d.post({ type: "theme", dark: false }); |
| 183 | d.post({ type: "events", batch: [] }); |
| 184 | await d.next((m) => m.type === "state"); |
| 185 | assert.equal(d.rootStyle.colorScheme, "light"); |
| 186 | } finally { |
| 187 | d.close(); |
| 188 | } |
| 189 | }); |
| 190 | |