test(web): add workbench golden reliability e2e

This commit is contained in:
lyon
2026-06-20 11:30:42 +08:00
parent ecbb13cdf4
commit bcb635a4c5
3 changed files with 166 additions and 16 deletions
@@ -1677,29 +1677,34 @@ function workbenchTurnPayload(traceId: string): JsonRecord {
function tracePayload(traceId: string, url: URL): JsonRecord {
const turn = turnPayload(traceId);
const events = Array.isArray(turn.events) ? turn.events as JsonRecord[] : [];
const afterSeq = traceAfterSeq(url);
const afterProjectedSeq = traceAfterProjectedSeq(url);
const requestedLimit = Number(url.searchParams.get("limit") ?? events.length);
const limit = requestedLimit || events.length || 100;
const filtered = afterSeq > 0 ? events.filter((event) => Number(event.seq ?? 0) > afterSeq) : events;
const page = filtered.slice(0, Math.max(1, Number.isFinite(limit) ? limit : 100));
const firstSeq = page.length > 0 ? Number(page[0]?.seq ?? 0) : null;
const lastSeq = page.length > 0 ? Number(page.at(-1)?.seq ?? 0) : null;
const limit = Math.max(1, Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : events.length || 100);
const indexedEvents = events.map((event, index) => {
const projectedSeq = nonNegativeInteger(event.projectedSeq ?? event.seq, index + 1);
return { event: { ...event, seq: projectedSeq, projectedSeq }, projectedSeq };
});
const filtered = afterProjectedSeq > 0 ? indexedEvents.filter((item) => item.projectedSeq > afterProjectedSeq) : indexedEvents;
const pageRecords = filtered.slice(0, limit);
const page = pageRecords.map((item) => item.event);
const firstProjectedSeq = pageRecords.length > 0 ? pageRecords[0]?.projectedSeq ?? null : null;
const lastProjectedSeq = pageRecords.length > 0 ? pageRecords.at(-1)?.projectedSeq ?? null : null;
const hasMore = filtered.length > page.length;
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: lastSeq, nextSeq: page.length > 0 ? lastSeq : afterSeq, range: { afterSeq, fromSeq: firstSeq, toSeq: lastSeq, limit: Math.max(1, Number.isFinite(limit) ? limit : 100), returned: page.length, total: events.length } };
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextProjectedSeq: page.length > 0 ? lastProjectedSeq : afterProjectedSeq, nextCursor: hasMore && lastProjectedSeq ? `projected:${lastProjectedSeq}` : null, range: { afterProjectedSeq, fromProjectedSeq: firstProjectedSeq, toProjectedSeq: lastProjectedSeq, limit, returned: page.length, total: events.length } };
}
function traceAfterSeq(url: URL): number {
function traceAfterProjectedSeq(url: URL): number {
const cursor = url.searchParams.get("cursor") ?? "";
if (cursor.startsWith("seq:")) return Math.max(0, Number.parseInt(cursor.slice(4), 10) || 0);
return Math.max(0, Number.parseInt(url.searchParams.get("sinceSeq") ?? "0", 10) || 0);
if (cursor.startsWith("projected:")) return nonNegativeInteger(cursor.slice("projected:".length), 0);
return nonNegativeInteger(url.searchParams.get("afterProjectedSeq"), 0);
}
function workbenchTracePayload(traceId: string, url: URL): JsonRecord {
const payload = tracePayload(traceId, url);
if (state.scenarioId === "tool-completed-projection-running" && traceId === "trc_running") {
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention };
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention };
}
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention, ...projectionEnvelope(payload) };
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention, ...projectionEnvelope(payload) };
}
function projectionEnvelope(payload: JsonRecord): JsonRecord {
@@ -1915,3 +1920,8 @@ function positiveInteger(value: unknown, fallback: number): number {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function nonNegativeInteger(value: unknown, fallback: number): number {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : Math.max(0, Math.trunc(fallback));
}
@@ -36,14 +36,25 @@ test.describe("durable projector resume", () => {
expect(traceBody.projectionStatus).toBe("caught-up");
expect(traceBody.projectionHealth).toBe("caught-up");
expect(traceBody.events.map((event: { seq?: number }) => event.seq)).toEqual([1, 2, 3, 4, 5, 6, 7]);
expect(traceBody.range).toMatchObject({ afterSeq: 0, fromSeq: 1, toSeq: 7, returned: 7, total: 7 });
expect(traceBody.range).toMatchObject({ afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 7, returned: 7, total: 7 });
expect(traceBody.nextProjectedSeq).toBe(7);
expect(traceBody).not.toHaveProperty("nextSeq");
expect(traceBody).not.toHaveProperty("nextSinceSeq");
expect(traceBody.range).not.toHaveProperty("afterSeq");
expect(traceBody.range).not.toHaveProperty("fromSeq");
expect(traceBody.range).not.toHaveProperty("toSeq");
const emptyTracePage = await page.request.get(`/v1/workbench/traces/${traceId}/events?cursor=seq:999999&limit=5`);
const emptyTracePage = await page.request.get(`/v1/workbench/traces/${traceId}/events?cursor=projected:999999&limit=5`);
expect(emptyTracePage.status()).toBe(200);
const emptyTraceBody = await emptyTracePage.json();
expect(emptyTraceBody.events).toHaveLength(0);
expect(emptyTraceBody.range).toMatchObject({ afterSeq: 999999, fromSeq: null, toSeq: null, returned: 0, total: 7 });
expect(emptyTraceBody.nextSeq).toBe(999999);
expect(emptyTraceBody.range).toMatchObject({ afterProjectedSeq: 999999, fromProjectedSeq: null, toProjectedSeq: null, returned: 0, total: 7 });
expect(emptyTraceBody.nextProjectedSeq).toBe(999999);
expect(emptyTraceBody).not.toHaveProperty("nextSeq");
expect(emptyTraceBody).not.toHaveProperty("nextSinceSeq");
expect(emptyTraceBody.range).not.toHaveProperty("afterSeq");
expect(emptyTraceBody.range).not.toHaveProperty("fromSeq");
expect(emptyTraceBody.range).not.toHaveProperty("toSeq");
const finalCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).filter({ hasText: "fake projector resume completed from durable checkpoint" });
await expect(finalCard).toHaveAttribute("data-status", "completed");
@@ -0,0 +1,129 @@
import type { Locator, Page } from "@playwright/test";
import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
type TracePagePayload = {
events?: Array<{ seq?: number; projectedSeq?: number; terminal?: boolean; status?: string; label?: string }>;
eventCount?: number;
hasMore?: boolean;
nextProjectedSeq?: number | null;
nextCursor?: string | null;
range?: Record<string, unknown>;
[key: string]: unknown;
};
test.describe("Workbench golden e2e chain", () => {
test.use({ scenarioId: "live-rest-backfill-without-terminal-sse" });
test("new session reaches sealed final response, survives reload and archives by authority", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await page.locator(selectors.sessionCreate).click();
const activeTab = page.locator('.session-tab[data-active="true"]').first();
await expect(activeTab).toHaveAttribute("data-session-id", /^ses_/u);
const sessionId = await activeTab.getAttribute("data-session-id");
expect(sessionId).toBeTruthy();
if (!sessionId) throw new Error("expected active session id");
await page.locator(selectors.commandInput).fill("hi");
await expect(page.locator(selectors.commandSend)).toBeEnabled();
await page.locator(selectors.commandSend).click();
const agentCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).last();
await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-running", "true");
await expect(agentCard).toHaveAttribute("data-status", "running");
await expect(agentCard.locator(selectors.traceTimeline)).toHaveAttribute("data-status", "running");
await saveScreenshot(page, testInfo, "golden-running");
await expect(agentCard).toHaveAttribute("data-status", "completed", { timeout: 12_000 });
await expect(agentCard).toContainText("fake AgentRun completed after REST backfill without terminal SSE.");
await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-running", "false");
const trace = agentCard.locator(selectors.traceTimeline);
await expect(trace).toHaveAttribute("data-status", "completed");
await openTrace(trace);
await expect(trace.locator(`${selectors.traceRow}[data-terminal="true"]`)).toBeVisible();
const tracePage = await fetchTracePage(page, "trc_live_backfill", "afterProjectedSeq=4&limit=10");
expect(projectedSeqs(tracePage)).toEqual([5, 6, 7]);
expect(tracePage.range).toMatchObject({ afterProjectedSeq: 4, fromProjectedSeq: 5, toProjectedSeq: 7, returned: 3, total: 7 });
expect(tracePage.nextProjectedSeq).toBe(7);
expectNoLegacyTraceCursorFields(tracePage);
expect(tracePage.events?.at(-1)?.terminal).toBe(true);
await saveScreenshot(page, testInfo, "golden-completed");
await page.reload();
await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last()).toContainText("fake AgentRun completed after REST backfill without terminal SSE.");
await saveScreenshot(page, testInfo, "golden-reloaded");
await page.locator(sessionTab("ses_completed")).click();
await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true");
await page.locator(sessionTab(sessionId)).click();
await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last()).toContainText("fake AgentRun completed after REST backfill without terminal SSE.");
await page.locator(selectors.sessionDelete).click();
await expect(page.locator(sessionTab(sessionId))).toHaveCount(0);
const detail = await page.request.get(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`);
expect(detail.status()).toBe(200);
const detailBody = await detail.json() as { session?: { status?: string } };
expect(detailBody.session?.status).toBe("archived");
const state = await fakeServerState(page) as { legacyRequestLedger?: unknown[]; chatRequests?: unknown[] };
expect(state.legacyRequestLedger).toEqual([]);
expect(state.chatRequests).toHaveLength(1);
});
});
test.describe("long trace projected pagination", () => {
test.use({ scenarioId: "scroll-follow-long-trace" });
test("long trace fixture pages only by projected cursor", async ({ page }, testInfo) => {
await gotoWorkbench(page, "/workbench/sessions/ses_scroll_follow");
const firstPage = await fetchTracePage(page, "trc_scroll_follow", "afterProjectedSeq=0&limit=25");
expect(projectedSeqs(firstPage)).toEqual(Array.from({ length: 25 }, (_, index) => index + 1));
expect(firstPage.range).toMatchObject({ afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 25, returned: 25, total: 90 });
expect(firstPage.nextProjectedSeq).toBe(25);
expect(firstPage.nextCursor).toBe("projected:25");
expectNoLegacyTraceCursorFields(firstPage);
const laterPage = await fetchTracePage(page, "trc_scroll_follow", "cursor=projected:70&limit=10");
expect(projectedSeqs(laterPage)).toEqual(Array.from({ length: 10 }, (_, index) => index + 71));
expect(laterPage.range).toMatchObject({ afterProjectedSeq: 70, fromProjectedSeq: 71, toProjectedSeq: 80, returned: 10, total: 90 });
expect(laterPage.nextProjectedSeq).toBe(80);
expect(laterPage.nextCursor).toBe("projected:80");
expectNoLegacyTraceCursorFields(laterPage);
await expect(page.locator(`${selectors.traceTimeline}[data-status="running"]`).last()).toBeVisible();
await saveScreenshot(page, testInfo, "long-trace-projected-pagination");
});
});
async function fetchTracePage(page: Page, traceId: string, query: string): Promise<TracePagePayload> {
const response = await page.request.get(`/v1/workbench/traces/${encodeURIComponent(traceId)}/events?${query}`);
expect(response.status()).toBe(200);
return await response.json() as TracePagePayload;
}
function projectedSeqs(payload: TracePagePayload): number[] {
return (payload.events ?? []).map((event) => Number(event.projectedSeq ?? event.seq));
}
function expectNoLegacyTraceCursorFields(payload: TracePagePayload): void {
expect(payload).not.toHaveProperty("nextSeq");
expect(payload).not.toHaveProperty("nextSinceSeq");
expect(payload).not.toHaveProperty("sinceSeq");
expect(payload.range).not.toHaveProperty("afterSeq");
expect(payload.range).not.toHaveProperty("fromSeq");
expect(payload.range).not.toHaveProperty("toSeq");
expect(payload.range).not.toHaveProperty("sinceSeq");
}
async function openTrace(trace: Locator): Promise<void> {
const details = trace.locator("details.trace-disclosure");
const open = await details.evaluate((element) => (element as HTMLDetailsElement).open).catch(() => false);
if (!open) await trace.locator("summary.trace-disclosure-summary").click();
}