test(web): add workbench golden reliability e2e
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user