// Regression coverage for issue #402 ("sandbox apps don't update theme until // you switch away and then back"): a live "theme" postMessage from the host // (sandbox.js's pushTheme, fired whenever the member changes theme/appearance) // must re-tint an already-mounted sandbox frame in place — not just seed the // frame's INITIAL theme at mount time. Same minimal-DOM-over-a-real- // MessageChannel harness as sandbox-bridge.test.mjs, with an instrumented // documentElement.style so applyTheme's effect is observable. // node test/sandbox-theme.test.mjs import test from "node:test"; import assert from "node:assert/strict"; import { MessageChannel } from "node:worker_threads"; const PARENT = { name: "parent-window" }; function fakeNode() { const node = { style: { setProperty() {}, cssText: "", font: "" }, dataset: {}, children: [], textContent: "", appendChild(c) { this.children.push(c); return c; }, prepend() {}, remove() {}, querySelector: () => null, setAttribute() {}, addEventListener() {}, classList: { add() {}, toggle() {} }, }; return node; } // A style object that records every setProperty call and the colorScheme // assignment, so a test can assert exactly what a "theme" message applied. function spyStyle() { const calls = []; const style = { setProperty(k, v) { calls.push([k, v]); }, get colorScheme() { return style._colorScheme || ""; }, set colorScheme(v) { style._colorScheme = v; }, }; return { style, calls }; } let bust = 0; async function connect({ source, manifest, seed, theme }) { const rootStyle = spyStyle(); const body = fakeNode(); globalThis.document = { createElement: () => fakeNode(), body, head: fakeNode(), documentElement: { style: rootStyle.style }, getElementById: () => null, addEventListener() {}, }; const listeners = {}; globalThis.window = { parent: PARENT, addEventListener: (t, fn) => ((listeners[t] ||= []).push(fn)), removeEventListener: (t, fn) => { listeners[t] = (listeners[t] || []).filter((f) => f !== fn); }, }; await import(`../web/static/sandbox-frame.js?b=${++bust}`); const chan = new MessageChannel(); const inbox = []; const waiters = []; chan.port1.onmessage = (ev) => { inbox.push(ev.data); for (const w of waiters.splice(0)) w(); }; for (const fn of listeners.message || []) { fn({ source: PARENT, origin: "http://instance.test", data: { type: "lchat-sandbox-init", source, manifest, seed, me: { uid: "u1", name: "Ana" }, theme: theme || {} }, ports: [chan.port2], }); } const next = (pred) => new Promise((resolve) => { const check = () => { const i = inbox.findIndex(pred); if (i >= 0) return resolve(inbox.splice(i, 1)[0]); waiters.push(check); }; check(); }); return { post: (m) => chan.port1.postMessage(m), next, rootCalls: rootStyle.calls, rootStyle: rootStyle.style, body, close: () => { chan.port1.close(); chan.port2.close(); }, }; } const HELLO_APP = ` import { defineApp } from "../apps.js"; export const app = defineApp({ name: "hello", title: "Hello", glyph: "H", initialState: () => ({ count: 0 }), events: { "hello.tap": (s) => ({ count: s.count + 1 }) }, render(state, el) { el.textContent = "count " + state.count; }, }); `; const MANIFEST = { v: 1, name: "hello", title: "Hello", glyph: "H", types: ["hello.tap"], chat: false, commands: [] }; test("init seeds the mount-time theme onto the frame's root", async () => { const d = await connect({ source: HELLO_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] }, theme: { vars: { "--panel": "#111" }, font: "13px sans", dark: true }, }); try { await d.next((m) => m.type === "state"); assert.deepEqual(d.rootCalls, [["--panel", "#111"]]); assert.equal(d.rootStyle.colorScheme, "dark"); assert.equal(d.body.style.font, "13px sans"); } finally { d.close(); } }); test("a live 'theme' message re-tints an already-mounted frame, not just init", async () => { const d = await connect({ source: HELLO_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] }, theme: { vars: { "--panel": "#111" }, font: "13px sans", dark: true }, }); try { await d.next((m) => m.type === "state"); d.rootCalls.length = 0; // only care about what the LIVE push applies, not init // The member switched to a light theme with different vars — this is what // sandbox.js's pushTheme sends to every mounted frame's port (issue #402). d.post({ type: "theme", vars: { "--panel": "#fff", "--ink": "#111" }, font: "15px sans", dark: false }); // Messages on one port are delivered in order — a no-op events batch posted // right after fences on the theme message actually having been handled // first, without a fixed/racy timeout. d.post({ type: "events", batch: [] }); await d.next((m) => m.type === "state"); assert.deepEqual( d.rootCalls.sort(), [ ["--ink", "#111"], ["--panel", "#fff"], ].sort(), ); assert.equal(d.rootStyle.colorScheme, "light"); assert.equal(d.body.style.font, "15px sans"); } finally { d.close(); } }); test("a live 'theme' message with no vars still flips colorScheme and font", async () => { const d = await connect({ source: HELLO_APP, manifest: MANIFEST, seed: { v: null, vUpTo: 0, pend: [], chat: [] }, theme: { vars: { "--panel": "#111" }, font: "13px sans", dark: true }, }); try { await d.next((m) => m.type === "state"); d.post({ type: "theme", dark: false }); d.post({ type: "events", batch: [] }); await d.next((m) => m.type === "state"); assert.equal(d.rootStyle.colorScheme, "light"); } finally { d.close(); } });