diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts new file mode 100644 index 0000000000..e9af320ecb --- /dev/null +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -0,0 +1,270 @@ +const words = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + "metro", + "nova", + "orbit", + "pixel", + "quartz", + "river", + "signal", + "vector", +] + +const sourceID = "ses_smoke_source" +const targetID = "ses_smoke_target" +const directory = "C:/OpenCode/SmokeProject" +const projectID = "proj_smoke_timeline" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type MessageInfo = Record & { id: string; role: "user" | "assistant" } +type MessagePart = Record & { id: string; type: string; text?: string; tool?: string } +type Message = { info: MessageInfo; parts: MessagePart[] } + +function lorem(seed: number, length: number) { + let out = "" + let i = seed + while (out.length < length) { + const word = words[i % words.length] + out += (out ? " " : "") + word + if (i % 17 === 0) out += ".\n\n" + i += 7 + } + return out.slice(0, length) +} + +function id(prefix: string, value: number) { + return `${prefix}_smoke_${String(value).padStart(4, "0")}` +} + +function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message { + const messageID = id("msg_user", index) + return { + info: { + id: messageID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs }, + agent: "build", + model, + }, + parts: [ + { + id: id("prt_user_text", index), + sessionID, + messageID, + type: "text", + text: lorem(index, textLength), + }, + ], + } +} + +function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message { + const messageID = id("msg_assistant", index) + return { + info: { + id: messageID, + sessionID, + role: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts: parts.map((part) => ({ + ...part, + sessionID, + messageID, + })), + } +} + +function textPart(index: number, partIndex: number, length: number): MessagePart { + return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) } +} + +function reasoningPart(index: number, partIndex: number, length: number): MessagePart { + return { + id: id(`prt_reasoning_${partIndex}`, index), + type: "reasoning", + text: lorem(index * 19 + partIndex, length), + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 }, + } +} + +function toolPart(index: number, partIndex: number, tool: string, input: Record, outputLength = 160): MessagePart { + const metadata = + tool === "apply_patch" + ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } + : tool === "edit" || tool === "write" + ? { + filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index), + diff: patch(index, outputLength), + preview: patch(index + 1, 420), + } + : tool === "question" + ? { answers: [["Proceed"], ["Keep sample output"]] } + : {} + return { + id: id(`prt_tool_${tool}_${partIndex}`, index), + type: "tool", + callID: id("call", index * 10 + partIndex), + tool, + state: { + status: "completed", + input, + output: lorem(index * 23 + partIndex, outputLength), + title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + metadata, + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, + }, + } +} + +function patchFile(seed: number, type: "add" | "update" | "delete") { + return { + filePath: `src/generated/patch-${seed}.ts`, + relativePath: `src/generated/patch-${seed}.ts`, + type, + additions: (seed % 7) + 1, + deletions: type === "add" ? 0 : seed % 4, + patch: patch(seed, 520), + before: type === "add" ? undefined : code(seed, 18), + after: type === "delete" ? undefined : code(seed + 1, 24), + } +} + +function fileDiff(file: string, seed: number) { + return { + file, + additions: (seed % 9) + 1, + deletions: seed % 4, + before: code(seed, 32), + after: code(seed + 1, 38), + } +} + +function patch(seed: number, length: number) { + return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}` +} + +function code(seed: number, lines: number) { + return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join("\n") +} + +function turn(index: number): Message[] { + const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : [] + const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff) + const parts = [ + ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []), + ...(index % 3 === 0 + ? [ + toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220), + toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140), + toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180), + toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120), + ] + : []), + textPart(index, 2, 160 + (index % 6) * 90), + ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []), + ...(index % 6 === 0 + ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] + : []), + ...(index % 8 === 0 ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), + ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] : []), + ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), + ...(index % 11 === 0 + ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] + : []), + ...(index % 13 === 0 + ? [ + toolPart( + index, + 11, + "question", + { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] }, + 120, + ), + ] + : []), + ...(index % 17 === 0 + ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)] + : []), + ] + return [user, assistantMessage(targetID, index, user.info.id, parts)] +} + +const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() +const sourceMessages = Array.from({ length: 12 }, (_, index) => [ + userMessage(sourceID, index + 1000, 120), + assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), +]).flat() + +function renderable(part: MessagePart) { + if (part.type === "tool" && part.tool === "todowrite") return false + if (part.type === "text") return !!part.text.trim() + if (part.type === "reasoning") return !!part.text.trim() + return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" +} + +function orderedParts(message: Message) { + return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) +} + +export const fixture = { + directory, + project: { id: projectID, worktree: directory, vcs: "git", name: "smoke-project", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + { id: sourceID, slug: "source", projectID, directory, title: "Uncommitted changes inquiry", version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }, + { id: targetID, slug: "target", projectID, directory, title: "Example Game: sample jump movement & sample physics analysis", version: "dev", time: { created: 1700000001000, updated: 1700000001000 } }, + ], + sourceID, + targetID, + messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, + expected: { + sourceTitle: "Uncommitted changes inquiry", + targetTitle: "Example Game: sample jump movement & sample physics analysis", + targetMessageIDs: targetMessages.filter((message) => message.info.role === "user").map((message) => message.info.id), + targetPartIDs: targetMessages.flatMap((message) => orderedParts(message).filter(renderable).map((part) => part.id)), + }, +} + +export function pageMessages(sessionID: string, limit: number, before?: string) { + const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] + const end = before ? Math.max(0, messages.findIndex((message) => message.info.id === before)) : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } +} diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts new file mode 100644 index 0000000000..868f1bcf44 --- /dev/null +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -0,0 +1,389 @@ +import { expect, test, type Page } from "@playwright/test" +import { fixture, pageMessages } from "./session-timeline.fixture" +import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors" +import { mockOpenCodeServer } from "../utils/mock-server" + +const forbiddenText = ["Load details", "Show earlier steps"] + +type SmokeState = { + ids: string[] + visibleIds: string[] + messageIds: string[] + visibleMessageIds: string[] + topVisibleId?: string + signature: string + scrollTop: number + scrollHeight: number + clientHeight: number + errorToasts: string[] + forbiddenText: string[] +} + +type SmokeWindow = Window & { + __timelineSmokeState?: () => SmokeState + __timelineSmokeErrorToasts?: string[] + __timelineSmokeForbiddenText?: string[] +} + +test.describe("smoke: session timeline", () => { + test.setTimeout(240_000) + + test("renders seeded timeline in order while paging through history", async ({ page }) => { + const errors = trackPageErrors(page) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page) + + await openProject(page, "SmokeProject") + await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle) + await expectSessionReady(page, "smoke-project") + await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle) + const expectedPartIDs = fixture.expected.targetPartIDs + const expectedMessageIDs = fixture.expected.targetMessageIDs + await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) + await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + }) +}) + +async function configureSmokePage(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + showSessionProgressBar: true, + }, + }), + ) + + const smoke = window as SmokeWindow + smoke.__timelineSmokeErrorToasts = [] + smoke.__timelineSmokeForbiddenText = [] + const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]" + const idsOf = (el: HTMLElement) => + [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id) + + smoke.__timelineSmokeState = () => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row], [data-session-title]"), + ) + if (!scroller) { + return { + ids: [], + visibleIds: [], + messageIds: [], + visibleMessageIds: [], + topVisibleId: undefined, + signature: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + errorToasts: smoke.__timelineSmokeErrorToasts ?? [], + forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], + } + } + + const ids: string[] = [] + const visibleIds: string[] = [] + const scrollerRect = scroller.getBoundingClientRect() + let topVisibleId: string | undefined + for (const el of scroller.querySelectorAll(partSelector)) { + const next = idsOf(el) + ids.push(...next) + + const rect = el.getBoundingClientRect() + if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) { + if (!topVisibleId) topVisibleId = next[0] + visibleIds.push(...next) + } + } + + const messageIds: string[] = [] + const visibleMessageIds: string[] = [] + const rows = [...scroller.querySelectorAll("[data-message-id]")].map((el) => { + const rect = el.getBoundingClientRect() + const id = el.dataset.messageId + if (id) { + messageIds.push(id) + if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id) + } + return { + id, + top: Math.round(rect.top), + bottom: Math.round(rect.bottom), + } + }) + const signature = JSON.stringify({ + top: Math.round(scroller.scrollTop), + height: Math.round(scroller.scrollHeight), + rows, + ids, + }) + + return { + ids, + visibleIds, + messageIds, + visibleMessageIds, + topVisibleId, + signature, + scrollTop: Math.round(scroller.scrollTop), + scrollHeight: Math.round(scroller.scrollHeight), + clientHeight: Math.round(scroller.clientHeight), + errorToasts: smoke.__timelineSmokeErrorToasts ?? [], + forbiddenText: smoke.__timelineSmokeForbiddenText ?? [], + } + } + let recordFrame: number | undefined + const record = () => { + for (const toast of document.querySelectorAll('[data-component="toast"][data-variant="error"]')) { + const text = toast.textContent?.trim() + if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text) + } + const text = document.body?.textContent ?? "" + for (const value of ["Load details", "Show earlier steps"]) { + if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) { + smoke.__timelineSmokeForbiddenText!.push(value) + } + } + } + const start = () => { + const root = document.documentElement ?? document.body + if (!root) return + new MutationObserver(() => { + if (recordFrame) return + recordFrame = requestAnimationFrame(() => { + recordFrame = undefined + record() + }) + }).observe(root, { childList: true, subtree: true }) + record() + } + if (document.documentElement ?? document.body) start() + else document.addEventListener("DOMContentLoaded", start, { once: true }) + }) +} + +async function expectCanScrollToStart(page: Page, expectedPartIDs: string[], expectedMessageIDs: string[], errors: string[]) { + await pointAtTimeline(page) + const seenParts = new Set() + const seenMessages = new Set() + const samples: TraversalSample[] = [] + let current = await timelineState(page) + let unchangedAtTop = 0 + + for (let attempt = 0; attempt < 600; attempt++) { + collectSeen(current, seenParts, seenMessages) + samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) + expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText) + expectOrderedIDs(expectedPartIDs, current.ids, "mounted part") + expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part") + expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message") + expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message") + + if (current.scrollTop <= 1 && seenParts.size === expectedPartIDs.length && seenMessages.size === expectedMessageIDs.length) { + expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) + return + } + + const before = current + const changed = await scrollTimelineUp(page, current) + current = await timelineState(page) + if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++ + else unchangedAtTop = 0 + if (unchangedAtTop >= 2) break + } + + collectSeen(current, seenParts, seenMessages) + samples.push(sampleTraversal(current, seenParts.size, seenMessages.size)) + expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples) +} + +async function timelineState(page: Page) { + return page.evaluate( + () => + (window as SmokeWindow).__timelineSmokeState?.() ?? { + ids: [], + visibleIds: [], + messageIds: [], + visibleMessageIds: [], + topVisibleId: undefined, + signature: "", + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + errorToasts: [], + forbiddenText: [], + }, + ) +} + +function timelineScroller(page: Page) { + return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) +} + +async function pointAtTimeline(page: Page) { + const box = await timelineScroller(page).boundingBox() + if (!box) throw new Error("Timeline scroller is not visible") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) +} + +async function scrollTimelineUp(page: Page, before: SmokeState) { + return page.evaluate( + (prev) => + new Promise((resolve) => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row], [data-session-title]"), + ) + if (!scroller) { + resolve(false) + return + } + + scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 })) + scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45))) + + const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + let frames = 0 + let stableFrames = 0 + let last = "" + let changed = false + const check = () => { + const current = read() + if (current !== prev) changed = true + if (current === last) stableFrames++ + else { + stableFrames = 0 + last = current + } + if (changed && stableFrames >= 2) { + resolve(true) + return + } + frames++ + if (frames >= 30) { + resolve(changed) + return + } + requestAnimationFrame(check) + } + requestAnimationFrame(check) + }), + before.signature, + ) +} + +function expectOrderedIDs(expected: string[], actual: string[], label: string) { + expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0) + const actualSet = new Set(actual) + expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id))) +} + +function unique(values: string[]) { + return values.filter((value, index) => values.indexOf(value) === index) +} + +function collectSeen(state: SmokeState, seenParts: Set, seenMessages: Set) { + for (const id of state.ids) seenParts.add(id) + for (const id of state.visibleIds) seenParts.add(id) + for (const id of state.messageIds) seenMessages.add(id) + for (const id of state.visibleMessageIds) seenMessages.add(id) +} + +type TraversalSample = ReturnType + +function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) { + return { + seenParts, + seenMessages, + mounted: state.ids.length, + visible: state.visibleIds.length, + mountedMessages: unique(state.messageIds).length, + visibleMessages: unique(state.visibleMessageIds).length, + top: state.scrollTop, + height: state.scrollHeight, + first: state.ids[0], + last: state.ids.at(-1), + topVisible: state.topVisibleId, + visibleFirst: state.visibleIds[0], + visibleLast: state.visibleIds.at(-1), + } +} + +function sampleSummary(samples: TraversalSample[]) { + return samples + .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1) + .map( + (sample, index) => + `${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`, + ) + .join("\n") +} + +async function waitForTimelineStable(page: Page) { + await page.waitForFunction( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + requestAnimationFrame(() => { + const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? "" + requestAnimationFrame(() => + resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")), + ) + }) + }) + }), + ) +} + +async function expectSessionTimelineReady(page: Page, expectedPartIDs: string[], expectedMessageIDs: string[], errors: string[]) { + await waitForTimelineStable(page) + for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0) + const currentState = await timelineState(page) + expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText) + expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part") + expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part") + expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message") + expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message") +} + +function expectCompleteScroll( + state: SmokeState, + expectedPartIDs: string[], + expectedMessageIDs: string[], + seenParts: Set, + seenMessages: Set, + samples: TraversalSample[], +) { + expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1) + expect(expectedPartIDs.filter((id) => !seenParts.has(id)), `missing visible timeline parts\n${sampleSummary(samples)}`).toEqual([]) + expect(expectedMessageIDs.filter((id) => !seenMessages.has(id)), `missing visible messages\n${sampleSummary(samples)}`).toEqual([]) + expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) + expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) + expect(expectedPartIDs.length).toBe(331) +} + +async function openProject(page: Page, projectName: string) { + await page.goto("/") + await page.getByRole("button", { name: new RegExp(projectName, "i") }).click() +} + +async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) { + // Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation + await page.locator(`a[href*="${sessionId}"]`).first().evaluate((el) => (el as HTMLElement).click()) + await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible() +} + +async function expectSessionReady(page: Page, projectName: string) { + await expect(page.getByText(projectName).first()).toBeVisible() + await expect(page.getByText("Ask anything...")).toBeVisible() +} diff --git a/packages/app/e2e/todo.spec.ts b/packages/app/e2e/todo.spec.ts deleted file mode 100644 index dac2d8ee82..0000000000 --- a/packages/app/e2e/todo.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { test } from "@playwright/test" - -test( - "test something cool", - { - annotation: { type: "todo" }, - }, - async () => { - test.fixme() - }, -) diff --git a/packages/app/e2e/utils/errors.ts b/packages/app/e2e/utils/errors.ts new file mode 100644 index 0000000000..74eb302b84 --- /dev/null +++ b/packages/app/e2e/utils/errors.ts @@ -0,0 +1,18 @@ +import { expect, type Page } from "@playwright/test" + +export function trackPageErrors(page: Page) { + const errors: string[] = [] + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()) + }) + page.on("pageerror", (error) => errors.push(error.stack ?? error.message)) + return errors +} + +export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) { + expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({ + consoleErrors: [], + toastErrors: [], + forbiddenText: [], + }) +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts new file mode 100644 index 0000000000..2ff1994d2f --- /dev/null +++ b/packages/app/e2e/utils/mock-server.ts @@ -0,0 +1,77 @@ +import type { Page, Route } from "@playwright/test" + +const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"]) + +export interface MockServerConfig { + provider: unknown + directory: string + project: unknown + sessions: ({ id: string } & Record)[] + pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } +} + +export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { + const staticRoutes: Record = { + "/provider": config.provider, + "/path": { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }, + "/project": [config.project], + "/project/current": config.project, + "/agent": [{ name: "build", mode: "primary" }], + "/vcs": { branch: "main", default_branch: "main" }, + "/session": config.sessions, + } + + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + if (url.port !== targetPort) return route.fallback() + + const path = url.pathname + if (path === "/global/event" || path === "/event") return sse(route) + if (emptyObject.has(path)) return json(route, {}) + if (emptyList.has(path)) return json(route, []) + if (path in staticRoutes) return json(route, staticRoutes[path]) + + const sessionMatch = path.match(/^\/session\/([^/]+)$/) + if (sessionMatch) { + const session = config.sessions.find((s) => s.id === sessionMatch[1]) + return json(route, session ?? {}) + } + + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) + + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) + if (messagesMatch) { + const limit = Number(url.searchParams.get("limit") ?? 80) + const before = url.searchParams.get("before") ?? undefined + const pageData = config.pageMessages(messagesMatch[1], limit, before) + return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) + } + + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, headers?: Record) { + return route.fulfill({ + status: 200, + contentType: "application/json", + headers: { + "access-control-allow-origin": "*", + "access-control-expose-headers": "x-next-cursor", + ...headers, + }, + body: JSON.stringify(body ?? null), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +}