From 81d5a3973d82d32d768eb293223fbf4c246b47a2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 30 Jul 2026 12:58:24 +0800 Subject: [PATCH] fix(app): update remaining v2 e2e fixtures --- .../remote-session-settings.spec.ts | 58 ++++++++++++---- .../e2e/regression/review-open-file.spec.ts | 2 +- .../review-state-persistence.spec.ts | 38 +++++------ .../review-terminal-stacked.spec.ts | 68 +++++++------------ .../session-timeline-collapse-state.spec.ts | 2 +- .../session-timeline-context-resize.spec.ts | 8 +-- .../session-timeline-history-root.spec.ts | 49 ++++++++----- .../session-timeline-lifecycle-state.spec.ts | 2 +- ...ession-timeline-reducer-projection.spec.ts | 7 +- .../session-timeline-shell-outline.spec.ts | 1 - .../session-timeline-transport.spec.ts | 2 +- .../app/e2e/smoke/session-timeline.fixture.ts | 27 +++++--- .../app/e2e/smoke/session-timeline.spec.ts | 14 ++-- packages/app/e2e/utils/mock-server.ts | 27 +++++--- 14 files changed, 170 insertions(+), 135 deletions(-) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index d17b2b4b15..3e6d0791a9 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { installSseTransport } from "../utils/sse-transport" import { currentSession } from "../utils/mock-server" -const serverA = "http://127.0.0.1:4096" +const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const serverB = "http://127.0.0.1:4097" const directoryA = "C:/server-a" const directoryB = "/home/server-b" @@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => { .poll(() => permissionRequests.some((request) => { const url = new URL(request) - return url.origin === serverB && url.searchParams.get("directory") === directoryB + return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB }), ) .toBe(true) @@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .poll(() => permissionRequests.some((request) => { const url = new URL(request) - return url.origin === serverA && url.searchParams.get("directory") === directoryA + return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA }), ) .toBe(true) @@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: sessionA.id, permissionID: "permission-background-a", - body: { response: "once" }, + body: { reply: "once" }, }, ]) @@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: sessionA.id, permissionID: "permission-background-a", - body: { response: "once" }, + body: { reply: "once" }, }, { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: childSessionA.id, permissionID: "permission-background-a-child", - body: { response: "once" }, + body: { reply: "once" }, }, ]) }) @@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR const remote = url.origin === serverB const directory = remote ? directoryB : directoryA const sessions = remote ? [sessionB] : [sessionA, childSessionA] - const requestDirectory = url.searchParams.get("directory") - const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + const requestDirectory = url.searchParams.get("location[directory]") + const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/) if (route.request().method() === "POST" && response) { permissionResponses.push({ origin: url.origin, @@ -183,10 +183,19 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (url.pathname === "/api/event") return sse(route) - if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") - return json(route, { data: [] }) - if (url.pathname === "/api/model/default") return json(route, { data: null }) - if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + if (url.pathname === "/api/provider") + return json(route, { + location: { directory }, + data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }], + }) + if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] }) + if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) }) + if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/permission/request") { + permissionRequests.push(url.toString()) + return json(route, { location: { directory }, data: [] }) + } + if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname)) return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp/resource") @@ -284,6 +293,25 @@ function provider(id: string) { } } +function model(remote: boolean) { + const id = remote ? "server-b" : "server-a" + const name = remote ? "Server B" : "Server A" + return { + id, + modelID: id, + providerID: id, + name: `${name} Model`, + family: id, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: Date.now() }, + cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }], + status: "active", + enabled: true, + limit: { context: 200_000, output: 32_000 }, + } +} + function json(route: Route, body: unknown, status = 200) { return route.fulfill({ status, diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 04e6d2cced..7cc7b32f0a 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -127,7 +127,7 @@ test("opens and searches project files inline", async ({ page }) => { await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() - expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) + expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 6c27ad6467..878eb84e37 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -19,36 +19,25 @@ test("restores review mode and selected file per session", async ({ page }) => { await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() - await selectMode(page, "Git changes", "Branch changes") - await selectFile(page, "beta.ts") + await selectFile(page, "alpha.ts") await switchSession(page, titleB) await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await selectFile(page, "gamma.ts") await switchSession(page, titleA) - await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() - await expectSelectedFile(page, "beta.ts") - await selectMode(page, "Branch changes", "Git changes") await expectSelectedFile(page, "alpha.ts") - await selectMode(page, "Git changes", "Branch changes") - await expectSelectedFile(page, "beta.ts") await page.reload() await expectSessionTitle(page, titleA) - await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() - await expectSelectedFile(page, "beta.ts") + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await expectSelectedFile(page, "alpha.ts") await switchSession(page, titleB) await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await expectSelectedFile(page, "gamma.ts") }) -async function selectMode(page: Page, current: string, next: string) { - await page.getByRole("button", { name: current }).click() - await page.getByRole("option", { name: next }).dispatchEvent("click") -} - async function selectFile(page: Page, file: string) { await page.getByRole("button", { name: file }).click() await expectSelectedFile(page, file) @@ -88,22 +77,27 @@ async function setup(page: Page) { sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], pageMessages: () => ({ items: [] }), }) - await page.route(/\/vcs(?:\?.*)?$/, (route) => + await page.route(/\/api\/vcs(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: { branch: "feature", defaultBranch: "dev" }, + }), }), ) - await page.route("**/vcs/diff**", (route) => + await page.route("**/api/vcs/diff**", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - new URL(route.request().url()).searchParams.get("mode") === "branch" - ? [diff("src/alpha.ts"), diff("src/beta.ts")] - : [diff("src/alpha.ts"), diff("src/gamma.ts")], - ), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + }), }), ) await page.addInitScript( diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 9eb21297ed..72d92188c5 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -61,33 +61,32 @@ test("keeps the review tree and terminal sized when both panels are open", async events: () => events.splice(0, 1), eventRetry: 16, }) - await page.route(/\/vcs(?:\?.*)?$/, (route) => + await page.route(/\/api\/vcs(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ - branch: "review-pane-performance", - default_branch: "dev", + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: { branch: "review-pane-performance", defaultBranch: "dev" }, }), }), ) - await page.route("**/vcs/diff**", (route) => { + await page.route("**/api/vcs/diff**", (route) => { const url = new URL(route.request().url()) - const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/") const detail = scope?.endsWith("/src/branch/d00027") if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - url.searchParams.get("mode") === "branch" - ? detail - ? branchDiffs - .filter((diff) => diff.file.startsWith("src/branch/d00027/")) - .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) - : branchDiffs - : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), - ), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory, canonical: directory } }, + data: detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs, + }), }) }) await page.route("**/pty*", (route) => @@ -148,9 +147,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) await expect(page.locator("#review-panel")).toBeVisible() - await expectTree(page, 8, "git-0.ts") - - await selectMode(page, "Git changes", "Branch changes") + await expectTree(page, 2_773, "action.yml") await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() @@ -173,9 +170,9 @@ test("keeps the review tree and terminal sized when both panels are open", async expect(bottomGap).toBeLessThanOrEqual(16) const lazyDiff = page.waitForRequest((request) => { const url = new URL(request.url()) - return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + return ( + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) await lastFile.click() @@ -189,59 +186,46 @@ test("keeps the review tree and terminal sized when both panels are open", async const refreshedDiff = page.waitForRequest((request) => { const url = new URL(request.url()) return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) sessionStatus[sessionID] = { type: "idle" } events.push(statusEvent("idle")) await refreshedDiff await expect(preview).toContainText("after-2") - await selectMode(page, "Branch changes", "Git changes") - await expectTree(page, 8, "git-0.ts") - await page.getByRole("button", { name: "git-0.ts" }).click() - await selectMode(page, "Git changes", "Branch changes") - await expectTree(page, 2_773, "action.yml") - const filter = page.getByRole("searchbox", { name: "Filter files" }) await filter.fill("generated-2738") await expectTree(page, 1, "generated-2738.ts") await filter.fill("") - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.getByRole("button", { name: "Toggle file tree" }).click() await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) await page.getByRole("button", { name: "Toggle file tree" }).click() - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toHaveCount(0) - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.getByRole("button", { name: "Toggle review" }).click() await expect(page.locator("#review-panel")).toHaveCount(0) await page.getByRole("button", { name: "Toggle review" }).click() - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await page.setViewportSize({ width: 1_000, height: 700 }) - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await expectStackGeometry(page) await page.setViewportSize({ width: 1_000, height: 120 }) await page.setViewportSize({ width: 1_400, height: 900 }) - await expectTree(page, 2_773, "action.yml") + await expectTree(page, 2_773, "generated-2738.ts") await expectStackGeometry(page) }) -async function selectMode(page: Page, current: string, next: string) { - await page.getByRole("button", { name: current }).click() - const option = page.getByRole("option", { name: next }) - await expect(option).toBeVisible() - await option.click() -} - async function expectTree(page: Page, total: number, file: string) { await expectMountedTree(page, total) await expect(page.getByRole("button", { name: file })).toBeVisible() diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 5b6e0b127b..6023467fdd 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -52,7 +52,7 @@ const editPart = { sessionID, messageID: assistantMessageID, type: "tool", - callID: "call_edit_regression", + callID: editPartID, tool: "edit", state: { status: "completed", diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a9a4738da9..053999e5e2 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression" const sessionID = "ses_context_resize_regression" const title = "Context resize regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] -const followingTextID = "prt_0104_text" +const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] +const followingTextID = `${id("msg_assistant", 10)}:text:0` type Message = { info: Record & { id: string; role: "user" | "assistant" } @@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" = ), contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), { - id: followingTextID, + id: "prt_0104_text", sessionID, messageID: assistantID, type: "text", @@ -295,7 +295,7 @@ function contextTool( sessionID, messageID, type: "tool", - callID: `call_${partID}`, + callID: partID, tool, state: { status, diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index e5ef7998ea..a259628f76 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -10,7 +10,6 @@ import { status, textPart, title, - userID, userMessage, } from "../performance/timeline-stability/fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 const historyPageSize = 200 -const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => - assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { - id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, - parentID: userID, - created: 1700000001000 + index * 1_000, - completed: index < initialPageSize, - }), -) -const messages = [userMessage(), ...assistants] +const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => { + const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` + return [ + userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: id, + created: 1700000001000 + index * 2_000, + completed: index < initialPageSize, + }), + ] +}).flat() +const assistants = messages.filter((message) => message.info.role === "assistant") const lastAssistant = assistants.at(-1)! -const lastPartID = assistants.at(-1)!.parts[0]!.id -const userPartID = `prt_${userID}_text` +const lastPartID = `${assistants.at(-1)!.info.id}:text:0` +const userPartID = `${messages.at(-2)!.info.id}:text:0` const completed = { ...lastAssistant.info, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, @@ -154,15 +157,23 @@ for (const scenario of scenarios) { await expectSessionTitle(page, title) await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() - await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) + const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await viewport.hover() + const deadline = Date.now() + 10_000 + while (requests.filter((request) => request.phase === "start").length < 2) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) - expect(sequence.slice(0, 4)).toEqual([ + expect(sequence.slice(0, 3)).toEqual([ "messages:start:latest", "messages:end:latest", - `message:${userID}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) + await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount( + initialPageSize / 2, + ) await page.evaluate(() => { ;( window as Window & { @@ -174,7 +185,9 @@ for (const scenario of scenarios) { expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect + .poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count()) + .toBeGreaterThan(initialPageSize / 2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) @@ -182,7 +195,7 @@ for (const scenario of scenarios) { { before: undefined, limit: initialPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, ]) - expect(roots).toEqual([{ sessionID, messageID: userID }]) + expect(roots).toEqual([]) const message = messageUpdated(scenario.info) const idle = status("idle") diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index b303071c87..1d83ea8108 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag await timeline.send(status("idle"), 350) await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response") }) function lines(count: number) { diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index ad35eef601..5db1051686 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries", ] await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() + await expect( + page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'), + ).toBeVisible() await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) + await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4) }) test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts index 54139cc371..4491515b15 100644 --- a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -145,7 +145,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async }), ], }) - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 81b9757e38..681e1bcd66 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -111,6 +111,6 @@ test("passes through non-event fetches", async ({ page }) => { return response.json() }) - expect(health).toEqual({ healthy: true }) + expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 }) expect(await timeline.transport.connections()).toHaveLength(1) }) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 3dce37cafd..939c8bd3f3 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,7 +21,7 @@ const words = [ "vector", ] -const serverKey = "http://127.0.0.1:4096" +const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -134,7 +134,7 @@ function toolPart( return { id: id(`prt_tool_${tool}_${partIndex}`, index), type: "tool", - callID: id("call", index * 10 + partIndex), + callID: id("call", index * 100 + partIndex), tool, state: { status: "completed", @@ -235,8 +235,17 @@ function renderable(part: MessagePart) { 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)) +function currentPartIDs(message: Message) { + const ordinals = { text: 0, reasoning: 0 } + return message.parts + .flatMap((part) => { + if (!renderable(part)) return [] + if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`] + if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`] + if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id] + return [] + }) + .sort() } export const fixture = { @@ -290,12 +299,10 @@ export const fixture = { 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), - ), - expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, + targetPartIDs: targetMessages.flatMap(currentPartIDs), + expandedShellPartID: targetMessages + .flatMap((message) => message.parts) + .find((part) => part.tool === "bash")!.callID, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index bdf3f55bdc..1c3375ac4e 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -125,20 +125,19 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ dirBase64, sourceID, targetID }) => { + ({ server, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server: "http://127.0.0.1:4096", - dirBase64, + server, sessionId, })), ), ) }, - { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) @@ -251,20 +250,19 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ dirBase64, sourceID, targetID }) => { + ({ server, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server: "http://127.0.0.1:4096", - dirBase64, + server, sessionId, })), ), ) }, - { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) await expectSessionTitle(page, fixture.expected.sourceTitle) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 58cfdcea0d..4cf3748305 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -243,7 +243,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!) if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, currentMessage(message)) + return json(route, { data: currentMessage(message) }) } const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) @@ -306,7 +306,12 @@ function currentModels(value: unknown) { providerID: provider.id, name: model.name, capabilities: { tools: true, input: ["text"], output: ["text"] }, - variants: [], + variants: record(model.variants) + ? Object.entries(model.variants).map(([id, settings]) => ({ + id, + ...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}), + })) + : [], time: { released: Date.now() }, cost: [ { @@ -577,14 +582,20 @@ function finish(value: unknown): SessionMessageAssistant["finish"] | undefined { } function jsonRecord(value: unknown): Record | undefined { - if (!record(value) || !Object.values(value).every(jsonValue)) return - return value as Record + if (!record(value)) return + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + const next = jsonValue(item) + return next === undefined ? [] : [[key, next]] + }), + ) } -function jsonValue(value: unknown): value is JsonValue { - if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true - if (Array.isArray(value)) return value.every(jsonValue) - return record(value) && Object.values(value).every(jsonValue) +function jsonValue(value: unknown): JsonValue | undefined { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null) + return jsonRecord(value) } function record(value: unknown): value is Record {