215 lines
15 KiB
TypeScript
215 lines
15 KiB
TypeScript
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: "<!doctype html><html><body><button onclick=\"this.textContent='Four'\">Reveal sum</button></body></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([]);
|
||
|
|
});
|
||
|
|
}
|