eleven / messenger Public

test: cover live theme push into mounted sandbox apps (#402) (#1039)

Investigated #402 ("sandbox apps don't update theme until you switch
away and then back"): the fix already exists — sandbox.js's
pushTheme() (registered as theme.js's onThemeApplied sink) posts a
"theme" message to every mounted frame's port on each theme/appearance
change, and sandbox-frame.js's applyTheme() sets the vars/colorScheme/
font on the frame's documentElement synchronously on receipt. That
machinery shipped with member apps (#397) but had zero test coverage,
so there was no way to tell "already fixed" from "looks right but
isn't actually wired up" without a live browser.

New test/sandbox-theme.test.mjs drives sandbox-frame.js over a real
MessageChannel (same harness as sandbox-bridge.test.mjs) with an
instrumented documentElement.style, and proves: init seeds the
mount-time theme, and a live "theme" message re-tints an
already-mounted frame in place — vars, colorScheme, and font all
update without a remount. Wired into npm test via package.json.

No production code changed — this locks in and verifies existing,
previously-untested behavior. If the reported staleness still
reproduces, the bug is elsewhere (a specific app's own CSS, or a
mount/unmount edge case) — worth a fresh, more specific issue with
repro steps rather than reopening this one blind.

Addresses #402
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 34c99fb722eee2a756d82d08da7274f1b7791920 parent 837083c
2 files changed, +190 −1
  • package.json +1 −1
  • test/sandbox-theme.test.mjs +189 −0
diff --git a/package.json b/package.json
index 2f712c0..36bd724 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/sandbox-theme.test.mjs b/test/sandbox-theme.test.mjs
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();
+ }
+});