From e3b6cb5776ddad2ce1dc6593d065ad73c3d98970 Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:25:26 +0100 Subject: [PATCH 01/12] test inline HTML theme bridge injection Co-authored-by: bobbit-ai --- .../inline-html-theme-bridge-repro.test.ts | 70 +++++++++++++++++++ tests2/tests-map.json | 9 +++ 2 files changed, 79 insertions(+) create mode 100644 tests2/core/inline-html-theme-bridge-repro.test.ts diff --git a/tests2/core/inline-html-theme-bridge-repro.test.ts b/tests2/core/inline-html-theme-bridge-repro.test.ts new file mode 100644 index 000000000..86e3edb23 --- /dev/null +++ b/tests2/core/inline-html-theme-bridge-repro.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, it } from "vitest"; + +const REPO_ROOT = path.resolve("."); +const RENDERER_PATH = path.join(REPO_ROOT, "src/ui/tools/renderers/HtmlRenderer.ts"); +const THEME_BRIDGE_PATH = path.join(REPO_ROOT, "src/shared/preview-bridge-scripts.ts"); +const FAILURE_MARKER = "INLINE_HTML_THEME_BRIDGE_MISSING"; + +function resolveSourceImport(importer: string, specifier: string): string | undefined { + if (!specifier.startsWith(".")) return undefined; + const unresolved = path.resolve(path.dirname(importer), specifier); + const withoutJsExtension = unresolved.replace(/\.js$/, ""); + const candidates = [ + unresolved, + `${unresolved}.ts`, + `${withoutJsExtension}.ts`, + path.join(unresolved, "index.ts"), + path.join(withoutJsExtension, "index.ts"), + ]; + return candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); +} + +/** Follow static relative imports exactly as Vite does for the renderer bundle. */ +function collectStaticImportGraph(entry: string): Set { + const visited = new Set(); + const pending = [entry]; + const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']/g; + + while (pending.length > 0) { + const file = path.normalize(pending.pop()!); + if (visited.has(file)) continue; + visited.add(file); + + const source = fs.readFileSync(file, "utf8"); + for (const match of source.matchAll(importPattern)) { + const resolved = resolveSourceImport(file, match[1]); + if (resolved && !visited.has(path.normalize(resolved))) pending.push(resolved); + } + } + + return visited; +} + +describe("inline HtmlRenderer theme bridge reproducer", () => { + it("injects the canonical bridge into completed srcdoc and streaming document.write payloads", () => { + const source = fs.readFileSync(RENDERER_PATH, "utf8"); + const graph = collectStaticImportGraph(RENDERER_PATH); + const completedBinding = source.match(/\.srcdoc\s*=\s*\$\{\s*([^}\n]+?)\s*\}/)?.[1].trim(); + const streamingWrite = source.match(/\bdoc\.write\(\s*([^);\n]+(?:\([^);\n]*\))?)\s*\)/)?.[1].trim(); + const missing: string[] = []; + + if (!graph.has(path.normalize(THEME_BRIDGE_PATH))) { + missing.push("PREVIEW_THEME_BRIDGE is absent from HtmlRenderer's static Vite import graph"); + } + if (!completedBinding || completedBinding === "htmlContent") { + missing.push("completed .srcdoc still receives raw htmlContent"); + } + if (!streamingWrite || streamingWrite === "content") { + missing.push("streaming document.write still receives raw content"); + } + + assert.deepEqual( + missing, + [], + `${FAILURE_MARKER}: ${missing.join("; ")}`, + ); + }); +}); diff --git a/tests2/tests-map.json b/tests2/tests-map.json index 3e0c9c06f..4dfb0626c 100644 --- a/tests2/tests-map.json +++ b/tests2/tests-map.json @@ -20,6 +20,15 @@ "vitest-e2e": 2 }, "v2Native": [ + { + "path": "tests2/core/inline-html-theme-bridge-repro.test.ts", + "reason": "Fast failing-first regression proving completed srcdoc and streaming document.write inline HTML payloads are prepared through the canonical preview theme bridge in the compiled UI dependency graph.", + "execution": { + "runner": "vitest", + "tier": "unit", + "project": "core" + } + }, { "path": "tests2/dom/project-audio-notification-paths.test.ts", "reason": "Deterministic foreground/background notification regressions proving agent-finish audio resolves from the source session project while favicon badges remain independent and non-blocking.", From 01615e10ad5d6a66224616d98b753b16f8d432d2 Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:38:11 +0100 Subject: [PATCH 02/12] Fix inline HTML theme inheritance Co-authored-by: bobbit-ai --- src/shared/preview-bridge-scripts.ts | 90 +++++++++++-------- src/ui/tools/renderers/HtmlRenderer.ts | 19 ++-- src/ui/tools/renderers/prepare-inline-html.ts | 49 ++++++++++ 3 files changed, 114 insertions(+), 44 deletions(-) create mode 100644 src/ui/tools/renderers/prepare-inline-html.ts diff --git a/src/shared/preview-bridge-scripts.ts b/src/shared/preview-bridge-scripts.ts index 938b9ddf1..35f267a76 100644 --- a/src/shared/preview-bridge-scripts.ts +++ b/src/shared/preview-bridge-scripts.ts @@ -21,57 +21,75 @@ export const PREVIEW_THEME_BRIDGE = ` + +
PACKAGED_INLINE_THEME_READY
+`; + +/** A focused pi RPC test double written into the clean consumer. It emits a + * completed Write tool call containing token-backed HTML; no repository source + * module is imported by the installed Bobbit process or its browser runtime. */ +export function packedWriteAgentSource(): string { + return `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +const html = ${JSON.stringify(PACKED_THEME_HTML)}; +const messages = []; +const agentDir = process.env.BOBBIT_AGENT_DIR || process.cwd(); +fs.mkdirSync(agentDir, { recursive: true }); +const sessionFile = path.join(agentDir, "packed-inline-theme-session.jsonl"); +const model = { provider: "mock", id: "mock-model", contextWindow: 128000, maxTokens: 16384, reasoning: false }; +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +const persist = () => fs.writeFileSync(sessionFile, messages.map((message) => JSON.stringify({ type: "message", message })).join("\\n") + (messages.length ? "\\n" : "")); +const emit = (event) => send(event); + +async function runPrompt(text) { + const user = { role: "user", content: [{ type: "text", text }] }; + messages.push(user); + emit({ type: "message_end", message: user }); + emit({ type: "agent_start" }); + emit({ type: "session_status", status: "streaming" }); + const toolId = "packed-inline-theme-write"; + const input = { path: "theme-card.html", content: html }; + emit({ type: "tool_execution_start", toolName: "Write", toolId, input }); + emit({ type: "tool_execution_update", toolName: "Write", toolId, status: "complete", output: "Wrote packaged inline theme fixture" }); + emit({ type: "tool_execution_end", toolName: "Write", toolCallId: toolId, isError: false }); + const assistant = { role: "assistant", content: [ + { type: "toolCall", id: toolId, name: "Write", arguments: input, input }, + { type: "text", text: "Rendered packaged inline HTML." } + ] }; + const result = { role: "toolResult", toolCallId: toolId, toolName: "Write", isError: false, content: [{ type: "text", text: "Wrote packaged inline theme fixture" }] }; + messages.push(assistant, result); + emit({ type: "message_end", message: assistant }); + emit({ type: "message_end", message: result }); + persist(); + emit({ type: "agent_end" }); + emit({ type: "session_status", status: "idle" }); +} + +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let message; + try { message = JSON.parse(line); } catch { return; } + if (message.type === "prompt" || message.type === "follow_up") { + send({ type: "response", id: message.id, success: true }); + void runPrompt(message.message || ""); + return; + } + if (message.type === "get_state") { + persist(); + send({ type: "response", id: message.id, success: true, data: { status: "idle", sessionFile, model } }); + return; + } + if (message.type === "get_messages") { + send({ type: "response", id: message.id, success: true, data: messages }); + return; + } + if (message.type === "abort") { + send({ type: "response", id: message.id, success: true }); + emit({ type: "agent_end" }); + emit({ type: "session_status", status: "idle" }); + return; + } + send({ type: "response", id: message.id, success: true }); +}); +send({ type: "session_status", status: "idle" }); +`; +} + +function displayCommand(command: string, args: readonly string[]): string { + return [command, ...args].map(value => /\s/.test(value) ? JSON.stringify(value) : value).join(" "); +} + +function killTree(child: ChildProcess): void { + if (!child.pid) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + return; + } + try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); } +} + +export function runCommand( + command: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; maxOutputBytes?: number }, +): Promise { + const rendered = displayCommand(command, args); + const timeoutMs = options.timeoutMs ?? 120_000; + const maxOutputBytes = options.maxOutputBytes ?? 20 * 1024 * 1024; + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command), + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let outputBytes = 0; + let terminalError: Error | undefined; + let settled = false; + const collect = (target: Buffer[], chunk: Buffer | string): void => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + outputBytes += buffer.byteLength; + if (outputBytes > maxOutputBytes && !terminalError) { + terminalError = new Error(`${rendered} exceeded the ${maxOutputBytes}-byte output limit`); + killTree(child); + return; + } + target.push(buffer); + }; + child.stdout?.on("data", chunk => collect(stdout, chunk)); + child.stderr?.on("data", chunk => collect(stderr, chunk)); + const timer = setTimeout(() => { + terminalError = new Error(`${rendered} timed out after ${timeoutMs}ms`); + killTree(child); + }, timeoutMs); + child.once("error", error => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error(`Failed to spawn ${rendered}: ${error.message}`, { cause: error })); + }); + child.once("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const stdoutText = Buffer.concat(stdout).toString("utf8"); + const stderrText = Buffer.concat(stderr).toString("utf8"); + if (terminalError) { + reject(new Error(`${terminalError.message}\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`)); + return; + } + if (signal || code === null) { + reject(new Error(`${rendered} terminated without an exit code (signal: ${signal ?? "unknown"})`)); + return; + } + resolve({ command, args: [...args], code, stdout: stdoutText, stderr: stderrText }); + }); + }); +} + +function npmInvocation(args: string[]): { command: string; args: string[] } { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath && existsSync(npmExecPath)) return { command: process.execPath, args: [npmExecPath, ...args] }; + return { command: process.platform === "win32" ? "npm.cmd" : "npm", args }; +} + +export function runNpm( + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; maxOutputBytes?: number }, +): Promise { + const invocation = npmInvocation(args); + return runCommand(invocation.command, invocation.args, options); +} + +/** Drop npm lifecycle variables inherited from the Bobbit test command so the + * empty consumer behaves like an independent npm project. Registry/cache/auth + * configuration remains inherited from the machine running the E2E lane. */ +export function cleanConsumerNpmEnv(cwd: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(env)) { + const lower = key.toLowerCase(); + if (lower.startsWith("npm_package_") || lower.startsWith("npm_lifecycle_") || [ + "npm_config_local_prefix", + "npm_config_package_lock", + "npm_config_shrinkwrap", + "npm_config_workspace", + "npm_config_workspaces", + "npm_config_include_workspace_root", + "npm_config_ignore_scripts", + "npm_config_omit", + "npm_config_include", + "npm_config_optional", + "npm_config_dry_run", + ].includes(lower)) delete env[key]; + } + delete env.INIT_CWD; + env.INIT_CWD = cwd; + return env; +} + +export async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("failed to allocate an IPv4 test port")); + return; + } + const port = address.port; + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +export function startPackagedCli(options: { + cliPath: string; + consumerDir: string; + workspaceDir: string; + agentPath: string; + secretsDir: string; + agentDir: string; + port: number; +}): RunningCli { + const stdout: string[] = []; + const stderr: string[] = []; + const home = join(options.consumerDir, "home"); + const child = spawn(process.execPath, [ + options.cliPath, + "--cwd", options.workspaceDir, + "--host", "127.0.0.1", + "--port", String(options.port), + "--no-tls", + "--agent-cli", options.agentPath, + ], { + cwd: options.consumerDir, + env: { + ...process.env, + NODE_ENV: "test", + NO_COLOR: "1", + BOBBIT_SKIP_MCP: "1", + BOBBIT_SKIP_TITLE_GENERATION: "1", + BOBBIT_SKIP_NPM_CI: "1", + BOBBIT_TEST_NO_EXTERNAL: "1", + BOBBIT_TEST_NO_REMOTE: "1", + BOBBIT_SECRETS_DIR: options.secretsDir, + BOBBIT_AGENT_DIR: options.agentDir, + HOME: home, + USERPROFILE: home, + }, + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", chunk => stdout.push(String(chunk))); + child.stderr?.on("data", chunk => stderr.push(String(chunk))); + return { child, stdout, stderr }; +} + +export async function waitForHealth(baseUrl: string, runtime: RunningCli, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = "not attempted"; + while (Date.now() < deadline) { + if (runtime.child.exitCode !== null) { + throw new Error(`packaged CLI exited ${runtime.child.exitCode} before health check\nstdout:\n${runtime.stdout.join("")}\nstderr:\n${runtime.stderr.join("")}`); + } + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) return; + lastError = `${response.status} ${response.statusText}`; + } catch (error) { + lastError = String(error); + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + throw new Error(`packaged CLI health timed out: ${lastError}\nstdout:\n${runtime.stdout.join("")}\nstderr:\n${runtime.stderr.join("")}`); +} + +export async function stopPackagedCli(runtime: RunningCli): Promise { + if (runtime.child.exitCode !== null) return; + const closed = new Promise(resolve => runtime.child.once("close", () => resolve())); + if (process.platform === "win32") killTree(runtime.child); + else { + try { process.kill(-runtime.child.pid!, "SIGTERM"); } catch { runtime.child.kill("SIGTERM"); } + } + await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 10_000))]); + if (runtime.child.exitCode === null) killTree(runtime.child); +} + +export async function readToken(secretsDir: string): Promise { + const token = (await readFile(join(secretsDir, "token"), "utf8")).trim(); + if (token.length < 64) throw new Error(`packaged CLI wrote an invalid token to ${secretsDir}`); + return token; +} + +export async function createProjectAndSession(baseUrl: string, token: string, workspaceDir: string): Promise { + const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + const projectResponse = await fetch(`${baseUrl}/api/projects`, { + method: "POST", + headers, + body: JSON.stringify({ + name: `packed-runtime-${process.pid}`, + rootPath: workspaceDir, + upsert: true, + acceptCanonical: true, + }), + }); + if (!projectResponse.ok) throw new Error(`packed project creation failed: ${projectResponse.status} ${await projectResponse.text()}`); + const project = await projectResponse.json() as { id?: string }; + if (!project.id) throw new Error("packed project creation returned no id"); + const sessionResponse = await fetch(`${baseUrl}/api/sessions`, { + method: "POST", + headers, + body: JSON.stringify({ projectId: project.id, cwd: workspaceDir }), + }); + if (sessionResponse.status !== 201) throw new Error(`packed session creation failed: ${sessionResponse.status} ${await sessionResponse.text()}`); + const session = await sessionResponse.json() as { id?: string }; + if (!session.id) throw new Error("packed session creation returned no id"); + return session.id; +} + +export async function promptSession(wsBaseUrl: string, sessionId: string, token: string): Promise { + const ws = new WebSocket(`${wsBaseUrl}/ws/${sessionId}`); + const messages: unknown[] = []; + let authenticated = false; + let finished = false; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`packaged session prompt timed out; messages=${JSON.stringify(messages.slice(-12))}`)); + }, 30_000); + ws.on("open", () => ws.send(JSON.stringify({ type: "auth", token }))); + ws.on("message", raw => { + let message: any; + try { message = JSON.parse(raw.toString()); } catch { return; } + messages.push(message); + if (message.type === "auth_ok" && !authenticated) { + authenticated = true; + ws.send(JSON.stringify({ type: "prompt", text: "emit the packaged inline HTML theme fixture" })); + } + if (authenticated && message.type === "event" && message.data?.type === "agent_end" && !finished) { + finished = true; + clearTimeout(timer); + ws.close(); + resolve(); + } + }); + ws.on("error", error => { + clearTimeout(timer); + reject(error); + }); + }); +} + +export async function writePackedAgent(filePath: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, packedWriteAgentSource(), "utf8"); +} + +export function commandFailure(result: CommandResult): string { + return `${displayCommand(result.command, result.args)} failed with ${result.code}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`; +} + +export function assetBasename(filePath: string): string { + return basename(filePath).replace(/\\/g, "/"); +} From 6d60904024a0b93222218b7606cdfab5417fb2f1 Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:45:31 +0100 Subject: [PATCH 04/12] test inline HTML renderer lifecycle Co-authored-by: bobbit-ai --- .../core/preview-theme-bridge-runtime.test.ts | 225 +++++++++++ .../inline-html-renderer-lifecycle.test.ts | 356 ++++++++++++++++++ 2 files changed, 581 insertions(+) create mode 100644 tests2/core/preview-theme-bridge-runtime.test.ts create mode 100644 tests2/dom/inline-html-renderer-lifecycle.test.ts diff --git a/tests2/core/preview-theme-bridge-runtime.test.ts b/tests2/core/preview-theme-bridge-runtime.test.ts new file mode 100644 index 000000000..e298e0fa3 --- /dev/null +++ b/tests2/core/preview-theme-bridge-runtime.test.ts @@ -0,0 +1,225 @@ +import vm from "node:vm"; +import { describe, expect, it } from "vitest"; +import { PREVIEW_THEME_BRIDGE } from "../../src/shared/preview-bridge-scripts.js"; + +function bridgeProgram(): string { + const match = PREVIEW_THEME_BRIDGE.match(/^]*)?>([\s\S]*)<\/script>$/i); + if (!match) throw new Error("canonical preview theme bridge is not one script element"); + return match[1]; +} + +class RootStub { + private readonly classes = new Set(); + private readonly attributes = new Map(); + readonly properties = new Map(); + readonly style = { + fontFamily: "", + setProperty: (name: string, value: string) => this.properties.set(name, value), + getPropertyValue: (name: string) => this.properties.get(name) ?? "", + }; + readonly classList = { + contains: (name: string) => this.classes.has(name), + toggle: (name: string, enabled?: boolean) => { + const next = enabled ?? !this.classes.has(name); + if (next) this.classes.add(name); + else this.classes.delete(name); + return next; + }, + }; + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + removeAttribute(name: string): void { + this.attributes.delete(name); + } +} + +function declaration(...names: string[]): Record { + const style: Record = { length: names.length }; + for (let index = 0; index < names.length; index++) style[index] = names[index]; + return style; +} + +interface ObserverRecord { + callback: () => void; + target?: unknown; + options?: unknown; +} + +function normalBridgeHarness() { + const childRoot = new RootStub(); + const parentRoot = new RootStub(); + parentRoot.classList.toggle("dark", true); + parentRoot.setAttribute("data-palette", "violet"); + + const values: Record = { + "--background": "oklch(0.18 0.01 280)", + "--foreground": "oklch(0.96 0.01 280)", + "--card": "oklch(0.22 0.01 280)", + "--positive": "oklch(0.72 0.18 145)", + "--chart-1": "oklch(0.68 0.20 305)", + }; + const inaccessibleSheet = { + get cssRules(): never { + throw new Error("SecurityError: cross-origin stylesheet"); + }, + }; + const styleSheets = [ + inaccessibleSheet, + { cssRules: [{ style: declaration("--background", "--foreground", "--card") }] }, + { cssRules: [{ style: declaration("--positive", "--chart-1", "--background") }] }, + ]; + const parentDocument = { documentElement: parentRoot, styleSheets }; + const parent = { + document: parentDocument, + getComputedStyle: () => ({ + fontFamily: 'Inter, ui-sans-serif, system-ui', + getPropertyValue: (name: string) => values[name] ?? "", + }), + }; + const observers: ObserverRecord[] = []; + class MutationObserverStub { + private readonly record: ObserverRecord; + constructor(callback: () => void) { + this.record = { callback }; + observers.push(this.record); + } + observe(target: unknown, options: unknown): void { + this.record.target = target; + this.record.options = options; + } + } + + const sandbox: Record = { + document: { documentElement: childRoot }, + parent, + MutationObserver: MutationObserverStub, + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + return { + context: vm.createContext(sandbox), + childRoot, + parentRoot, + values, + observers, + }; +} + +function runBridgeThenAuthored(context: vm.Context): void { + vm.runInContext(`${bridgeProgram()}\n;globalThis.__authoredRuns = (globalThis.__authoredRuns || 0) + 1;`, context); +} + +describe("canonical preview theme bridge runtime", () => { + it("mirrors representative tokens and live host state once even when installed twice", () => { + const harness = normalBridgeHarness(); + + runBridgeThenAuthored(harness.context); + runBridgeThenAuthored(harness.context); + + expect(harness.childRoot.classList.contains("dark")).toBe(true); + expect(harness.childRoot.getAttribute("data-palette")).toBe("violet"); + expect(harness.childRoot.style.fontFamily).toBe("Inter, ui-sans-serif, system-ui"); + for (const [token, value] of Object.entries(harness.values)) { + expect(harness.childRoot.style.getPropertyValue(token), token).toBe(value); + } + expect(harness.observers).toHaveLength(1); + expect(harness.observers[0].target).toBe(harness.parentRoot); + expect(harness.observers[0].options).toEqual({ + attributes: true, + attributeFilter: ["class", "data-palette", "style"], + }); + expect(vm.runInContext("globalThis.__authoredRuns", harness.context)).toBe(2); + + harness.parentRoot.classList.toggle("dark", false); + harness.parentRoot.removeAttribute("data-palette"); + harness.values["--background"] = "oklch(0.98 0.01 280)"; + harness.observers[0].callback(); + + expect(harness.childRoot.classList.contains("dark")).toBe(false); + expect(harness.childRoot.getAttribute("data-palette")).toBeNull(); + expect(harness.childRoot.style.getPropertyValue("--background")).toBe("oklch(0.98 0.01 280)"); + }); + + it("fails open while subsequent authored code runs when parent access or browser seams are unavailable", () => { + const cases: Array<{ name: string; makeSandbox: () => Record }> = [ + { + name: "standalone parent", + makeSandbox: () => { + const sandbox: Record = { + document: { documentElement: new RootStub() }, + MutationObserver: class {}, + }; + sandbox.window = sandbox; + sandbox.parent = sandbox; + sandbox.globalThis = sandbox; + return sandbox; + }, + }, + { + name: "inaccessible parent document", + makeSandbox: () => { + const parent = Object.create(null, { + document: { get: () => { throw new Error("SecurityError"); } }, + }); + return { document: { documentElement: new RootStub() }, parent, MutationObserver: class {} }; + }, + }, + { + name: "unavailable computed style", + makeSandbox: () => ({ + document: { documentElement: new RootStub() }, + parent: { + document: { documentElement: new RootStub(), styleSheets: [] }, + getComputedStyle: () => { throw new Error("not available"); }, + }, + MutationObserver: class {}, + }), + }, + { + name: "inaccessible stylesheet collection", + makeSandbox: () => { + const parentRoot = new RootStub(); + const parentDocument = Object.create(null, { + documentElement: { value: parentRoot }, + styleSheets: { get: () => { throw new Error("SecurityError"); } }, + }); + return { + document: { documentElement: new RootStub() }, + parent: { + document: parentDocument, + getComputedStyle: () => ({ fontFamily: "system-ui", getPropertyValue: () => "" }), + }, + MutationObserver: class { observe(): void {} }, + }; + }, + }, + { + name: "unavailable observer", + makeSandbox: () => ({ + document: { documentElement: new RootStub() }, + parent: { + document: { documentElement: new RootStub(), styleSheets: [] }, + getComputedStyle: () => ({ fontFamily: "system-ui", getPropertyValue: () => "" }), + }, + MutationObserver: undefined, + }), + }, + ]; + + for (const testCase of cases) { + const sandbox = testCase.makeSandbox(); + sandbox.window ??= sandbox; + sandbox.globalThis ??= sandbox; + const context = vm.createContext(sandbox); + expect(() => runBridgeThenAuthored(context), testCase.name).not.toThrow(); + expect(vm.runInContext("globalThis.__authoredRuns", context), testCase.name).toBe(1); + } + }); +}); diff --git a/tests2/dom/inline-html-renderer-lifecycle.test.ts b/tests2/dom/inline-html-renderer-lifecycle.test.ts new file mode 100644 index 000000000..719546f32 --- /dev/null +++ b/tests2/dom/inline-html-renderer-lifecycle.test.ts @@ -0,0 +1,356 @@ +import { beforeAll as __syncBeforeAll } from "vitest"; +import { syncCustomElements as __syncCE } from "./_setup/custom-elements.js"; +__syncBeforeAll(() => __syncCE()); + +import vm from "node:vm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "lit"; +import { PREVIEW_SWIPE_SCRIPT, PREVIEW_THEME_BRIDGE } from "../../src/shared/preview-bridge-scripts.js"; +import { EditRenderer } from "../../src/ui/tools/renderers/EditRenderer.js"; +import { HtmlRenderer } from "../../src/ui/tools/renderers/HtmlRenderer.js"; +import { WriteRenderer } from "../../src/ui/tools/renderers/WriteRenderer.js"; + +const okResult = { + isError: false, + toolCallId: "tool-call-theme-preview", + content: [{ type: "text", text: "ok" }], +} as any; + +const AUTHORED_HTML = ` + + + + + + +
themed
+ +`; + +function canonicalBridgeBody(): string { + const doc = new DOMParser().parseFromString(PREVIEW_THEME_BRIDGE, "text/html"); + const script = doc.querySelector("script"); + if (!script) throw new Error("canonical theme bridge is not a script"); + return script.textContent?.trim() ?? ""; +} + +function bridgeScripts(doc: Document): HTMLScriptElement[] { + const canonical = canonicalBridgeBody(); + return Array.from(doc.querySelectorAll("script")).filter(script => script.textContent?.trim() === canonical); +} + +function mountHtml(renderer: HtmlRenderer, content: string, result: any = okResult, streaming = false) { + const container = document.createElement("div"); + document.body.appendChild(container); + render(renderer.render({ path: "theme-card.html", content }, result, streaming).content, container); + const iframe = container.querySelector("iframe"); + if (!iframe) throw new Error("HtmlRenderer did not render an iframe"); + return { container, iframe }; +} + +function parsedSrcdoc(iframe: HTMLIFrameElement): Document { + expect(iframe.srcdoc, "inline iframe should receive a prepared srcdoc payload").not.toBe(""); + return new DOMParser().parseFromString(iframe.srcdoc, "text/html"); +} + +function originalSource(container: HTMLElement): string { + const codeBlock = container.querySelector("code-block") as any; + if (!codeBlock) throw new Error("inline source disclosure is missing"); + expect(codeBlock.parentElement?.classList.contains("max-h-0"), "inline source should start collapsed").toBe(true); + return codeBlock.code; +} + +function declaration(...names: string[]): Record { + const style: Record = { length: names.length }; + for (let index = 0; index < names.length; index++) style[index] = names[index]; + return style; +} + +function executePreparedScripts(doc: Document): Record { + const hostRoot = { + classList: { contains: (name: string) => name === "dark" }, + getAttribute: (name: string) => name === "data-palette" ? "violet" : null, + }; + const values: Record = { + "--background": "surface-value", + "--foreground": "foreground-value", + "--card": "card-value", + "--positive": "positive-value", + "--chart-1": "chart-value", + }; + const hostDocument = { + documentElement: hostRoot, + styleSheets: [{ cssRules: [{ style: declaration(...Object.keys(values)) }] }], + }; + class MutationObserverStub { + observe(): void {} + } + const sandbox: Record = { + document: doc, + parent: { + document: hostDocument, + getComputedStyle: () => ({ + fontFamily: "Inter, ui-sans-serif, system-ui", + getPropertyValue: (name: string) => values[name] ?? "", + }), + }, + MutationObserver: MutationObserverStub, + getComputedStyle: (element: HTMLElement) => ({ + getPropertyValue: (name: string) => element.style.getPropertyValue(name), + }), + }; + sandbox.window = sandbox; + sandbox.globalThis = sandbox; + const context = vm.createContext(sandbox); + for (const script of Array.from(doc.querySelectorAll("script"))) { + vm.runInContext(script.textContent ?? "", context); + } + return sandbox; +} + +function expectPreparedInlineFrame(iframe: HTMLIFrameElement): Document { + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); + const doc = parsedSrcdoc(iframe); + expect(bridgeScripts(doc)).toHaveLength(1); + expect(iframe.srcdoc).not.toContain("preview-swipe-start"); + expect(iframe.srcdoc).not.toContain(PREVIEW_SWIPE_SCRIPT); + return doc; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + localStorage.clear(); + window.location.hash = ""; +}); + +describe("inline HtmlRenderer preparation", () => { + it("preserves hostile authored syntax and makes the canonical bridge first for parse-time theme reads", () => { + const renderer = new HtmlRenderer(); + const { container, iframe } = mountHtml(renderer, AUTHORED_HTML); + const doc = expectPreparedInlineFrame(iframe); + + // happy-dom does not expose Document.compatMode; preserving an HTML doctype + // at byte zero is the standards-mode contract exercised by real browsers. + expect(iframe.srcdoc).toMatch(/^/i); + expect(doc.doctype?.name.toLowerCase()).toBe("html"); + const documentNodes = Array.from(doc.childNodes); + const leadingComment = documentNodes.find(node => node.nodeType === Node.COMMENT_NODE && node.textContent === "leading-document-comment"); + expect(leadingComment).toBeTruthy(); + expect(iframe.srcdoc.match(//g)).toHaveLength(1); + + const scripts = Array.from(doc.querySelectorAll("script")); + expect(scripts[0].textContent?.trim()).toBe(canonicalBridgeBody()); + expect(scripts.slice(1).map(script => script.id)).toEqual(["authored-init", "authored-tail"]); + expect(doc.querySelector("#authored-init")?.textContent).toContain('""'); + expect(doc.querySelector("#hostile-style")?.textContent).toContain('""'); + const textarea = doc.querySelector("#hostile-textarea"); + const textareaProbe = new DOMParser().parseFromString("", "text/html").querySelector("textarea"); + if (textareaProbe?.textContent === "") { + expect(textarea?.textContent).toBe(""); + } else { + // happy-dom 20 incorrectly drops this RCDATA literal. Keep the raw fixture + // and source-disclosure assertion active while browser coverage owns parsing. + expect(textarea).toBeTruthy(); + expect(AUTHORED_HTML).toContain(''); + } + expect(Array.from(doc.body.childNodes).some(node => + node.nodeType === Node.COMMENT_NODE && node.textContent === "hostile-comment:", + )).toBe(true); + expect(doc.querySelector("#authored-body")?.textContent).toBe("themed"); + + const executed = executePreparedScripts(doc); + expect(executed.__themeAtParse).toEqual({ + background: "surface-value", + foreground: "foreground-value", + card: "card-value", + positive: "positive-value", + chart: "chart-value", + font: "Inter, ui-sans-serif, system-ui", + dark: true, + palette: "violet", + }); + expect(executed.__scriptLiteral).toBe(""); + expect(executed.__templateLiteral).toBe("template:"); + expect(executed.__authoredTail).toBe(1); + expect(originalSource(container)).toBe(AUTHORED_HTML); + + const frameStyle = iframe.getAttribute("style") ?? ""; + expect(frameStyle).toMatch(/background(?:-color)?\s*:\s*var\(--(?:background|card)/); + expect(frameStyle).not.toContain("#0c0c1a"); + }); + + it("is preparation-idempotent and retains fragments and SVG-in-HTML inputs", () => { + const first = mountHtml(new HtmlRenderer(), AUTHORED_HTML); + const preparedOnce = first.iframe.srcdoc; + first.container.remove(); + + const second = mountHtml(new HtmlRenderer(), preparedOnce); + const preparedTwice = parsedSrcdoc(second.iframe); + expect(bridgeScripts(preparedTwice)).toHaveLength(1); + expect(preparedTwice.querySelectorAll("#authored-init")).toHaveLength(1); + + for (const fragment of [ + '
fragment body
', + 'svg body', + ]) { + const mounted = mountHtml(new HtmlRenderer(), fragment); + const parsed = expectPreparedInlineFrame(mounted.iframe); + expect(parsed.querySelector(fragment.startsWith(" { + const renderer = new HtmlRenderer(); + const { container, iframe } = mountHtml(renderer, AUTHORED_HTML); + const prepared = iframe.srcdoc; + + render(renderer.render({ path: "theme-card.html", content: AUTHORED_HTML }, okResult, false).content, container); + + const rerendered = container.querySelector("iframe")!; + expect(rerendered).toBe(iframe); + expect(rerendered.srcdoc).toBe(prepared); + expectPreparedInlineFrame(rerendered); + expect(originalSource(container)).toBe(AUTHORED_HTML); + }); +}); + +describe("inline HtmlRenderer streaming lifecycle", () => { + it("writes prepared content on load, preserves debounce and resize, then completes declaratively", () => { + vi.useFakeTimers(); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + const renderer = new HtmlRenderer(); + const firstContent = '
first
'; + const secondContent = '
second
'; + const { container, iframe } = mountHtml(renderer, firstContent, null, true); + const written: string[] = []; + const fakeDocument = { + open: vi.fn(), + write: vi.fn((payload: string) => written.push(payload)), + close: vi.fn(), + body: { scrollHeight: 420 }, + }; + Object.defineProperty(iframe, "contentDocument", { configurable: true, value: fakeDocument }); + + iframe.dispatchEvent(new Event("load")); + expect(fakeDocument.open).toHaveBeenCalledTimes(1); + expect(fakeDocument.close).toHaveBeenCalledTimes(1); + expect(written).toHaveLength(1); + expect(bridgeScripts(new DOMParser().parseFromString(written[0], "text/html"))).toHaveLength(1); + expect(written[0]).not.toContain("preview-swipe-start"); + expect(iframe.style.height).toBe("436px"); + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); + expect(originalSource(container)).toBe(firstContent); + + render(renderer.render({ path: "theme-card.html", content: secondContent }, undefined, true).content, container); + expect(written).toHaveLength(1); + vi.advanceTimersByTime(1499); + expect(written).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(written).toHaveLength(2); + const streamed = new DOMParser().parseFromString(written[1], "text/html"); + expect(bridgeScripts(streamed)).toHaveLength(1); + expect(streamed.querySelector("#second")?.textContent).toBe("second"); + + render(renderer.render({ path: "theme-card.html", content: secondContent }, okResult, false).content, container); + const completedIframe = container.querySelector("iframe")!; + expectPreparedInlineFrame(completedIframe); + expect(parsedSrcdoc(completedIframe).querySelector("#second")?.textContent).toBe("second"); + expect(originalSource(container)).toBe(secondContent); + expect(container.querySelector("iframe + div")).toBeNull(); + }); + + it("keeps streaming chrome theme-backed and ignores a stale about:blank load after completion", () => { + const renderer = new HtmlRenderer(); + const streaming = mountHtml(renderer, '
partial
', null, true); + const staleIframe = streaming.iframe; + const staleWrite = vi.fn(); + Object.defineProperty(staleIframe, "contentDocument", { + configurable: true, + value: { open: vi.fn(), write: staleWrite, close: vi.fn(), body: { scrollHeight: 100 } }, + }); + const overlay = streaming.container.querySelector("iframe + div") as HTMLElement; + expect(overlay).toBeTruthy(); + const chromeStyles = [ + staleIframe.getAttribute("style") ?? "", + overlay.getAttribute("style") ?? "", + ...Array.from(overlay.querySelectorAll("[style]")).map(element => element.getAttribute("style") ?? ""), + ].join(" "); + expect(chromeStyles).toContain("var(--"); + expect(chromeStyles).toMatch(/var\(--(?:background|card)/); + expect(chromeStyles).toMatch(/var\(--(?:foreground|primary|border)/); + expect(chromeStyles).not.toContain("#0c0c1a"); + expect(chromeStyles).not.toContain("rgba(10, 10, 20, 0.2)"); + expect(chromeStyles).not.toContain("rgba(255,255,255,0.15)"); + expect(chromeStyles).not.toContain("rgba(255,255,255,0.6)"); + + render(renderer.render({ path: "theme-card.html", content: AUTHORED_HTML }, okResult, false).content, streaming.container); + staleIframe.dispatchEvent(new Event("load")); + expect(staleWrite).not.toHaveBeenCalled(); + expectPreparedInlineFrame(streaming.container.querySelector("iframe")!); + }); +}); + +describe("HTML renderer delegation", () => { + it.each(["card.html", "CARD.HTM"])("WriteRenderer routes %s through the themed inline renderer", path => { + const container = document.createElement("div"); + document.body.appendChild(container); + render(new WriteRenderer().render({ path, content: AUTHORED_HTML }, okResult, false).content, container); + const iframe = container.querySelector("iframe"); + expect(iframe, `${path} should use HtmlRenderer`).toBeTruthy(); + expectPreparedInlineFrame(iframe!); + expect(originalSource(container)).toBe(AUTHORED_HTML); + }); + + it("EditRenderer fetches completed HTML and delegates the cached bytes through the same preparation", async () => { + window.location.hash = "#/session/11111111-1111-4111-8111-111111111111"; + localStorage.setItem("gateway.url", "https://gateway.test"); + localStorage.setItem("gateway.token", "test-token"); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ content: AUTHORED_HTML }), + }); + vi.stubGlobal("fetch", fetchMock); + const renderer = new EditRenderer(); + const params = { path: "edited-theme.html", oldText: "old", newText: "new" }; + const result = { ...okResult, toolCallId: "edit-call-theme" } as any; + const ready = new Promise(resolve => { + document.addEventListener("bobbit-tool-preview-ready", () => resolve(), { once: true }); + }); + + renderer.render(params, result, false); + await ready; + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toContain("/api/sessions/11111111-1111-4111-8111-111111111111/file-content"); + expect(fetchMock.mock.calls[0][0]).toContain("snapshotId=edit-call-theme"); + expect(fetchMock.mock.calls[0][1]).toEqual({ headers: { Authorization: "Bearer test-token" } }); + + const container = document.createElement("div"); + document.body.appendChild(container); + render(renderer.render(params, result, false).content, container); + const iframe = container.querySelector("iframe"); + expect(iframe).toBeTruthy(); + expectPreparedInlineFrame(iframe!); + expect(originalSource(container)).toBe(AUTHORED_HTML); + }); +}); From 513341abf938bb6bb968b80aef33e8cfc38ff7b5 Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:49:01 +0100 Subject: [PATCH 05/12] test inline HTML source theme journey Co-authored-by: bobbit-ai --- .../inline-html-theme-renderers-entry.ts | 262 ++++++++++++++++++ .../fixtures/inline-html-theme-source.spec.ts | 248 +++++++++++++++++ 2 files changed, 510 insertions(+) create mode 100644 tests/ui-fixtures/inline-html-theme-renderers-entry.ts create mode 100644 tests2/browser/fixtures/inline-html-theme-source.spec.ts diff --git a/tests/ui-fixtures/inline-html-theme-renderers-entry.ts b/tests/ui-fixtures/inline-html-theme-renderers-entry.ts new file mode 100644 index 000000000..69f39b5dd --- /dev/null +++ b/tests/ui-fixtures/inline-html-theme-renderers-entry.ts @@ -0,0 +1,262 @@ +import { render } from "lit"; +import { WriteRenderer } from "../../src/ui/tools/renderers/WriteRenderer.js"; +import { EditRenderer } from "../../src/ui/tools/renderers/EditRenderer.js"; + +const SESSION_ID = "11111111-1111-4111-8111-111111111111"; +const FONT_STACK = '"Fixture Source Sans", "Segoe UI", sans-serif'; + +const hostThemeStyle = document.createElement("style"); +hostThemeStyle.dataset.fixture = "inline-html-host-theme"; +hostThemeStyle.textContent = ` + :root { + --background: #f8fafc; + --foreground: #172033; + --card: #ffffff; + --positive: #15803d; + --chart-1: #2563eb; + font-family: ${FONT_STACK}; + } + :root[data-palette="azure"] { --chart-1: #2563eb; } + :root[data-palette="rose"] { --chart-1: #e11d48; } + :root.dark { + --background: #111827; + --foreground: #f8fafc; + --card: #1f2937; + --positive: #4ade80; + } + :root.dark[data-palette="rose"] { --chart-1: #fb7185; } + body { background: var(--background); color: var(--foreground); } + .fixture-renderer-host { display: block; width: 720px; margin: 12px; } +`; +document.head.appendChild(hostThemeStyle); + +function authoredDocument(marker: string): string { + const markerLiteral = JSON.stringify(marker); + return ` + + + + +marker collision`; + + const preparedOnce = prepareInlineHtml(collision); + const preparedDocument = new DOMParser().parseFromString(preparedOnce, "text/html"); + const markedScripts = preparedDocument.querySelectorAll( + `script[${INLINE_HTML_THEME_BRIDGE_ATTRIBUTE}]`, + ); + expect(markedScripts).toHaveLength(2); + expect(bridgeScripts(preparedDocument)).toHaveLength(1); + expect(preparedDocument.querySelector("head > script")?.textContent?.trim()).toBe(canonicalBridgeBody()); + expect(Array.from(markedScripts).some(script => script.textContent === "")).toBe(true); + expect(preparedDocument.querySelector("#authored-after-marker")?.textContent).toContain("__authoredAfterMarker"); + + const preparedTwice = prepareInlineHtml(preparedOnce); + expect(preparedTwice).toBe(preparedOnce); + const repeatedDocument = new DOMParser().parseFromString(preparedTwice, "text/html"); + expect(bridgeScripts(repeatedDocument)).toHaveLength(1); + expect(repeatedDocument.querySelectorAll(`script[${INLINE_HTML_THEME_BRIDGE_ATTRIBUTE}]`)).toHaveLength(2); + }); + it("keeps a historical completed iframe stable across equivalent parent renders", () => { const renderer = new HtmlRenderer(); const { container, iframe } = mountHtml(renderer, AUTHORED_HTML); From 4f47912baf699a51da317388a648af1f95e76a23 Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:42:13 +0100 Subject: [PATCH 09/12] test inline theme through Vite source runtime Co-authored-by: bobbit-ai --- .../e2e/source-vite-inline-html-theme.spec.ts | 314 ++++++++++++++++++ .../e2e/source-vite-runtime-helpers.ts | 242 ++++++++++++++ tests2/tests-map.json | 9 + 3 files changed, 565 insertions(+) create mode 100644 tests2/browser/e2e/source-vite-inline-html-theme.spec.ts create mode 100644 tests2/browser/e2e/source-vite-runtime-helpers.ts diff --git a/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts b/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts new file mode 100644 index 000000000..0c163e239 --- /dev/null +++ b/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts @@ -0,0 +1,314 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + createProjectAndSession, + getFreePort, + promptSession, + readToken, + waitForHealth, +} from "./packaged-runtime-helpers.js"; +import { + processFailure, + startIsolatedSourceGateway, + startSourceVite, + stopSourceProcess, + waitForSourceVite, + writeSourceViteAgent, + type RunningSourceProcess, +} from "./source-vite-runtime-helpers.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); +const SOURCE_MODULE_PATHS = { + main: "/src/app/main.ts", + htmlRenderer: "/src/ui/tools/renderers/HtmlRenderer", + canonicalBridge: "/src/shared/preview-bridge-scripts", +}; + +interface ThemeState { + background: string; + foreground: string; + card: string; + positive: string; + chart: string; + font: string; + dark: boolean; + palette: string | null; +} + +interface InlineFrameState { + capture: ThemeState | null; + current: ThemeState; + authoredScriptRan: boolean; + canonicalBridgeCount: number; + swipeBridgeCount: number; + snapshotStyleCount: number; + identity: string | null; + srcdoc: string; +} + +interface RuntimeReport { + requests: string[]; + responses: Array<{ path: string; status: number }>; + gatewayStdout?: string; + gatewayStderr?: string; + viteStdout?: string; + viteStderr?: string; +} + +async function hostTheme(page: Page): Promise { + return page.evaluate(() => { + const root = document.documentElement; + const style = getComputedStyle(root); + return { + background: style.getPropertyValue("--background").trim(), + foreground: style.getPropertyValue("--foreground").trim(), + card: style.getPropertyValue("--card").trim(), + positive: style.getPropertyValue("--positive").trim(), + chart: style.getPropertyValue("--chart-1").trim(), + font: style.fontFamily, + dark: root.classList.contains("dark"), + palette: root.getAttribute("data-palette"), + }; + }); +} + +async function inlineFrameState(page: Page): Promise { + return page.locator('iframe[title="theme-card.html"]').evaluate((element) => { + const iframe = element as HTMLIFrameElement; + const frameWindow = iframe.contentWindow as (Window & { + __sourceViteThemeCapture?: ThemeState; + __sourceViteFrameIdentity?: string; + }) | null; + const frameDocument = iframe.contentDocument!; + const root = frameDocument.documentElement; + const scripts = [...frameDocument.scripts]; + const style = iframe.contentWindow!.getComputedStyle(root); + return { + capture: frameWindow?.__sourceViteThemeCapture ?? null, + current: { + background: style.getPropertyValue("--background").trim(), + foreground: style.getPropertyValue("--foreground").trim(), + card: style.getPropertyValue("--card").trim(), + positive: style.getPropertyValue("--positive").trim(), + chart: style.getPropertyValue("--chart-1").trim(), + font: style.fontFamily, + dark: root.classList.contains("dark"), + palette: root.getAttribute("data-palette"), + }, + authoredScriptRan: root.getAttribute("data-source-vite-authored-script") === "true", + canonicalBridgeCount: scripts.filter(script => script.hasAttribute("data-bobbit-inline-theme-bridge")).length, + swipeBridgeCount: scripts.filter(script => (script.textContent ?? "").includes("preview-swipe-start")).length, + snapshotStyleCount: frameDocument.querySelectorAll('style[data-bobbit-preview-theme="snapshot"]').length, + identity: frameWindow?.__sourceViteFrameIdentity ?? null, + srcdoc: iframe.srcdoc, + }; + }); +} + +function expectThemeMatches(actual: ThemeState, expected: ThemeState, label: string): void { + for (const key of ["background", "foreground", "card", "positive", "chart"] as const) { + expect(actual[key], `${label} ${key} must be resolved`).not.toBe(""); + expect(actual[key], `${label} ${key} must match the Vite host stylesheet`).toBe(expected[key]); + } + expect(actual.font, `${label} font stack must be resolved`).not.toBe(""); + expect(actual.font, `${label} font stack must match the Vite host`).toBe(expected.font); + expect(actual.dark, `${label} dark state must match the Vite host`).toBe(expected.dark); + expect(actual.palette, `${label} palette must match the Vite host`).toBe(expected.palette); +} + +function sanitizedRequestUrl(rawUrl: string): string { + const url = new URL(rawUrl); + return `${url.origin}${url.pathname}`; +} + +function processLog(runtime: RunningSourceProcess | undefined): { stdout?: string; stderr?: string } { + if (!runtime) return {}; + return { + stdout: runtime.stdout.join("").slice(-20_000), + stderr: runtime.stderr.join("").slice(-20_000), + }; +} + +async function attachReport(testInfo: TestInfo, report: RuntimeReport): Promise { + await testInfo.attach("source-vite-inline-theme-report.json", { + body: Buffer.from(`${JSON.stringify(report, null, 2)}\n`), + contentType: "application/json", + }); +} + +// This is a real source-runtime smoke. It intentionally owns both child +// processes rather than relying on Playwright's compiled-dist gateway fixture. +test.describe("source Vite inline HTML theme runtime", () => { + test.describe.configure({ retries: 0 }); + + test("real chat WriteRenderer uses the canonical source bridge at parse time and across a live theme switch", async ({ page }, testInfo) => { + test.setTimeout(4 * 60_000); + const tempRoot = await mkdtemp(join(tmpdir(), "bobbit-source-vite-inline-theme-")); + const workspaceDir = join(tempRoot, "workspace"); + const agentPath = join(tempRoot, "source-vite-write-agent.mjs"); + const report: RuntimeReport = { requests: [], responses: [] }; + let gateway: RunningSourceProcess | undefined; + let vite: RunningSourceProcess | undefined; + + try { + await mkdir(workspaceDir, { recursive: true }); + await writeSourceViteAgent(agentPath); + + const gatewayPort = await getFreePort(); + const vitePort = await getFreePort(); + const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`; + const gatewayWsUrl = `ws://127.0.0.1:${gatewayPort}`; + const viteBaseUrl = `http://127.0.0.1:${vitePort}`; + + gateway = startIsolatedSourceGateway({ + repoRoot: REPO_ROOT, + tempRoot, + workspaceDir, + agentPath, + port: gatewayPort, + }); + await waitForHealth(gatewayBaseUrl, gateway, 120_000); + const token = await readToken(join(tempRoot, "secrets")); + const sessionId = await createProjectAndSession(gatewayBaseUrl, token, workspaceDir); + await promptSession(gatewayWsUrl, sessionId, token); + + vite = startSourceVite({ + repoRoot: REPO_ROOT, + tempRoot, + gatewayUrl: gatewayBaseUrl, + port: vitePort, + }); + await waitForSourceVite(viteBaseUrl, vite); + + page.on("request", request => report.requests.push(sanitizedRequestUrl(request.url()))); + page.on("response", response => { + const url = new URL(response.url()); + if (url.origin === viteBaseUrl) report.responses.push({ path: url.pathname, status: response.status() }); + }); + await page.addInitScript(() => { + localStorage.setItem("theme", "light"); + localStorage.setItem("palette", "ocean"); + }); + await page.goto(`${viteBaseUrl}/?token=${encodeURIComponent(token)}`, { waitUntil: "domcontentloaded" }); + await expect(page.locator(".sidebar-edge").first()).toBeVisible({ timeout: 30_000 }); + + // Set a known light/palette host state only after boot preferences have + // settled, then navigate to the already-completed real Write tool call. + await page.evaluate(() => { + const root = document.documentElement; + root.classList.remove("dark"); + root.setAttribute("data-palette", "ocean"); + localStorage.setItem("theme", "light"); + localStorage.setItem("palette", "ocean"); + }); + await page.evaluate(id => { window.location.hash = `#/session/${id}`; }, sessionId); + + const iframe = page.locator('iframe[title="theme-card.html"]'); + await expect(iframe).toBeVisible({ timeout: 30_000 }); + await expect.poll( + async () => (await inlineFrameState(page)).capture?.background ?? "", + { timeout: 20_000, message: "authored head script must capture tokens synchronously after the injected bridge" }, + ).not.toBe(""); + + const initialHost = await hostTheme(page); + const initialFrame = await inlineFrameState(page); + expect(initialHost.dark).toBe(false); + expect(initialHost.palette).toBe("ocean"); + expect(initialFrame.authoredScriptRan).toBe(true); + expect(initialFrame.canonicalBridgeCount, "inline srcdoc must contain exactly one canonical bridge").toBe(1); + expect(initialFrame.swipeBridgeCount, "inline chat iframe must not receive side-panel swipe forwarding").toBe(0); + expect(initialFrame.snapshotStyleCount, "inline srcdoc must not use the standalone server-filesystem theme snapshot").toBe(0); + expect(initialFrame.capture).not.toBeNull(); + expectThemeMatches(initialFrame.capture!, initialHost, "parse-time authored capture"); + expectThemeMatches(initialFrame.current, initialHost, "initial inline computed theme"); + expect(initialFrame.srcdoc).toContain("data-bobbit-inline-theme-bridge"); + expect(initialFrame.srcdoc).not.toContain("data-bobbit-preview-theme=\"snapshot\""); + expect(initialFrame.srcdoc).not.toContain("preview-swipe-start"); + + await iframe.evaluate(element => { + const frameWindow = (element as HTMLIFrameElement).contentWindow as (Window & { + __sourceViteFrameIdentity?: string; + }) | null; + if (frameWindow) frameWindow.__sourceViteFrameIdentity = "same-source-vite-iframe"; + }); + await page.evaluate(() => { + const root = document.documentElement; + root.classList.add("dark"); + root.setAttribute("data-palette", "rose"); + localStorage.setItem("theme", "dark"); + localStorage.setItem("palette", "rose"); + }); + const switchedHost = await hostTheme(page); + await expect.poll( + async () => { + const state = await inlineFrameState(page); + return { + background: state.current.background, + dark: state.current.dark, + palette: state.current.palette, + identity: state.identity, + }; + }, + { timeout: 20_000, message: "the same inline iframe must mirror the live host theme and palette" }, + ).toEqual({ + background: switchedHost.background, + dark: true, + palette: "rose", + identity: "same-source-vite-iframe", + }); + + const switchedFrame = await inlineFrameState(page); + expect(switchedFrame.srcdoc, "a live host switch must not recreate or rewrite the tool call iframe").toBe(initialFrame.srcdoc); + expect(switchedFrame.capture, "the authored parse-time script must not rerun during a live switch").toEqual(initialFrame.capture); + expectThemeMatches(switchedFrame.current, switchedHost, "live-switched inline computed theme"); + expect(switchedFrame.current.background).not.toBe(initialFrame.current.background); + expect(switchedFrame.canonicalBridgeCount).toBe(1); + + const viteRequestPaths = report.requests + .map(rawUrl => new URL(rawUrl)) + .filter(url => url.origin === viteBaseUrl) + .map(url => decodeURIComponent(url.pathname)); + expect(viteRequestPaths, "browser must load the real Vite source entry").toContain(SOURCE_MODULE_PATHS.main); + expect( + viteRequestPaths.some(pathname => pathname.startsWith(SOURCE_MODULE_PATHS.htmlRenderer)), + "actual chat rendering must load HtmlRenderer from Vite's source module graph", + ).toBe(true); + expect( + viteRequestPaths.some(pathname => pathname.startsWith(SOURCE_MODULE_PATHS.canonicalBridge)), + "HtmlRenderer's Vite graph must load the canonical shared preview theme bridge source module", + ).toBe(true); + expect( + viteRequestPaths.some(pathname => pathname.includes("/dist/ui/")), + "source-mode browser must not load compiled dist/ui assets", + ).toBe(false); + expect( + viteRequestPaths.some(pathname => pathname.includes("theme-snapshot") || pathname.startsWith("/preview/")), + "inline source-mode rendering must not request the server filesystem snapshot or standalone preview route", + ).toBe(false); + + for (const sourcePath of Object.values(SOURCE_MODULE_PATHS)) { + const sourceResponses = report.responses.filter(response => response.path.startsWith(sourcePath)); + expect(sourceResponses.length, `${sourcePath} must be served through Vite's source graph`).toBeGreaterThan(0); + for (const response of sourceResponses) expect(response.status, `${response.path} must be served by Vite`).toBe(200); + } + } catch (error) { + if (gateway && gateway.child.exitCode !== null) throw processFailure(gateway, `failed during test: ${String(error)}`); + if (vite && vite.child.exitCode !== null) throw processFailure(vite, `failed during test: ${String(error)}`); + throw error; + } finally { + await page.close().catch(() => undefined); + if (vite) await stopSourceProcess(vite); + if (gateway) await stopSourceProcess(gateway); + const gatewayLog = processLog(gateway); + const viteLog = processLog(vite); + report.gatewayStdout = gatewayLog.stdout; + report.gatewayStderr = gatewayLog.stderr; + report.viteStdout = viteLog.stdout; + report.viteStderr = viteLog.stderr; + await attachReport(testInfo, report); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 6, retryDelay: 250 }); + } + }); +}); diff --git a/tests2/browser/e2e/source-vite-runtime-helpers.ts b/tests2/browser/e2e/source-vite-runtime-helpers.ts new file mode 100644 index 000000000..b84b724d4 --- /dev/null +++ b/tests2/browser/e2e/source-vite-runtime-helpers.ts @@ -0,0 +1,242 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +export interface RunningSourceProcess { + child: ChildProcess; + label: string; + stdout: string[]; + stderr: string[]; +} + +export interface SourceGatewayOptions { + repoRoot: string; + tempRoot: string; + workspaceDir: string; + agentPath: string; + port: number; +} + +export interface SourceViteOptions { + repoRoot: string; + tempRoot: string; + gatewayUrl: string; + port: number; +} + +const SOURCE_VITE_THEME_HTML = ` + + + + + +
SOURCE_VITE_INLINE_THEME_READY
+`; + +/** Focused pi RPC test double: one prompt emits one completed Write tool call. */ +export function sourceViteWriteAgentSource(): string { + return `#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +const html = ${JSON.stringify(SOURCE_VITE_THEME_HTML)}; +const messages = []; +const agentDir = process.env.BOBBIT_AGENT_DIR || process.cwd(); +fs.mkdirSync(agentDir, { recursive: true }); +const sessionFile = path.join(agentDir, "source-vite-inline-theme-session.jsonl"); +const model = { provider: "mock", id: "source-vite-write-agent", contextWindow: 128000, maxTokens: 16384, reasoning: false }; +const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); +const persist = () => fs.writeFileSync(sessionFile, messages.map((message) => JSON.stringify({ type: "message", message })).join("\\n") + (messages.length ? "\\n" : "")); +const emit = (event) => send(event); + +async function runPrompt(text) { + const user = { role: "user", content: [{ type: "text", text }] }; + messages.push(user); + emit({ type: "message_end", message: user }); + emit({ type: "agent_start" }); + emit({ type: "session_status", status: "streaming" }); + const toolId = "source-vite-inline-theme-write"; + const input = { path: "theme-card.html", content: html }; + emit({ type: "tool_execution_start", toolName: "write", toolId, input }); + emit({ type: "tool_execution_update", toolName: "write", toolId, status: "complete", output: "Wrote source-Vite inline theme fixture" }); + emit({ type: "tool_execution_end", toolName: "write", toolCallId: toolId, isError: false }); + const assistant = { role: "assistant", content: [ + { type: "toolCall", id: toolId, name: "write", arguments: input, input }, + { type: "text", text: "Rendered source-Vite inline HTML." } + ] }; + const result = { role: "toolResult", toolCallId: toolId, toolName: "write", isError: false, content: [{ type: "text", text: "Wrote source-Vite inline theme fixture" }] }; + messages.push(assistant, result); + emit({ type: "message_end", message: assistant }); + emit({ type: "message_end", message: result }); + persist(); + emit({ type: "agent_end" }); + emit({ type: "session_status", status: "idle" }); +} + +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + let message; + try { message = JSON.parse(line); } catch { return; } + if (message.type === "prompt" || message.type === "follow_up") { + send({ type: "response", id: message.id, success: true }); + void runPrompt(message.message || ""); + return; + } + if (message.type === "get_state") { + persist(); + send({ type: "response", id: message.id, success: true, data: { status: "idle", sessionFile, model } }); + return; + } + if (message.type === "get_messages") { + send({ type: "response", id: message.id, success: true, data: messages }); + return; + } + if (message.type === "abort") { + send({ type: "response", id: message.id, success: true }); + emit({ type: "agent_end" }); + emit({ type: "session_status", status: "idle" }); + return; + } + send({ type: "response", id: message.id, success: true }); +}); +send({ type: "session_status", status: "idle" }); +`; +} + +export async function writeSourceViteAgent(filePath: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, sourceViteWriteAgentSource(), "utf8"); +} + +function isolatedEnvironment(tempRoot: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of [ + "BOBBIT_TOKEN", + "BOBBIT_PI_DIR", + "GATEWAY_URL", + "VITE_HOST", + ]) delete env[key]; + const home = join(tempRoot, "home"); + return { + ...env, + NODE_ENV: "test", + NO_COLOR: "1", + BOBBIT_DIR: join(tempRoot, "headquarters"), + BOBBIT_SECRETS_DIR: join(tempRoot, "secrets"), + BOBBIT_AGENT_DIR: join(tempRoot, "agent-state"), + BOBBIT_SKIP_MCP: "1", + BOBBIT_SKIP_TITLE_GENERATION: "1", + BOBBIT_SKIP_NPM_CI: "1", + BOBBIT_TEST_NO_EXTERNAL: "1", + BOBBIT_TEST_NO_REMOTE: "1", + HOME: home, + USERPROFILE: home, + }; +} + +function captureProcess(child: ChildProcess, label: string): RunningSourceProcess { + const runtime = { child, label, stdout: [] as string[], stderr: [] as string[] }; + child.stdout?.on("data", chunk => runtime.stdout.push(String(chunk))); + child.stderr?.on("data", chunk => runtime.stderr.push(String(chunk))); + return runtime; +} + +export function startIsolatedSourceGateway(options: SourceGatewayOptions): RunningSourceProcess { + const cliPath = resolve(options.repoRoot, "dist", "server", "cli.js"); + const child = spawn(process.execPath, [ + cliPath, + "--cwd", options.workspaceDir, + "--host", "127.0.0.1", + "--port", String(options.port), + "--no-tls", + "--no-ui", + "--agent-cli", options.agentPath, + ], { + cwd: options.repoRoot, + env: isolatedEnvironment(options.tempRoot), + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + return captureProcess(child, "isolated Bobbit gateway"); +} + +export function startSourceVite(options: SourceViteOptions): RunningSourceProcess { + const viteCli = resolve(options.repoRoot, "node_modules", "vite", "bin", "vite.js"); + const env = isolatedEnvironment(options.tempRoot); + env.NODE_ENV = "development"; + env.GATEWAY_URL = options.gatewayUrl; + env.VITE_HOST = "localhost"; + const child = spawn(process.execPath, [ + viteCli, + "--host", "127.0.0.1", + "--port", String(options.port), + "--strictPort", + ], { + cwd: options.repoRoot, + env, + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + return captureProcess(child, "Vite source server"); +} + +export async function waitForSourceVite(baseUrl: string, runtime: RunningSourceProcess, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = "not attempted"; + while (Date.now() < deadline) { + if (runtime.child.exitCode !== null) throw processFailure(runtime, `exited ${runtime.child.exitCode} before readiness`); + try { + const response = await fetch(`${baseUrl}/`); + const body = await response.text(); + if (response.ok && body.includes('/src/app/main.ts')) return; + lastError = `${response.status} ${response.statusText}; sourceEntry=${body.includes('/src/app/main.ts')}`; + } catch (error) { + lastError = String(error); + } + await new Promise(resolveDelay => setTimeout(resolveDelay, 250)); + } + throw processFailure(runtime, `readiness timed out: ${lastError}`); +} + +function killTree(child: ChildProcess): void { + if (!child.pid) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + return; + } + try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); } +} + +export async function stopSourceProcess(runtime: RunningSourceProcess): Promise { + if (runtime.child.exitCode !== null) return; + const closed = new Promise(resolveClosed => runtime.child.once("close", () => resolveClosed())); + killTree(runtime.child); + await Promise.race([closed, new Promise(resolveDelay => setTimeout(resolveDelay, 10_000))]); + if (runtime.child.exitCode === null) killTree(runtime.child); +} + +export function processFailure(runtime: RunningSourceProcess, message: string): Error { + return new Error(`${runtime.label} ${message}\nstdout:\n${runtime.stdout.join("")}\nstderr:\n${runtime.stderr.join("")}`); +} diff --git a/tests2/tests-map.json b/tests2/tests-map.json index ba306d1ba..e1e38dea6 100644 --- a/tests2/tests-map.json +++ b/tests2/tests-map.json @@ -56,6 +56,15 @@ "project": "browser" } }, + { + "path": "tests2/browser/e2e/source-vite-inline-html-theme.spec.ts", + "reason": "Real Vite development-runtime smoke against an isolated focused-write-agent gateway proving the actual chat WriteRenderer receives parse-time and live same-iframe theme, palette, font, surface, semantic, and chart state from the canonical source bridge without swipe forwarding, compiled dist/ui, or the standalone server-filesystem snapshot path.", + "execution": { + "runner": "playwright", + "tier": "browser", + "project": "browser" + } + }, { "path": "tests2/browser/e2e/packaged-inline-html-theme.spec.ts", "reason": "Clean-consumer npm tarball smoke proving the packaged CLI serves compiled dist/ui with the canonical bridge bundled, ships canonical src/ui/app.css and referenced UI assets, and renders an inline HTML write whose parse-time and live theme state follows the host without iframe recreation or source-module runtime requests.", From 8163a0866b90ee0421cd71a9bb49532b39e3d14b Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:55:08 +0100 Subject: [PATCH 10/12] stabilize isolated source Vite smoke Co-authored-by: bobbit-ai --- .../e2e/source-vite-inline-html-theme.spec.ts | 16 +++++++++++++--- .../browser/e2e/source-vite-runtime-helpers.ts | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts b/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts index 0c163e239..e7c7d84ec 100644 --- a/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts +++ b/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts @@ -7,13 +7,13 @@ import { getFreePort, promptSession, readToken, - waitForHealth, } from "./packaged-runtime-helpers.js"; import { processFailure, startIsolatedSourceGateway, startSourceVite, stopSourceProcess, + waitForSourceGateway, waitForSourceVite, writeSourceViteAgent, type RunningSourceProcess, @@ -169,10 +169,15 @@ test.describe("source Vite inline HTML theme runtime", () => { agentPath, port: gatewayPort, }); - await waitForHealth(gatewayBaseUrl, gateway, 120_000); + await waitForSourceGateway(gatewayBaseUrl, gateway); const token = await readToken(join(tempRoot, "secrets")); + const preferenceResponse = await fetch(`${gatewayBaseUrl}/api/preferences`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ palette: "ocean" }), + }); + expect(preferenceResponse.ok, `failed to seed ocean palette: ${preferenceResponse.status} ${await preferenceResponse.text()}`).toBe(true); const sessionId = await createProjectAndSession(gatewayBaseUrl, token, workspaceDir); - await promptSession(gatewayWsUrl, sessionId, token); vite = startSourceVite({ repoRoot: REPO_ROOT, @@ -203,6 +208,11 @@ test.describe("source Vite inline HTML theme runtime", () => { localStorage.setItem("theme", "light"); localStorage.setItem("palette", "ocean"); }); + // Emit the focused Write call only after the source UI has reached the + // known host state. Whether the root view already connected this sole + // session or the hash navigation restores it historically, iframe parse + // now necessarily happens against the same light/ocean host. + await promptSession(gatewayWsUrl, sessionId, token); await page.evaluate(id => { window.location.hash = `#/session/${id}`; }, sessionId); const iframe = page.locator('iframe[title="theme-card.html"]'); diff --git a/tests2/browser/e2e/source-vite-runtime-helpers.ts b/tests2/browser/e2e/source-vite-runtime-helpers.ts index b84b724d4..33f7a1e55 100644 --- a/tests2/browser/e2e/source-vite-runtime-helpers.ts +++ b/tests2/browser/e2e/source-vite-runtime-helpers.ts @@ -202,6 +202,23 @@ export function startSourceVite(options: SourceViteOptions): RunningSourceProces return captureProcess(child, "Vite source server"); } +export async function waitForSourceGateway(baseUrl: string, runtime: RunningSourceProcess, timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = "not attempted"; + while (Date.now() < deadline) { + if (runtime.child.exitCode !== null) throw processFailure(runtime, `exited ${runtime.child.exitCode} before readiness`); + try { + const response = await fetch(`${baseUrl}/api/health`); + if (response.ok) return; + lastError = `${response.status} ${response.statusText}`; + } catch (error) { + lastError = String(error); + } + await new Promise(resolveDelay => setTimeout(resolveDelay, 250)); + } + throw processFailure(runtime, `readiness timed out: ${lastError}`); +} + export async function waitForSourceVite(baseUrl: string, runtime: RunningSourceProcess, timeoutMs = 120_000): Promise { const deadline = Date.now() + timeoutMs; let lastError = "not attempted"; From ed12870e0ef3155a6a08ff8aa27c79c393c871ca Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:29:32 +0100 Subject: [PATCH 11/12] Document inline HTML theme bridge Co-authored-by: bobbit-ai --- defaults/docs/html-rendering.md | 68 +++++++++++------------ docs/preview-architecture.md | 98 ++++++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 42 deletions(-) diff --git a/defaults/docs/html-rendering.md b/defaults/docs/html-rendering.md index a7e82a288..5a090fa99 100644 --- a/defaults/docs/html-rendering.md +++ b/defaults/docs/html-rendering.md @@ -101,8 +101,9 @@ Full architecture: [docs/preview-architecture.md](../../docs/preview-architectur ## Theme integration is mandatory -Bobbit injects a theme-bridge script (`src/shared/preview-bridge-scripts.ts`) -into every preview iframe. The bridge mirrors: +Bobbit injects the shared theme bridge (`src/shared/preview-bridge-scripts.ts`) +into both side-panel previews and inline `.html` / `.htm` chat cards produced by +`write` or `edit`. The bridge mirrors: - the parent's `dark` class - the parent's `data-palette` attribute @@ -231,9 +232,9 @@ mode), **not** a text colour. If you alias it to a foreground: the live bridge mirrors the real `--muted` (the light surface) as an **inline** style on the iframe root, and inline styles **beat your `:root` alias**. So `color: var(--muted)` resolves to the light surface colour → light-on-light → -invisible. This only manifests with the live bridge (the **preview pane**); -standalone / inline render has no bridge to clobber the alias, so it looks fine -— *the exact "fine inline, broken in preview" signature.* +invisible. This manifests in both embedded surfaces—the side-panel iframe and +inline chat-card render—because both use the live bridge. A standalone +side-panel tab uses the server snapshot instead. **Rule: never name a custom property after a real token** (`--muted`, `--card`, `--border`, `--accent`, `--ring`, `--primary`, `--secondary`, @@ -275,31 +276,28 @@ body { background: var(--background); color: var(--foreground); } ``` **Do not add a surface-token fallback at all for anything rendered inside -Bobbit** (`preview_open`, inline `.html` render). Both surfaces inject a -complete, contrast-correct, *palette-matched* `:root`/`.dark` snapshot, and the -live bridge mirrors the app's tokens on top. A standalone fallback can only -make things worse here — see the next rule. The single exception is an HTML -file that will *only ever* be opened directly from disk **outside** Bobbit -(never via `preview_open` or inline render); only then supply a **matched -`:root` + `.dark` pair** so light and dark stay internally consistent (never a -lone single-mode hex). If there is any chance the file is previewed in Bobbit, -omit it. - -### ❌ Never override the snapshot from your own `:root{}` - -The server injects a complete, contrast-correct, palette-matched `:root`/`.dark` -theme snapshot *before* your `` string. `content-route.ts` injects this snapshot for every served HTML response — the `data-bobbit-preview-theme="snapshot"` marker is the debugging @@ -720,7 +800,8 @@ back the preview tree sees the same bytes the gateway just wrote. | File | Responsibility | |---|---| | `src/server/preview/mount.ts` | Per-session mount lifecycle, atomic writes, `mountFile` (explicit asset opt-in), `contentHash` calculation, watcher | -| `src/server/preview/content-route.ts` | `/preview//` handler (live mount) and `/preview//_artifact//` (per-artifact stable URL), entry pick, `` + bridge injection | +| `src/server/preview/content-route.ts` | `/preview//` handler (live mount) and `/preview//_artifact//` (per-artifact stable URL), entry pick, `` + snapshot + bridge injection | +| `src/server/preview/theme-snapshot.ts` | Cached `src/ui/app.css` token snapshot for standalone side-panel URLs | | `src/server/preview/path-guard.ts` | Path-traversal defence (realpath-based) | | `src/server/preview/mime.ts` | MIME-type lookup | | `src/server/preview/events.ts` | Per-session `preview-changed` channel carrying mount identity payloads | @@ -729,7 +810,10 @@ back the preview tree sees the same bytes the gateway just wrote. | `src/server/auth/browser-cookie.ts` | Central browser bootstrap/renewal eligibility classifier | | `src/server/server.ts` | `POST/GET /api/preview/mount`, SSE route, broadcast on success | | `src/server/agent/docker-args.ts` | Sandbox bind mounts (`/bobbit/preview`, `/bobbit/preview-root`) | -| `src/shared/preview-bridge-scripts.ts` | Theme/swipe bridge scripts injected into HTML responses | +| `src/shared/preview-bridge-scripts.ts` | Canonical live theme bridge plus the side-panel-only swipe bridge | +| `src/ui/tools/renderers/prepare-inline-html.ts` | Parser-backed, early theme-bridge preparation for inline `srcdoc` HTML | +| `src/ui/tools/renderers/HtmlRenderer.ts` | Completed and streaming inline iframe lifecycle; original-source disclosure and themed chrome | +| `src/ui/tools/renderers/WriteRenderer.ts`, `EditRenderer.ts` | `.html` / `.htm` write and successful-edit delegation into `HtmlRenderer` | | `defaults/tools/html/extension.ts` | `preview_open` tool — POSTs to `/api/preview/mount`, stamps v3 marker with optional `contentHash` | | `defaults/tools/html/snapshot.ts` | Marker constants, `buildPreviewSnapshotV3Block`, `parseSnapshot`, 250-byte v3 cap | | `src/server/preview/artifacts.ts` | Immutable artifact store — capture, restore, hash-based dedupe, orphan sweep | From 0fb13d4575f775de3e71c88548d1428d0532808e Mon Sep 17 00:00:00 2001 From: "Josh S." <8768403+SuuBro@users.noreply.github.com> Date: Thu, 23 Jul 2026 07:42:07 +0100 Subject: [PATCH 12/12] Memoize inline HTML preparation Co-authored-by: bobbit-ai --- src/ui/tools/renderers/prepare-inline-html.ts | 127 ++++++++++++++++-- .../inline-html-renderer-lifecycle.test.ts | 107 ++++++++++++++- 2 files changed, 223 insertions(+), 11 deletions(-) diff --git a/src/ui/tools/renderers/prepare-inline-html.ts b/src/ui/tools/renderers/prepare-inline-html.ts index 9af83c6d7..30c5503db 100644 --- a/src/ui/tools/renderers/prepare-inline-html.ts +++ b/src/ui/tools/renderers/prepare-inline-html.ts @@ -3,6 +3,94 @@ import { PREVIEW_THEME_BRIDGE } from "../../../shared/preview-bridge-scripts.js" /** Marker used to make canonical theme-bridge preparation idempotent. */ export const INLINE_HTML_THEME_BRIDGE_ATTRIBUTE = "data-bobbit-inline-theme-bridge"; +/** + * Preparation is shared by every inline HTML renderer, so keep several cards + * warm without allowing transcript HTML to become an unbounded module cache. + * Byte accounting uses the conservative UTF-16 size of both the Map key and + * prepared value. + */ +export const INLINE_HTML_PREPARATION_CACHE_LIMITS = Object.freeze({ + maxEntries: 16, + maxRetainedBytes: 2 * 1024 * 1024, + maxCacheableContentBytes: 512 * 1024, +}); + +interface CanonicalBridgeDescriptor { + textContent: string; + attributes: ReadonlyArray; +} + +interface PreparedHtmlCacheEntry { + prepared: string; + retainedBytes: number; +} + +// Module-scoped by design: shared renderer instances reuse it, while a Vite HMR +// module replacement naturally drops both prepared documents and the descriptor. +const preparedHtmlCache = new Map(); +let preparedHtmlCacheBytes = 0; +let canonicalBridgeDescriptor: CanonicalBridgeDescriptor | undefined; + +function retainedStringBytes(value: string): number { + return value.length * 2; +} + +function cachedPreparation(content: string): string | undefined { + const entry = preparedHtmlCache.get(content); + if (!entry) return undefined; + + // Refresh insertion order so active cards win over old transcript entries. + preparedHtmlCache.delete(content); + preparedHtmlCache.set(content, entry); + return entry.prepared; +} + +function cachePreparation(content: string, prepared: string): void { + const contentBytes = retainedStringBytes(content); + if (contentBytes > INLINE_HTML_PREPARATION_CACHE_LIMITS.maxCacheableContentBytes) return; + + const retainedBytes = contentBytes + retainedStringBytes(prepared); + if (retainedBytes > INLINE_HTML_PREPARATION_CACHE_LIMITS.maxRetainedBytes) return; + + while ( + preparedHtmlCache.size >= INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries + || preparedHtmlCacheBytes + retainedBytes > INLINE_HTML_PREPARATION_CACHE_LIMITS.maxRetainedBytes + ) { + const oldestKey = preparedHtmlCache.keys().next().value as string | undefined; + if (oldestKey === undefined) break; + const oldest = preparedHtmlCache.get(oldestKey); + preparedHtmlCache.delete(oldestKey); + preparedHtmlCacheBytes -= oldest?.retainedBytes ?? 0; + } + + preparedHtmlCache.set(content, { prepared, retainedBytes }); + preparedHtmlCacheBytes += retainedBytes; +} + +/** + * Parse the canonical bridge once into realm-neutral strings. Keeping a DOM + * node would tie future documents to the descriptor parser's owner document. + * Failures are deliberately not memoized so a temporarily unavailable parser + * can recover on a later render. + */ +function getCanonicalBridgeDescriptor(): CanonicalBridgeDescriptor | undefined { + if (canonicalBridgeDescriptor) return canonicalBridgeDescriptor; + + const bridgeDocument = new DOMParser().parseFromString(PREVIEW_THEME_BRIDGE, "text/html"); + const canonicalBridge = bridgeDocument.querySelector("script"); + if (!canonicalBridge) return undefined; + + const descriptor: CanonicalBridgeDescriptor = { + textContent: canonicalBridge.textContent ?? "", + attributes: Array.from( + canonicalBridge.attributes, + attribute => [attribute.name, attribute.value] as const, + ), + }; + canonicalBridgeDescriptor = descriptor; + return descriptor; +} + /** * Serialize every parsed document-level node while using the browser's HTML * serializer for the document element. This retains the doctype and leading or @@ -22,7 +110,10 @@ function serializeHtmlDocument(document: Document): string { * same attribute. Verify that the marked element is the canonical bridge, * including executable script attributes, before skipping injection. */ -function isCanonicalMarkedBridge(candidate: HTMLScriptElement, canonical: HTMLScriptElement): boolean { +function isCanonicalMarkedBridge( + candidate: HTMLScriptElement, + canonical: CanonicalBridgeDescriptor, +): boolean { if (candidate.textContent !== canonical.textContent) return false; const candidateAttributes = Array.from(candidate.attributes) @@ -30,7 +121,7 @@ function isCanonicalMarkedBridge(candidate: HTMLScriptElement, canonical: HTMLSc if (candidateAttributes.length !== canonical.attributes.length) return false; return candidateAttributes.every(attribute => - canonical.getAttribute(attribute.name) === attribute.value, + canonical.attributes.some(([name, value]) => name === attribute.name && value === attribute.value), ); } @@ -41,31 +132,47 @@ function isCanonicalMarkedBridge(candidate: HTMLScriptElement, canonical: HTMLSc * so tag-shaped text inside scripts, comments, styles, and textareas remains * authored content. The canonical bridge is the first node in ``, which * lets authored scripts synchronously observe the host theme while parsing. - * Any unavailable browser API or parser/serializer failure is fail-open. + * Any unavailable browser API or parser/serializer failure is fail-open and is + * not cached, allowing a later render to recover when browser APIs return. */ export function prepareInlineHtml(content: string): string { + const cached = cachedPreparation(content); + if (cached !== undefined) return cached; + try { if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") return content; - const parser = new DOMParser(); - const document = parser.parseFromString(content, "text/html"); - const bridgeDocument = parser.parseFromString(PREVIEW_THEME_BRIDGE, "text/html"); - const canonicalBridge = bridgeDocument.querySelector("script"); - if (!canonicalBridge || !document.head) return content; + const canonicalBridge = getCanonicalBridgeDescriptor(); + if (!canonicalBridge) return content; + + const document = new DOMParser().parseFromString(content, "text/html"); + if (!document.head) return content; const markedBridges = document.querySelectorAll( `script[${INLINE_HTML_THEME_BRIDGE_ATTRIBUTE}]`, ); if (Array.from(markedBridges).some(candidate => isCanonicalMarkedBridge(candidate, canonicalBridge))) { + cachePreparation(content, content); return content; } - const bridge = document.importNode(canonicalBridge, true); + const bridge = document.createElement("script"); + for (const [name, value] of canonicalBridge.attributes) bridge.setAttribute(name, value); + bridge.textContent = canonicalBridge.textContent; bridge.setAttribute(INLINE_HTML_THEME_BRIDGE_ATTRIBUTE, ""); document.head.insertBefore(bridge, document.head.firstChild); - return serializeHtmlDocument(document); + const prepared = serializeHtmlDocument(document); + cachePreparation(content, prepared); + return prepared; } catch { return content; } } + +/** Reset module memoization between deterministic tests. */ +export function resetInlineHtmlPreparationCacheForTests(): void { + preparedHtmlCache.clear(); + preparedHtmlCacheBytes = 0; + canonicalBridgeDescriptor = undefined; +} diff --git a/tests2/dom/inline-html-renderer-lifecycle.test.ts b/tests2/dom/inline-html-renderer-lifecycle.test.ts index 7edd94134..16cdfe754 100644 --- a/tests2/dom/inline-html-renderer-lifecycle.test.ts +++ b/tests2/dom/inline-html-renderer-lifecycle.test.ts @@ -3,14 +3,16 @@ import { syncCustomElements as __syncCE } from "./_setup/custom-elements.js"; __syncBeforeAll(() => __syncCE()); import vm from "node:vm"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "lit"; import { PREVIEW_SWIPE_SCRIPT, PREVIEW_THEME_BRIDGE } from "../../src/shared/preview-bridge-scripts.js"; import { EditRenderer } from "../../src/ui/tools/renderers/EditRenderer.js"; import { HtmlRenderer } from "../../src/ui/tools/renderers/HtmlRenderer.js"; import { + INLINE_HTML_PREPARATION_CACHE_LIMITS, INLINE_HTML_THEME_BRIDGE_ATTRIBUTE, prepareInlineHtml, + resetInlineHtmlPreparationCacheForTests, } from "../../src/ui/tools/renderers/prepare-inline-html.js"; import { WriteRenderer } from "../../src/ui/tools/renderers/WriteRenderer.js"; @@ -134,6 +136,10 @@ function expectPreparedInlineFrame(iframe: HTMLIFrameElement): Document { return doc; } +beforeEach(() => { + resetInlineHtmlPreparationCacheForTests(); +}); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); @@ -143,6 +149,105 @@ afterEach(() => { window.location.hash = ""; }); +describe("inline HTML preparation memoization", () => { + function fixture(id: string, body = id): string { + return `${id}${body}`; + } + + it("reuses several prepared documents while parsing the canonical bridge only once", () => { + const parse = vi.spyOn(DOMParser.prototype, "parseFromString"); + const first = fixture("first"); + const changed = fixture("changed"); + + const preparedFirst = prepareInlineHtml(first); + expect(preparedFirst).toContain(INLINE_HTML_THEME_BRIDGE_ATTRIBUTE); + expect(parse).toHaveBeenCalledTimes(2); // canonical bridge + authored document + + expect(prepareInlineHtml(first)).toBe(preparedFirst); + expect(parse).toHaveBeenCalledTimes(2); + + const preparedChanged = prepareInlineHtml(changed); + expect(preparedChanged).toContain("changed"); + expect(parse).toHaveBeenCalledTimes(3); // changed authored document only + + // This is a bounded multi-entry cache, not a single last-value shortcut. + expect(prepareInlineHtml(first)).toBe(preparedFirst); + expect(parse).toHaveBeenCalledTimes(3); + }); + + it("evicts the least-recently-used document at the entry bound", () => { + const parse = vi.spyOn(DOMParser.prototype, "parseFromString"); + const documents = Array.from( + { length: INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries + 1 }, + (_, index) => fixture(`entry-${index}`), + ); + + for (const content of documents.slice(0, INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries)) { + prepareInlineHtml(content); + } + expect(parse).toHaveBeenCalledTimes(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries + 1); + + prepareInlineHtml(documents[0]); // promote the oldest entry + expect(parse).toHaveBeenCalledTimes(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries + 1); + + prepareInlineHtml(documents.at(-1)!); // evicts entry 1, not promoted entry 0 + expect(parse).toHaveBeenCalledTimes(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries + 2); + prepareInlineHtml(documents[1]); + expect(parse).toHaveBeenCalledTimes(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries + 3); + }); + + it("evicts by retained input-plus-output size below the entry bound", () => { + const parse = vi.spyOn(DOMParser.prototype, "parseFromString"); + const payloadCharacters = Math.floor(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxRetainedBytes / 10); + const documents = Array.from( + { length: 3 }, + (_, index) => fixture(`sized-${index}`, `${index}${"x".repeat(payloadCharacters)}`), + ); + expect(documents).toHaveLength(3); + expect(documents.length).toBeLessThan(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxEntries); + expect(documents.every(content => content.length * 2 < INLINE_HTML_PREPARATION_CACHE_LIMITS.maxCacheableContentBytes)).toBe(true); + + for (const content of documents) prepareInlineHtml(content); + expect(parse).toHaveBeenCalledTimes(4); // canonical bridge + three documents + + // Three input/output pairs exceed the byte budget, so the first is gone. + prepareInlineHtml(documents[0]); + expect(parse).toHaveBeenCalledTimes(5); + }); + + it("does not retain oversized authored documents", () => { + const parse = vi.spyOn(DOMParser.prototype, "parseFromString"); + const oversized = fixture( + "oversized", + "x".repeat(Math.floor(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxCacheableContentBytes / 2) + 1), + ); + expect(oversized.length * 2).toBeGreaterThan(INLINE_HTML_PREPARATION_CACHE_LIMITS.maxCacheableContentBytes); + + const prepared = prepareInlineHtml(oversized); + expect(prepared).toContain(INLINE_HTML_THEME_BRIDGE_ATTRIBUTE); + expect(parse).toHaveBeenCalledTimes(2); + expect(prepareInlineHtml(oversized)).toBe(prepared); + expect(parse).toHaveBeenCalledTimes(3); // canonical descriptor is warm; authored HTML is not + }); + + it("does not cache a fail-open raw return and recovers when browser APIs return", () => { + const NativeXMLSerializer = XMLSerializer; + const parse = vi.spyOn(DOMParser.prototype, "parseFromString"); + const content = fixture("api-recovery"); + + vi.stubGlobal("XMLSerializer", undefined); + expect(prepareInlineHtml(content)).toBe(content); + expect(parse).not.toHaveBeenCalled(); + + vi.stubGlobal("XMLSerializer", NativeXMLSerializer); + const recovered = prepareInlineHtml(content); + expect(recovered).toContain(INLINE_HTML_THEME_BRIDGE_ATTRIBUTE); + expect(parse).toHaveBeenCalledTimes(2); + expect(prepareInlineHtml(content)).toBe(recovered); + expect(parse).toHaveBeenCalledTimes(2); + }); +}); + describe("inline HtmlRenderer preparation", () => { it("preserves hostile authored syntax and makes the canonical bridge first for parse-time theme reads", () => { const renderer = new HtmlRenderer();