diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 0de30c5b481..10d34aeb8a9 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -123,6 +123,7 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: project(), provider: provider(), @@ -467,7 +468,7 @@ export function toolPart( input: Record, options: ToolOptions = {}, ): Omit { - const base = { id, type: "tool" as const, callID: `call_${id}`, tool } + const base = { id, type: "tool" as const, callID: id, tool } if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } if (state === "running") return { diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 7cc7b32f0ad..ccdfcef0407 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -14,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } }) test("opens and searches project files inline", async ({ page }) => { const searches: { query: string; dirs?: string; limit?: number }[] = [] await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 878eb84e372..b91ba13a9e4 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -54,6 +54,7 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 72d92188c56..2ccae4a8edd 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -25,6 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 6c253f462ac..5ea9d4f7613 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -173,6 +173,7 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, 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 053999e5e20..a9a4738da92 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 = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] -const followingTextID = `${id("msg_assistant", 10)}:text:0` +const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"] +const followingTextID = "prt_0104_text" 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: "prt_0104_text", + id: followingTextID, sessionID, messageID: assistantID, type: "text", @@ -295,7 +295,7 @@ function contextTool( sessionID, messageID, type: "tool", - callID: partID, + callID: `call_${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 a259628f766..e5ef7998ea7 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -10,6 +10,7 @@ import { status, textPart, title, + userID, userMessage, } from "../performance/timeline-stability/fixture" import { mockOpenCodeServer } from "../utils/mock-server" @@ -18,22 +19,18 @@ import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 const historyPageSize = 200 -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 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 lastAssistant = assistants.at(-1)! -const lastPartID = `${assistants.at(-1)!.info.id}:text:0` -const userPartID = `${messages.at(-2)!.info.id}:text:0` +const lastPartID = assistants.at(-1)!.parts[0]!.id +const userPartID = `prt_${userID}_text` const completed = { ...lastAssistant.info, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, @@ -157,23 +154,15 @@ 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() - 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) - } + await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) - expect(sequence.slice(0, 3)).toEqual([ + expect(sequence.slice(0, 4)).toEqual([ "messages:start:latest", "messages:end:latest", + `message:${userID}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) - await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount( - initialPageSize / 2, - ) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { @@ -185,9 +174,7 @@ for (const scenario of scenarios) { expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect - .poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count()) - .toBeGreaterThan(initialPageSize / 2) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) @@ -195,7 +182,7 @@ for (const scenario of scenarios) { { before: undefined, limit: initialPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, ]) - expect(roots).toEqual([]) + expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) const idle = status("idle") diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index c0fa157c6eb..55e71212753 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -23,7 +23,7 @@ type EventPayload = { test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) -test("animates todo opening without replaying it across session tabs", async ({ page }) => { +test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } @@ -86,8 +86,28 @@ test("animates todo opening without replaying it across session tabs", async ({ await switchSession(page, otherID, otherTitle) await expect(dock).toHaveCount(0) + const returningOpen = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + const openSamples = (await returningOpen).filter((sample) => sample.present) + expect(openSamples.length).toBeGreaterThan(0) + expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) + expect(openSamples[0]!.height).toBeGreaterThan(70) + await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) + + const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) + const closing = sampleDock(page, 1_000) + todos[sourceID] = completedTodos + events.push(todoEvent(sourceID, completedTodos)) + await expect(dock).toHaveCount(0) + expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + todos[sourceID] = [] + events.push(todoEvent(sourceID, [])) + + await switchSession(page, otherID, otherTitle) + const returningEmpty = sampleDock(page, 700) await switchSession(page, sourceID, sourceTitle) await expect(dock).toHaveCount(0) + expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) }) function session(id: string, title: string, created: number) { diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 633dcb485ec..8e08d60ff2a 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -10,6 +10,7 @@ const title = "Hidden terminal regression" test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 5b8d53affaa..165920753cb 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -66,6 +66,7 @@ async function readProbe(page: Page) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 939c8bd3f30..3dce37cafd9 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:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const serverKey = "http://127.0.0.1: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 * 100 + partIndex), + callID: id("call", index * 10 + partIndex), tool, state: { status: "completed", @@ -235,17 +235,8 @@ function renderable(part: MessagePart) { return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" } -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() +function orderedParts(message: Message) { + return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) } export const fixture = { @@ -299,10 +290,12 @@ export const fixture = { targetMessageIDs: targetMessages .filter((message) => message.info.role === "user") .map((message) => message.info.id), - targetPartIDs: targetMessages.flatMap(currentPartIDs), - expandedShellPartID: targetMessages - .flatMap((message) => message.parts) - .find((part) => part.tool === "bash")!.callID, + targetPartIDs: targetMessages.flatMap((message) => + orderedParts(message) + .filter(renderable) + .map((part) => part.id), + ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 1c3375ac4ef..bdf3f55bdc1 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -125,19 +125,20 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ server, sourceID, targetID }) => { + ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server, + server: "http://127.0.0.1:4096", + dirBase64, sessionId, })), ), ) }, - { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, ) await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) @@ -250,19 +251,20 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) await page.addInitScript( - ({ server, sourceID, targetID }) => { + ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", - server, + server: "http://127.0.0.1:4096", + dirBase64, sessionId, })), ), ) }, - { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, + { dirBase64: base64Encode(fixture.directory), 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/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index c92e6bd4f45..b8b379d05c1 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -6,6 +6,7 @@ const directory = "C:/OpenCode/NewProject" test("creates a session in a new project and selects its model", async ({ page }) => { await mockOpenCodeServer(page, { + protocol: "v1", directory, project: { id: "proj_model_selection_flow", diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 4cf37483055..2cd7d8defd1 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -8,10 +8,15 @@ import type { SessionStructuredError, } from "@opencode-ai/client/promise" +const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) + export interface MockServerConfig { + protocol?: "v1" | "v2" provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -36,6 +41,20 @@ export interface MockServerConfig { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 + const staticRoutes: Record = { + "/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" @@ -45,15 +64,60 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/api/event") { + if (path === "/global/event" || path === "/event" || path === "/api/event") { const events = config.events?.() return sse( route, - [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])], + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : [ + ...(path === "/global/event" + ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] + : []), + ...(events ?? []), + ], config.eventRetry, ) } - if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) + if (path === "/provider") return json(route, providerConfig(config)) + if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) + const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] + if (legacyAuth && route.request().method() === "PUT") { + config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) + return json(route, true) + } + if (path === "/instance/dispose" && route.request().method() === "POST") { + config.onInstanceDispose?.() + return json(route, true) + } + if (path === "/permission") + return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) + if (path === "/question") + return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) + if (path === "/session/status") + return json( + route, + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), + ) + if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) + if (path === "/file" && config.fileList) + return json(route, await config.fileList(url.searchParams.get("path") ?? "")) + if (path === "/file/content" && config.fileContent) + return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) if (path === "/api/reference") return json(route, { location: { @@ -218,6 +282,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true) + if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") + return json(route, true) if ( /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && route.request().method() === "POST" @@ -227,6 +294,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (emptyObject.has(path)) return json(route, {}) + if (emptyList.has(path)) return json(route, []) + if (path in staticRoutes) return json(route, staticRoutes[path]) const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) if (currentSessionMatch) { @@ -246,6 +316,25 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, { data: currentMessage(message) }) } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) + if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {}) + + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + + const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) + if (messageMatch) { + config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) + return json(route, message) + } + + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) + if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) if (currentMessagesMatch) { const token = url.searchParams.get("cursor") ?? undefined @@ -264,6 +353,22 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) + if (messagesMatch) { + const token = url.searchParams.get("before") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) + if (!pageData.cursor) return json(route, pageData.items) + const cursor = `cursor_${++nextCursor}` + cursors.set(cursor, pageData.cursor) + return json(route, pageData.items, { "x-next-cursor": cursor }) + } + if (url.port === targetPort && targetPort !== appPort) return json(route, {}) return route.fallback() }) diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index a245082bc71..b0e3b74c6d9 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/api/event" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -174,7 +174,10 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.origin !== server || url.pathname !== "/api/event") + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -190,9 +193,18 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) + if (url.pathname === "/global/event") + controller.enqueue( + encoder.encode( + frame({ + payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, + }), + ), + ) request.signal.addEventListener( "abort", () => {