feat: Added six offline real-browser application and quiz cases with...
- e2e/fixtures.ts - e2e/application-flows.spec.ts GSD context: - Milestone: M001 - Merge upstream v0.4.0 into the Gitea fork - Slice: S02 - Verify combined application behavior - Task: T03 - Added six offline real-browser application and quiz cases with synthetic native records, acknowledged SSE replies, and real artifact persistence. GSD-Task: S02/T03
This commit is contained in:
+104
-2
@@ -1,4 +1,4 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test as base, expect, type Route } from "@playwright/test";
|
||||
import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts/models";
|
||||
@@ -6,6 +6,12 @@ import type { UserSettings } from "../src/core/settings";
|
||||
import type { ChatGPTStatus } from "../src/core/contracts/chatgpt";
|
||||
import type { NativeSubscriptionStatus } from "../src/core/contracts/subscriptions";
|
||||
import type { ProviderKeyInfo } from "../components/settings/keys-section";
|
||||
import type { ChatRecord } from "../src/core/contracts/chat";
|
||||
import type { GraphSnapshot } from "../src/core/contracts/graph";
|
||||
import type { JobRecord } from "../src/core/contracts/jobs";
|
||||
import type { LhMessage } from "../src/core/native-messages";
|
||||
import { compactAnswerForModel } from "../src/core/question-core";
|
||||
import type { ChatInput } from "../src/core/input-events";
|
||||
|
||||
/** Setup/teardown owns only the UUID run directory beneath this worktree. */
|
||||
export default function setup() {
|
||||
@@ -24,6 +30,15 @@ export const syntheticModel: ModelCatalogEntry = {
|
||||
const provider: ProviderKeyInfo = {
|
||||
id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI",
|
||||
};
|
||||
export interface ApplicationFixture {
|
||||
chats: ChatRecord[];
|
||||
jobs: JobRecord[];
|
||||
graph: GraphSnapshot;
|
||||
inputs: Array<{ chatId: string; input: ChatInput }>;
|
||||
reads: string[];
|
||||
reply: LhMessage | null;
|
||||
failures: Array<{ status: number; body: unknown }>;
|
||||
}
|
||||
export interface HarnessState {
|
||||
keyPresent: boolean;
|
||||
settings: UserSettings | null;
|
||||
@@ -35,8 +50,20 @@ export interface HarnessState {
|
||||
modelsWrite: { status: number; wait?: Promise<void> };
|
||||
transcription: { configured: boolean; status: number; body: { text?: string; message?: string } };
|
||||
seedGeneral: (general: NonNullable<UserSettings["general"]>) => Promise<void>;
|
||||
application: ApplicationFixture | null;
|
||||
seedChat: (chat: ChatRecord) => Promise<void>;
|
||||
}
|
||||
const json = (route: Route, body: unknown, status = 200) => route.fulfill({ status, json: body });
|
||||
// Native shapes from native-messages.ts; avoid its transitive locale JSON imports
|
||||
// in Playwright's Node loader. These messages never leave the offline boundary.
|
||||
export function syntheticUserMessage(text: string): LhMessage {
|
||||
return { id: `msg_${crypto.randomUUID()}`, role: "user", content: text, timestamp: Date.now() };
|
||||
}
|
||||
function hasPendingQuiz(chat: ChatRecord): boolean {
|
||||
const newest = chat.messages.findLast((message) => message.role === "assistant");
|
||||
return newest?.role === "assistant" && newest.content.some((block) => block.type === "toolCall" && block.name === "quiz"
|
||||
&& !chat.messages.some((message) => message.role === "toolResult" && message.toolCallId === block.id));
|
||||
}
|
||||
|
||||
export const test = base.extend<{ harness: HarnessState }>({
|
||||
harness: async ({ context }, use) => {
|
||||
@@ -45,8 +72,25 @@ export const test = base.extend<{ harness: HarnessState }>({
|
||||
setup(); // validate run directory before any filesystem access
|
||||
const settingsFile = path.join(process.env.LH_E2E_RUN_DIR!, "data", "settings.json");
|
||||
await mkdir(path.dirname(settingsFile), { recursive: true });
|
||||
const ownedChats = new Set<string>();
|
||||
const streams = new Map<string, LhMessage>();
|
||||
const persistChat = async (chat: ChatRecord) => {
|
||||
if (!/^chat_[a-z0-9_]+$/.test(chat.id)) throw new Error("Invalid synthetic chat id");
|
||||
const file = path.join(path.dirname(settingsFile), "chats", `${chat.id}.json`);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
ownedChats.add(file);
|
||||
await writeFile(file, JSON.stringify(chat), "utf8");
|
||||
};
|
||||
const state: HarnessState = {
|
||||
keyPresent: false, settings: null, writes: [], blocked: [], generation: [], catalogFailure: false,
|
||||
application: null,
|
||||
seedChat: async (chat) => {
|
||||
if (!state.application) throw new Error("Application fixture must be explicitly enabled");
|
||||
state.application.chats = [...state.application.chats.filter((c) => c.id !== chat.id), chat];
|
||||
await persistChat(chat);
|
||||
// RSC pages read these synthetic records/settings through production stores.
|
||||
await writeFile(settingsFile, JSON.stringify(state.settings), "utf8");
|
||||
},
|
||||
modelsWrite: { status: 200 },
|
||||
transcription: { configured: true, status: 200, body: { text: "Synthetic transcript" } },
|
||||
seedGeneral: async (general) => {
|
||||
@@ -80,6 +124,61 @@ export const test = base.extend<{ harness: HarnessState }>({
|
||||
if (method === "GET" || method === "HEAD") return route.continue();
|
||||
return block();
|
||||
}
|
||||
const app = state.application;
|
||||
if (app) {
|
||||
if (method === "GET") app.reads.push(p);
|
||||
if (p === "/api/graph" && method === "GET") return json(route, app.graph);
|
||||
if (p === "/api/chats" && method === "GET") return json(route, { chats: app.chats.map((chat) => ({
|
||||
id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt,
|
||||
kind: chat.kind ?? "teach", messageCount: chat.messages.length, phase: "probe",
|
||||
pendingQuestion: hasPendingQuiz(chat), pendingProblem: false, busy: false, compacting: false,
|
||||
})) });
|
||||
if (p === "/api/jobs" && method === "GET") return json(route, { jobs: app.jobs.filter((job) => !url.searchParams.has("chatId") || job.chatId === url.searchParams.get("chatId")), turnInFlight: false, compacting: false });
|
||||
const chatPath = p.match(/^\/api\/chats\/(chat_[a-z0-9_]+)(?:\/(stream|artifact-ui))?$/);
|
||||
if (chatPath) {
|
||||
const chat = app.chats.find((c) => c.id === chatPath[1]);
|
||||
if (!chat) return json(route, { error: "Not found" }, 404);
|
||||
if (!chatPath[2] && method === "GET") return json(route, { ...chat, turnInFlight: false, compacting: false });
|
||||
if (chatPath[2] === "artifact-ui" && method === "POST") {
|
||||
const body = request.postDataJSON() as { input: { messageId: string; inverted: boolean } };
|
||||
state.writes.push({ path: p, body });
|
||||
// Exercise the real silent persistence route against our owned chat.
|
||||
const response = await route.fetch();
|
||||
if (response.ok()) {
|
||||
const saved = JSON.parse(await readFile(path.join(path.dirname(settingsFile), "chats", `${chat.id}.json`), "utf8")) as ChatRecord;
|
||||
chat.messages = saved.messages;
|
||||
}
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
if (chatPath[2] === "stream" && method === "GET") {
|
||||
const reply = streams.get(chat.id);
|
||||
streams.delete(chat.id);
|
||||
if (reply) { chat.messages.push(reply); await persistChat(chat); }
|
||||
// Pi SSE carries no app record id; SessionModel assigns local ids.
|
||||
const wire = reply && Object.fromEntries(Object.entries(reply).filter(([key]) => key !== "id"));
|
||||
const events = reply ? [{ type: "agent_start" }, { type: "message_start", message: wire }, { type: "message_update", message: wire }, { type: "message_end", message: wire }, { type: "agent_end", messages: [wire] }] : [{ type: "turn-settled" }];
|
||||
return route.fulfill({ contentType: "text/event-stream", body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") + "data: [DONE]\n\n" });
|
||||
}
|
||||
}
|
||||
if (p === "/api/chat" && method === "POST" && app.reply) {
|
||||
const body = request.postDataJSON() as { chatId: string; input: ChatInput };
|
||||
app.inputs.push(body);
|
||||
const failure = app.failures.shift();
|
||||
if (failure) return json(route, failure.body, failure.status);
|
||||
const chat = app.chats.find((c) => c.id === body.chatId);
|
||||
if (!chat) throw new Error("Generation targeted an unseeded chat");
|
||||
if (body.input.type === "text") chat.messages.push(syntheticUserMessage(body.input.text));
|
||||
else if (body.input.type === "answer") chat.messages.push({
|
||||
id: `msg_${crypto.randomUUID()}`, role: "toolResult", toolCallId: body.input.toolCallId, toolName: "quiz",
|
||||
content: [{ type: "text", text: JSON.stringify(compactAnswerForModel(body.input.answer)) }],
|
||||
details: body.input.answer as unknown as Extract<LhMessage, { role: "toolResult" }>["details"], isError: false, timestamp: Date.now(),
|
||||
});
|
||||
else if (body.input.type !== "retry") throw new Error(`Unscripted input: ${body.input.type}`);
|
||||
await persistChat(chat);
|
||||
streams.set(chat.id, app.reply);
|
||||
return json(route, { ok: true });
|
||||
}
|
||||
}
|
||||
if ((/\/(turn|generate|transcribe)(\/|$)/.test(p) && p !== "/api/stt/transcribe") || (p.startsWith("/api/chats") && method !== "GET")) {
|
||||
state.generation.push(`${method} ${p}`);
|
||||
return block();
|
||||
@@ -148,7 +247,10 @@ export const test = base.extend<{ harness: HarnessState }>({
|
||||
return block();
|
||||
});
|
||||
try { await use(state); }
|
||||
finally { await rm(settingsFile, { force: true }); }
|
||||
finally {
|
||||
await rm(settingsFile, { force: true });
|
||||
for (const file of ownedChats) await rm(file, { force: true });
|
||||
}
|
||||
expect(state.blocked, "Unexpected network request (blocked before dispatch)").toEqual([]);
|
||||
expect(state.generation, "Setup/settings must not generate or send").toEqual([]);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user