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 | 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, "/"); +} 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..e7c7d84ec --- /dev/null +++ b/tests2/browser/e2e/source-vite-inline-html-theme.spec.ts @@ -0,0 +1,324 @@ +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, +} from "./packaged-runtime-helpers.js"; +import { + processFailure, + startIsolatedSourceGateway, + startSourceVite, + stopSourceProcess, + waitForSourceGateway, + 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 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); + + 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"); + }); + // 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"]'); + 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..33f7a1e55 --- /dev/null +++ b/tests2/browser/e2e/source-vite-runtime-helpers.ts @@ -0,0 +1,259 @@ +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 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"; + 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/browser/fixtures/inline-html-theme-source.spec.ts b/tests2/browser/fixtures/inline-html-theme-source.spec.ts new file mode 100644 index 000000000..9c8d75412 --- /dev/null +++ b/tests2/browser/fixtures/inline-html-theme-source.spec.ts @@ -0,0 +1,248 @@ +import { expect, test, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { buildBundle } from "../fixtures/build-bundle.js"; + +const SHELL = path.resolve("tests/ui-fixtures/fixture-shell.html"); +const ENTRY = path.resolve("tests/ui-fixtures/inline-html-theme-renderers-entry.ts"); +const BUNDLE_DIR = path.resolve(".bobbit/tmp/ui-fixtures"); +const BUNDLE = path.join(BUNDLE_DIR, "inline-html-theme-renderers-bundle.js"); +const WRITE_RENDERER_SRC = path.resolve("src/ui/tools/renderers/WriteRenderer.ts"); +const EDIT_RENDERER_SRC = path.resolve("src/ui/tools/renderers/EditRenderer.ts"); +const HTML_RENDERER_SRC = path.resolve("src/ui/tools/renderers/HtmlRenderer.ts"); +const THEME_BRIDGE_SRC = path.resolve("src/shared/preview-bridge-scripts.ts"); + +const LIGHT_TOKENS = { + background: "#f8fafc", + foreground: "#172033", + card: "#ffffff", + positive: "#15803d", + chart: "#2563eb", +}; +const LIGHT_RESOLVED = { + background: "rgb(248, 250, 252)", + foreground: "rgb(23, 32, 51)", + card: "rgb(255, 255, 255)", + cardForeground: "rgb(23, 32, 51)", + positive: "rgb(21, 128, 61)", + chart: "rgb(37, 99, 235)", +}; +const DARK_ROSE_TOKENS = { + background: "#111827", + foreground: "#f8fafc", + card: "#1f2937", + positive: "#4ade80", + chart: "#fb7185", +}; +const DARK_ROSE_RESOLVED = { + background: "rgb(17, 24, 39)", + foreground: "rgb(248, 250, 252)", + card: "rgb(31, 41, 55)", + cardForeground: "rgb(248, 250, 252)", + positive: "rgb(74, 222, 128)", + chart: "rgb(251, 113, 133)", +}; + +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 source-relative imports so this browser fixture cannot silently fall back to a renderer mirror. */ +function collectStaticSourceGraph(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) pending.push(resolved); + } + } + return visited; +} + +let sourceGraph = new Set(); + +test.beforeAll(() => { + sourceGraph = collectStaticSourceGraph(ENTRY); + fs.mkdirSync(BUNDLE_DIR, { recursive: true }); + buildBundle({ entry: ENTRY, outfile: BUNDLE, deps: [...sourceGraph] }); +}); + +async function loadFixture(page: Page): Promise { + await page.goto(`file://${SHELL.replace(/\\/g, "/")}`); + await page.addScriptTag({ path: BUNDLE }); + await page.waitForFunction(() => (window as any).__inlineThemeFixtureReady === true, null, { timeout: 10_000 }); +} + +async function frameState(page: Page, hostId: "write-host" | "edit-host"): Promise { + return page.evaluate((id) => (window as any).__inlineThemeFrameState(id), hostId); +} + +async function waitForAuthoredMarker(page: Page, hostId: "write-host" | "edit-host", marker: string): Promise { + await expect(page.locator(`#${hostId} iframe`)).toHaveCount(1); + await expect.poll(async () => (await frameState(page, hostId)).authored?.marker, { + timeout: 5_000, + message: `authored script ${marker} should execute in the inline iframe`, + }).toBe(marker); +} + +async function documents(page: Page): Promise> { + return page.evaluate(() => (window as any).__inlineThemeDocuments); +} + +test.describe("inline HTML theme bridge from source renderer modules", () => { + test.beforeEach(async ({ page }) => { + await loadFixture(page); + }); + + test("source import graph and executed browser bundle include the canonical theme bridge without server snapshot code", async () => { + const normalized = [...sourceGraph]; + expect(normalized, "fixture must import the real WriteRenderer source module").toContain(path.normalize(WRITE_RENDERER_SRC)); + expect(normalized, "fixture must import the real EditRenderer source module").toContain(path.normalize(EDIT_RENDERER_SRC)); + expect(normalized, "WriteRenderer/EditRenderer must statically reach the real HtmlRenderer").toContain(path.normalize(HTML_RENDERER_SRC)); + expect(normalized, "HtmlRenderer's browser import graph must reach the canonical PREVIEW_THEME_BRIDGE module").toContain(path.normalize(THEME_BRIDGE_SRC)); + + const bundle = fs.readFileSync(BUNDLE, "utf8"); + expect(bundle, "the executed source fixture bundle must carry the canonical bridge module").toContain("src/shared/preview-bridge-scripts.ts"); + expect(bundle, "the executed source fixture bundle must carry inline bridge preparation").toContain("data-bobbit-inline-theme-bridge"); + expect(bundle, "inline srcdoc theming must not import the server filesystem snapshot path").not.toContain("src/server/preview/theme-snapshot"); + }); + + test("completed WriteRenderer resolves light tokens and live-switches dark palette on the same iframe", async ({ page }) => { + const fixtureDocuments = await documents(page); + await page.evaluate((content) => { + (window as any).__setInlineThemeHost(false, "azure"); + (window as any).__renderInlineThemeWrite(content, true); + }, fixtureDocuments.write); + await waitForAuthoredMarker(page, "write-host", "write-complete"); + + const light = await frameState(page, "write-host"); + expect(light.sandbox).toBe("allow-scripts allow-same-origin"); + expect(light.dark).toBe(false); + expect(light.palette).toBe("azure"); + expect(light.font).toContain("Fixture Source Sans"); + expect(light.tokens).toEqual(LIGHT_TOKENS); + expect(light.resolved).toEqual(LIGHT_RESOLVED); + expect(light.authored).toMatchObject({ + marker: "write-complete", + runs: 1, + parse: { dark: false, palette: "azure", tokens: LIGHT_TOKENS }, + }); + expect(light.authored.parse.font).toContain("Fixture Source Sans"); + expect(light.source).toBe(fixtureDocuments.write); + expect(light.sourceCollapsed).toBe(true); + expect(light.srcdoc).toContain("write-complete"); + expect(light.srcdoc).not.toContain("preview-swipe-start"); + + await page.evaluate(() => { + (window as any).__tagInlineThemeFrame("write-host", "same-write-iframe"); + (window as any).__setInlineThemeHost(true, "rose"); + }); + await expect.poll(async () => (await frameState(page, "write-host")).tokens, { timeout: 5_000 }).toEqual(DARK_ROSE_TOKENS); + + const dark = await frameState(page, "write-host"); + expect(dark.identity, "live theme changes must not recreate the iframe/tool call").toBe("same-write-iframe"); + expect(dark.srcdoc, "live theme changes must not rewrite authored srcdoc").toBe(light.srcdoc); + expect(dark.dark).toBe(true); + expect(dark.palette).toBe("rose"); + expect(dark.font).toContain("Fixture Source Sans"); + expect(dark.resolved).toEqual(DARK_ROSE_RESOLVED); + expect(dark.authored, "authored initialization must not rerun for a live host theme mutation").toMatchObject({ + marker: "write-complete", + runs: 1, + parse: { dark: false, palette: "azure", tokens: LIGHT_TOKENS }, + }); + + const swipeMessages = await page.evaluate(() => (window as any).__dispatchInlineThemeSwipe("write-host")); + expect(swipeMessages, "inline chat iframe gestures must not emit side-panel swipe messages").toEqual([]); + + await page.locator("#write-host button").first().click(); + expect((await frameState(page, "write-host")).sourceCollapsed).toBe(false); + expect((await frameState(page, "write-host")).source).toBe(fixtureDocuments.write); + }); + + test("WriteRenderer keeps the canonical bridge through debounced streaming and completion", async ({ page }) => { + const fixtureDocuments = await documents(page); + await page.evaluate((content) => { + (window as any).__setInlineThemeHost(true, "rose"); + (window as any).__renderInlineThemeWrite(content, false); + }, fixtureDocuments.streamInitial); + await waitForAuthoredMarker(page, "write-host", "stream-initial"); + + const initial = await frameState(page, "write-host"); + expect(initial.authored).toMatchObject({ + marker: "stream-initial", + parse: { dark: true, palette: "rose", tokens: DARK_ROSE_TOKENS }, + }); + expect(initial.tokens).toEqual(DARK_ROSE_TOKENS); + expect(initial.resolved).toEqual(DARK_ROSE_RESOLVED); + expect(initial.streamingChrome).toBe(true); + + await page.evaluate((content) => { + (window as any).__tagInlineThemeFrame("write-host", "streaming-iframe"); + (window as any).__renderInlineThemeWrite(content, false); + }, fixtureDocuments.streamUpdate); + await waitForAuthoredMarker(page, "write-host", "stream-updated"); + expect((await frameState(page, "write-host")).identity, "debounced stream writes should reuse the mounted iframe").toBe("streaming-iframe"); + expect((await frameState(page, "write-host")).tokens).toEqual(DARK_ROSE_TOKENS); + + await page.evaluate((content) => (window as any).__renderInlineThemeWrite(content, true), fixtureDocuments.streamComplete); + await waitForAuthoredMarker(page, "write-host", "stream-complete"); + const complete = await frameState(page, "write-host"); + expect(complete.streamingChrome).toBe(false); + expect(complete.sandbox).toBe("allow-scripts allow-same-origin"); + expect(complete.dark).toBe(true); + expect(complete.palette).toBe("rose"); + expect(complete.tokens).toEqual(DARK_ROSE_TOKENS); + expect(complete.resolved).toEqual(DARK_ROSE_RESOLVED); + expect(complete.source).toBe(fixtureDocuments.streamComplete); + expect(complete.sourceCollapsed).toBe(true); + expect(complete.srcdoc).not.toContain("preview-swipe-start"); + }); + + test("EditRenderer fetch delegation uses the same themed iframe and preserves authored source", async ({ page }) => { + const fixtureDocuments = await documents(page); + await page.evaluate(async (content) => { + (window as any).__setInlineThemeHost(false, "azure"); + await (window as any).__renderInlineThemeEdit(content); + }, fixtureDocuments.edit); + await waitForAuthoredMarker(page, "edit-host", "edit-complete"); + + const edit = await frameState(page, "edit-host"); + expect(edit.sandbox).toBe("allow-scripts allow-same-origin"); + expect(edit.tokens).toEqual(LIGHT_TOKENS); + expect(edit.resolved).toEqual(LIGHT_RESOLVED); + expect(edit.authored).toMatchObject({ + marker: "edit-complete", + runs: 1, + parse: { dark: false, palette: "azure", tokens: LIGHT_TOKENS }, + }); + expect(edit.source).toBe(fixtureDocuments.edit); + expect(edit.sourceCollapsed).toBe(true); + expect(edit.srcdoc).not.toContain("preview-swipe-start"); + + const fetchLog = await page.evaluate(() => (window as any).__inlineThemeFetchLog()); + expect(fetchLog).toHaveLength(1); + expect(fetchLog[0].url).toContain("/api/sessions/11111111-1111-4111-8111-111111111111/file-content?path=edited-theme-card.htm&snapshotId=edit-theme-call"); + expect(fetchLog[0].authorization).toBe("Bearer fixture-token"); + + await page.locator("#edit-host button").first().click(); + expect((await frameState(page, "edit-host")).sourceCollapsed).toBe(false); + expect(await page.evaluate(() => (window as any).__dispatchInlineThemeSwipe("edit-host"))).toEqual([]); + }); +}); 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/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..16cdfe754 --- /dev/null +++ b/tests2/dom/inline-html-renderer-lifecycle.test.ts @@ -0,0 +1,489 @@ +import { beforeAll as __syncBeforeAll } from "vitest"; +import { syncCustomElements as __syncCE } from "./_setup/custom-elements.js"; +__syncBeforeAll(() => __syncCE()); + +import vm from "node:vm"; +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"; + +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; +} + +beforeEach(() => { + resetInlineHtmlPreparationCacheForTests(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + localStorage.clear(); + 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(); + 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 collision = ` + + +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); + 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); + }); +}); diff --git a/tests2/tests-map.json b/tests2/tests-map.json index 4388d8f93..3be48d41c 100644 --- a/tests2/tests-map.json +++ b/tests2/tests-map.json @@ -20,6 +20,60 @@ "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/core/preview-theme-bridge-runtime.test.ts", + "reason": "Deterministic VM coverage proving the canonical preview theme bridge mirrors representative tokens, font, dark state, and palette live; installs only one observer when executed twice; skips inaccessible stylesheets; and fails open when parent or browser seams are unavailable.", + "execution": { + "runner": "vitest", + "tier": "unit", + "project": "core" + } + }, + { + "path": "tests2/dom/inline-html-renderer-lifecycle.test.ts", + "reason": "DOM coverage for parser-safe first-head theme-bridge preparation, idempotence, hostile authored syntax, fragments and SVG, completed and historical iframe stability, debounced streaming-to-complete behavior, theme-backed chrome, and shared .html/.htm write and edit delegation.", + "execution": { + "runner": "vitest", + "tier": "unit", + "project": "dom" + } + }, + { + "path": "tests2/browser/fixtures/inline-html-theme-source.spec.ts", + "reason": "Real-browser source-module coverage proving WriteRenderer and EditRenderer statically bundle the canonical bridge without server snapshot code, resolve light and dark palette tokens at parse time, update the same iframe live, preserve streaming/edit/source behavior, and omit side-panel swipe forwarding.", + "execution": { + "runner": "playwright", + "tier": "browser", + "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.", + "execution": { + "runner": "playwright", + "tier": "browser", + "project": "browser" + } + }, { "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.",