| new file mode 100644 |
| index 0000000..4819f8b |
| --- /dev/null |
| +++ b/test/sandbox-theme.test.mjs |
| @@ -0,0 +1,189 @@ |
| +// 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(); |
| + } |
| +}); |