/**
* Demo + visual artifact tests (W6) — deterministic, zero network, no real
* Chrome:
*
* 1. bridge-script injection (buildDemoSrcDoc) + the pure bridge-message
* reducer (reduceDemoBridgeState)
* 2. card rendering (static markup): sandboxed iframe attrs, error chip,
* failure notes, inline sanitized svg
* 3. the demo-builder loop with a FAKE model + FAKE headless (success with
* a fix iteration, untested-final verification, never-clean failure,
* no-Chrome failure)
* 4. the visual-maker loop (fake model + fake headless): render-and-look
* success, forced look on premature finish, never-writes failure
* 5. job wrappers + spawn-hook kind dispatch (demo part → demo job →
* part fulfilled; visual failure path)
* 6. tool registry wiring (client-side demo/visual tools, schemas)
*
* Cleanup follows tests/jobs.test.ts idioms (created chat/job files removed
* in the after() hook; overrides reset in finally blocks).
*/
import React from "react";
(globalThis as Record).React = React; // tsx classic-JSX shim (see render-cards.test.tsx)
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import { renderToStaticMarkup } from "react-dom/server";
import { PATHS } from "../config.ts";
import {
buildDemoSrcDoc,
DemoCard,
DemoErrorChip,
INITIAL_DEMO_BRIDGE_STATE,
reduceDemoBridgeState,
} from "../components/artifacts/demo-card.tsx";
import { sanitizeSvg, VisualCard } from "../components/artifacts/visual-card.tsx";
import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts";
import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
/** The runner with the same wired fs ports the chat-handler injects. */
const jobRunner = createJobRunner({ chatStore, jobStore });
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { artifactRenderers } from "../components/artifacts/index.ts";
const JOBS_DIR = path.join(PATHS.data, "jobs");
const createdChats: string[] = [];
const createdJobs: string[] = [];
after(() => {
for (const id of createdChats) {
try {
fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
} catch {
/* already gone */
}
}
for (const id of createdJobs) {
try {
fs.unlinkSync(path.join(JOBS_DIR, `${id}.json`));
} catch {
/* already gone */
}
}
headlessOverride.current = undefined;
demoModelOverride.current = undefined;
visualModelOverride.current = undefined;
});
function usage() {
return {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
}
/** A model that answers a scripted sequence of doGenerate turns (tool calls or final text). */
function scriptedModel(turns: Array<{ toolCalls?: Array<{ toolCallId: string; toolName: string; input: unknown }>; text?: string }>): MockLanguageModelV4 {
let turn = -1;
return new MockLanguageModelV4({
doGenerate: async () => {
turn++;
const step = turns[turn];
if (!step) throw new Error(`scripted model exhausted after ${turns.length} turns`);
if (step.toolCalls) {
return {
content: step.toolCalls.map((c) => ({ type: "tool-call" as const, toolCallId: c.toolCallId, toolName: c.toolName, input: JSON.stringify(c.input) })),
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
usage: usage(),
warnings: [],
};
}
return {
content: [{ type: "text" as const, text: step.text ?? "" }],
finishReason: { unified: "stop" as const, raw: "stop" },
usage: usage(),
warnings: [],
};
},
});
}
// ─── fake headless ───────────────────────────────────────────────────────────
const CLEAN_RUN: DemoRunResult = { consoleErrors: [], pageErrors: [], failedRequests: [], ok: true };
const BROKEN_RUN: DemoRunResult = {
consoleErrors: ["Uncaught ReferenceError: foo is not defined"],
pageErrors: [],
failedRequests: [],
ok: false,
};
function fakeHeadless(opts: {
/** Return BROKEN_RUN for html matching this substring, else CLEAN_RUN. */
brokenWhenIncludes?: string;
/** Throw for the first N renderSvg calls (then succeed). */
renderFailures?: number;
} = {}): HeadlessApi & { demoRuns: string[]; svgRenders: string[] } {
const api = {
demoRuns: [] as string[],
svgRenders: [] as string[],
available() {
return true;
},
unavailableReason() {
return null;
},
async runDemo(html: string): Promise {
api.demoRuns.push(html);
return opts.brokenWhenIncludes && html.includes(opts.brokenWhenIncludes) ? BROKEN_RUN : CLEAN_RUN;
},
async renderSvg(svg: string): Promise {
api.svgRenders.push(svg);
if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) {
throw new Error("XML parse error");
}
return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic
},
};
return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] };
}
function noChromeHeadless(): HeadlessApi {
return {
available: () => false,
unavailableReason: () => "No Chrome/Chromium found for headless testing. Install Google Chrome or set CHROME_PATH.",
runDemo: async () => {
throw new Error("should not run");
},
renderSvg: async () => {
throw new Error("should not render");
},
};
}
// ─── 1. bridge injection + reducer ───────────────────────────────────────────
describe("demo bridge", () => {
it("injects the resize + error hooks before