From ca3ccbab8d35e8369f3a84ed6ec3c9b334f270a6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 1 Apr 2026 22:48:24 -0400 Subject: [PATCH 01/23] test(app): add golden-path e2e fixtures --- packages/app/e2e/backend.ts | 3 +- packages/app/e2e/fixtures.ts | 278 +++++++++++++++--- packages/app/e2e/prompt/prompt-async.spec.ts | 43 +-- .../app/e2e/prompt/prompt-history.spec.ts | 89 ++---- packages/app/e2e/prompt/prompt.spec.ts | 46 +-- packages/opencode/src/provider/provider.ts | 17 ++ packages/opencode/test/lib/llm-server.ts | 46 ++- 7 files changed, 343 insertions(+), 179 deletions(-) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 4dfa7c64f0..9febc4b3ff 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -62,7 +62,7 @@ function tail(input: string[]) { return input.slice(-40).join("") } -export async function startBackend(label: string): Promise { +export async function startBackend(label: string, input?: { llmUrl?: string }): Promise { const port = await freePort() const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`)) const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -80,6 +80,7 @@ export async function startBackend(label: string): Promise { XDG_STATE_HOME: path.join(sandbox, "state"), OPENCODE_CLIENT: "app", OPENCODE_STRICT_CONFIG_DEPS: "true", + OPENCODE_E2E_LLM_URL: input?.llmUrl, } satisfies Record const out: string[] = [] const err: string[] = [] diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 8c018a9f0b..55ab227308 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -12,11 +12,14 @@ import { setHealthPhase, seedProjects, sessionIDFromUrl, - waitSlug, waitSession, + waitSessionIdle, + waitSessionSaved, + waitSlug, } from "./actions" import { openaiModel, withMockOpenAI } from "./prompt/mock" -import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" +import { promptSelector } from "./selectors" +import { createSdk, dirSlug, getWorktree, resolveDirectory, sessionPath } from "./utils" type LLMFixture = { url: string @@ -51,6 +54,23 @@ type LLMFixture = { misses: () => Promise }>> } +type LLMWorker = LLMFixture & { + reset: () => Promise +} + +type AssistantFixture = { + reply: (value: string, opts?: { usage?: Usage }) => Promise + tool: (name: string, input: unknown) => Promise + toolHang: (name: string, input: unknown) => Promise + reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise + fail: (message?: unknown) => Promise + error: (status: number, body: unknown) => Promise + hang: () => Promise + hold: (value: string, wait: PromiseLike) => Promise + calls: () => Promise + pending: () => Promise +} + export const settingsKey = "settings.v3" const seedModel = (() => { @@ -79,8 +99,15 @@ type ProjectOptions = { beforeGoto?: (project: { directory: string; sdk: ReturnType }) => Promise } +type ProjectFixture = ProjectHandle & { + open: (options?: ProjectOptions) => Promise + prompt: (text: string) => Promise +} + type TestFixtures = { llm: LLMFixture + assistant: AssistantFixture + project: ProjectFixture sdk: ReturnType gotoSession: (sessionID?: string) => Promise withProject: (callback: (project: ProjectHandle) => Promise, options?: ProjectOptions) => Promise @@ -89,6 +116,7 @@ type TestFixtures = { } type WorkerFixtures = { + _llm: LLMWorker backend: { url: string sdk: (directory?: string) => ReturnType @@ -98,9 +126,42 @@ type WorkerFixtures = { } export const test = base.extend({ + _llm: [ + async ({}, use) => { + const rt = ManagedRuntime.make(TestLLMServer.layer) + try { + const svc = await rt.runPromise(TestLLMServer.asEffect()) + await use({ + url: svc.url, + push: (...input) => rt.runPromise(svc.push(...input)), + pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)), + textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)), + toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)), + text: (value, opts) => rt.runPromise(svc.text(value, opts)), + tool: (name, input) => rt.runPromise(svc.tool(name, input)), + toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)), + reason: (value, opts) => rt.runPromise(svc.reason(value, opts)), + fail: (message) => rt.runPromise(svc.fail(message)), + error: (status, body) => rt.runPromise(svc.error(status, body)), + hang: () => rt.runPromise(svc.hang), + hold: (value, wait) => rt.runPromise(svc.hold(value, wait)), + reset: () => rt.runPromise(svc.reset), + hits: () => rt.runPromise(svc.hits), + calls: () => rt.runPromise(svc.calls), + wait: (count) => rt.runPromise(svc.wait(count)), + inputs: () => rt.runPromise(svc.inputs), + pending: () => rt.runPromise(svc.pending), + misses: () => rt.runPromise(svc.misses), + }) + } finally { + await rt.dispose() + } + }, + { scope: "worker" }, + ], backend: [ - async ({}, use, workerInfo) => { - const handle = await startBackend(`w${workerInfo.workerIndex}`) + async ({ _llm }, use, workerInfo) => { + const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url }) try { await use({ url: handle.url, @@ -112,35 +173,48 @@ export const test = base.extend({ }, { scope: "worker" }, ], - llm: async ({}, use) => { - const rt = ManagedRuntime.make(TestLLMServer.layer) - try { - const svc = await rt.runPromise(TestLLMServer.asEffect()) - await use({ - url: svc.url, - push: (...input) => rt.runPromise(svc.push(...input)), - pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)), - textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)), - toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)), - text: (value, opts) => rt.runPromise(svc.text(value, opts)), - tool: (name, input) => rt.runPromise(svc.tool(name, input)), - toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)), - reason: (value, opts) => rt.runPromise(svc.reason(value, opts)), - fail: (message) => rt.runPromise(svc.fail(message)), - error: (status, body) => rt.runPromise(svc.error(status, body)), - hang: () => rt.runPromise(svc.hang), - hold: (value, wait) => rt.runPromise(svc.hold(value, wait)), - hits: () => rt.runPromise(svc.hits), - calls: () => rt.runPromise(svc.calls), - wait: (count) => rt.runPromise(svc.wait(count)), - inputs: () => rt.runPromise(svc.inputs), - pending: () => rt.runPromise(svc.pending), - misses: () => rt.runPromise(svc.misses), - }) - } finally { - await rt.dispose() + llm: async ({ _llm }, use) => { + await _llm.reset() + await use({ + url: _llm.url, + push: _llm.push, + pushMatch: _llm.pushMatch, + textMatch: _llm.textMatch, + toolMatch: _llm.toolMatch, + text: _llm.text, + tool: _llm.tool, + toolHang: _llm.toolHang, + reason: _llm.reason, + fail: _llm.fail, + error: _llm.error, + hang: _llm.hang, + hold: _llm.hold, + hits: _llm.hits, + calls: _llm.calls, + wait: _llm.wait, + inputs: _llm.inputs, + pending: _llm.pending, + misses: _llm.misses, + }) + const pending = await _llm.pending() + if (pending > 0) { + throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`) } }, + assistant: async ({ llm }, use) => { + await use({ + reply: llm.text, + tool: llm.tool, + toolHang: llm.toolHang, + reason: llm.reason, + fail: llm.fail, + error: llm.error, + hang: llm.hang, + hold: llm.hold, + calls: llm.calls, + pending: llm.pending, + }) + }, page: async ({ page }, use) => { let boundary: string | undefined setHealthPhase(page, "test") @@ -166,8 +240,7 @@ export const test = base.extend({ }, directory: [ async ({}, use) => { - const directory = await getWorktree() - await use(directory) + await use(await getWorktree()) }, { scope: "worker" }, ], @@ -189,6 +262,14 @@ export const test = base.extend({ } await use(gotoSession) }, + project: async ({ page, llm, backend }, use) => { + const item = makeProject(page, llm, backend) + try { + await use(item.project) + } finally { + await item.cleanup() + } + }, withProject: async ({ page }, use) => { await use((callback, options) => runProject(page, callback, options)) }, @@ -214,6 +295,137 @@ export const test = base.extend({ }, }) +function makeProject( + page: Page, + llm: LLMFixture, + backend: { url: string; sdk: (directory?: string) => ReturnType }, +) { + let state: + | { + directory: string + slug: string + sdk: ReturnType + sessions: Map + dirs: Set + } + | undefined + + const need = () => { + if (state) return state + throw new Error("project.open() must be called first") + } + + const trackSession = (sessionID: string, directory?: string) => { + const cur = need() + cur.sessions.set(sessionID, directory ?? cur.directory) + } + + const trackDirectory = (directory: string) => { + const cur = need() + if (directory !== cur.directory) cur.dirs.add(directory) + } + + const gotoSession = async (sessionID?: string) => { + const cur = need() + await page.goto(sessionPath(cur.directory, sessionID)) + await waitSession(page, { directory: cur.directory, sessionID, serverUrl: backend.url }) + const current = sessionIDFromUrl(page.url()) + if (current) trackSession(current) + } + + const open = async (options?: ProjectOptions) => { + if (state) return + const directory = await createTestProject({ serverUrl: backend.url }) + const sdk = backend.sdk(directory) + await options?.setup?.(directory) + await seedStorage(page, { + directory, + extra: options?.extra, + model: options?.model, + serverUrl: backend.url, + }) + state = { + directory, + slug: "", + sdk, + sessions: new Map(), + dirs: new Set(), + } + await options?.beforeGoto?.({ directory, sdk }) + await gotoSession() + need().slug = await waitSlug(page) + } + + const prompt = async (text: string) => { + const cur = need() + if ((await llm.pending()) === 0) { + await llm.text("ok") + } + + const prompt = page.locator(promptSelector).first() + await expect(prompt).toBeVisible() + await prompt.click() + await prompt.fill(text) + await prompt.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + const sessionID = sessionIDFromUrl(page.url()) + if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) + + const current = await page + .evaluate(() => { + const win = window as E2EWindow + const next = win.__opencode_e2e?.model?.current + if (!next) return null + return { dir: next.dir, sessionID: next.sessionID } + }) + .catch(() => null as { dir?: string; sessionID?: string } | null) + const directory = current?.dir + ? await resolveDirectory(current.dir, backend.url).catch(() => cur.directory) + : cur.directory + + trackSession(sessionID, directory) + await waitSessionSaved(directory, sessionID, 30_000, backend.url) + await waitSessionIdle(backend.sdk(directory), sessionID, 30_000).catch(() => undefined) + return sessionID + } + + const cleanup = async () => { + const cur = state + if (!cur) return + setHealthPhase(page, "cleanup") + await Promise.allSettled( + Array.from(cur.sessions, ([sessionID, directory]) => + cleanupSession({ sessionID, directory, serverUrl: backend.url }), + ), + ) + await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory))) + await cleanupTestProject(cur.directory) + state = undefined + setHealthPhase(page, "test") + } + + return { + project: { + open, + prompt, + gotoSession, + trackSession, + trackDirectory, + get directory() { + return need().directory + }, + get slug() { + return need().slug + }, + get sdk() { + return need().sdk + }, + }, + cleanup, + } +} + async function runProject( page: Page, callback: (project: ProjectHandle) => Promise, diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts index a9a12cb951..403369947b 100644 --- a/packages/app/e2e/prompt/prompt-async.spec.ts +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -1,52 +1,25 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" -import { assistantText, sessionIDFromUrl, withSession } from "../actions" -import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock" +import { assistantText, withSession } from "../actions" const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() // Regression test for Issue #12453: the synchronous POST /message endpoint holds // the connection open while the agent works, causing "Failed to fetch" over // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. -test("prompt succeeds when sync message endpoint is unreachable", async ({ - page, - llm, - backend, - withBackendProject, -}) => { +test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => { test.setTimeout(120_000) // Simulate Tailscale/VPN killing the long-lived sync connection await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) - await withMockOpenAI({ - serverUrl: backend.url, - llmUrl: llm.url, - fn: async () => { - const token = `E2E_ASYNC_${Date.now()}` - await llm.textMatch(titleMatch, "E2E Title") - await llm.textMatch(promptMatch(token), token) + const token = `E2E_ASYNC_${Date.now()}` + await project.open() + await assistant.reply(token) + const sessionID = await project.prompt(`Reply with exactly: ${token}`) - await withBackendProject( - async (project) => { - await page.locator(promptSelector).click() - await page.keyboard.type(`Reply with exactly: ${token}`) - await page.keyboard.press("Enter") - - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - const sessionID = sessionIDFromUrl(page.url())! - project.trackSession(sessionID) - - await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1) - - await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) - }, - { - model: openaiModel, - }, - ) - }, - }) + await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) + await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) }) test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts index f2d15914d3..55cb0c9aa3 100644 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -1,10 +1,9 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { assistantText, sessionIDFromUrl } from "../actions" +import { assistantText } from "../actions" import { promptSelector } from "../selectors" import { createSdk } from "../utils" -import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock" const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() type Sdk = ReturnType @@ -43,73 +42,45 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) { .toContain(token) } -test("prompt history restores unsent draft with arrow navigation", async ({ - page, - llm, - backend, - withBackendProject, -}) => { +test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => { test.setTimeout(120_000) - await withMockOpenAI({ - serverUrl: backend.url, - llmUrl: llm.url, - fn: async () => { - const firstToken = `E2E_HISTORY_ONE_${Date.now()}` - const secondToken = `E2E_HISTORY_TWO_${Date.now()}` - const first = `Reply with exactly: ${firstToken}` - const second = `Reply with exactly: ${secondToken}` - const draft = `draft ${Date.now()}` + const firstToken = `E2E_HISTORY_ONE_${Date.now()}` + const secondToken = `E2E_HISTORY_TWO_${Date.now()}` + const first = `Reply with exactly: ${firstToken}` + const second = `Reply with exactly: ${secondToken}` + const draft = `draft ${Date.now()}` - await llm.textMatch(titleMatch, "E2E Title") - await llm.textMatch(promptMatch(firstToken), firstToken) - await llm.textMatch(promptMatch(secondToken), secondToken) + await project.open() + await assistant.reply(firstToken) + const sessionID = await project.prompt(first) + await wait(page, "") + await reply(project.sdk, sessionID, firstToken) - await withBackendProject( - async (project) => { - const prompt = page.locator(promptSelector) + await assistant.reply(secondToken) + await project.prompt(second) + await wait(page, "") + await reply(project.sdk, sessionID, secondToken) - await prompt.click() - await page.keyboard.type(first) - await page.keyboard.press("Enter") - await wait(page, "") + const prompt = page.locator(promptSelector) + await prompt.click() + await page.keyboard.type(draft) + await wait(page, draft) - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - const sessionID = sessionIDFromUrl(page.url())! - project.trackSession(sessionID) - await reply(project.sdk, sessionID, firstToken) + await prompt.fill("") + await wait(page, "") - await prompt.click() - await page.keyboard.type(second) - await page.keyboard.press("Enter") - await wait(page, "") - await reply(project.sdk, sessionID, secondToken) + await page.keyboard.press("ArrowUp") + await wait(page, second) - await prompt.click() - await page.keyboard.type(draft) - await wait(page, draft) + await page.keyboard.press("ArrowUp") + await wait(page, first) - await prompt.fill("") - await wait(page, "") + await page.keyboard.press("ArrowDown") + await wait(page, second) - await page.keyboard.press("ArrowUp") - await wait(page, second) - - await page.keyboard.press("ArrowUp") - await wait(page, first) - - await page.keyboard.press("ArrowDown") - await wait(page, second) - - await page.keyboard.press("ArrowDown") - await wait(page, "") - }, - { - model: openaiModel, - }, - ) - }, - }) + await page.keyboard.press("ArrowDown") + await wait(page, "") }) test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts index 3c9ed51dca..b5dc02badb 100644 --- a/packages/app/e2e/prompt/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -1,9 +1,7 @@ import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { assistantText, sessionIDFromUrl } from "../actions" -import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock" +import { assistantText } from "../actions" -test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => { +test("can send a prompt and receive a reply", async ({ page, project, assistant }) => { test.setTimeout(120_000) const pageErrors: string[] = [] @@ -13,41 +11,13 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB page.on("pageerror", onPageError) try { - await withMockOpenAI({ - serverUrl: backend.url, - llmUrl: llm.url, - fn: async () => { - const token = `E2E_OK_${Date.now()}` + const token = `E2E_OK_${Date.now()}` + await project.open() + await assistant.reply(token) + const sessionID = await project.prompt(`Reply with exactly: ${token}`) - await llm.textMatch(titleMatch, "E2E Title") - await llm.textMatch(promptMatch(token), token) - - await withBackendProject( - async (project) => { - const prompt = page.locator(promptSelector) - await prompt.click() - await page.keyboard.type(`Reply with exactly: ${token}`) - await page.keyboard.press("Enter") - - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - - const sessionID = (() => { - const id = sessionIDFromUrl(page.url()) - if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) - return id - })() - project.trackSession(sessionID) - - await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1) - - await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token) - }, - { - model: openaiModel, - }, - ) - }, - }) + await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) + await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token) } finally { page.off("pageerror", onPageError) } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 441f84b907..c3ca1b3852 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -114,6 +114,12 @@ export namespace Provider { }) } + function e2eURL() { + const url = Env.get("OPENCODE_E2E_LLM_URL") + if (typeof url !== "string" || url === "") return + return url + } + type BundledSDK = { languageModel(modelId: string): LanguageModelV3 } @@ -1450,6 +1456,17 @@ export namespace Provider { if (s.models.has(key)) return s.models.get(key)! return yield* Effect.promise(async () => { + const url = e2eURL() + if (url) { + const language = createOpenAI({ + name: model.providerID, + apiKey: "test-key", + baseURL: url, + }).responses(model.api.id) + s.models.set(key, language) + return language + } + const provider = s.providers[model.providerID] const sdk = await resolveSDK(model, s) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 747693d70b..b3dd220c22 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -599,6 +599,11 @@ function isToolResultFollowUp(body: unknown): boolean { return false } +function isTitleRequest(body: unknown): boolean { + if (!body || typeof body !== "object") return false + return JSON.stringify(body).includes("Generate a title for this conversation") +} + function requestSummary(body: unknown): string { if (!body || typeof body !== "object") return "empty body" if ("messages" in body && Array.isArray(body.messages)) { @@ -623,6 +628,7 @@ namespace TestLLMServer { readonly error: (status: number, body: unknown) => Effect.Effect readonly hang: Effect.Effect readonly hold: (value: string, wait: PromiseLike) => Effect.Effect + readonly reset: Effect.Effect readonly hits: Effect.Effect readonly calls: Effect.Effect readonly wait: (count: number) => Effect.Effect @@ -671,21 +677,29 @@ export class TestLLMServer extends ServiceMap.Service ({}))) const current = hit(req.originalUrl, body) + if (isTitleRequest(body)) { + hits = [...hits, current] + yield* notify() + const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] } + if (mode === "responses") return send(responses(auto, modelFrom(body))) + return send(auto) + } + // Auto-acknowledge tool-result follow-ups so tests only need to + // queue one response per tool call instead of two. + if (isToolResultFollowUp(body)) { + hits = [...hits, current] + yield* notify() + const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] } + if (mode === "responses") return send(responses(auto, modelFrom(body))) + return send(auto) + } const next = pull(current) if (!next) { - // Auto-acknowledge tool-result follow-ups so tests only need to - // queue one response per tool call instead of two. - if (isToolResultFollowUp(body)) { - hits = [...hits, current] - yield* notify() - const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] } - if (mode === "responses") return send(responses(auto, modelFrom(body))) - return send(auto) - } - misses = [...misses, current] - const summary = requestSummary(body) - console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`) - return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 }) + hits = [...hits, current] + yield* notify() + const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] } + if (mode === "responses") return send(responses(auto, modelFrom(body))) + return send(auto) } hits = [...hits, current] yield* notify() @@ -755,6 +769,12 @@ export class TestLLMServer extends ServiceMap.Service) { queue(reply().wait(wait).text(value).stop().item()) }), + reset: Effect.sync(() => { + hits = [] + list = [] + waits = [] + misses = [] + }), hits: Effect.sync(() => [...hits]), calls: Effect.sync(() => hits.length), wait: Effect.fn("TestLLMServer.wait")(function* (count: number) { From 2089e5e0d60bdf815e53d940bfc04b4fc7f7c0d1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 1 Apr 2026 23:05:35 -0400 Subject: [PATCH 02/23] test(app): stabilize golden e2e prompt helper --- packages/app/e2e/fixtures.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 55ab227308..d34caf1331 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -83,6 +83,10 @@ const seedModel = (() => { } })() +function clean(value: string | null) { + return (value ?? "").replace(/\u200B/g, "").trim() +} + type ProjectHandle = { directory: string slug: string @@ -341,7 +345,7 @@ function makeProject( await seedStorage(page, { directory, extra: options?.extra, - model: options?.model, + model: options?.model ?? openaiModel, serverUrl: backend.url, }) state = { @@ -365,10 +369,21 @@ function makeProject( const prompt = page.locator(promptSelector).first() await expect(prompt).toBeVisible() await prompt.click() - await prompt.fill(text) - await prompt.press("Enter") + await page.keyboard.type(text) + await expect.poll(async () => clean(await prompt.textContent())).toBe(text) + await page.keyboard.press("Enter") + const sent = await expect + .poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 5_000 }) + .not.toBe("") + .then(() => true) + .catch(() => false) + if (!sent) { + const send = page.getByRole("button", { name: "Send" }).first() + await expect(send).toBeEnabled() + await send.click() + } - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 90_000 }) const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) @@ -385,8 +400,8 @@ function makeProject( : cur.directory trackSession(sessionID, directory) - await waitSessionSaved(directory, sessionID, 30_000, backend.url) - await waitSessionIdle(backend.sdk(directory), sessionID, 30_000).catch(() => undefined) + await waitSessionSaved(directory, sessionID, 90_000, backend.url) + await waitSessionIdle(backend.sdk(directory), sessionID, 90_000).catch(() => undefined) return sessionID } From 7f5307ff30ee8356ad29349f369347f81490ac01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 00:37:00 -0400 Subject: [PATCH 03/23] test(app): migrate e2e to the golden project fixture --- packages/app/e2e/actions.ts | 73 +- packages/app/e2e/fixtures.ts | 130 +++- .../app/e2e/projects/project-edit.spec.ts | 62 +- .../app/e2e/projects/projects-close.spec.ts | 63 +- .../app/e2e/projects/projects-switch.spec.ts | 117 ++- .../projects/workspace-new-session.spec.ts | 51 +- packages/app/e2e/projects/workspaces.spec.ts | 548 +++++++------- packages/app/e2e/prompt/prompt-shell.spec.ts | 49 +- .../app/e2e/prompt/prompt-slash-share.spec.ts | 67 +- .../session/session-child-navigation.spec.ts | 55 +- .../e2e/session/session-composer-dock.spec.ts | 672 +++++++++--------- .../session/session-model-persistence.spec.ts | 178 ++--- .../app/e2e/session/session-review.spec.ts | 263 ++++--- .../app/e2e/session/session-undo-redo.spec.ts | 289 ++++---- packages/app/e2e/session/session.spec.ts | 218 +++--- .../sidebar/sidebar-popover-actions.spec.ts | 68 +- .../e2e/terminal/terminal-reconnect.spec.ts | 45 +- .../app/e2e/terminal/terminal-tabs.spec.ts | 213 +++--- .../app/src/components/prompt-input/submit.ts | 3 + packages/app/src/testing/prompt.ts | 27 + packages/app/src/testing/terminal.ts | 1 + 21 files changed, 1586 insertions(+), 1606 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index df8e0768ed..1b44138784 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -602,12 +602,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) { } export async function openSharePopover(page: Page) { - const rightSection = page.locator(titlebarRightSelector) - const shareButton = rightSection.getByRole("button", { name: "Share" }).first() - await expect(shareButton).toBeVisible() + const scroller = page.locator(".scroll-view__viewport").first() + await expect(scroller).toBeVisible() + await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + + const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() + await expect(menuTrigger).toBeVisible({ timeout: 30_000 }) const popoverBody = page - .locator(popoverBodySelector) + .locator('[data-component="popover-content"]') .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) .first() @@ -617,10 +620,13 @@ export async function openSharePopover(page: Page) { .catch(() => false) if (!opened) { - await shareButton.click() - await expect(popoverBody).toBeVisible() + const menu = page.locator(dropdownMenuContentSelector).first() + await menuTrigger.click() + await clickMenuItem(menu, /share/i) + await expect(menu).toHaveCount(0) + await expect(popoverBody).toBeVisible({ timeout: 30_000 }) } - return { rightSection, popoverBody } + return { rightSection: scroller, popoverBody } } export async function clickPopoverButton(page: Page, buttonName: string | RegExp) { @@ -1005,30 +1011,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) { } export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { - const current = await page - .getByRole("button", { name: "New workspace" }) - .first() - .isVisible() - .then((x) => x) - .catch(() => false) + const current = () => + page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) - if (current === enabled) return + if ((await current()) === enabled) return + + if (enabled) { + await page.goto(page.url()) + await openSidebar(page) + if ((await current()) === enabled) return + } const flip = async (timeout?: number) => { const menu = await openProjectMenu(page, projectSlug) const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() await expect(toggle).toBeVisible() - return toggle.click({ force: true, timeout }) + await expect(toggle).toBeEnabled({ timeout: 30_000 }) + const clicked = await toggle + .click({ force: true, timeout }) + .then(() => true) + .catch(() => false) + if (clicked) return + await toggle.focus() + await page.keyboard.press("Enter") } - const flipped = await flip(1500) - .then(() => true) - .catch(() => false) + for (const timeout of [1500, undefined, undefined]) { + if ((await current()) === enabled) break + await flip(timeout) + .then(() => undefined) + .catch(() => undefined) + const matched = await expect + .poll(current, { timeout: 5_000 }) + .toBe(enabled) + .then(() => true) + .catch(() => false) + if (matched) break + } - if (!flipped) await flip() + if ((await current()) !== enabled) { + await page.goto(page.url()) + await openSidebar(page) + } const expected = enabled ? "New workspace" : "New session" - await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() + await expect.poll(current, { timeout: 60_000 }).toBe(enabled) + await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 }) } export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index d34caf1331..77a609c45d 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -16,10 +16,11 @@ import { waitSessionIdle, waitSessionSaved, waitSlug, + withNoReplyPrompt, } from "./actions" import { openaiModel, withMockOpenAI } from "./prompt/mock" import { promptSelector } from "./selectors" -import { createSdk, dirSlug, getWorktree, resolveDirectory, sessionPath } from "./utils" +import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" type LLMFixture = { url: string @@ -87,6 +88,21 @@ function clean(value: string | null) { return (value ?? "").replace(/\u200B/g, "").trim() } +async function promptSend(page: Page) { + return page + .evaluate(() => { + const win = window as E2EWindow + const sent = win.__opencode_e2e?.prompt?.sent + return { + started: sent?.started ?? 0, + count: sent?.count ?? 0, + sessionID: sent?.sessionID, + directory: sent?.directory, + } + }) + .catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined })) +} + type ProjectHandle = { directory: string slug: string @@ -106,6 +122,8 @@ type ProjectOptions = { type ProjectFixture = ProjectHandle & { open: (options?: ProjectOptions) => Promise prompt: (text: string) => Promise + user: (text: string) => Promise + shell: (cmd: string) => Promise } type TestFixtures = { @@ -345,7 +363,7 @@ function makeProject( await seedStorage(page, { directory, extra: options?.extra, - model: options?.model ?? openaiModel, + model: options?.model, serverUrl: backend.url, }) state = { @@ -360,49 +378,87 @@ function makeProject( need().slug = await waitSlug(page) } - const prompt = async (text: string) => { - const cur = need() - if ((await llm.pending()) === 0) { + const send = async (text: string, input: { noReply: boolean; shell: boolean }) => { + const prev = await promptSend(page) + if (!input.noReply && !input.shell && (await llm.pending()) === 0) { await llm.text("ok") } const prompt = page.locator(promptSelector).first() - await expect(prompt).toBeVisible() - await prompt.click() - await page.keyboard.type(text) - await expect.poll(async () => clean(await prompt.textContent())).toBe(text) - await page.keyboard.press("Enter") - const sent = await expect - .poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 5_000 }) - .not.toBe("") - .then(() => true) - .catch(() => false) - if (!sent) { + const submit = async () => { + await expect(prompt).toBeVisible() + await prompt.click() + if (input.shell) { + await page.keyboard.type("!") + await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) + } + await page.keyboard.type(text) + await expect.poll(async () => clean(await prompt.textContent())).toBe(text) + await page.keyboard.press("Enter") + const started = await expect + .poll(async () => (await promptSend(page)).started, { timeout: 5_000 }) + .toBeGreaterThan(prev.started) + .then(() => true) + .catch(() => false) + if (started) return const send = page.getByRole("button", { name: "Send" }).first() - await expect(send).toBeEnabled() - await send.click() + const enabled = await send + .isEnabled() + .then((x) => x) + .catch(() => false) + if (enabled) { + await send.click() + } else { + await prompt.click() + await page.keyboard.press("Enter") + } + await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started) } - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 90_000 }) - const sessionID = sessionIDFromUrl(page.url()) - if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) + if (input.noReply) { + await withNoReplyPrompt(page, submit) + } else { + await submit() + } - const current = await page - .evaluate(() => { - const win = window as E2EWindow - const next = win.__opencode_e2e?.model?.current - if (!next) return null - return { dir: next.dir, sessionID: next.sessionID } - }) - .catch(() => null as { dir?: string; sessionID?: string } | null) - const directory = current?.dir - ? await resolveDirectory(current.dir, backend.url).catch(() => cur.directory) - : cur.directory + let next: { sessionID: string; directory: string } | undefined + await expect + .poll( + async () => { + const sent = await promptSend(page) + if (sent.count <= prev.count) return "" + if (!sent.sessionID || !sent.directory) return "" + next = { sessionID: sent.sessionID, directory: sent.directory } + return sent.sessionID + }, + { timeout: 90_000 }, + ) + .not.toBe("") - trackSession(sessionID, directory) - await waitSessionSaved(directory, sessionID, 90_000, backend.url) - await waitSessionIdle(backend.sdk(directory), sessionID, 90_000).catch(() => undefined) - return sessionID + if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe") + const active = await waitSession(page, { + directory: next.directory, + sessionID: next.sessionID, + serverUrl: backend.url, + }) + trackSession(next.sessionID, active.directory) + if (!input.shell) { + await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url) + } + await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined) + return next.sessionID + } + + const prompt = async (text: string) => { + return send(text, { noReply: false, shell: false }) + } + + const user = async (text: string) => { + return send(text, { noReply: true, shell: false }) + } + + const shell = async (cmd: string) => { + return send(cmd, { noReply: false, shell: true }) } const cleanup = async () => { @@ -424,6 +480,8 @@ function makeProject( project: { open, prompt, + user, + shell, gotoSession, trackSession, trackDirectory, diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index 7c20f29ec1..bc182a6953 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,43 +1,47 @@ import { test, expect } from "../fixtures" import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" -test("dialog edit project updates name and startup script", async ({ page, withProject }) => { +test("dialog edit project updates name and startup script", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug }) => { - await openSidebar(page) + await project.open() + await openSidebar(page) - const open = async () => { - const menu = await openProjectMenu(page, slug) - await clickMenuItem(menu, /^Edit$/i, { force: true }) + const open = async () => { + const menu = await openProjectMenu(page, project.slug) + await clickMenuItem(menu, /^Edit$/i, { force: true }) - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") - return dialog - } + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") + return dialog + } - const name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` + const name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` - const dialog = await open() + const dialog = await open() - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) - await dialog.getByRole("button", { name: "Save" }).click() - await expect(dialog).toHaveCount(0) + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).toHaveCount(0) - const header = page.locator(".group\\/project").first() - await expect(header).toContainText(name) - - const reopened = await open() - await expect(reopened.getByLabel("Name")).toHaveValue(name) - await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) - await reopened.getByRole("button", { name: "Cancel" }).click() - await expect(reopened).toHaveCount(0) - }) + await expect + .poll( + async () => { + const reopened = await open() + const value = await reopened.getByLabel("Name").inputValue() + const next = await reopened.getByLabel("Workspace startup script").inputValue() + await reopened.getByRole("button", { name: "Cancel" }).click() + await expect(reopened).toHaveCount(0) + return `${value}\n${next}` + }, + { timeout: 30_000 }, + ) + .toBe(`${name}\n${startup}`) }) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index 9454d683f0..75e6f2ce68 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("closing active project navigates to another open project", async ({ page, withProject }) => { +test("closing active project navigates to another open project", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) try { - await withProject( - async ({ slug }) => { - await openSidebar(page) + await project.open({ extra: [other] }) + await openSidebar(page) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - const menu = await openProjectMenu(page, otherSlug) + const menu = await openProjectMenu(page, otherSlug) + await clickMenuItem(menu, /^Close$/i, { force: true }) - await clickMenuItem(menu, /^Close$/i, { force: true }) + await expect + .poll( + () => { + const pathname = new URL(page.url()).pathname + if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" + if (pathname === "/") return "home" + return "" + }, + { timeout: 15_000 }, + ) + .toMatch(/^(project|home)$/) - await expect - .poll( - () => { - const pathname = new URL(page.url()).pathname - if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" - if (pathname === "/") return "home" - return "" - }, - { timeout: 15_000 }, - ) - .toMatch(/^(project|home)$/) - - await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) - await expect - .poll( - async () => { - return await page.locator(projectSwitchSelector(otherSlug)).count() - }, - { timeout: 15_000 }, - ) - .toBe(0) - }, - { extra: [other] }, - ) + await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) + await expect + .poll( + async () => { + return await page.locator(projectSwitchSelector(otherSlug)).count() + }, + { timeout: 15_000 }, + ) + .toBe(0) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index f87a47cf09..67d09afd15 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -5,114 +5,89 @@ import { createTestProject, cleanupTestProject, openSidebar, - sessionIDFromUrl, setWorkspacesEnabled, waitSession, - waitSessionSaved, waitSlug, - withNoReplyPrompt, } from "../actions" -import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" +import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" -test("can switch between projects from sidebar", async ({ page, withProject }) => { +test("can switch between projects from sidebar", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) try { - await withProject( - async ({ directory }) => { - await defocus(page) + await project.open({ extra: [other] }) + await defocus(page) - const currentSlug = dirSlug(directory) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + const currentSlug = dirSlug(project.directory) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() - await expect(currentButton).toBeVisible() - await currentButton.click() + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() - await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) - }, - { extra: [other] }, - ) + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) } finally { await cleanupTestProject(other) } }) -test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => { +test("switching back to a project opens the latest workspace session", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) try { - await withProject( - async ({ directory, slug, trackSession, trackDirectory }) => { - await defocus(page) - await setWorkspacesEnabled(page, slug, true) - await openSidebar(page) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await project.open({ extra: [other] }) + await defocus(page) + await setWorkspacesEnabled(page, project.slug, true) + await openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - await page.getByRole("button", { name: "New workspace" }).first().click() + await page.getByRole("button", { name: "New workspace" }).first().click() - const raw = await waitSlug(page, [slug]) - const dir = base64Decode(raw) - if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) - const space = await resolveDirectory(dir) - const next = dirSlug(space) - trackDirectory(space) - await openSidebar(page) + const raw = await waitSlug(page, [project.slug]) + const dir = base64Decode(raw) + if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) + const space = await resolveDirectory(dir) + const next = dirSlug(space) + project.trackDirectory(space) + await openSidebar(page) - const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() - await expect(item).toBeVisible() - await item.hover() + const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() + await expect(item).toBeVisible() + await item.hover() - const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() - await expect(btn).toBeVisible() - await btn.click({ force: true }) + const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() + await expect(btn).toBeVisible() + await btn.click({ force: true }) - await waitSession(page, { directory: space }) + await waitSession(page, { directory: space }) - // Create a session by sending a prompt - const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await withNoReplyPrompt(page, async () => { - await prompt.fill("test") - await page.keyboard.press("Enter") - }) + const created = await project.user("test") - // Wait for the URL to update with the new session ID - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") + await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) - const created = sessionIDFromUrl(page.url()) - if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) - trackSession(created, space) - await waitSessionSaved(space, created) + await openSidebar(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click({ force: true }) + await waitSession(page, { directory: other }) - await openSidebar(page) + const rootButton = page.locator(projectSwitchSelector(project.slug)).first() + await expect(rootButton).toBeVisible() + await rootButton.click({ force: true }) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click({ force: true }) - await waitSession(page, { directory: other }) - - const rootButton = page.locator(projectSwitchSelector(slug)).first() - await expect(rootButton).toBeVisible() - await rootButton.click({ force: true }) - - await waitSession(page, { directory: space, sessionID: created }) - await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) - }, - { extra: [other] }, - ) + await waitSession(page, { directory: space, sessionID: created }) + await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 835c8c99ed..d9d010b4dc 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -7,12 +7,9 @@ import { setWorkspacesEnabled, waitDir, waitSession, - waitSessionSaved, waitSlug, - withNoReplyPrompt, } from "../actions" -import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" -import { createSdk } from "../utils" +import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" function item(space: { slug: string; raw: string }) { return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` @@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s } async function createSessionFromWorkspace( + project: Parameters[0]["project"], page: Page, space: { slug: string; raw: string; directory: string }, text: string, ) { await openWorkspaceNewSession(page, space) - - const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await withNoReplyPrompt(page, async () => { - await prompt.fill(text) - await page.keyboard.press("Enter") - }) - - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") - const sessionID = sessionIDFromUrl(page.url()) - if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - - await waitSessionSaved(space.directory, sessionID) - await createSdk(space.directory) - .session.abort({ sessionID }) - .catch(() => undefined) - return sessionID + return project.user(text) } -test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { +test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug: root, trackDirectory, trackSession }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, root, true) + await project.open() + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) - const first = await createWorkspace(page, root, []) - trackDirectory(first.directory) - await waitWorkspaceReady(page, first) + const first = await createWorkspace(page, project.slug, []) + project.trackDirectory(first.directory) + await waitWorkspaceReady(page, first) - const second = await createWorkspace(page, root, [first.slug]) - trackDirectory(second.directory) - await waitWorkspaceReady(page, second) + const second = await createWorkspace(page, project.slug, [first.slug]) + project.trackDirectory(second.directory) + await waitWorkspaceReady(page, second) - trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory) - trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory) - trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory) - }) + await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`) + await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`) + await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`) }) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 297cdb9fc9..16caa3d496 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -20,9 +20,9 @@ import { waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" -import { createSdk, dirSlug } from "../utils" +import { dirSlug } from "../utils" -async function setupWorkspaceTest(page: Page, project: { slug: string }) { +async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) { const rootSlug = project.slug await openSidebar(page) @@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await page.getByRole("button", { name: "New workspace" }).first().click() const next = await resolveSlug(await waitSlug(page, [rootSlug])) await waitDir(page, next.directory) + project.trackDirectory(next.directory) await openSidebar(page) @@ -52,44 +53,206 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { return { rootSlug, slug: next.slug, directory: next.directory } } -test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { +test("can enable and disable workspaces from project menu", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() - await withProject(async ({ slug }) => { - await openSidebar(page) + await openSidebar(page) - await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - await setWorkspacesEnabled(page, slug, true) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible() + await setWorkspacesEnabled(page, project.slug, true) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible() - await setWorkspacesEnabled(page, slug, false) - await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) - }) + await setWorkspacesEnabled(page, project.slug, false) + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0) }) -test("can create a workspace", async ({ page, withProject }) => { +test("can create a workspace", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) + + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + + await page.getByRole("button", { name: "New workspace" }).first().click() + const next = await resolveSlug(await waitSlug(page, [project.slug])) + await waitDir(page, next.directory) + project.trackDirectory(next.directory) + + await openSidebar(page) + + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(next.slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() +}) + +test("non-git projects keep workspace mode disabled", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) + const nonGitSlug = dirSlug(nonGit) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [slug])) - await waitDir(page, next.directory) + try { + await project.open({ extra: [nonGit] }) + await page.goto(`/${nonGitSlug}/session`) + + await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") + + const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) + expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") await openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) + const trigger = page.locator('[data-action="project-menu"]').first() + const hasMenu = await trigger + .isVisible() + .then((x) => x) + .catch(() => false) + if (!hasMenu) return + + const menu = await openProjectMenu(page, nonGitSlug) + + const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() + + await expect(toggle).toBeVisible() + await expect(toggle).toBeDisabled() + await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) + } finally { + await cleanupTestProject(nonGit) + } +}) + +test("can rename a workspace", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + + const { slug } = await setupWorkspaceTest(page, project) + + const rename = `e2e workspace ${Date.now()}` + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Rename$/i, { force: true }) + + await expect(menu).toHaveCount(0) + + const item = page.locator(workspaceItemSelector(slug)).first() + await expect(item).toBeVisible() + const input = item.locator(inlineInputSelector).first() + const shown = await input + .isVisible() + .then((x) => x) + .catch(() => false) + if (!shown) { + const retry = await openWorkspaceMenu(page, slug) + await clickMenuItem(retry, /^Rename$/i, { force: true }) + await expect(retry).toHaveCount(0) + } + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") + await expect(item).toContainText(rename) +}) + +test("can reset a workspace", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + + const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) + + const readme = path.join(createdDir, "README.md") + const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) + const original = await fs.readFile(readme, "utf8") + const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` + await fs.writeFile(readme, dirty, "utf8") + await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(true) + + await expect + .poll(async () => { + const files = await project.sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }) + .toBeGreaterThan(0) + + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Reset$/i, { force: true }) + await confirmDialog(page, /^Reset workspace$/i) + + await expect + .poll( + async () => { + const files = await project.sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }, + { timeout: 120_000 }, + ) + .toBe(0) + + await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original) + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(false) +}) + +test("can reorder workspaces by drag and drop", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + const rootSlug = project.slug + + const listSlugs = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs + } + + const waitReady = async (slug: string) => { await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(next.slug)).first() + const item = page.locator(workspaceItemSelector(slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -100,276 +263,107 @@ test("can create a workspace", async ({ page, withProject }) => { { timeout: 60_000 }, ) .toBe(true) - - await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() - - await cleanupTestProject(next.directory) - }) -}) - -test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) - const nonGitSlug = dirSlug(nonGit) - - await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") - - try { - await withProject(async () => { - await page.goto(`/${nonGitSlug}/session`) - - await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - - const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) - expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") - - await openSidebar(page) - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - - const trigger = page.locator('[data-action="project-menu"]').first() - const hasMenu = await trigger - .isVisible() - .then((x) => x) - .catch(() => false) - if (!hasMenu) return - - await trigger.click({ force: true }) - - const menu = page.locator(dropdownMenuContentSelector).first() - await expect(menu).toBeVisible() - - const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() - - await expect(toggle).toBeVisible() - await expect(toggle).toBeDisabled() - await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) - }) - } finally { - await cleanupTestProject(nonGit) } -}) -test("can rename a workspace", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() - await withProject(async (project) => { - const { slug } = await setupWorkspaceTest(page, project) + const a = await src.boundingBox() + const b = await dst.boundingBox() + if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") - const rename = `e2e workspace ${Date.now()}` - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Rename$/i, { force: true }) + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } - await expect(menu).toHaveCount(0) + await openSidebar(page) - const item = page.locator(workspaceItemSelector(slug)).first() - await expect(item).toBeVisible() - const input = item.locator(inlineInputSelector).first() - await expect(input).toBeVisible() - await input.fill(rename) - await input.press("Enter") - await expect(item).toContainText(rename) - }) -}) + await setWorkspacesEnabled(page, rootSlug, true) -test("can reset a workspace", async ({ page, sdk, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - await withProject(async (project) => { - const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) - - const readme = path.join(createdDir, "README.md") - const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) - const original = await fs.readFile(readme, "utf8") - const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` - await fs.writeFile(readme, dirty, "utf8") - await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") - - await expect - .poll(async () => { - return await fs - .stat(extra) - .then(() => true) - .catch(() => false) - }) - .toBe(true) - - await expect - .poll(async () => { - const files = await sdk.file - .status({ directory: createdDir }) - .then((r) => r.data ?? []) - .catch(() => []) - return files.length - }) - .toBeGreaterThan(0) - - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Reset$/i, { force: true }) - await confirmDialog(page, /^Reset workspace$/i) - - await expect - .poll( - async () => { - const files = await sdk.file - .status({ directory: createdDir }) - .then((r) => r.data ?? []) - .catch(() => []) - return files.length - }, - { timeout: 60_000 }, - ) - .toBe(0) - - await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original) - - await expect - .poll(async () => { - return await fs - .stat(extra) - .then(() => true) - .catch(() => false) - }) - .toBe(false) - }) -}) - -test("can delete a workspace", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - await withProject(async (project) => { - const sdk = createSdk(project.directory) - const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) - - await expect - .poll( - async () => { - const worktrees = await sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 30_000 }, - ) - .toBe(true) - - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Delete$/i, { force: true }) - await confirmDialog(page, /^Delete workspace$/i) - - await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) - - await expect - .poll( - async () => { - const worktrees = await sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 60_000 }, - ) - .toBe(false) - - await project.gotoSession() + const workspaces = [] as { directory: string; slug: string }[] + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) + await waitDir(page, next.directory) + project.trackDirectory(next.directory) + workspaces.push(next) await openSidebar(page) - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) - await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() - }) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) }) -test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { +test("can delete a workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug: rootSlug }) => { - const workspaces = [] as { directory: string; slug: string }[] + await project.open() - const listSlugs = async () => { - const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') - const slugs = await nodes.evaluateAll((els) => { - return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) - }) - return slugs - } + const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) - const waitReady = async (slug: string) => { - await expect - .poll( - async () => { - const item = page.locator(workspaceItemSelector(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) - } + await expect + .poll( + async () => { + const worktrees = await project.sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 30_000 }, + ) + .toBe(true) - const drag = async (from: string, to: string) => { - const src = page.locator(workspaceItemSelector(from)).first() - const dst = page.locator(workspaceItemSelector(to)).first() + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Delete$/i, { force: true }) + await confirmDialog(page, /^Delete workspace$/i) - const a = await src.boundingBox() - const b = await dst.boundingBox() - if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") + await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) - await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) - await page.mouse.down() - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) - await page.mouse.up() - } + await expect + .poll( + async () => { + const worktrees = await project.sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 60_000 }, + ) + .toBe(false) - try { - await openSidebar(page) - - await setWorkspacesEnabled(page, rootSlug, true) - - for (const _ of [0, 1]) { - const prev = slugFromUrl(page.url()) - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) - await waitDir(page, next.directory) - workspaces.push(next) - - await openSidebar(page) - } - - if (workspaces.length !== 2) throw new Error("Expected two created workspaces") - - const a = workspaces[0].slug - const b = workspaces[1].slug - - await waitReady(a) - await waitReady(b) - - const list = async () => { - const slugs = await listSlugs() - return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) - } - - await expect - .poll(async () => { - const slugs = await list() - return slugs.length === 2 - }) - .toBe(true) - - const before = await list() - const from = before[1] - const to = before[0] - if (!from || !to) throw new Error("Failed to resolve initial workspace order") - - await drag(from, to) - - await expect.poll(async () => await list()).toEqual([from, to]) - } finally { - await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) - } - }) + await openSidebar(page) + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) + await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() }) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index 7c39a2db34..d81f1d4c40 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -1,7 +1,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client" import { test, expect } from "../fixtures" -import { sessionIDFromUrl } from "../actions" -import { promptSelector } from "../selectors" +import { withSession } from "../actions" const isBash = (part: unknown): part is ToolPart => { if (!part || typeof part !== "object") return false @@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => { return "state" in part } -test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => { +async function setAutoAccept(page: Parameters[0]["page"], enabled: boolean) { + const button = page.locator('[data-action="prompt-permissions"]').first() + await expect(button).toBeVisible() + const pressed = (await button.getAttribute("aria-pressed")) === "true" + if (pressed === enabled) return + await button.click() + await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") +} + +test("shell mode runs a command in the project directory", async ({ page, project }) => { test.setTimeout(120_000) - await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => { - const prompt = page.locator(promptSelector) - const cmd = process.platform === "win32" ? "dir" : "command ls" + await project.open() + const cmd = process.platform === "win32" ? "dir" : "command ls" - await gotoSession() - await prompt.click() - await page.keyboard.type("!") - await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) - - await page.keyboard.type(cmd) - await page.keyboard.press("Enter") - - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - - const id = sessionIDFromUrl(page.url()) - if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) - trackSession(id, directory) + await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + await setAutoAccept(page, true) + await project.shell(cmd) await expect .poll( async () => { - const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? []) + const list = await project.sdk.session + .messages({ sessionID: session.id, limit: 50 }) + .then((x) => x.data ?? []) const msg = list.findLast( - (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory, + (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory, ) if (!msg) return @@ -49,12 +50,10 @@ test("shell mode runs a command in the project directory", async ({ page, withBa typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output if (!output.includes("README.md")) return - return { cwd: directory, output } + return { cwd: project.directory, output } }, { timeout: 90_000 }, ) - .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") })) - - await expect(prompt).toHaveText("") + .toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") })) }) }) diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts index 5371d8a918..f3eeceee5f 100644 --- a/packages/app/e2e/prompt/prompt-slash-share.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts @@ -22,46 +22,45 @@ async function seed(sdk: Parameters[0], sessionID: string) { .toBeGreaterThan(0) } -test("/share and /unshare update session share state", async ({ page, withBackendProject }) => { +test("/share and /unshare update session share state", async ({ page, project }) => { test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") - await withBackendProject(async (project) => { - await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { - project.trackSession(session.id) - const prompt = page.locator(promptSelector) + await project.open() + await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { + project.trackSession(session.id) + const prompt = page.locator(promptSelector) - await seed(project.sdk, session.id) - await project.gotoSession(session.id) + await seed(project.sdk, session.id) + await project.gotoSession(session.id) - await prompt.click() - await page.keyboard.type("/share") - await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() - await page.keyboard.press("Enter") + await prompt.click() + await page.keyboard.type("/share") + await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - await prompt.click() - await page.keyboard.type("/unshare") - await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() - await page.keyboard.press("Enter") + await prompt.click() + await page.keyboard.type("/unshare") + await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .toBeUndefined() - }) + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() }) }) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index 1ab4746e42..34a1a9e2e7 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from "../fixtures" import { inputMatch } from "../prompt/mock" import { promptSelector } from "../selectors" -test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => { +test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test.setTimeout(120_000) const errs: string[] = [] @@ -13,34 +13,33 @@ test("task tool child-session link does not trigger stale show errors", async ({ page.on("pageerror", onError) try { - await withMockProject(async ({ gotoSession, trackSession, sdk }) => { - await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { - const taskInput = { - description: "Open child session", - prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", - subagent_type: "general", - } - await llm.toolMatch(inputMatch(taskInput), "task", taskInput) - const child = await seedSessionTask(sdk, { - sessionID: session.id, - description: taskInput.description, - prompt: taskInput.prompt, - }) - trackSession(child.sessionID) - - await gotoSession(session.id) - - const link = page - .locator("a.subagent-link") - .filter({ hasText: /open child session/i }) - .first() - await expect(link).toBeVisible({ timeout: 30_000 }) - await link.click() - - await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) - await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) - await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) + await project.open() + await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => { + const taskInput = { + description: "Open child session", + prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", + subagent_type: "general", + } + await llm.toolMatch(inputMatch(taskInput), "task", taskInput) + const child = await seedSessionTask(project.sdk, { + sessionID: session.id, + description: taskInput.description, + prompt: taskInput.prompt, }) + project.trackSession(child.sessionID) + + await project.gotoSession(session.id) + + const link = page + .locator("a.subagent-link") + .filter({ hasText: /open child session/i }) + .first() + await expect(link).toBeVisible({ timeout: 30_000 }) + await link.click() + + await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) + await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) }) } finally { page.off("pageerror", onError) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index bf0cc35b71..e997f29f84 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -269,240 +269,227 @@ async function withMockPermission( } } -test("default dock shows prompt input", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock default", - async (session) => { +test("default dock shows prompt input", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock default", + async (session) => { + await project.gotoSession(session.id) + + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() + await expect(page.locator(promptSelector)).toBeVisible() + await expect(page.locator(questionDockSelector)).toHaveCount(0) + await expect(page.locator(permissionDockSelector)).toHaveCount(0) + + await page.locator(promptSelector).click() + await expect(page.locator(promptSelector)).toBeFocused() + }, + { trackSession: project.trackSession }, + ) +}) + +test("auto-accept toggle works before first submit", async ({ page, project }) => { + await project.open() + + const button = page.locator('[data-action="prompt-permissions"]').first() + await expect(button).toBeVisible() + await expect(button).toHaveAttribute("aria-pressed", "false") + + await setAutoAccept(page, true) + await setAutoAccept(page, false) +}) + +test("blocked question flow unblocks after submit", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock question", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { await project.gotoSession(session.id) - await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - await expect(page.locator(promptSelector)).toBeVisible() - await expect(page.locator(questionDockSelector)).toHaveCount(0) - await expect(page.locator(permissionDockSelector)).toHaveCount(0) - - await page.locator(promptSelector).click() - await expect(page.locator(promptSelector)).toBeFocused() - }, - { trackSession: project.trackSession }, - ) - }) -}) - -test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => { - await withBackendProject(async ({ gotoSession }) => { - await gotoSession() - - const button = page.locator('[data-action="prompt-permissions"]').first() - await expect(button).toBeVisible() - await expect(button).toHaveAttribute("aria-pressed", "false") - - await setAutoAccept(page, true) - await setAutoAccept(page, false) - }) -}) - -test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => { - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock question", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) - - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - await expectQuestionBlocked(page) - - await dock.locator('[data-slot="question-option"]').first().click() - await dock.getByRole("button", { name: /submit/i }).click() - - await expectQuestionOpen(page) + await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions: defaultQuestions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + const dock = page.locator(questionDockSelector) + await expectQuestionBlocked(page) + + await dock.locator('[data-slot="question-option"]').first().click() + await dock.getByRole("button", { name: /submit/i }).click() + + await expectQuestionOpen(page) + }) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => { - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock question keyboard", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) +test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock question keyboard", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { + await project.gotoSession(session.id) - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - const first = dock.locator('[data-slot="question-option"]').first() - const second = dock.locator('[data-slot="question-option"]').nth(1) - - await expectQuestionBlocked(page) - await expect(first).toBeFocused() - - await page.keyboard.press("ArrowDown") - await expect(second).toBeFocused() - - await page.keyboard.press("Space") - await page.keyboard.press(`${modKey}+Enter`) - await expectQuestionOpen(page) + await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions: defaultQuestions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + const second = dock.locator('[data-slot="question-option"]').nth(1) + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("ArrowDown") + await expect(second).toBeFocused() + + await page.keyboard.press("Space") + await page.keyboard.press(`${modKey}+Enter`) + await expectQuestionOpen(page) + }) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => { - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock question escape", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) +test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock question escape", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { + await project.gotoSession(session.id) - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - const first = dock.locator('[data-slot="question-option"]').first() - - await expectQuestionBlocked(page) - await expect(first).toBeFocused() - - await page.keyboard.press("Escape") - await expectQuestionOpen(page) + await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions: defaultQuestions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("Escape") + await expectQuestionOpen(page) + }) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock permission once", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_once", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-once"], - metadata: { description: "Need permission for command" }, - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) +test("blocked permission flow supports allow once", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock permission once", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) + await withMockPermission( + page, + { + id: "per_e2e_once", + sessionID: session.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-once"], + metadata: { description: "Need permission for command" }, + }, + undefined, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /allow once/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) + await clearPermissionDock(page, /allow once/i) + await state.resolved() + await page.goto(page.url()) + await expectPermissionOpen(page) + }, + ) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked permission flow supports reject", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock permission reject", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_reject", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-reject"], - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) +test("blocked permission flow supports reject", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock permission reject", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) + await withMockPermission( + page, + { + id: "per_e2e_reject", + sessionID: session.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-reject"], + }, + undefined, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /deny/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) + await clearPermissionDock(page, /deny/i) + await state.resolved() + await page.goto(page.url()) + await expectPermissionOpen(page) + }, + ) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock permission always", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_always", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-always"], - metadata: { description: "Need permission for command" }, - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) +test("blocked permission flow supports allow always", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock permission always", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) + await withMockPermission( + page, + { + id: "per_e2e_always", + sessionID: session.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-always"], + metadata: { description: "Need permission for command" }, + }, + undefined, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /allow always/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) + await clearPermissionDock(page, /allow always/i) + await state.resolved() + await page.goto(page.url()) + await expectPermissionOpen(page) + }, + ) + }, + { trackSession: project.trackSession }, + ) }) -test("child session question request blocks parent dock and unblocks after submit", async ({ - page, - llm, - withMockProject, -}) => { +test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => { const questions = [ { header: "Child input", @@ -513,137 +500,131 @@ test("child session question request blocks parent dock and unblocks after submi ], }, ] - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock child question parent", - async (session) => { - await project.gotoSession(session.id) + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock child question parent", + async (session) => { + await project.gotoSession(session.id) - const child = await project.sdk.session - .create({ - title: "e2e composer dock child question", - parentID: session.id, + const child = await project.sdk.session + .create({ + title: "e2e composer dock child question", + parentID: session.id, + }) + .then((r) => r.data) + if (!child?.id) throw new Error("Child session create did not return an id") + project.trackSession(child.id) + + try { + await withDockSeed(project.sdk, child.id, async () => { + await llm.toolMatch(inputMatch({ questions }), "question", { questions }) + await seedSessionQuestion(project.sdk, { + sessionID: child.id, + questions, }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") - project.trackSession(child.id) - try { - await withDockSeed(project.sdk, child.id, async () => { - await llm.toolMatch(inputMatch({ questions }), "question", { questions }) - await seedSessionQuestion(project.sdk, { - sessionID: child.id, - questions, - }) + const dock = page.locator(questionDockSelector) + await expectQuestionBlocked(page) - const dock = page.locator(questionDockSelector) - await expectQuestionBlocked(page) + await dock.locator('[data-slot="question-option"]').first().click() + await dock.getByRole("button", { name: /submit/i }).click() - await dock.locator('[data-slot="question-option"]').first().click() - await dock.getByRole("button", { name: /submit/i }).click() - - await expectQuestionOpen(page) - }) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: child.id }) - } - }, - { trackSession: project.trackSession }, - ) - }) + await expectQuestionOpen(page) + }) + } finally { + await cleanupSession({ sdk: project.sdk, sessionID: child.id }) + } + }, + { trackSession: project.trackSession }, + ) }) -test("child session permission request blocks parent dock and supports allow once", async ({ - page, - withBackendProject, -}) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock child permission parent", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) +test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock child permission parent", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) - const child = await project.sdk.session - .create({ - title: "e2e composer dock child permission", - parentID: session.id, - }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") - project.trackSession(child.id) + const child = await project.sdk.session + .create({ + title: "e2e composer dock child permission", + parentID: session.id, + }) + .then((r) => r.data) + if (!child?.id) throw new Error("Child session create did not return an id") + project.trackSession(child.id) - try { - await withMockPermission( - page, - { - id: "per_e2e_child", - sessionID: child.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-child"], - metadata: { description: "Need child permission" }, - }, - { child }, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) + try { + await withMockPermission( + page, + { + id: "per_e2e_child", + sessionID: child.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-child"], + metadata: { description: "Need child permission" }, + }, + { child }, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /allow once/i) - await state.resolved() - await page.goto(page.url()) + await clearPermissionDock(page, /allow once/i) + await state.resolved() + await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: child.id }) - } - }, - { trackSession: project.trackSession }, - ) - }) + await expectPermissionOpen(page) + }, + ) + } finally { + await cleanupSession({ sdk: project.sdk, sessionID: child.id }) + } + }, + { trackSession: project.trackSession }, + ) }) -test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock todo", - async (session) => { - const dock = await todoDock(page, session.id) - await project.gotoSession(session.id) - await expect(page.locator(sessionComposerDockSelector)).toBeVisible() +test("todo dock transitions and collapse behavior", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - try { - await dock.open([ - { content: "first task", status: "pending", priority: "high" }, - { content: "second task", status: "in_progress", priority: "medium" }, - ]) - await dock.expectOpen(["pending", "in_progress"]) + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectOpen(["pending", "in_progress"]) - await dock.collapse() - await dock.expectCollapsed(["pending", "in_progress"]) + await dock.collapse() + await dock.expectCollapsed(["pending", "in_progress"]) - await dock.expand() - await dock.expectOpen(["pending", "in_progress"]) + await dock.expand() + await dock.expectOpen(["pending", "in_progress"]) - await dock.finish([ - { content: "first task", status: "completed", priority: "high" }, - { content: "second task", status: "cancelled", priority: "medium" }, - ]) - await dock.expectClosed() - } finally { - await dock.clear() - } - }, - { trackSession: project.trackSession }, - ) - }) + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "cancelled", priority: "medium" }, + ]) + await dock.expectClosed() + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) }) -test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => { +test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { const questions = [ { header: "Need input", @@ -651,28 +632,27 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo options: [{ label: "Continue", description: "Continue now" }], }, ] - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock keyboard", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock keyboard", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { + await project.gotoSession(session.id) - await llm.toolMatch(inputMatch({ questions }), "question", { questions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions, - }) - - await expectQuestionBlocked(page) - - await page.locator("main").click({ position: { x: 5, y: 5 } }) - await page.keyboard.type("abc") - await expect(page.locator(promptSelector)).toHaveCount(0) + await llm.toolMatch(inputMatch({ questions }), "question", { questions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + await expectQuestionBlocked(page) + + await page.locator("main").click({ position: { x: 5, y: 5 } }) + await page.keyboard.type("abc") + await expect(page.locator(promptSelector)).toHaveCount(0) + }) + }, + { trackSession: project.trackSession }, + ) }) diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 66bc451bcf..8801e410f2 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,15 +1,6 @@ import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { - openSidebar, - resolveSlug, - sessionIDFromUrl, - setWorkspacesEnabled, - waitSession, - waitSessionIdle, - waitSlug, - withNoReplyPrompt, -} from "../actions" +import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions" import { promptAgentSelector, promptModelSelector, @@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) { await waitSession(page, { directory, sessionID }) } -async function submit(page: Page, value: string) { - const prompt = page.locator('[data-component="prompt-input"]') - await expect(prompt).toBeVisible() - - await withNoReplyPrompt(page, async () => { - await prompt.click() - await prompt.fill(value) - await prompt.press("Enter") - }) - - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") - const id = sessionIDFromUrl(page.url()) - if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`) - return id -} - -async function waitUser(directory: string, sessionID: string) { - const sdk = createSdk(directory) - await expect - .poll( - async () => { - const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? []) - return items.some((item) => item.info.role === "user") - }, - { timeout: 30_000 }, - ) - .toBe(true) - await sdk.session.abort({ sessionID }).catch(() => undefined) - await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined) +async function submit(project: Parameters[0]["project"], value: string) { + return project.user(value) } async function createWorkspace(page: Page, root: string, seen: string[]) { @@ -301,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) { return waitSession(page, { directory: next.directory }).then((item) => item.directory) } -test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => { +test("session model restore per session without leaking into new sessions", async ({ page, project }) => { await page.setViewportSize({ width: 1440, height: 900 }) - await withProject(async ({ directory, gotoSession, trackSession }) => { - await gotoSession() + await project.open() + await project.gotoSession() - const firstState = await chooseOtherModel(page) - const firstKey = await currentModel(page) - const first = await submit(page, `session variant ${Date.now()}`) - trackSession(first) - await waitUser(directory, first) + const firstState = await chooseOtherModel(page) + const firstKey = await currentModel(page) + const first = await submit(project, `session variant ${Date.now()}`) - await page.reload() - await waitSession(page, { directory, sessionID: first }) - await waitFooter(page, firstState) + await page.reload() + await waitSession(page, { directory: project.directory, sessionID: first }) + await waitFooter(page, firstState) - await gotoSession() - const fresh = await read(page) - expect(fresh.model).not.toBe(firstState.model) + await project.gotoSession() + const fresh = await read(page) + expect(fresh.model).not.toBe(firstState.model) - const secondState = await chooseOtherModel(page, [firstKey]) - const second = await submit(page, `session model ${Date.now()}`) - trackSession(second) - await waitUser(directory, second) + const secondState = await chooseOtherModel(page, [firstKey]) + const second = await submit(project, `session model ${Date.now()}`) - await goto(page, directory, first) - await waitFooter(page, firstState) + await goto(page, project.directory, first) + await waitFooter(page, firstState) - await goto(page, directory, second) - await waitFooter(page, secondState) + await goto(page, project.directory, second) + await waitFooter(page, secondState) - await gotoSession() - await waitFooter(page, fresh) - }) + await project.gotoSession() + await page.reload() + await waitSession(page, { directory: project.directory }) + await waitFooter(page, fresh) }) -test("session model restore across workspaces", async ({ page, withProject }) => { +test("session model restore across workspaces", async ({ page, project }) => { await page.setViewportSize({ width: 1440, height: 900 }) - await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => { - await gotoSession() + await project.open() + const root = project.directory + await project.gotoSession() - const firstState = await chooseOtherModel(page) - const firstKey = await currentModel(page) - const first = await submit(page, `root session ${Date.now()}`) - trackSession(first, root) - await waitUser(root, first) + const firstState = await chooseOtherModel(page) + const firstKey = await currentModel(page) + const first = await submit(project, `root session ${Date.now()}`) - await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) - const one = await createWorkspace(page, slug, []) - const oneDir = await newWorkspaceSession(page, one.slug) - trackDirectory(oneDir) + const one = await createWorkspace(page, project.slug, []) + const oneDir = await newWorkspaceSession(page, one.slug) + project.trackDirectory(oneDir) - const secondState = await chooseOtherModel(page, [firstKey]) - const secondKey = await currentModel(page) - const second = await submit(page, `workspace one ${Date.now()}`) - trackSession(second, oneDir) - await waitUser(oneDir, second) + const secondState = await chooseOtherModel(page, [firstKey]) + const secondKey = await currentModel(page) + const second = await submit(project, `workspace one ${Date.now()}`) - const two = await createWorkspace(page, slug, [one.slug]) - const twoDir = await newWorkspaceSession(page, two.slug) - trackDirectory(twoDir) + const two = await createWorkspace(page, project.slug, [one.slug]) + const twoDir = await newWorkspaceSession(page, two.slug) + project.trackDirectory(twoDir) - const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) - const third = await submit(page, `workspace two ${Date.now()}`) - trackSession(third, twoDir) - await waitUser(twoDir, third) + const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) + const third = await submit(project, `workspace two ${Date.now()}`) - await goto(page, root, first) - await waitFooter(page, firstState) + await goto(page, root, first) + await waitFooter(page, firstState) - await goto(page, oneDir, second) - await waitFooter(page, secondState) + await goto(page, oneDir, second) + await waitFooter(page, secondState) - await goto(page, twoDir, third) - await waitFooter(page, thirdState) + await goto(page, twoDir, third) + await waitFooter(page, thirdState) - await goto(page, root, first) - await waitFooter(page, firstState) - }) + await goto(page, root, first) + await waitFooter(page, firstState) }) -test("variant preserved when switching agent modes", async ({ page, withProject }) => { +test("variant preserved when switching agent modes", async ({ page, project }) => { await page.setViewportSize({ width: 1440, height: 900 }) - await withProject(async ({ directory, gotoSession }) => { - await gotoSession() + await project.open() + await project.gotoSession() - await ensureVariant(page, directory) - const updated = await chooseDifferentVariant(page) + await ensureVariant(page, project.directory) + const updated = await chooseDifferentVariant(page) - const available = await agents(page) - const other = available.find((name) => name !== updated.agent) - test.skip(!other, "only one agent available") - if (!other) return + const available = await agents(page) + const other = available.find((name) => name !== updated.agent) + test.skip(!other, "only one agent available") + if (!other) return - await choose(page, promptAgentSelector, other) - await waitFooter(page, { agent: other, variant: updated.variant }) + await choose(page, promptAgentSelector, other) + await waitFooter(page, { agent: other, variant: updated.variant }) - await choose(page, promptAgentSelector, updated.agent) - await waitFooter(page, { agent: updated.agent, variant: updated.variant }) - }) + await choose(page, promptAgentSelector, updated.agent) + await waitFooter(page, { agent: updated.agent, variant: updated.variant }) }) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index c7529112ff..b7695cc0b5 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -245,7 +245,7 @@ async function fileOverflow(page: Parameters[0]["page"]) { } } -test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => { +test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => { test.setTimeout(180_000) const tag = `review-comment-${Date.now()}` @@ -254,46 +254,45 @@ test("review applies inline comment clicks without horizontal overflow", async ( await page.setViewportSize({ width: 1280, height: 900 }) - await withMockProject(async (project) => { - await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { - project.trackSession(session.id) - await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) + await project.open() + await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { + project.trackSession(session.id) + await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - return diff.length - }, - { timeout: 60_000 }, - ) - .toBe(1) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(1) - await project.gotoSession(session.id) - await show(page) + await project.gotoSession(session.id) + await show(page) - const tab = page.getByRole("tab", { name: /Review/i }).first() - await expect(tab).toBeVisible() - await tab.click() + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() - await expand(page) - await waitMark(page, file, tag) - await comment(page, file, note) + await expand(page) + await waitMark(page, file, tag) + await comment(page, file, note) - await expect - .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - }) + await expect + .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) }) }) -test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => { +test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => { test.setTimeout(180_000) const tag = `review-file-comment-${Date.now()}` @@ -302,47 +301,46 @@ test("review file comments submit on click without clipping actions", async ({ p await page.setViewportSize({ width: 1280, height: 900 }) - await withMockProject(async (project) => { - await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { - project.trackSession(session.id) - await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) + await project.open() + await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { + project.trackSession(session.id) + await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - return diff.length - }, - { timeout: 60_000 }, - ) - .toBe(1) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(1) - await project.gotoSession(session.id) - await show(page) + await project.gotoSession(session.id) + await show(page) - const tab = page.getByRole("tab", { name: /Review/i }).first() - await expect(tab).toBeVisible() - await tab.click() + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() - await expand(page) - await waitMark(page, file, tag) - await openReviewFile(page, file) - await fileComment(page, note) + await expand(page) + await waitMark(page, file, tag) + await openReviewFile(page, file) + await fileComment(page, note) - await expect - .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - }) + await expect + .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) }) }) -test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => { +test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => { test.setTimeout(180_000) const tag = `review-${Date.now()}` @@ -352,84 +350,83 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag await page.setViewportSize({ width: 1600, height: 1000 }) - await withMockProject(async (project) => { - await withSession(project.sdk, `e2e review ${tag}`, async (session) => { - project.trackSession(session.id) - await patchWithMock(llm, project.sdk, session.id, seed(list)) + await project.open() + await withSession(project.sdk, `e2e review ${tag}`, async (session) => { + project.trackSession(session.id) + await patchWithMock(llm, project.sdk, session.id, seed(list)) - await expect - .poll( - async () => { - const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) - return info?.summary?.files ?? 0 - }, - { timeout: 60_000 }, - ) - .toBe(list.length) + await expect + .poll( + async () => { + const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) + return info?.summary?.files ?? 0 + }, + { timeout: 60_000 }, + ) + .toBe(list.length) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - return diff.length - }, - { timeout: 60_000 }, - ) - .toBe(list.length) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(list.length) - await project.gotoSession(session.id) - await show(page) + await project.gotoSession(session.id) + await show(page) - const tab = page.getByRole("tab", { name: /Review/i }).first() - await expect(tab).toBeVisible() - await tab.click() + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() - const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() - await expect(view).toBeVisible() - const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) - await expect(heads).toHaveCount(list.length, { timeout: 60_000 }) + const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() + await expect(view).toBeVisible() + const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) + await expect(heads).toHaveCount(list.length, { timeout: 60_000 }) - await expand(page) - await waitMark(page, hit.file, hit.mark) + await expand(page) + await waitMark(page, hit.file, hit.mark) - const row = page - .getByRole("heading", { - level: 3, - name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - }) - .first() - await expect(row).toBeVisible() - await row.evaluate((el) => el.scrollIntoView({ block: "center" })) + const row = page + .getByRole("heading", { + level: 3, + name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + }) + .first() + await expect(row).toBeVisible() + await row.evaluate((el) => el.scrollIntoView({ block: "center" })) - await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200) - const prev = await spot(page, hit.file) - if (!prev) throw new Error(`missing review row for ${hit.file}`) + await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200) + const prev = await spot(page, hit.file) + if (!prev) throw new Error(`missing review row for ${hit.file}`) - await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) + await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - const item = diff.find((item) => item.file === hit.file) - return typeof item?.after === "string" ? item.after : "" - }, - { timeout: 60_000 }, - ) - .toContain(`mark ${next}`) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + const item = diff.find((item) => item.file === hit.file) + return typeof item?.after === "string" ? item.after : "" + }, + { timeout: 60_000 }, + ) + .toContain(`mark ${next}`) - await waitMark(page, hit.file, next) + await waitMark(page, hit.file, next) - await expect - .poll( - async () => { - const next = await spot(page, hit.file) - if (!next) return Number.POSITIVE_INFINITY - return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) - }, - { timeout: 60_000 }, - ) - .toBeLessThanOrEqual(32) - }) + await expect + .poll( + async () => { + const next = await spot(page, hit.file) + if (!next) return Number.POSITIVE_INFINITY + return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) + }, + { timeout: 60_000 }, + ) + .toBeLessThanOrEqual(32) }) }) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index a63bd9e3b5..709a45b4c4 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -49,188 +49,185 @@ async function seedConversation(input: { return { prompt, userMessageID } } -test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => { +test("slash undo sets revert and restores prior prompt", async ({ page, project }) => { test.setTimeout(120_000) const token = `undo_${Date.now()}` - await withBackendProject(async (project) => { - const sdk = project.sdk + await project.open() + const sdk = project.sdk - await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) - const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) + const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) - await seeded.prompt.click() - await page.keyboard.type("/undo") + await seeded.prompt.click() + await page.keyboard.type("/undo") - const undo = page.locator('[data-slash-id="session.undo"]').first() - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(seeded.userMessageID) + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(seeded.userMessageID) - await expect(seeded.prompt).toContainText(token) - await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0) - }) + await expect(seeded.prompt).toContainText(token) + await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0) }) }) -test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => { +test("slash redo clears revert and restores latest state", async ({ page, project }) => { test.setTimeout(120_000) const token = `redo_${Date.now()}` - await withBackendProject(async (project) => { - const sdk = project.sdk + await project.open() + const sdk = project.sdk - await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) - const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) + const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) - await seeded.prompt.click() - await page.keyboard.type("/undo") + await seeded.prompt.click() + await page.keyboard.type("/undo") - const undo = page.locator('[data-slash-id="session.undo"]').first() - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(seeded.userMessageID) + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(seeded.userMessageID) - await seeded.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/redo") + await seeded.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/redo") - const redo = page.locator('[data-slash-id="session.redo"]').first() - await expect(redo).toBeVisible() - await page.keyboard.press("Enter") + const redo = page.locator('[data-slash-id="session.redo"]').first() + await expect(redo).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBeUndefined() + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBeUndefined() - await expect(seeded.prompt).not.toContainText(token) - await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) - }) + await expect(seeded.prompt).not.toContainText(token) + await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) }) }) -test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => { +test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => { test.setTimeout(120_000) const firstToken = `undo_redo_first_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}` - await withBackendProject(async (project) => { - const sdk = project.sdk + await project.open() + const sdk = project.sdk - await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) - const first = await seedConversation({ - page, - sdk, - sessionID: session.id, - token: firstToken, - }) - const second = await seedConversation({ - page, - sdk, - sessionID: session.id, - token: secondToken, - }) - - expect(first.userMessageID).not.toBe(second.userMessageID) - - const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`) - const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`) - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(1) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/undo") - - const undo = page.locator('[data-slash-id="session.undo"]').first() - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(second.userMessageID) - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(0) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/undo") - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(first.userMessageID) - - await expect(firstMessage).toHaveCount(0) - await expect(secondMessage).toHaveCount(0) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/redo") - - const redo = page.locator('[data-slash-id="session.redo"]').first() - await expect(redo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(second.userMessageID) - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(0) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/redo") - await expect(redo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBeUndefined() - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(1) + const first = await seedConversation({ + page, + sdk, + sessionID: session.id, + token: firstToken, }) + const second = await seedConversation({ + page, + sdk, + sessionID: session.id, + token: secondToken, + }) + + expect(first.userMessageID).not.toBe(second.userMessageID) + + const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`) + const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`) + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/undo") + + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(second.userMessageID) + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(0) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/undo") + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(first.userMessageID) + + await expect(firstMessage).toHaveCount(0) + await expect(secondMessage).toHaveCount(0) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/redo") + + const redo = page.locator('[data-slash-id="session.redo"]').first() + await expect(redo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(second.userMessageID) + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(0) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/redo") + await expect(redo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBeUndefined() + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) }) }) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 6c885460c4..1b5fb1b600 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -31,156 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) { .toBeGreaterThan(0) } -test("session can be renamed via header menu", async ({ page, withBackendProject }) => { +test("session can be renamed via header menu", async ({ page, project }) => { const stamp = Date.now() const originalTitle = `e2e rename test ${stamp}` const renamedTitle = `e2e renamed ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, originalTitle, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) + await project.open() + await withSession(project.sdk, originalTitle, async (session) => { + project.trackSession(session.id) + await seedMessage(project.sdk, session.id) + await project.gotoSession(session.id) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) - const menu = await openSessionMoreMenu(page, session.id) - await clickMenuItem(menu, /rename/i) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /rename/i) - const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() - await expect(input).toBeVisible() - await expect(input).toBeFocused() - await input.fill(renamedTitle) - await expect(input).toHaveValue(renamedTitle) - await input.press("Enter") + const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() + await expect(input).toBeVisible() + await expect(input).toBeFocused() + await input.fill(renamedTitle) + await expect(input).toHaveValue(renamedTitle) + await input.press("Enter") - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.title - }, - { timeout: 30_000 }, - ) - .toBe(renamedTitle) + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.title + }, + { timeout: 30_000 }, + ) + .toBe(renamedTitle) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) - }) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) }) }) -test("session can be archived via header menu", async ({ page, withBackendProject }) => { +test("session can be archived via header menu", async ({ page, project }) => { const stamp = Date.now() const title = `e2e archive test ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, title, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) - const menu = await openSessionMoreMenu(page, session.id) - await clickMenuItem(menu, /archive/i) + await project.open() + await withSession(project.sdk, title, async (session) => { + project.trackSession(session.id) + await seedMessage(project.sdk, session.id) + await project.gotoSession(session.id) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /archive/i) - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.time?.archived - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.time?.archived + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - await openSidebar(page) - await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) - }) + await openSidebar(page) + await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) }) }) -test("session can be deleted via header menu", async ({ page, withBackendProject }) => { +test("session can be deleted via header menu", async ({ page, project }) => { const stamp = Date.now() const title = `e2e delete test ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, title, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) - const menu = await openSessionMoreMenu(page, session.id) - await clickMenuItem(menu, /delete/i) - await confirmDialog(page, /delete/i) + await project.open() + await withSession(project.sdk, title, async (session) => { + project.trackSession(session.id) + await seedMessage(project.sdk, session.id) + await project.gotoSession(session.id) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /delete/i) + await confirmDialog(page, /delete/i) - await expect - .poll( - async () => { - const data = await project.sdk.session - .get({ sessionID: session.id }) - .then((r) => r.data) - .catch(() => undefined) - return data?.id - }, - { timeout: 30_000 }, - ) - .toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session + .get({ sessionID: session.id }) + .then((r) => r.data) + .catch(() => undefined) + return data?.id + }, + { timeout: 30_000 }, + ) + .toBeUndefined() - await openSidebar(page) - await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) - }) + await openSidebar(page) + await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) }) }) -test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => { +test("session can be shared and unshared via header button", async ({ page, project }) => { test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") const stamp = Date.now() const title = `e2e share test ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, title, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) + await project.open() + await withSession(project.sdk, title, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + await project.prompt(`share seed ${stamp}`) - const shared = await openSharePopover(page) - const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() - await expect(publish).toBeVisible({ timeout: 30_000 }) - await publish.click() + const shared = await openSharePopover(page) + const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() + await expect(publish).toBeVisible({ timeout: 30_000 }) + await publish.click() - await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ - timeout: 30_000, - }) + await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ + timeout: 30_000, + }) - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() - await expect(unpublish).toBeVisible({ timeout: 30_000 }) - await unpublish.click() + const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() + await expect(unpublish).toBeVisible({ timeout: 30_000 }) + await unpublish.click() - await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ - timeout: 30_000, - }) + await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, + }) - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() - const unshared = await openSharePopover(page) - await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ - timeout: 30_000, - }) + const unshared = await openSharePopover(page) + await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, }) }) }) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index 1317d2bb68..8f7646c3e7 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -48,70 +48,62 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p } }) -test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => { +test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const slug = dirSlug(other) try { - await withProject( - async () => { - await openSidebar(page) + await project.open({ extra: [other] }) + await openSidebar(page) - const project = page.locator(projectSwitchSelector(slug)).first() - const card = page.locator('[data-component="hover-card-content"]') + const projectButton = page.locator(projectSwitchSelector(slug)).first() + const card = page.locator('[data-component="hover-card-content"]') - await expect(project).toBeVisible() - await project.hover() - await expect(card.getByText(/recent sessions/i)).toBeVisible() + await expect(projectButton).toBeVisible() + await projectButton.hover() + await expect(card.getByText(/recent sessions/i)).toBeVisible() - await page.mouse.down() - await expect(card).toHaveCount(0) - await page.mouse.up() + await page.mouse.down() + await expect(card).toHaveCount(0) + await page.mouse.up() - await waitSession(page, { directory: other }) - await expect(card).toHaveCount(0) - }, - { extra: [other] }, - ) + await waitSession(page, { directory: other }) + await expect(card).toHaveCount(0) } finally { await cleanupTestProject(other) } }) -test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => { +test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const slug = dirSlug(other) try { - await withProject( - async () => { - await openSidebar(page) - await defocus(page) + await project.open({ extra: [other] }) + await openSidebar(page) + await defocus(page) - const project = page.locator(projectSwitchSelector(slug)).first() + const projectButton = page.locator(projectSwitchSelector(slug)).first() - await expect(project).toBeVisible() + await expect(projectButton).toBeVisible() - let hit = false - for (let i = 0; i < 20; i++) { - hit = await project.evaluate((el) => { - return el.matches(":focus") || !!el.parentElement?.matches(":focus") - }) - if (hit) break - await page.keyboard.press("Tab") - } + let hit = false + for (let i = 0; i < 20; i++) { + hit = await projectButton.evaluate((el) => { + return el.matches(":focus") || !!el.parentElement?.matches(":focus") + }) + if (hit) break + await page.keyboard.press("Tab") + } - expect(hit).toBe(true) + expect(hit).toBe(true) - await page.keyboard.press("Enter") - await waitSession(page, { directory: other }) - }, - { extra: [other] }, - ) + await page.keyboard.press("Enter") + await waitSession(page, { directory: other }) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/terminal/terminal-reconnect.spec.ts b/packages/app/e2e/terminal/terminal-reconnect.spec.ts index b03ed89568..1a11a047a4 100644 --- a/packages/app/e2e/terminal/terminal-reconnect.spec.ts +++ b/packages/app/e2e/terminal/terminal-reconnect.spec.ts @@ -12,35 +12,34 @@ async function open(page: Page) { return term } -test("terminal reconnects without replacing the pty", async ({ page, withProject }) => { - await withProject(async ({ gotoSession }) => { - const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` - const token = `E2E_RECONNECT_${Date.now()}` +test("terminal reconnects without replacing the pty", async ({ page, project }) => { + await project.open() + const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` + const token = `E2E_RECONNECT_${Date.now()}` - await gotoSession() + await project.gotoSession() - const term = await open(page) - const id = await term.getAttribute("data-pty-id") - if (!id) throw new Error("Active terminal missing data-pty-id") + const term = await open(page) + const id = await term.getAttribute("data-pty-id") + if (!id) throw new Error("Active terminal missing data-pty-id") - const prev = await terminalConnects(page, { term }) + const prev = await terminalConnects(page, { term }) - await runTerminal(page, { - term, - cmd: `export ${name}=${token}; echo ${token}`, - token, - }) + await runTerminal(page, { + term, + cmd: `export ${name}=${token}; echo ${token}`, + token, + }) - await disconnectTerminal(page, { term }) + await disconnectTerminal(page, { term }) - await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev) - await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id) + await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev) + await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id) - await runTerminal(page, { - term, - cmd: `echo $${name}`, - token, - timeout: 15_000, - }) + await runTerminal(page, { + term, + cmd: `echo $${name}`, + token, + timeout: 15_000, }) }) diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts index 6b6fa4c62b..5cb5bbf202 100644 --- a/packages/app/e2e/terminal/terminal-tabs.spec.ts +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -36,133 +36,130 @@ async function store(page: Page, key: string) { }, key) } -test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => { - await withProject(async ({ directory, gotoSession }) => { - const key = workspacePersistKey(directory, "terminal") - const one = `E2E_TERM_ONE_${Date.now()}` - const two = `E2E_TERM_TWO_${Date.now()}` - const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') - const first = tabs.filter({ hasText: /Terminal 1/ }).first() - const second = tabs.filter({ hasText: /Terminal 2/ }).first() +test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => { + await project.open() + const key = workspacePersistKey(project.directory, "terminal") + const one = `E2E_TERM_ONE_${Date.now()}` + const two = `E2E_TERM_TWO_${Date.now()}` + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + const second = tabs.filter({ hasText: /Terminal 2/ }).first() - await gotoSession() - await open(page) + await project.gotoSession() + await open(page) - await runTerminal(page, { cmd: `echo ${one}`, token: one }) + await runTerminal(page, { cmd: `echo ${one}`, token: one }) - await page.getByRole("button", { name: /new terminal/i }).click() - await expect(tabs).toHaveCount(2) + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) - await runTerminal(page, { cmd: `echo ${two}`, token: two }) + await runTerminal(page, { cmd: `echo ${two}`, token: two }) - await first.click() - await expect(first).toHaveAttribute("aria-selected", "true") + await first.click() + await expect(first).toHaveAttribute("aria-selected", "true") - await expect - .poll( - async () => { - const state = await store(page, key) - const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" - const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" - return { - first: first.includes(one), - second: second.includes(two), - } - }, - { timeout: 5_000 }, - ) - .toEqual({ first: false, second: true }) + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return { + first: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 5_000 }, + ) + .toEqual({ first: false, second: true }) - await second.click() - await expect(second).toHaveAttribute("aria-selected", "true") - await expect - .poll( - async () => { - const state = await store(page, key) - const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" - const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" - return { - first: first.includes(one), - second: second.includes(two), - } - }, - { timeout: 5_000 }, - ) - .toEqual({ first: true, second: false }) - }) + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return { + first: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 5_000 }, + ) + .toEqual({ first: true, second: false }) }) -test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => { - await withProject(async ({ directory, gotoSession }) => { - const key = workspacePersistKey(directory, "terminal") - const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') +test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => { + await project.open() + const key = workspacePersistKey(project.directory, "terminal") + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') - await gotoSession() - await open(page) + await project.gotoSession() + await open(page) - await page.getByRole("button", { name: /new terminal/i }).click() - await expect(tabs).toHaveCount(2) + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) - const second = tabs.filter({ hasText: /Terminal 2/ }).first() - await second.click() - await expect(second).toHaveAttribute("aria-selected", "true") + const second = tabs.filter({ hasText: /Terminal 2/ }).first() + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") - await second.hover() - await page - .getByRole("button", { name: /close terminal/i }) - .nth(1) - .click({ force: true }) + await second.hover() + await page + .getByRole("button", { name: /close terminal/i }) + .nth(1) + .click({ force: true }) - const first = tabs.filter({ hasText: /Terminal 1/ }).first() - await expect(tabs).toHaveCount(1) - await expect(first).toHaveAttribute("aria-selected", "true") - await expect - .poll( - async () => { - const state = await store(page, key) - return { - count: state?.all.length ?? 0, - first: state?.all.some((item) => item.titleNumber === 1) ?? false, - } - }, - { timeout: 15_000 }, - ) - .toEqual({ count: 1, first: true }) - }) + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + await expect(tabs).toHaveCount(1) + await expect(first).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + return { + count: state?.all.length ?? 0, + first: state?.all.some((item) => item.titleNumber === 1) ?? false, + } + }, + { timeout: 15_000 }, + ) + .toEqual({ count: 1, first: true }) }) -test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => { - await withProject(async ({ directory, gotoSession }) => { - const key = workspacePersistKey(directory, "terminal") - const rename = `E2E term ${Date.now()}` - const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() +test("terminal tab can be renamed from the context menu", async ({ page, project }) => { + await project.open() + const key = workspacePersistKey(project.directory, "terminal") + const rename = `E2E term ${Date.now()}` + const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() - await gotoSession() - await open(page) + await project.gotoSession() + await open(page) - await expect(tab).toContainText(/Terminal 1/) - await tab.click({ button: "right" }) + await expect(tab).toContainText(/Terminal 1/) + await tab.click({ button: "right" }) - const menu = page.locator(dropdownMenuContentSelector).first() - await expect(menu).toBeVisible() - await menu.getByRole("menuitem", { name: /^Rename$/i }).click() - await expect(menu).toHaveCount(0) + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + await menu.getByRole("menuitem", { name: /^Rename$/i }).click() + await expect(menu).toHaveCount(0) - const input = page.locator('#terminal-panel input[type="text"]').first() - await expect(input).toBeVisible() - await input.fill(rename) - await input.press("Enter") + const input = page.locator('#terminal-panel input[type="text"]').first() + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") - await expect(input).toHaveCount(0) - await expect(tab).toContainText(rename) - await expect - .poll( - async () => { - const state = await store(page, key) - return state?.all[0]?.title - }, - { timeout: 5_000 }, - ) - .toBe(rename) - }) + await expect(input).toHaveCount(0) + await expect(tab).toContainText(rename) + await expect + .poll( + async () => { + const state = await store(page, key) + return state?.all[0]?.title + }, + { timeout: 5_000 }, + ) + .toBe(rename) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ba299fe365..06b6c1e351 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { promptProbe } from "@/testing/prompt" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" @@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.addToHistory(currentPrompt, mode) input.resetHistoryNavigation() + promptProbe.start() const projectDirectory = sdk.directory const isNewSession = !params.id @@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } + promptProbe.submit({ sessionID: session.id, directory: sessionDirectory }) input.onSubmit?.() if (mode === "shell") { diff --git a/packages/app/src/testing/prompt.ts b/packages/app/src/testing/prompt.ts index e11462f301..5102ed825b 100644 --- a/packages/app/src/testing/prompt.ts +++ b/packages/app/src/testing/prompt.ts @@ -10,6 +10,13 @@ export type PromptProbeState = { selects: number } +export type PromptSendState = { + started: number + count: number + sessionID?: string + directory?: string +} + export const promptEnabled = () => { if (typeof window === "undefined") return false return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true @@ -53,4 +60,24 @@ export const promptProbe = { if (!state) return state.current = undefined }, + start() { + const state = root() + if (!state) return + state.sent = { + started: (state.sent?.started ?? 0) + 1, + count: state.sent?.count ?? 0, + sessionID: state.sent?.sessionID, + directory: state.sent?.directory, + } + }, + submit(input: { sessionID: string; directory: string }) { + const state = root() + if (!state) return + state.sent = { + started: state.sent?.started ?? 0, + count: (state.sent?.count ?? 0) + 1, + sessionID: input.sessionID, + directory: input.directory, + } + }, } diff --git a/packages/app/src/testing/terminal.ts b/packages/app/src/testing/terminal.ts index 2bca39b31c..db8001ddf9 100644 --- a/packages/app/src/testing/terminal.ts +++ b/packages/app/src/testing/terminal.ts @@ -23,6 +23,7 @@ export type E2EWindow = Window & { prompt?: { enabled?: boolean current?: import("./prompt").PromptProbeState + sent?: import("./prompt").PromptSendState } terminal?: { enabled?: boolean From b782650dfef174a694e1db2fe9053dad28d6501d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 01:09:08 -0400 Subject: [PATCH 04/23] test(app): stabilize migrated e2e suite --- packages/app/e2e/fixtures.ts | 21 ++++++++++++++++--- .../app/e2e/projects/project-edit.spec.ts | 2 ++ packages/app/e2e/projects/workspaces.spec.ts | 3 ++- packages/app/e2e/settings/settings.spec.ts | 1 + .../sidebar/sidebar-popover-actions.spec.ts | 3 +-- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 77a609c45d..7b1941319e 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -88,6 +88,21 @@ function clean(value: string | null) { return (value ?? "").replace(/\u200B/g, "").trim() } +async function visit(page: Page, url: string) { + let err: unknown + for (const _ of [0, 1, 2]) { + try { + await page.goto(url) + return + } catch (cause) { + err = cause + if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause + await new Promise((resolve) => setTimeout(resolve, 300)) + } + } + throw err +} + async function promptSend(page: Page) { return page .evaluate(() => { @@ -279,7 +294,7 @@ export const test = base.extend({ await seedStorage(page, { directory }) const gotoSession = async (sessionID?: string) => { - await page.goto(sessionPath(directory, sessionID)) + await visit(page, sessionPath(directory, sessionID)) await waitSession(page, { directory, sessionID }) } await use(gotoSession) @@ -349,7 +364,7 @@ function makeProject( const gotoSession = async (sessionID?: string) => { const cur = need() - await page.goto(sessionPath(cur.directory, sessionID)) + await visit(page, sessionPath(cur.directory, sessionID)) await waitSession(page, { directory: cur.directory, sessionID, serverUrl: backend.url }) const current = sessionIDFromUrl(page.url()) if (current) trackSession(current) @@ -521,7 +536,7 @@ async function runProject( }) const gotoSession = async (sessionID?: string) => { - await page.goto(sessionPath(root, sessionID)) + await visit(page, sessionPath(root, sessionID)) await waitSession(page, { directory: root, sessionID, serverUrl: url }) const current = sessionIDFromUrl(page.url()) if (current) trackSession(current) diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index bc182a6953..1ffe4219d1 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -34,6 +34,8 @@ test("dialog edit project updates name and startup script", async ({ page, proje await expect .poll( async () => { + await page.reload() + await openSidebar(page) const reopened = await open() const value = await reopened.getByLabel("Name").inputValue() const next = await reopened.getByLabel("Workspace startup script").inputValue() diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 16caa3d496..b8a0720401 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -12,6 +12,7 @@ import { clickMenuItem, confirmDialog, openSidebar, + openProjectMenu, openWorkspaceMenu, resolveSlug, setWorkspacesEnabled, @@ -19,7 +20,7 @@ import { waitDir, waitSlug, } from "../actions" -import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" +import { inlineInputSelector, workspaceItemSelector } from "../selectors" import { dirSlug } from "../utils" async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) { diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 1b151b6066..3327bf3114 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -92,6 +92,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) => await select.locator('[data-slot="select-select-trigger"]').click() const items = page.locator('[data-slot="select-select-item"]') + await expect(items.first()).toBeVisible() const count = await items.count() expect(count).toBeGreaterThan(1) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index 8f7646c3e7..05a129a613 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -65,9 +65,8 @@ test("open sidebar project popover stays closed after clicking avatar", async ({ await projectButton.hover() await expect(card.getByText(/recent sessions/i)).toBeVisible() - await page.mouse.down() + await projectButton.click() await expect(card).toHaveCount(0) - await page.mouse.up() await waitSession(page, { directory: other }) await expect(card).toHaveCount(0) From 598296fc226f4b1b2793161e861e8ea5dac4cbf5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 09:18:38 -0400 Subject: [PATCH 05/23] test: align e2e fixtures with worker backends --- packages/app/e2e/fixtures.ts | 14 +++++++------- packages/opencode/test/lib/llm-server.ts | 9 --------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 7b1941319e..38b445a205 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -276,8 +276,8 @@ export const test = base.extend({ if (boundary) throw new Error(boundary) }, directory: [ - async ({}, use) => { - await use(await getWorktree()) + async ({ backend }, use) => { + await use(await getWorktree(backend.url)) }, { scope: "worker" }, ], @@ -287,15 +287,15 @@ export const test = base.extend({ }, { scope: "worker" }, ], - sdk: async ({ directory }, use) => { - await use(createSdk(directory)) + sdk: async ({ directory, backend }, use) => { + await use(backend.sdk(directory)) }, - gotoSession: async ({ page, directory }, use) => { - await seedStorage(page, { directory }) + gotoSession: async ({ page, directory, backend }, use) => { + await seedStorage(page, { directory, serverUrl: backend.url }) const gotoSession = async (sessionID?: string) => { await visit(page, sessionPath(directory, sessionID)) - await waitSession(page, { directory, sessionID }) + await waitSession(page, { directory, sessionID, serverUrl: backend.url }) } await use(gotoSession) }, diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index b3dd220c22..7bc3066e33 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -684,15 +684,6 @@ export class TestLLMServer extends ServiceMap.Service Date: Thu, 2 Apr 2026 09:37:19 -0400 Subject: [PATCH 06/23] test(app): stabilize session routing and workspace e2e --- packages/app/e2e/actions.ts | 2 -- packages/app/e2e/projects/workspaces.spec.ts | 17 +---------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 1b44138784..63935d8b4f 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -491,11 +491,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio if (!resolved || resolved.directory !== target) return false const current = sessionIDFromUrl(page.url()) if (input.sessionID && current !== input.sessionID) return false - if (!input.sessionID && current) return false const state = await probeSession(page) if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false - if (!input.sessionID && state?.sessionID) return false if (state?.dir) { const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "") if (dir !== target) return false diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index b8a0720401..aea4c80686 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -12,7 +12,6 @@ import { clickMenuItem, confirmDialog, openSidebar, - openProjectMenu, openWorkspaceMenu, resolveSlug, setWorkspacesEnabled, @@ -125,21 +124,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, project }) await openSidebar(page) await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - - const trigger = page.locator('[data-action="project-menu"]').first() - const hasMenu = await trigger - .isVisible() - .then((x) => x) - .catch(() => false) - if (!hasMenu) return - - const menu = await openProjectMenu(page, nonGitSlug) - - const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() - - await expect(toggle).toBeVisible() - await expect(toggle).toBeDisabled() - await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible() } finally { await cleanupTestProject(nonGit) } From cfcdd5c1dd3bc5f8a83c0de53c8f4352297a9b8b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 09:55:26 -0400 Subject: [PATCH 07/23] test(app): relax review patch mock matching --- packages/app/e2e/session/session-review.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index b7695cc0b5..6e7df88eed 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -1,6 +1,6 @@ import { waitSessionIdle, withSession } from "../actions" import { test, expect } from "../fixtures" -import { inputMatch } from "../prompt/mock" +import { promptMatch } from "../prompt/mock" const count = 14 @@ -47,7 +47,7 @@ async function patchWithMock( patchText: string, ) { const callsBefore = await llm.calls() - await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText }) + await llm.toolMatch(promptMatch("Apply the provided patch exactly once."), "apply_patch", { patchText }) await sdk.session.promptAsync({ sessionID, agent: "build", From 1c0812fe01e0853378246b061ea04ae9dd9556a6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 10:51:30 -0400 Subject: [PATCH 08/23] test(app): stabilize review and workspace e2e --- packages/app/e2e/actions.ts | 22 ++++-- packages/app/e2e/fixtures.ts | 67 ++++++++++++++++--- packages/app/e2e/projects/workspaces.spec.ts | 15 ++++- .../session/session-model-persistence.spec.ts | 2 +- .../app/e2e/session/session-review.spec.ts | 20 +++--- .../src/context/global-sync/event-reducer.ts | 5 +- packages/app/src/context/global-sync/utils.ts | 10 ++- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 63935d8b4f..32e3d6ba7c 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -44,7 +44,7 @@ export async function defocus(page: Page) { } export async function withNoReplyPrompt(page: Page, fn: () => Promise) { - const url = "**/session/*/prompt_async" + const urls = ["**/session/*/prompt_async", "**/session/*/message"] const route = async (input: Route) => { const body = input.request().postDataJSON() await input.continue({ @@ -56,11 +56,11 @@ export async function withNoReplyPrompt(page: Page, fn: () => Promise) { }) } - await page.route(url, route) + await Promise.all(urls.map((url) => page.route(url, route))) try { return await fn() } finally { - await page.unroute(url, route) + await Promise.all(urls.map((url) => page.unroute(url, route))) } } @@ -479,7 +479,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl return { directory: target, slug: base64Encode(target) } } -export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) { +export async function waitSession( + page: Page, + input: { + directory: string + sessionID?: string + serverUrl?: string + allowAnySession?: boolean + }, +) { const target = await resolveDirectory(input.directory, input.serverUrl) await expect .poll( @@ -491,9 +499,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio if (!resolved || resolved.directory !== target) return false const current = sessionIDFromUrl(page.url()) if (input.sessionID && current !== input.sessionID) return false + if (!input.sessionID && !input.allowAnySession && current) return false const state = await probeSession(page) if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false + if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false if (state?.dir) { const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "") if (dir !== target) return false @@ -1020,7 +1030,7 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab if ((await current()) === enabled) return if (enabled) { - await page.goto(page.url()) + await page.reload() await openSidebar(page) if ((await current()) === enabled) return } @@ -1053,7 +1063,7 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab } if ((await current()) !== enabled) { - await page.goto(page.url()) + await page.reload() await openSidebar(page) } diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 38b445a205..bb3ea4a6c4 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -295,7 +295,12 @@ export const test = base.extend({ const gotoSession = async (sessionID?: string) => { await visit(page, sessionPath(directory, sessionID)) - await waitSession(page, { directory, sessionID, serverUrl: backend.url }) + await waitSession(page, { + directory, + sessionID, + serverUrl: backend.url, + allowAnySession: !sessionID, + }) } await use(gotoSession) }, @@ -365,7 +370,12 @@ function makeProject( const gotoSession = async (sessionID?: string) => { const cur = need() await visit(page, sessionPath(cur.directory, sessionID)) - await waitSession(page, { directory: cur.directory, sessionID, serverUrl: backend.url }) + await waitSession(page, { + directory: cur.directory, + sessionID, + serverUrl: backend.url, + allowAnySession: !sessionID, + }) const current = sessionIDFromUrl(page.url()) if (current) trackSession(current) } @@ -394,6 +404,46 @@ function makeProject( } const send = async (text: string, input: { noReply: boolean; shell: boolean }) => { + if (input.noReply) { + const cur = need() + const state = await page.evaluate(() => { + const model = (window as E2EWindow).__opencode_e2e?.model?.current + if (!model) return null + return { + dir: model.dir, + sessionID: model.sessionID, + agent: model.agent, + model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined, + variant: model.variant ?? undefined, + } + }) + const dir = state?.dir ?? cur.directory + const sdk = backend.sdk(dir) + const sessionID = state?.sessionID + ? state.sessionID + : await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => { + if (!res.data?.id) throw new Error("Failed to create no-reply session") + return res.data.id + }) + await sdk.session.prompt({ + sessionID, + agent: state?.agent, + model: state?.model, + variant: state?.variant, + noReply: true, + parts: [{ type: "text", text }], + }) + await visit(page, sessionPath(dir, sessionID)) + const active = await waitSession(page, { + directory: dir, + sessionID, + serverUrl: backend.url, + }) + trackSession(sessionID, active.directory) + await waitSessionSaved(active.directory, sessionID, 90_000, backend.url) + return sessionID + } + const prev = await promptSend(page) if (!input.noReply && !input.shell && (await llm.pending()) === 0) { await llm.text("ok") @@ -430,11 +480,7 @@ function makeProject( await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started) } - if (input.noReply) { - await withNoReplyPrompt(page, submit) - } else { - await submit() - } + await submit() let next: { sessionID: string; directory: string } | undefined await expect @@ -537,7 +583,12 @@ async function runProject( const gotoSession = async (sessionID?: string) => { await visit(page, sessionPath(root, sessionID)) - await waitSession(page, { directory: root, sessionID, serverUrl: url }) + await waitSession(page, { + directory: root, + sessionID, + serverUrl: url, + allowAnySession: !sessionID, + }) const current = sessionIDFromUrl(page.url()) if (current) trackSession(current) } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aea4c80686..206baa47ce 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -315,7 +315,20 @@ test("can delete a workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) await project.open() - const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) + const rootSlug = project.slug + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data) + if (!created?.directory) throw new Error("Failed to create workspace for delete test") + + const directory = created.directory + const slug = dirSlug(directory) + project.trackDirectory(directory) + + await page.reload() + await openSidebar(page) + await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 }) await expect .poll( diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 8801e410f2..c107cc5187 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -222,7 +222,7 @@ async function goto(page: Page, directory: string, sessionID?: string) { } async function submit(project: Parameters[0]["project"], value: string) { - return project.user(value) + return project.prompt(value) } async function createWorkspace(page: Page, root: string, seen: string[]) { diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 6e7df88eed..8693f1c30e 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -1,6 +1,6 @@ import { waitSessionIdle, withSession } from "../actions" import { test, expect } from "../fixtures" -import { promptMatch } from "../prompt/mock" +import { bodyText } from "../prompt/mock" const count = 14 @@ -47,8 +47,12 @@ async function patchWithMock( patchText: string, ) { const callsBefore = await llm.calls() - await llm.toolMatch(promptMatch("Apply the provided patch exactly once."), "apply_patch", { patchText }) - await sdk.session.promptAsync({ + await llm.toolMatch( + (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."), + "apply_patch", + { patchText }, + ) + await sdk.session.prompt({ sessionID, agent: "build", system: [ @@ -61,12 +65,12 @@ async function patchWithMock( parts: [{ type: "text", text: "Apply the provided patch exactly once." }], }) - // Wait for the agent loop to actually start before checking idle. - // promptAsync is fire-and-forget — without this, waitSessionIdle can - // return immediately because the session status is still undefined. await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true) - - await waitSessionIdle(sdk, sessionID, 120_000) + await expect + .poll(async () => (await sdk.session.get({ sessionID }).then((res) => res.data?.summary?.files)) ?? 0, { + timeout: 120_000, + }) + .toBeGreaterThan(0) } async function show(page: Parameters[0]["page"]) { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d..4b93997805 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -14,6 +14,7 @@ import type { import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" +import { sanitizeProject } from "./utils" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -33,12 +34,12 @@ export function applyGlobalEvent(input: { const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { input.setGlobalProject((draft) => { - draft[result.index] = { ...draft[result.index], ...properties } + draft[result.index] = sanitizeProject({ ...draft[result.index], ...properties }) }) return } input.setGlobalProject((draft) => { - draft.splice(result.index, 0, properties) + draft.splice(result.index, 0, sanitizeProject(properties)) }) } diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index cac58f3174..82f797945a 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -27,13 +27,21 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList } export function sanitizeProject(project: Project) { - if (!project.icon?.url && !project.icon?.override) return project return { ...project, + commands: project.commands + ? { + ...project.commands, + } + : undefined, icon: { ...project.icon, url: undefined, override: undefined, }, + sandboxes: [...(project.sandboxes ?? [])], + time: { + ...project.time, + }, } } From 0022cba7c5a462c3eb851fd5e4f91869700848fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 11:29:47 -0400 Subject: [PATCH 09/23] test: stabilize patch seeding across e2e backends --- .github/workflows/test.yml | 5 + .../app/e2e/session/session-review.spec.ts | 3 + packages/opencode/test/lib/llm-server.ts | 12 + .../test/session/e2e-url-repro.test.ts | 228 ++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 packages/opencode/test/session/e2e-url-repro.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c58be30ab..03c0741b52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,11 @@ jobs: - name: Run unit tests run: bun turbo test + env: + # Bun 1.3.11 intermittently crashes on Windows during test teardown + # inside the native @parcel/watcher binding. Unit CI does not rely on + # the live watcher backend there, so disable it for that platform. + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} e2e: name: e2e (${{ matrix.settings.name }}) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 8693f1c30e..6c07de0a40 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -2,6 +2,8 @@ import { waitSessionIdle, withSession } from "../actions" import { test, expect } from "../fixtures" import { bodyText } from "../prompt/mock" +const patchModel = { providerID: "openai", modelID: "gpt-5.4" } as const + const count = 14 function body(mark: string) { @@ -55,6 +57,7 @@ async function patchWithMock( await sdk.session.prompt({ sessionID, agent: "build", + model: patchModel, system: [ "You are seeding deterministic e2e UI state.", "Your only valid response is one apply_patch tool call.", diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 7bc3066e33..fbad6ac145 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -254,6 +254,16 @@ function responseToolArgs(id: string, text: string, seq: number) { } } +function responseToolArgsDone(id: string, args: string, seq: number) { + return { + type: "response.function_call_arguments.done", + sequence_number: seq, + output_index: 0, + item_id: id, + arguments: args, + } +} + function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) { return { type: "response.output_item.done", @@ -390,6 +400,8 @@ function responses(item: Sse, model: string) { lines.push(responseReasonDone(reason, seq)) } if (call && !item.hang && !item.error) { + seq += 1 + lines.push(responseToolArgsDone(call.item, call.args, seq)) seq += 1 lines.push(responseToolDone(call, seq)) } diff --git a/packages/opencode/test/session/e2e-url-repro.test.ts b/packages/opencode/test/session/e2e-url-repro.test.ts new file mode 100644 index 0000000000..1160e98ad8 --- /dev/null +++ b/packages/opencode/test/session/e2e-url-repro.test.ts @@ -0,0 +1,228 @@ +/** + * Reproduction test for e2e LLM URL routing. + * + * Tests whether OPENCODE_E2E_LLM_URL correctly routes LLM calls + * to the mock server when no explicit provider config is set. + * This mimics the e2e `project` fixture path (vs. withMockOpenAI). + */ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionSummary } from "../../src/session/summary" +import { Log } from "../../src/util/log" +import { provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { TestLLMServer } from "../lib/llm-server" + +import { NodeFileSystem } from "@effect/platform-node" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Command } from "../../src/command" +import { Config } from "../../src/config/config" +import { FileTime } from "../../src/file/time" +import { LSP } from "../../src/lsp" +import { MCP } from "../../src/mcp" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider as ProviderSvc } from "../../src/provider/provider" +import { SessionCompaction } from "../../src/session/compaction" +import { Instruction } from "../../src/session/instruction" +import { SessionProcessor } from "../../src/session/processor" +import { SessionStatus } from "../../src/session/status" +import { LLM } from "../../src/session/llm" +import { Shell } from "../../src/shell/shell" +import { Snapshot } from "../../src/snapshot" +import { ToolRegistry } from "../../src/tool/registry" +import { Truncate } from "../../src/tool/truncate" +import { AppFileSystem } from "../../src/filesystem" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +Log.init({ print: false }) + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const filetime = Layer.succeed( + FileTime.Service, + FileTime.Service.of({ + read: () => Effect.void, + get: () => Effect.succeed(undefined), + assert: () => Effect.void, + withLock: (_filepath, fn) => Effect.promise(fn), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const patchModel = { providerID: "openai", modelID: "gpt-5.4" } as const + +function makeHttp() { + const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + LLM.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.layer, + Plugin.defaultLayer, + Config.defaultLayer, + ProviderSvc.defaultLayer, + filetime, + lsp, + mcp, + AppFileSystem.defaultLayer, + status, + ).pipe(Layer.provideMerge(infra)) + const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps)) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) + const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + return Layer.mergeAll( + TestLLMServer.layer, + SessionPrompt.layer.pipe( + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provide(Instruction.defaultLayer), + Layer.provideMerge(deps), + ), + ) +} + +const it = testEffect(makeHttp()) + +it.live("e2eURL routes apply_patch through mock server", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ dir, llm }) { + // Set the env var to route all LLM calls through the mock + const prev = process.env.OPENCODE_E2E_LLM_URL + process.env.OPENCODE_E2E_LLM_URL = llm.url + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL + else process.env.OPENCODE_E2E_LLM_URL = prev + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const session = yield* sessions.create({ + title: "e2e url test", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + const patch = ["*** Begin Patch", "*** Add File: e2e-test.txt", "+line 1", "+line 2", "*** End Patch"].join("\n") + + // Queue mock response: match on system prompt, return apply_patch tool call + yield* llm.toolMatch( + (hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"), + "apply_patch", + { patchText: patch }, + ) + // After tool execution, LLM gets called again with tool result — return "done" + yield* llm.text("done") + + // Seed user message + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: patchModel, + noReply: true, + system: [ + "You are seeding deterministic e2e UI state.", + "Your only valid response is one apply_patch tool call.", + `Use this JSON input: ${JSON.stringify({ patchText: patch })}`, + "Do not call any other tools.", + "Do not output plain text.", + ].join("\n"), + parts: [{ type: "text", text: "Apply the provided patch exactly once." }], + }) + + // Run the agent loop + const result = yield* prompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + + const calls = yield* llm.calls + expect(calls).toBe(2) + + const missed = yield* llm.misses + expect(missed.length).toBe(0) + + const content = yield* Effect.promise(() => + Bun.file(`${dir}/e2e-test.txt`) + .text() + .catch(() => "NOT FOUND"), + ) + expect(content).toContain("line 1") + + let diff: Awaited> = [] + for (let i = 0; i < 20; i++) { + diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id })) + if (diff.length > 0) break + yield* Effect.sleep("100 millis") + } + expect(diff.length).toBeGreaterThan(0) + }), + { + git: true, + config: () => ({ + model: "openai/gpt-5.4", + agent: { + build: { + model: "openai/gpt-5.4", + }, + }, + provider: { + openai: { + options: { + apiKey: "test-openai-key", + }, + }, + }, + }), + }, + ), +) From d33cf0145e9df29f6d51f3eb2604890b8a3084d8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 11:30:34 -0400 Subject: [PATCH 10/23] test(opencode): type fast patch repro model ids --- packages/opencode/test/session/e2e-url-repro.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/e2e-url-repro.test.ts b/packages/opencode/test/session/e2e-url-repro.test.ts index 1160e98ad8..411e88f625 100644 --- a/packages/opencode/test/session/e2e-url-repro.test.ts +++ b/packages/opencode/test/session/e2e-url-repro.test.ts @@ -26,6 +26,7 @@ import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" @@ -95,7 +96,7 @@ const filetime = Layer.succeed( const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -const patchModel = { providerID: "openai", modelID: "gpt-5.4" } as const +const patchModel = { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.4") } as const function makeHttp() { const deps = Layer.mergeAll( From bcddb59c9c07c4ee17484474779b4121ba1d6821 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:00:17 -0400 Subject: [PATCH 11/23] refactor(app): remove dead e2e fixtures, helpers, and types Delete unused code from the e2e test infrastructure: - fixtures: withProject, withBackendProject, withMockProject, runProject, AssistantFixture type, assistant fixture - actions: clickPopoverButton, seedSessionPermission, seedSessionTodos - Update AGENTS.md to reflect the project fixture pattern --- packages/app/e2e/AGENTS.md | 15 +++-- packages/app/e2e/actions.ts | 70 ---------------------- packages/app/e2e/fixtures.ts | 112 ----------------------------------- 3 files changed, 7 insertions(+), 190 deletions(-) diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md index f263e49a02..bdd6ba185b 100644 --- a/packages/app/e2e/AGENTS.md +++ b/packages/app/e2e/AGENTS.md @@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => { ### Using Fixtures - `page` - Playwright page -- `sdk` - OpenCode SDK client for API calls -- `gotoSession(sessionID?)` - Navigate to session +- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.) +- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`) +- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory) +- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory) ### Helper Functions @@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => { - `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output - `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output - `withSession(sdk, title, callback)` - Create temp session -- `withProject(...)` - Create temp project/workspace - `sessionIDFromUrl(url)` - Read session ID from URL - `slugFromUrl(url)` - Read workspace slug from URL - `waitSlug(page, skip?)` - Wait for resolved workspace slug -- `trackSession(sessionID, directory?)` - Register session for fixture cleanup -- `trackDirectory(directory)` - Register directory for fixture cleanup - `clickListItem(container, filter)` - Click list item by key/text **Selectors** (`selectors.ts`): @@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => { }) ``` -- Prefer `withSession(...)` for temp sessions -- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)` -- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency +- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc. +- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory +- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up - Avoid calling `sdk.session.delete(...)` directly ### Timeouts diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 32e3d6ba7c..f62c0555d1 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -637,12 +637,6 @@ export async function openSharePopover(page: Page) { return { rightSection: scroller, popoverBody } } -export async function clickPopoverButton(page: Page, buttonName: string | RegExp) { - const button = page.getByRole("button").filter({ hasText: buttonName }).first() - await expect(button).toBeVisible() - await button.click() -} - export async function clickListItem( container: Locator | Page, filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string }, @@ -808,40 +802,6 @@ export async function seedSessionQuestion( return { id: result.id } } -export async function seedSessionPermission( - sdk: ReturnType, - input: { - sessionID: string - permission: string - patterns: string[] - description?: string - }, -) { - const text = [ - "Your only valid response is one bash tool call.", - `Use this JSON input: ${JSON.stringify({ - command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd", - workdir: "/", - description: input.description ?? `seed ${input.permission} permission request`, - })}`, - "Do not output plain text.", - ].join("\n") - - const result = await seed({ - sdk, - sessionID: input.sessionID, - prompt: text, - timeout: 30_000, - probe: async () => { - const list = await sdk.permission.list().then((x) => x.data ?? []) - return list.find((item) => item.sessionID === input.sessionID) - }, - }) - - if (!result) throw new Error("Timed out seeding permission request") - return { id: result.id } -} - export async function seedSessionTask( sdk: ReturnType, input: { @@ -900,36 +860,6 @@ export async function seedSessionTask( return result } -export async function seedSessionTodos( - sdk: ReturnType, - input: { - sessionID: string - todos: Array<{ content: string; status: string; priority: string }> - }, -) { - const text = [ - "Your only valid response is one todowrite tool call.", - `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`, - "Do not output plain text.", - ].join("\n") - const target = JSON.stringify(input.todos) - - const result = await seed({ - sdk, - sessionID: input.sessionID, - prompt: text, - timeout: 30_000, - probe: async () => { - const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? []) - if (JSON.stringify(todos) !== target) return - return true - }, - }) - - if (!result) throw new Error("Timed out seeding todos") - return true -} - export async function clearSessionDockSeed(sdk: ReturnType, sessionID: string) { const [questions, permissions] = await Promise.all([ sdk.question.list().then((x) => x.data ?? []), diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index bb3ea4a6c4..e6f9a62804 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -18,7 +18,6 @@ import { waitSlug, withNoReplyPrompt, } from "./actions" -import { openaiModel, withMockOpenAI } from "./prompt/mock" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" @@ -59,19 +58,6 @@ type LLMWorker = LLMFixture & { reset: () => Promise } -type AssistantFixture = { - reply: (value: string, opts?: { usage?: Usage }) => Promise - tool: (name: string, input: unknown) => Promise - toolHang: (name: string, input: unknown) => Promise - reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise - fail: (message?: unknown) => Promise - error: (status: number, body: unknown) => Promise - hang: () => Promise - hold: (value: string, wait: PromiseLike) => Promise - calls: () => Promise - pending: () => Promise -} - export const settingsKey = "settings.v3" const seedModel = (() => { @@ -143,13 +129,9 @@ type ProjectFixture = ProjectHandle & { type TestFixtures = { llm: LLMFixture - assistant: AssistantFixture project: ProjectFixture sdk: ReturnType gotoSession: (sessionID?: string) => Promise - withProject: (callback: (project: ProjectHandle) => Promise, options?: ProjectOptions) => Promise - withBackendProject: (callback: (project: ProjectHandle) => Promise, options?: ProjectOptions) => Promise - withMockProject: (callback: (project: ProjectHandle) => Promise, options?: ProjectOptions) => Promise } type WorkerFixtures = { @@ -238,20 +220,6 @@ export const test = base.extend({ throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`) } }, - assistant: async ({ llm }, use) => { - await use({ - reply: llm.text, - tool: llm.tool, - toolHang: llm.toolHang, - reason: llm.reason, - fail: llm.fail, - error: llm.error, - hang: llm.hang, - hold: llm.hold, - calls: llm.calls, - pending: llm.pending, - }) - }, page: async ({ page }, use) => { let boundary: string | undefined setHealthPhase(page, "test") @@ -312,29 +280,6 @@ export const test = base.extend({ await item.cleanup() } }, - withProject: async ({ page }, use) => { - await use((callback, options) => runProject(page, callback, options)) - }, - withBackendProject: async ({ page, backend }, use) => { - await use((callback, options) => - runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }), - ) - }, - withMockProject: async ({ page, llm, backend }, use) => { - await use((callback, options) => - withMockOpenAI({ - serverUrl: backend.url, - llmUrl: llm.url, - fn: () => - runProject(page, callback, { - ...options, - model: options?.model ?? openaiModel, - serverUrl: backend.url, - sdk: backend.sdk, - }), - }), - ) - }, }) function makeProject( @@ -560,63 +505,6 @@ function makeProject( } } -async function runProject( - page: Page, - callback: (project: ProjectHandle) => Promise, - options?: ProjectOptions & { - serverUrl?: string - sdk?: (directory?: string) => ReturnType - }, -) { - const url = options?.serverUrl - const root = await createTestProject(url ? { serverUrl: url } : undefined) - const sdk = options?.sdk?.(root) ?? createSdk(root, url) - const sessions = new Map() - const dirs = new Set() - await options?.setup?.(root) - await seedStorage(page, { - directory: root, - extra: options?.extra, - model: options?.model, - serverUrl: url, - }) - - const gotoSession = async (sessionID?: string) => { - await visit(page, sessionPath(root, sessionID)) - await waitSession(page, { - directory: root, - sessionID, - serverUrl: url, - allowAnySession: !sessionID, - }) - const current = sessionIDFromUrl(page.url()) - if (current) trackSession(current) - } - - const trackSession = (sessionID: string, directory?: string) => { - sessions.set(sessionID, directory ?? root) - } - - const trackDirectory = (directory: string) => { - if (directory !== root) dirs.add(directory) - } - - try { - await options?.beforeGoto?.({ directory: root, sdk }) - await gotoSession() - const slug = await waitSlug(page) - return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk }) - } finally { - setHealthPhase(page, "cleanup") - await Promise.allSettled( - Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })), - ) - await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) - await cleanupTestProject(root) - setHealthPhase(page, "test") - } -} - async function seedStorage( page: Page, input: { From 042c594446715812a6bc6893e52624f47668d7e7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:02:49 -0400 Subject: [PATCH 12/23] test(app): select review seed model from providers --- .../app/e2e/session/session-review.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 6c07de0a40..d8876a0ff9 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -2,8 +2,6 @@ import { waitSessionIdle, withSession } from "../actions" import { test, expect } from "../fixtures" import { bodyText } from "../prompt/mock" -const patchModel = { providerID: "openai", modelID: "gpt-5.4" } as const - const count = 14 function body(mark: string) { @@ -42,12 +40,26 @@ function edit(file: string, prev: string, next: string) { ) } +async function patchModel(sdk: Parameters[0]) { + const all = await sdk.provider.list().then((res) => res.data?.all ?? []) + for (const provider of all) { + for (const model of Object.values(provider.models)) { + if (!model.id.includes("gpt-")) continue + if (model.id.includes("gpt-4")) continue + if (model.id.includes("oss")) continue + return { providerID: provider.id, modelID: model.id } + } + } + throw new Error("No apply_patch-capable GPT model found for review seed") +} + async function patchWithMock( llm: Parameters[0]["llm"], sdk: Parameters[0], sessionID: string, patchText: string, ) { + const model = await patchModel(sdk) const callsBefore = await llm.calls() await llm.toolMatch( (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."), @@ -57,7 +69,7 @@ async function patchWithMock( await sdk.session.prompt({ sessionID, agent: "build", - model: patchModel, + model, system: [ "You are seeding deterministic e2e UI state.", "Your only valid response is one apply_patch tool call.", From a355898c74ee0c1a5e8a7a426b13092b5bddc20c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:03:22 -0400 Subject: [PATCH 13/23] test(app): drop unused e2e fixture imports --- packages/app/e2e/fixtures.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index e6f9a62804..067272c7ee 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -10,16 +10,14 @@ import { cleanupTestProject, createTestProject, setHealthPhase, - seedProjects, sessionIDFromUrl, waitSession, waitSessionIdle, waitSessionSaved, waitSlug, - withNoReplyPrompt, } from "./actions" import { promptSelector } from "./selectors" -import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" +import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils" type LLMFixture = { url: string From fd14f281d0cc878a1e3e21bd6d795c7cc3118008 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:06:42 -0400 Subject: [PATCH 14/23] refactor(app): inline seedProjects into seedStorage, remove unused imports - Merge seedProjects into seedStorage as a single addInitScript call - Remove unused withNoReplyPrompt import from fixtures - Remove seedProjects export from actions --- packages/app/e2e/actions.ts | 57 ----------------------- packages/app/e2e/fixtures.ts | 87 +++++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 82 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index f62c0555d1..7056295910 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -333,63 +333,6 @@ export async function openSettings(page: Page) { return dialog } -export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) { - await page.addInitScript( - (args: { directory: string; serverUrl: string; extra: string[] }) => { - const key = "opencode.global.dat:server" - const defaultKey = "opencode.settings.dat:defaultServerUrl" - const raw = localStorage.getItem(key) - const parsed = (() => { - if (!raw) return undefined - try { - return JSON.parse(raw) as unknown - } catch { - return undefined - } - })() - - const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} - const list = Array.isArray(store.list) ? store.list : [] - const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} - const projects = store.projects && typeof store.projects === "object" ? store.projects : {} - const nextProjects = { ...(projects as Record) } - const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list] - - const add = (origin: string, directory: string) => { - const current = nextProjects[origin] - const items = Array.isArray(current) ? current : [] - const existing = items.filter( - (p): p is { worktree: string; expanded?: boolean } => - !!p && - typeof p === "object" && - "worktree" in p && - typeof (p as { worktree?: unknown }).worktree === "string", - ) - - if (existing.some((p) => p.worktree === directory)) return - nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing] - } - - const directories = [args.directory, ...args.extra] - for (const directory of directories) { - add("local", directory) - add(args.serverUrl, directory) - } - - localStorage.setItem( - key, - JSON.stringify({ - list: nextList, - projects: nextProjects, - lastProject, - }), - ) - localStorage.setItem(defaultKey, args.serverUrl) - }, - { directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] }, - ) -} - export async function createTestProject(input?: { serverUrl?: string }) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const id = `e2e-${path.basename(root)}` diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 067272c7ee..a24602982d 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -512,31 +512,68 @@ async function seedStorage( serverUrl?: string }, ) { - await seedProjects(page, input) - await page.addInitScript((model: { providerID: string; modelID: string }) => { - const win = window as E2EWindow - win.__opencode_e2e = { - ...win.__opencode_e2e, - model: { - enabled: true, - }, - prompt: { - enabled: true, - }, - terminal: { - enabled: true, - terminals: {}, - }, - } - localStorage.setItem( - "opencode.global.dat:model", - JSON.stringify({ - recent: [model], - user: [], - variant: {}, - }), - ) - }, input.model ?? seedModel) + const origin = input.serverUrl ?? serverUrl + await page.addInitScript( + (args: { + directory: string + serverUrl: string + extra: string[] + model: { providerID: string; modelID: string } + }) => { + const key = "opencode.global.dat:server" + const raw = localStorage.getItem(key) + const parsed = (() => { + if (!raw) return undefined + try { + return JSON.parse(raw) as unknown + } catch { + return undefined + } + })() + + const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} + const list = Array.isArray(store.list) ? store.list : [] + const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} + const projects = store.projects && typeof store.projects === "object" ? store.projects : {} + const next = { ...(projects as Record) } + const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list] + + const add = (origin: string, directory: string) => { + const current = next[origin] + const items = Array.isArray(current) ? current : [] + const existing = items.filter( + (p): p is { worktree: string; expanded?: boolean } => + !!p && + typeof p === "object" && + "worktree" in p && + typeof (p as { worktree?: unknown }).worktree === "string", + ) + if (existing.some((p) => p.worktree === directory)) return + next[origin] = [{ worktree: directory, expanded: true }, ...existing] + } + + for (const directory of [args.directory, ...args.extra]) { + add("local", directory) + add(args.serverUrl, directory) + } + + localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject })) + localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl) + + const win = window as E2EWindow + win.__opencode_e2e = { + ...win.__opencode_e2e, + model: { enabled: true }, + prompt: { enabled: true }, + terminal: { enabled: true, terminals: {} }, + } + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ recent: [args.model], user: [], variant: {} }), + ) + }, + { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel }, + ) } export { expect } From 4453ff26f68bf4b7ba7a71c32e1ef181b88d308d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:19:20 -0400 Subject: [PATCH 15/23] refactor(app): remove dead e2e selectors, mock helpers, and unused imports - selectors: permissionReject/AllowAlways/AllowOnce, sessionTodoDock/Toggle/List, projectClearNotifications, dropdownMenuTrigger - mock: titleMatch, promptMatch, openaiModel, withMockOpenAI - actions: withNoReplyPrompt, unused dropdownMenuTriggerSelector/Route imports --- packages/app/e2e/actions.ts | 24 +------------------ packages/app/e2e/prompt/mock.ts | 41 --------------------------------- packages/app/e2e/selectors.ts | 11 --------- 3 files changed, 1 insertion(+), 75 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 7056295910..fb9eb208bc 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -1,5 +1,5 @@ import { base64Decode, base64Encode } from "@opencode-ai/util/encode" -import { expect, type Locator, type Page, type Route } from "@playwright/test" +import { expect, type Locator, type Page } from "@playwright/test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -7,7 +7,6 @@ import { execSync } from "node:child_process" import { terminalAttr, type E2EWindow } from "../src/testing/terminal" import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { - dropdownMenuTriggerSelector, dropdownMenuContentSelector, projectSwitchSelector, projectMenuTriggerSelector, @@ -43,27 +42,6 @@ export async function defocus(page: Page) { .catch(() => undefined) } -export async function withNoReplyPrompt(page: Page, fn: () => Promise) { - const urls = ["**/session/*/prompt_async", "**/session/*/message"] - const route = async (input: Route) => { - const body = input.request().postDataJSON() - await input.continue({ - postData: JSON.stringify({ ...body, noReply: true }), - headers: { - ...input.request().headers(), - "content-type": "application/json", - }, - }) - } - - await Promise.all(urls.map((url) => page.route(url, route))) - try { - return await fn() - } finally { - await Promise.all(urls.map((url) => page.unroute(url, route))) - } -} - async function terminalID(term: Locator) { const id = await term.getAttribute(terminalAttr) if (id) return id diff --git a/packages/app/e2e/prompt/mock.ts b/packages/app/e2e/prompt/mock.ts index bd09af2665..c7eb54b526 100644 --- a/packages/app/e2e/prompt/mock.ts +++ b/packages/app/e2e/prompt/mock.ts @@ -1,21 +1,9 @@ -import { createSdk } from "../utils" - -export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" } - type Hit = { body: Record } export function bodyText(hit: Hit) { return JSON.stringify(hit.body) } -export function titleMatch(hit: Hit) { - return bodyText(hit).includes("Generate a title for this conversation") -} - -export function promptMatch(token: string) { - return (hit: Hit) => bodyText(hit).includes(token) -} - /** * Match requests whose body contains the exact serialized tool input. * The seed prompts embed JSON.stringify(input) in the prompt text, which @@ -25,32 +13,3 @@ export function inputMatch(input: unknown) { const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1) return (hit: Hit) => bodyText(hit).includes(escaped) } - -export async function withMockOpenAI(input: { serverUrl: string; llmUrl: string; fn: () => Promise }) { - const sdk = createSdk(undefined, input.serverUrl) - const prev = await sdk.global.config.get().then((res) => res.data ?? {}) - - try { - await sdk.global.config.update({ - config: { - ...prev, - model: `${openaiModel.providerID}/${openaiModel.modelID}`, - enabled_providers: ["openai"], - provider: { - ...prev.provider, - openai: { - ...prev.provider?.openai, - options: { - ...prev.provider?.openai?.options, - apiKey: "test-key", - baseURL: input.llmUrl, - }, - }, - }, - }, - }) - return await input.fn() - } finally { - await sdk.global.config.update({ config: prev }) - } -} diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 32e4ecd8a4..0a850c7440 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -4,13 +4,7 @@ export const terminalSelector = `${terminalPanelSelector} [data-component="termi export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' -export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)` -export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)` -export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)` -export const sessionTodoDockSelector = '[data-component="session-todo-dock"]' -export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]' export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]' -export const sessionTodoListSelector = '[data-slot="session-todo-list"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const promptAgentSelector = '[data-component="prompt-agent-control"]' @@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) => export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` -export const projectClearNotificationsSelector = (slug: string) => - `[data-action="project-clear-notifications"][data-project="${slug}"]` - export const projectWorkspacesToggleSelector = (slug: string) => `[data-action="project-workspaces-toggle"][data-project="${slug}"]` @@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right" export const popoverBodySelector = '[data-slot="popover-body"]' -export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]' - export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]' export const inlineInputSelector = '[data-component="inline-input"]' From fae97cc55944017763a9dd9bb0d38c0f5d5a19e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:20:56 -0400 Subject: [PATCH 16/23] fix(app): restore assistant fixture, simplify route.fulfill - Re-add AssistantFixture type and assistant fixture (used by prompt specs) - Simplify route.fulfill to use response param directly --- packages/app/e2e/fixtures.ts | 33 ++++++++++++++++--- .../e2e/session/session-composer-dock.spec.ts | 4 +-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index a24602982d..fe2eb9c1a0 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -56,6 +56,19 @@ type LLMWorker = LLMFixture & { reset: () => Promise } +type AssistantFixture = { + reply: LLMFixture["text"] + tool: LLMFixture["tool"] + toolHang: LLMFixture["toolHang"] + reason: LLMFixture["reason"] + fail: LLMFixture["fail"] + error: LLMFixture["error"] + hang: LLMFixture["hang"] + hold: LLMFixture["hold"] + calls: LLMFixture["calls"] + pending: LLMFixture["pending"] +} + export const settingsKey = "settings.v3" const seedModel = (() => { @@ -127,6 +140,7 @@ type ProjectFixture = ProjectHandle & { type TestFixtures = { llm: LLMFixture + assistant: AssistantFixture project: ProjectFixture sdk: ReturnType gotoSession: (sessionID?: string) => Promise @@ -218,6 +232,20 @@ export const test = base.extend({ throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`) } }, + assistant: async ({ llm }, use) => { + await use({ + reply: llm.text, + tool: llm.tool, + toolHang: llm.toolHang, + reason: llm.reason, + fail: llm.fail, + error: llm.error, + hang: llm.hang, + hold: llm.hold, + calls: llm.calls, + pending: llm.pending, + }) + }, page: async ({ page }, use) => { let boundary: string | undefined setHealthPhase(page, "test") @@ -567,10 +595,7 @@ async function seedStorage( prompt: { enabled: true }, terminal: { enabled: true, terminals: {} }, } - localStorage.setItem( - "opencode.global.dat:model", - JSON.stringify({ recent: [args.model], user: [], variant: {} }), - ) + localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} })) }, { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel }, ) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index e997f29f84..8eeac5b1a1 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -242,9 +242,7 @@ async function withMockPermission( const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child) await route.fulfill({ - status: res.status(), - headers: res.headers(), - contentType: "application/json", + response: res, body: JSON.stringify(json), }) } From 9a87b785e6ef7028958bf1a95be96de5b14378f5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:32:11 -0400 Subject: [PATCH 17/23] test(app): restore e2e fixtures and flaky waits --- packages/app/e2e/settings/settings.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 3327bf3114..6455892cca 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -88,10 +88,19 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) => return document.documentElement.getAttribute("data-theme") }) const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" - - await select.locator('[data-slot="select-select-trigger"]').click() - + const trigger = select.locator('[data-slot="select-select-trigger"]') const items = page.locator('[data-slot="select-select-item"]') + + await trigger.click() + const open = await expect + .poll(async () => (await items.count()) > 0, { timeout: 5_000 }) + .toBe(true) + .then(() => true) + .catch(() => false) + if (!open) { + await trigger.click() + await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true) + } await expect(items.first()).toBeVisible() const count = await items.count() expect(count).toBeGreaterThan(1) From d603d9da654435711b9679511de116ffc2d13933 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 12:49:28 -0400 Subject: [PATCH 18/23] fix(opencode): use openai-compatible SDK for e2e LLM mock routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2eURL() path used createOpenAI().responses() which produces a Responses API format that the mock server's SSE stream can't correctly convey tool calls through. Switch to createOpenAICompatible().chatModel() which uses the chat completions format — matching what the mock was designed for and what the integration tests use. This only affects the OPENCODE_E2E_LLM_URL code path (e2e tests only). --- packages/opencode/src/provider/provider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c3ca1b3852..81479dcd75 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1458,11 +1458,11 @@ export namespace Provider { return yield* Effect.promise(async () => { const url = e2eURL() if (url) { - const language = createOpenAI({ + const language = createOpenAICompatible({ name: model.providerID, apiKey: "test-key", baseURL: url, - }).responses(model.api.id) + }).chatModel(model.api.id) s.models.set(key, language) return language } From 3f81434315c1faf97ac763627d8c53df9af30321 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 13:01:27 -0400 Subject: [PATCH 19/23] test(opencode): wait for stderr writes before exit --- .../opencode/test/effect/cross-spawn-spawner.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 287d04ed31..2cc5092029 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -159,7 +159,17 @@ describe("cross-spawn spawner", () => { fx.effect( "captures both stdout and stderr", Effect.gen(function* () { - const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")') + const handle = yield* js( + [ + "let pending = 2", + "const done = () => {", + " pending -= 1", + " if (pending === 0) setTimeout(() => process.exit(0), 0)", + "}", + 'process.stdout.write("stdout\\n", done)', + 'process.stderr.write("stderr\\n", done)', + ].join("\n"), + ) const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") From 1401208cb5ac7aa08916ea15e9e5d67bf20a3a4c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 13:11:56 -0400 Subject: [PATCH 20/23] fix(app): drop patchModel, let default model resolve for review seeds The dynamic GPT model search was fragile across CI environments. The e2eURL override routes any model to the mock server, so there's no need to pick a specific model. --- packages/app/e2e/session/session-review.spec.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index d8876a0ff9..8693f1c30e 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -40,26 +40,12 @@ function edit(file: string, prev: string, next: string) { ) } -async function patchModel(sdk: Parameters[0]) { - const all = await sdk.provider.list().then((res) => res.data?.all ?? []) - for (const provider of all) { - for (const model of Object.values(provider.models)) { - if (!model.id.includes("gpt-")) continue - if (model.id.includes("gpt-4")) continue - if (model.id.includes("oss")) continue - return { providerID: provider.id, modelID: model.id } - } - } - throw new Error("No apply_patch-capable GPT model found for review seed") -} - async function patchWithMock( llm: Parameters[0]["llm"], sdk: Parameters[0], sessionID: string, patchText: string, ) { - const model = await patchModel(sdk) const callsBefore = await llm.calls() await llm.toolMatch( (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."), @@ -69,7 +55,6 @@ async function patchWithMock( await sdk.session.prompt({ sessionID, agent: "build", - model, system: [ "You are seeding deterministic e2e UI state.", "Your only valid response is one apply_patch tool call.", From 2666def93331e002ec52e1792a776d941566a6b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 13:27:10 -0400 Subject: [PATCH 21/23] fix(app): poll session.diff instead of summary.files, revert sanitizeProject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit summary.files is computed asynchronously and never resolves in CI. Poll session.diff() directly — matches the pattern used by the snapshot-tool-race integration test. Also revert the unrelated sanitizeProject changes to event-reducer and utils that were accidentally included in this PR. --- packages/app/e2e/session/session-review.spec.ts | 10 +++++++--- packages/app/src/context/global-sync/event-reducer.ts | 5 ++--- packages/app/src/context/global-sync/utils.ts | 10 +--------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 8693f1c30e..c0a98cb2e3 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -67,9 +67,13 @@ async function patchWithMock( await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true) await expect - .poll(async () => (await sdk.session.get({ sessionID }).then((res) => res.data?.summary?.files)) ?? 0, { - timeout: 120_000, - }) + .poll( + async () => { + const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 120_000 }, + ) .toBeGreaterThan(0) } diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 4b93997805..5d8b7c4e3d 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -14,7 +14,6 @@ import type { import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" -import { sanitizeProject } from "./utils" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -34,12 +33,12 @@ export function applyGlobalEvent(input: { const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { input.setGlobalProject((draft) => { - draft[result.index] = sanitizeProject({ ...draft[result.index], ...properties }) + draft[result.index] = { ...draft[result.index], ...properties } }) return } input.setGlobalProject((draft) => { - draft.splice(result.index, 0, sanitizeProject(properties)) + draft.splice(result.index, 0, properties) }) } diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 82f797945a..cac58f3174 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -27,21 +27,13 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList } export function sanitizeProject(project: Project) { + if (!project.icon?.url && !project.icon?.override) return project return { ...project, - commands: project.commands - ? { - ...project.commands, - } - : undefined, icon: { ...project.icon, url: undefined, override: undefined, }, - sandboxes: [...(project.sandboxes ?? [])], - time: { - ...project.time, - }, } } From 8b6219d83467d68e74b73a566dc6c1980ec2c9a3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 14:10:26 -0400 Subject: [PATCH 22/23] fix(opencode): allow patch tools in e2e model routing --- packages/opencode/src/tool/registry.ts | 4 +- .../test/session/e2e-url-repro.test.ts | 85 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 133a5018ad..2620405144 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -32,6 +32,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Env } from "../env" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -166,7 +167,8 @@ export namespace ToolRegistry { } const usePatch = - model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") + !!Env.get("OPENCODE_E2E_LLM_URL") || + (model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")) if (tool.id === "apply_patch") return usePatch if (tool.id === "edit" || tool.id === "write") return !usePatch diff --git a/packages/opencode/test/session/e2e-url-repro.test.ts b/packages/opencode/test/session/e2e-url-repro.test.ts index 411e88f625..744c7bf0bc 100644 --- a/packages/opencode/test/session/e2e-url-repro.test.ts +++ b/packages/opencode/test/session/e2e-url-repro.test.ts @@ -27,6 +27,7 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" +import { Server } from "../../src/server/server" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" @@ -227,3 +228,87 @@ it.live("e2eURL routes apply_patch through mock server", () => }, ), ) + +it.live("server message route produces diff through mock server", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ dir, llm }) { + const prev = process.env.OPENCODE_E2E_LLM_URL + process.env.OPENCODE_E2E_LLM_URL = llm.url + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL + else process.env.OPENCODE_E2E_LLM_URL = prev + }), + ) + + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "e2e route test", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const app = Server.Default() + const patch = ["*** Begin Patch", "*** Add File: route-test.txt", "+line 1", "+line 2", "*** End Patch"].join( + "\n", + ) + + yield* llm.toolMatch( + (hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"), + "apply_patch", + { patchText: patch }, + ) + yield* llm.text("done") + + const res = yield* Effect.promise(() => + Promise.resolve( + app.request(`/session/${session.id}/message`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencode-directory": dir, + }, + body: JSON.stringify({ + agent: "build", + system: [ + "You are seeding deterministic e2e UI state.", + "Your only valid response is one apply_patch tool call.", + `Use this JSON input: ${JSON.stringify({ patchText: patch })}`, + "Do not call any other tools.", + "Do not output plain text.", + ].join("\n"), + parts: [{ type: "text", text: "Apply the provided patch exactly once." }], + }), + }), + ), + ) + expect(res.status).toBe(200) + yield* Effect.promise(() => res.json()) + + const calls = yield* llm.calls + expect(calls).toBe(2) + + const content = yield* Effect.promise(() => + Bun.file(`${dir}/route-test.txt`) + .text() + .catch(() => "NOT FOUND"), + ) + expect(content).toContain("line 1") + + let diff: Awaited> = [] + for (let i = 0; i < 30; i++) { + diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id })) + if (diff.length > 0) break + yield* Effect.sleep("100 millis") + } + + expect(diff.length).toBeGreaterThan(0) + }), + { + git: true, + config: () => ({ + model: "openai/gpt-5.4", + agent: { build: { model: "openai/gpt-5.4" } }, + provider: { openai: { options: { apiKey: "test-openai-key" } } }, + }), + }, + ), +) From 9bf227f81f8021c6250ff4b3ce02f212e1fe1c49 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Apr 2026 14:30:06 -0400 Subject: [PATCH 23/23] test(opencode): remove temporary e2e url repro --- .../test/session/e2e-url-repro.test.ts | 314 ------------------ 1 file changed, 314 deletions(-) delete mode 100644 packages/opencode/test/session/e2e-url-repro.test.ts diff --git a/packages/opencode/test/session/e2e-url-repro.test.ts b/packages/opencode/test/session/e2e-url-repro.test.ts deleted file mode 100644 index 744c7bf0bc..0000000000 --- a/packages/opencode/test/session/e2e-url-repro.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Reproduction test for e2e LLM URL routing. - * - * Tests whether OPENCODE_E2E_LLM_URL correctly routes LLM calls - * to the mock server when no explicit provider config is set. - * This mimics the e2e `project` fixture path (vs. withMockOpenAI). - */ -import { expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Session } from "../../src/session" -import { SessionPrompt } from "../../src/session/prompt" -import { SessionSummary } from "../../src/session/summary" -import { Log } from "../../src/util/log" -import { provideTmpdirServer } from "../fixture/fixture" -import { testEffect } from "../lib/effect" -import { TestLLMServer } from "../lib/llm-server" - -import { NodeFileSystem } from "@effect/platform-node" -import { Agent as AgentSvc } from "../../src/agent/agent" -import { Bus } from "../../src/bus" -import { Command } from "../../src/command" -import { Config } from "../../src/config/config" -import { FileTime } from "../../src/file/time" -import { LSP } from "../../src/lsp" -import { MCP } from "../../src/mcp" -import { Permission } from "../../src/permission" -import { Plugin } from "../../src/plugin" -import { Provider as ProviderSvc } from "../../src/provider/provider" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Server } from "../../src/server/server" -import { SessionCompaction } from "../../src/session/compaction" -import { Instruction } from "../../src/session/instruction" -import { SessionProcessor } from "../../src/session/processor" -import { SessionStatus } from "../../src/session/status" -import { LLM } from "../../src/session/llm" -import { Shell } from "../../src/shell/shell" -import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool/registry" -import { Truncate } from "../../src/tool/truncate" -import { AppFileSystem } from "../../src/filesystem" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" - -Log.init({ print: false }) - -const mcp = Layer.succeed( - MCP.Service, - MCP.Service.of({ - status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.succeed({}), - prompts: () => Effect.succeed({}), - resources: () => Effect.succeed({}), - add: () => Effect.succeed({ status: { status: "disabled" as const } }), - connect: () => Effect.void, - disconnect: () => Effect.void, - getPrompt: () => Effect.succeed(undefined), - readResource: () => Effect.succeed(undefined), - startAuth: () => Effect.die("unexpected MCP auth"), - authenticate: () => Effect.die("unexpected MCP auth"), - finishAuth: () => Effect.die("unexpected MCP auth"), - removeAuth: () => Effect.void, - supportsOAuth: () => Effect.succeed(false), - hasStoredTokens: () => Effect.succeed(false), - getAuthStatus: () => Effect.succeed("not_authenticated" as const), - }), -) - -const lsp = Layer.succeed( - LSP.Service, - LSP.Service.of({ - init: () => Effect.void, - status: () => Effect.succeed([]), - hasClients: () => Effect.succeed(false), - touchFile: () => Effect.void, - diagnostics: () => Effect.succeed({}), - hover: () => Effect.succeed(undefined), - definition: () => Effect.succeed([]), - references: () => Effect.succeed([]), - implementation: () => Effect.succeed([]), - documentSymbol: () => Effect.succeed([]), - workspaceSymbol: () => Effect.succeed([]), - prepareCallHierarchy: () => Effect.succeed([]), - incomingCalls: () => Effect.succeed([]), - outgoingCalls: () => Effect.succeed([]), - }), -) - -const filetime = Layer.succeed( - FileTime.Service, - FileTime.Service.of({ - read: () => Effect.void, - get: () => Effect.succeed(undefined), - assert: () => Effect.void, - withLock: (_filepath, fn) => Effect.promise(fn), - }), -) - -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -const patchModel = { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.4") } as const - -function makeHttp() { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.layer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - filetime, - lsp, - mcp, - AppFileSystem.defaultLayer, - status, - ).pipe(Layer.provideMerge(infra)) - const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps)) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps)) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provide(Instruction.defaultLayer), - Layer.provideMerge(deps), - ), - ) -} - -const it = testEffect(makeHttp()) - -it.live("e2eURL routes apply_patch through mock server", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ dir, llm }) { - // Set the env var to route all LLM calls through the mock - const prev = process.env.OPENCODE_E2E_LLM_URL - process.env.OPENCODE_E2E_LLM_URL = llm.url - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL - else process.env.OPENCODE_E2E_LLM_URL = prev - }), - ) - - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - - const session = yield* sessions.create({ - title: "e2e url test", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - const patch = ["*** Begin Patch", "*** Add File: e2e-test.txt", "+line 1", "+line 2", "*** End Patch"].join("\n") - - // Queue mock response: match on system prompt, return apply_patch tool call - yield* llm.toolMatch( - (hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"), - "apply_patch", - { patchText: patch }, - ) - // After tool execution, LLM gets called again with tool result — return "done" - yield* llm.text("done") - - // Seed user message - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - model: patchModel, - noReply: true, - system: [ - "You are seeding deterministic e2e UI state.", - "Your only valid response is one apply_patch tool call.", - `Use this JSON input: ${JSON.stringify({ patchText: patch })}`, - "Do not call any other tools.", - "Do not output plain text.", - ].join("\n"), - parts: [{ type: "text", text: "Apply the provided patch exactly once." }], - }) - - // Run the agent loop - const result = yield* prompt.loop({ sessionID: session.id }) - expect(result.info.role).toBe("assistant") - - const calls = yield* llm.calls - expect(calls).toBe(2) - - const missed = yield* llm.misses - expect(missed.length).toBe(0) - - const content = yield* Effect.promise(() => - Bun.file(`${dir}/e2e-test.txt`) - .text() - .catch(() => "NOT FOUND"), - ) - expect(content).toContain("line 1") - - let diff: Awaited> = [] - for (let i = 0; i < 20; i++) { - diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id })) - if (diff.length > 0) break - yield* Effect.sleep("100 millis") - } - expect(diff.length).toBeGreaterThan(0) - }), - { - git: true, - config: () => ({ - model: "openai/gpt-5.4", - agent: { - build: { - model: "openai/gpt-5.4", - }, - }, - provider: { - openai: { - options: { - apiKey: "test-openai-key", - }, - }, - }, - }), - }, - ), -) - -it.live("server message route produces diff through mock server", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ dir, llm }) { - const prev = process.env.OPENCODE_E2E_LLM_URL - process.env.OPENCODE_E2E_LLM_URL = llm.url - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL - else process.env.OPENCODE_E2E_LLM_URL = prev - }), - ) - - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "e2e route test", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - const app = Server.Default() - const patch = ["*** Begin Patch", "*** Add File: route-test.txt", "+line 1", "+line 2", "*** End Patch"].join( - "\n", - ) - - yield* llm.toolMatch( - (hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"), - "apply_patch", - { patchText: patch }, - ) - yield* llm.text("done") - - const res = yield* Effect.promise(() => - Promise.resolve( - app.request(`/session/${session.id}/message`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-opencode-directory": dir, - }, - body: JSON.stringify({ - agent: "build", - system: [ - "You are seeding deterministic e2e UI state.", - "Your only valid response is one apply_patch tool call.", - `Use this JSON input: ${JSON.stringify({ patchText: patch })}`, - "Do not call any other tools.", - "Do not output plain text.", - ].join("\n"), - parts: [{ type: "text", text: "Apply the provided patch exactly once." }], - }), - }), - ), - ) - expect(res.status).toBe(200) - yield* Effect.promise(() => res.json()) - - const calls = yield* llm.calls - expect(calls).toBe(2) - - const content = yield* Effect.promise(() => - Bun.file(`${dir}/route-test.txt`) - .text() - .catch(() => "NOT FOUND"), - ) - expect(content).toContain("line 1") - - let diff: Awaited> = [] - for (let i = 0; i < 30; i++) { - diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id })) - if (diff.length > 0) break - yield* Effect.sleep("100 millis") - } - - expect(diff.length).toBeGreaterThan(0) - }), - { - git: true, - config: () => ({ - model: "openai/gpt-5.4", - agent: { build: { model: "openai/gpt-5.4" } }, - provider: { openai: { options: { apiKey: "test-openai-key" } } }, - }), - }, - ), -)