import type { Locator, Page } from "@playwright/test"; import { test, expect, syntheticModel, syntheticUserMessage, type HarnessState, type ApplicationFixture } from "./fixtures"; import type { ChatRecord } from "../src/core/contracts/chat"; import type { GraphNode } from "../src/core/contracts/graph"; import type { DemoResult, JobRecord } from "../src/core/contracts/jobs"; import type { LhMessage } from "../src/core/native-messages"; // Inline, synthetic native records; never copy golden-data's personal content. const id = "chat_e2e_application"; const title = "Synthetic arithmetic lesson"; const timestamp = 1_800_000_000_000; function assistant(messageId: string, content: unknown[], stopReason = "stop"): LhMessage { return { id: messageId, role: "assistant", provider: "openai", api: "openai-responses", model: syntheticModel.id, content, stopReason, timestamp, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, } as LhMessage; } function record(messages: LhMessage[]): ChatRecord { return { version: 1, id, title, createdAt: new Date(timestamp).toISOString(), updatedAt: new Date(timestamp).toISOString(), messages, turns: [] }; } function configure(harness: HarnessState, language: "en" | "ru" = "en"): ApplicationFixture { const slot = { model: { provider: "openai", id: syntheticModel.id }, thinking: "off" as const }; harness.keyPresent = true; harness.settings = { version: 1, general: { language }, graph: { rendering: false }, models: { strong: slot, cheap: slot, vision: slot } }; const node: GraphNode = { id: "n_synthetic_sum", name: "Addition", statement: "Two plus two equals four.", aliases: [], provenance: [{ chatId: id, messageId: "lesson-opening" }], createdAt: new Date(timestamp).toISOString() }; const app: ApplicationFixture = { chats: [], jobs: [], inputs: [], reads: [], reply: null, failures: [], graph: { nodes: [node], propositions: [node], edges: [], logicalEdges: [], impLinks: [], chains: [], sessions: { [id]: title }, fileSources: {}, nodeSessions: { [node.id]: [id] }, clusters: [] } }; harness.application = app; return app; } async function openSession(page: Page) { const open = page.getByRole("button", { name: "Open sidebar", exact: true }); if (await open.isVisible()) await open.click(); await page.getByTestId("session-row").filter({ hasText: title }).click(); await expect(page).toHaveURL(`/chat/${id}`); } async function withinViewport(control: Locator, page: Page) { await expect(control).toBeVisible(); // Sidebar/dock have CSS transitions: assert their settled layout, not a frame mid-animation. await expect.poll(async () => { const box = await control.boundingBox(); const viewport = page.viewportSize()!; return !!box && box.x >= 0 && box.y >= 0 && box.x + box.width <= viewport.width + 1 && box.y + box.height <= viewport.height + 1; }, { message: "Control must fit inside the settled viewport" }).toBe(true); const box = await control.boundingBox(); expect(box).not.toBeNull(); const viewport = page.viewportSize()!; expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.y).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1); expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height + 1); // Geometry alone misses occlusion/overflow clipping; hit-test its center too. expect(await control.evaluate((el) => { const r = el.getBoundingClientRect(); const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return hit === el || (hit !== null && el.contains(hit)); })).toBe(true); } function demoJob(): JobRecord { const result: DemoResult = { title: "Synthetic addition demo", tested: true, html: "
" }; return { id: "job_synthetic_demo", kind: "demo", chatId: id, toolCallId: "demo-call", status: "done", activity: "", createdAt: new Date(timestamp).toISOString(), updatedAt: new Date(timestamp).toISOString(), result }; } // buildSubagentSteer's native marker shape, with synthetic demo failure payloads. function syntheticSteer(job: JobRecord): LhMessage { const result: DemoResult = job.status === "error" ? { title: "Synthetic failed demo", tested: true, html: "", notes: job.error } : job.result as DemoResult; const content = job.status === "error" ? `The demo "${result.title}" could not be built: ${result.notes ?? "unknown reason"}. Fall back to explaining in prose; if the brief was the problem, re-call demo with a tighter one.` : `The demo "${result.title}" is built, headlessly tested, and rendered in the lesson. Builder notes: ${result.notes ?? "(none)"}.\nTeach with it — reference its controls by name and point the learner at what to try.`; return { id: `steer_${job.id}`, role: "custom", customType: "subagent-steer", display: true, timestamp, content, details: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, result } }; } test("entrypoint, learning chains, chat, sandboxed artifact and settings compose without duplicate sends", async ({ page, harness }) => { const app = configure(harness); const job = demoJob(); app.jobs.push(job); const chat = record([ { ...syntheticUserMessage("Teach synthetic addition"), id: "learner-opening" }, assistant("lesson-opening", [{ type: "text", text: "Synthetic addition lesson: two plus two equals four." }, { type: "toolCall", id: job.toolCallId, name: "demo", arguments: { description: "Show addition" } }], "toolUse"), { id: "demo-ack", role: "toolResult", toolCallId: job.toolCallId, toolName: "demo", content: [{ type: "text", text: "Dispatched synthetic demo" }], isError: false, timestamp }, { ...syntheticSteer(job), id: "demo-steer" }, ]); await harness.seedChat(chat); await page.goto("/"); await page.getByRole("button", { name: "Chains", exact: true }).click(); await page.getByTestId(`cluster-toggle-${id}`).click(); await page.getByRole("button", { name: "Expand chain Addition", exact: true }).click(); await expect(page.getByText("Two plus two equals four.", { exact: true })).toBeVisible(); await page.getByTestId("chain-table-search").fill("nothing-matches-this-synthetic-query"); await expect(page.getByTestId("chain-table-nomatch")).toBeVisible(); await page.getByTestId("chain-table-search").fill(""); await expect(page.getByTestId(`cluster-group-${id}`)).toBeVisible(); await page.getByTestId("chain-table-close").click(); await openSession(page); await expect(page.getByText("Synthetic addition lesson: two plus two equals four.", { exact: true })).toHaveCount(1); const frame = page.frameLocator('iframe[title="Synthetic addition demo"]'); await frame.getByRole("button", { name: "Reveal sum" }).click(); await expect(frame.getByRole("button", { name: "Four" })).toBeVisible(); await expect(page.locator('iframe[title="Synthetic addition demo"]')).toHaveAttribute("sandbox", "allow-scripts"); const inverted = page.waitForResponse((response) => response.url().endsWith(`/api/chats/${id}/artifact-ui`) && response.request().method() === "POST"); await page.getByRole("switch", { name: "Invert demo brightness", exact: true }).click(); expect((await inverted).ok()).toBe(true); await expect.poll(() => harness.writes.filter((w) => w.path.endsWith("/artifact-ui"))).toEqual([ { path: `/api/chats/${id}/artifact-ui`, body: { input: { messageId: "demo-steer", inverted: true } } }, ]); await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByTestId("settings-nav-general").click(); await expect(page.getByTestId("language-select-language")).toBeVisible(); await page.getByTestId("settings-close").click(); await expect(page).toHaveURL(`/chat/${id}`); await expect(frame.getByRole("button", { name: "Four" })).toBeVisible(); await page.getByRole("link", { name: "Home", exact: true }).click(); await expect(page).toHaveURL("/"); await openSession(page); await expect(page.getByRole("switch", { name: "Invert demo brightness", exact: true })).toHaveAttribute("aria-checked", "true"); app.reply = assistant("generated-reply", [{ type: "text", text: "Synthetic streamed explanation received." }]); await page.getByRole("textbox", { name: "Ask, answer, or steer the lesson…", exact: true }).fill("Explain the synthetic sum"); await page.getByRole("textbox", { name: "Ask, answer, or steer the lesson…", exact: true }).press("Enter"); await expect(page.getByText("Synthetic streamed explanation received.", { exact: true })).toBeVisible(); await expect.poll(() => app.inputs).toEqual([{ chatId: id, input: { type: "text", text: "Explain the synthetic sum" } }]); expect(app.reads.filter((p) => p.endsWith("/stream"))).toEqual([`/api/chats/${id}/stream`]); await page.reload(); await expect(page.getByText("Synthetic streamed explanation received.", { exact: true })).toHaveCount(1); expect(app.inputs).toHaveLength(1); }); for (const width of [1280, 390]) { test(`quiz controls remain reachable and submit exactly once at ${width}px`, async ({ page, harness }) => { await page.setViewportSize({ width, height: 900 }); const app = configure(harness); const question = { type: "predict", nodeId: "synthetic-addition", question: "Which expression equals four?", options: [ { label: "Two plus two", value: "sum" }, { label: "Two minus two", value: "difference" }, ], correctAnswer: "sum", explanation: "Adding two pairs gives four." }; await harness.seedChat(record([assistant("quiz-message", [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: question }], "toolUse")])); app.reply = assistant("quiz-feedback", [{ type: "text", text: "Synthetic quiz feedback received." }]); await page.goto(`/chat/${id}`); // Server-rendered controls precede hydration; jobs is a client mount effect. await expect.poll(() => app.reads.includes("/api/jobs")).toBe(true); const close = page.getByRole("button", { name: "Close sidebar", exact: true }); if (await close.isVisible()) await close.click(); await expect(page.getByTestId("question-dock")).toBeVisible(); await expect(page.getByText(question.question, { exact: true })).toBeVisible(); const answer = page.getByRole("radio", { name: "Two plus two", exact: true }); await withinViewport(answer, page); await answer.click(); const submit = page.getByRole("button", { name: "Submit answer", exact: true }); await withinViewport(submit, page); await expect(submit).toBeEnabled(); const controls = page.getByTestId("question-dock").locator('button, textarea, [role="radio"], [role="slider"]'); for (const control of await controls.all()) { if (await control.isVisible() && await control.isEnabled()) await withinViewport(control, page); } await submit.click(); await expect(page.getByText("Synthetic quiz feedback received.", { exact: true })).toBeVisible(); await expect.poll(() => app.inputs.length).toBe(1); expect(app.inputs[0]!.chatId).toBe(id); expect(app.inputs[0]!.input).toMatchObject({ type: "answer", toolCallId: "quiz-call", answer: { answers: [{ value: "sum", label: "Two plus two" }], correct: true, confidence: 50 } }); await expect(page.getByTestId("question-dock")).toHaveCount(0); expect(app.reads.filter((p) => p.endsWith("/stream"))).toHaveLength(1); await page.reload(); await expect(page.getByText("Synthetic quiz feedback received.", { exact: true })).toHaveCount(1); expect(app.inputs).toHaveLength(1); }); } test("failed response displays Retry and visibly recovers through the real acknowledged stream", async ({ page, harness }) => { const app = configure(harness); const failed = { ...assistant("failed-response", [], "error"), errorMessage: "Synthetic upstream response failed" } as LhMessage; await harness.seedChat(record([syntheticUserMessage("Explain synthetic addition"), failed])); app.reply = assistant("retry-success", [{ type: "text", text: "Synthetic retry recovered." }]); app.failures.push({ status: 500, body: { error: "Synthetic retry still unavailable" } }); await page.goto(`/chat/${id}`); await expect(page.getByText("Something went wrong: Synthetic upstream response failed", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Retry", exact: true }).click(); // Non-2xx opening acknowledgements surface their raw response text. await expect(page.getByText('Something went wrong: {"error":"Synthetic retry still unavailable"}', { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Retry", exact: true }).click(); await expect(page.getByText("Synthetic retry recovered.", { exact: true })).toBeVisible(); await expect(page.getByTestId("retry-failed-turn")).toHaveCount(0); await expect(page.getByText(/Something went wrong:/)).toHaveCount(0); expect(app.inputs).toEqual([{ chatId: id, input: { type: "retry" } }, { chatId: id, input: { type: "retry" } }]); expect(app.reads.filter((p) => p.endsWith("/stream"))).toHaveLength(1); }); for (const language of ["en", "ru"] as const) { test(`settled job ${language} keeps localized completion, error and refusal registers`, async ({ page, harness }) => { const app = configure(harness, language); const done = demoJob(); const error: JobRecord = { ...done, id: "job_synthetic_error", toolCallId: "error-call", status: "error", result: undefined, error: "Synthetic demo build failed" }; const refused: JobRecord = { ...error, id: "job_synthetic_refused", toolCallId: "refused-call", error: "No model configured — set up your models first" }; app.jobs.push(done, error, refused); const messages: LhMessage[] = [assistant("dispatches", [done, error, refused].map((job) => ({ type: "toolCall", id: job.toolCallId, name: "demo", arguments: { description: "Synthetic job" } })), "toolUse")]; for (const job of app.jobs) messages.push({ id: `${job.id}-ack`, role: "toolResult", toolCallId: job.toolCallId, toolName: "demo", content: [{ type: "text", text: "Dispatched synthetic job" }], isError: false, timestamp }); for (const job of app.jobs) messages.push(syntheticSteer(job)); await harness.seedChat(record(messages)); await page.goto(`/chat/${id}`); await expect(page.getByText(language === "ru" ? "завершено" : "finished", { exact: true })).toHaveCount(1); // The error and refusal result markers each have a failed artifact card. await expect(page.getByTestId("demo-card-failed")).toHaveCount(2); await expect(page.getByTestId("demo-card-failed").getByText(language === "ru" ? "ошибка" : "failed", { exact: true })).toHaveCount(2); await expect(page.getByRole("button", { name: language === "ru" ? "Настроить модели" : "Set up models", exact: true })).toBeVisible(); await expect(page.getByText("Synthetic demo build failed", { exact: true })).toBeVisible(); // Stored builder notes stay verbatim; the dispatch refusal CTA is localized. await expect(page.getByText("No model configured — set up your models first", { exact: true })).toBeVisible(); expect(app.inputs).toEqual([]); }); }