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 | 105 +++---- 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, 351 insertions(+), 187 deletions(-) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 4dfa7c64f07f..9febc4b3ff4d 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 8c018a9f0b7e..55ab22730835 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 a9a12cb95128..403369947b83 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 f2d15914d31c..55cb0c9aa33d 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()}` - - await llm.textMatch(titleMatch, "E2E Title") - await llm.textMatch(promptMatch(firstToken), firstToken) - await llm.textMatch(promptMatch(secondToken), secondToken) - - await withBackendProject( - async (project) => { - const prompt = page.locator(promptSelector) - - await prompt.click() - await page.keyboard.type(first) - await page.keyboard.press("Enter") - await wait(page, "") - - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - const sessionID = sessionIDFromUrl(page.url())! - project.trackSession(sessionID) - await reply(project.sdk, sessionID, firstToken) - - await prompt.click() - await page.keyboard.type(second) - await page.keyboard.press("Enter") - await wait(page, "") - await reply(project.sdk, sessionID, secondToken) - - await prompt.click() - await page.keyboard.type(draft) - await wait(page, draft) - - await prompt.fill("") - await wait(page, "") - - 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, - }, - ) - }, - }) + 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 project.open() + await assistant.reply(firstToken) + const sessionID = await project.prompt(first) + await wait(page, "") + await reply(project.sdk, sessionID, firstToken) + + await assistant.reply(secondToken) + await project.prompt(second) + await wait(page, "") + await reply(project.sdk, sessionID, secondToken) + + const prompt = page.locator(promptSelector) + await prompt.click() + await page.keyboard.type(draft) + await wait(page, draft) + + await prompt.fill("") + await wait(page, "") + + 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, "") }) 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 3c9ed51dcaf2..b5dc02badba3 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 441f84b907cc..c3ca1b385259 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 747693d70bd6..b3dd220c22f4 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 55ab22730835..d34caf13314b 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 | 134 +++- .../app/e2e/projects/project-edit.spec.ts | 78 +- .../app/e2e/projects/projects-close.spec.ts | 73 +- .../app/e2e/projects/projects-switch.spec.ts | 139 ++-- .../projects/workspace-new-session.spec.ts | 51 +- packages/app/e2e/projects/workspaces.spec.ts | 498 +++++++------ packages/app/e2e/prompt/prompt-shell.spec.ts | 49 +- .../app/e2e/prompt/prompt-slash-share.spec.ts | 67 +- .../session/session-child-navigation.spec.ts | 51 +- .../e2e/session/session-composer-dock.spec.ts | 666 +++++++++--------- .../session/session-model-persistence.spec.ts | 178 ++--- .../app/e2e/session/session-review.spec.ts | 307 ++++---- .../app/e2e/session/session-undo-redo.spec.ts | 285 ++++---- packages/app/e2e/session/session.spec.ts | 248 ++++--- .../sidebar/sidebar-popover-actions.spec.ts | 86 +-- .../e2e/terminal/terminal-reconnect.spec.ts | 45 +- .../app/e2e/terminal/terminal-tabs.spec.ts | 247 ++++--- .../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, 1643 insertions(+), 1663 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index df8e0768ed6e..1b44138784a5 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 ((await current()) === enabled) return - if (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 d34caf13314b..77a609c45dbe 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()}`) - - 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, 90_000, backend.url) - await waitSessionIdle(backend.sdk(directory), sessionID, 90_000).catch(() => undefined) - return sessionID + if (input.noReply) { + await withNoReplyPrompt(page, submit) + } else { + await submit() + } + + 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("") + + 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 7c20f29ec1dc..bc182a6953c1 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) - - const open = async () => { - const menu = await openProjectMenu(page, 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 name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` - - const dialog = await open() - - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) - - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) - - 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 project.open() + await openSidebar(page) + + 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 name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` + + const dialog = await open() + + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) + + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) + + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).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 9454d683f021..75e6f2ce689d 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) - - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() - - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - - const menu = await openProjectMenu(page, otherSlug) - - await clickMenuItem(menu, /^Close$/i, { force: true }) - - 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 project.open({ extra: [other] }) + await openSidebar(page) + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + + const menu = await openProjectMenu(page, otherSlug) + 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(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 f87a47cf0910..67d09afd15af 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 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 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 }) - - 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") - }) - - // Wait for the URL to update with the new session ID - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") - - 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 expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) - - await openSidebar(page) - - 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 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() + + 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 btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() + await expect(btn).toBeVisible() + await btn.click({ force: true }) + + await waitSession(page, { directory: space }) + + const created = await project.user("test") + + await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) + + await openSidebar(page) + + 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(project.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}(?:[/?#]|$)`)) } 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 835c8c99eda7..d9d010b4dc2e 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 297cdb9fc969..16caa3d496b9 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,62 +53,59 @@ 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 withProject(async ({ slug }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) - - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [slug])) - await waitDir(page, next.directory) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - await openSidebar(page) + 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 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 openSidebar(page) - await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() + 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 cleanupTestProject(next.directory) - }) + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() }) -test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => { +test("non-git projects keep workspace mode disabled", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) @@ -116,260 +114,256 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") try { - await withProject(async () => { - await page.goto(`/${nonGitSlug}/session`) + await project.open({ extra: [nonGit] }) + await page.goto(`/${nonGitSlug}/session`) - await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") + 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-") + 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 openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - await trigger.click({ force: true }) + const trigger = page.locator('[data-action="project-menu"]').first() + const hasMenu = await trigger + .isVisible() + .then((x) => x) + .catch(() => false) + if (!hasMenu) return - const menu = page.locator(dropdownMenuContentSelector).first() - await expect(menu).toBeVisible() + const menu = await openProjectMenu(page, nonGitSlug) - const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() + 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(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 }) => { +test("can rename a workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - - await withProject(async (project) => { - 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() - await expect(input).toBeVisible() - await input.fill(rename) - await input.press("Enter") - await expect(item).toContainText(rename) - }) + 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, sdk, withProject }) => { +test("can reset a workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() - await withProject(async (project) => { - const { slug, directory: createdDir } = await setupWorkspaceTest(page, 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") + 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 () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(true) - await expect - .poll(async () => { - const files = await sdk.file + 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 - }) - .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) + }, + { timeout: 120_000 }, + ) + .toBe(0) - await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original) + 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) - }) + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(false) }) -test("can delete a workspace", async ({ page, withProject }) => { +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 - await withProject(async (project) => { - const sdk = createSdk(project.directory) - const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) + 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 worktrees = await sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } }, - { timeout: 30_000 }, + { timeout: 60_000 }, ) .toBe(true) + } - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Delete$/i, { force: true }) - await confirmDialog(page, /^Delete workspace$/i) + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() - await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) + 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( - async () => { - const worktrees = await sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 60_000 }, - ) - .toBe(false) + 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 openSidebar(page) + + await setWorkspacesEnabled(page, rootSlug, true) - 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 }[] - - 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(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_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 a = await src.boundingBox() - const b = await dst.boundingBox() - if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") - - 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() - } - - 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 project.open() + + const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) + + 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 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 project.sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 60_000 }, + ) + .toBe(false) + + 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 7c39a2db3428..d81f1d4c40f1 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 }) => { - 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 gotoSession() - await prompt.click() - await page.keyboard.type("!") - await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) +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") +} - await page.keyboard.type(cmd) - await page.keyboard.press("Enter") +test("shell mode runs a command in the project directory", async ({ page, project }) => { + test.setTimeout(120_000) - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + await project.open() + const cmd = process.platform === "win32" ? "dir" : "command ls" - 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 5371d8a91825..f3eeceee5f17 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 1ab4746e424e..34a1a9e2e745 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 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 gotoSession(session.id) + 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() + 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 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 bf0cc35b71b7..e997f29f8415 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) => { - 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("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, withBackendProject }) => { - await withBackendProject(async ({ gotoSession }) => { - await gotoSession() +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") + 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) - }) + 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) +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 llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) + 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) + 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) - }) - }, - { trackSession: project.trackSession }, - ) - }) + 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, - }) + 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) + 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 expectQuestionBlocked(page) + await expect(first).toBeFocused() - await page.keyboard.press("ArrowDown") - await expect(second).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 }, - ) - }) + 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, - }) + 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 dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() - await expectQuestionBlocked(page) - await expect(first).toBeFocused() + await expectQuestionBlocked(page) + await expect(first).toBeFocused() - await page.keyboard.press("Escape") - await expectQuestionOpen(page) - }) - }, - { trackSession: project.trackSession }, - ) - }) + 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) - - 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 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 }, + ) }) -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) - - await clearPermissionDock(page, /deny/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) +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 }, + ) }) -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) - - await clearPermissionDock(page, /allow always/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) +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 }, + ) }) -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, + }) + .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 question", - parentID: session.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) - - 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 }, - ) - }) + 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 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() - - 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.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 }, - ) - }) +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"]) + + await dock.collapse() + await dock.expectCollapsed(["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 }, + ) }) -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 llm.toolMatch(inputMatch({ questions }), "question", { questions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions, + }) - await expectQuestionBlocked(page) + 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 }, - ) - }) + 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 66bc451bcfce..8801e410f209 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 c7529112ffda..b7695cc0b5cf 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 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) - - 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 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 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 project.gotoSession(session.id) + await show(page) + + 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 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 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) - - 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 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 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 project.gotoSession(session.id) + await show(page) + + 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 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 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 project.gotoSession(session.id) - await show(page) - - 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 }) - - 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" })) - - 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 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 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 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 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) + + 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 }) + + 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" })) + + 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 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 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 a63bd9e3b5aa..709a45b4c46a 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) + + 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") - await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") - const first = await seedConversation({ - page, - sdk, - sessionID: session.id, - token: firstToken, + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, }) - const second = await seedConversation({ - page, - sdk, - sessionID: session.id, - token: secondToken, + .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) - 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) - }) + 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 6c885460c43d..1b5fb1b60067 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) - - 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") - - 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 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 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(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 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 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 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 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 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 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) - - 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 - .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() - - 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() - - const unshared = await openSharePopover(page) - await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ - timeout: 30_000, - }) + 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() + + 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() + + 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 + .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, }) }) }) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index 1317d2bb688a..8f7646c3e733 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) - - const project = 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 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 project.open({ extra: [other] }) + await openSidebar(page) + + const projectButton = page.locator(projectSwitchSelector(slug)).first() + const card = page.locator('[data-component="hover-card-content"]') + + 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 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) - - const project = page.locator(projectSwitchSelector(slug)).first() - - await expect(project).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") - } - - expect(hit).toBe(true) - - await page.keyboard.press("Enter") - await waitSession(page, { directory: other }) - }, - { extra: [other] }, - ) + await project.open({ extra: [other] }) + await openSidebar(page) + await defocus(page) + + const projectButton = page.locator(projectSwitchSelector(slug)).first() + + await expect(projectButton).toBeVisible() + + 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) + + 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 b03ed89568c7..1a11a047a4d2 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 6b6fa4c62b4d..5cb5bbf20230 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() - - await gotoSession() - await open(page) - - await runTerminal(page, { cmd: `echo ${one}`, token: one }) - - await page.getByRole("button", { name: /new terminal/i }).click() - await expect(tabs).toHaveCount(2) - - await runTerminal(page, { cmd: `echo ${two}`, token: two }) - - 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 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("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 project.gotoSession() + await open(page) + + await runTerminal(page, { cmd: `echo ${one}`, token: one }) + + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) + + await runTerminal(page, { cmd: `echo ${two}`, token: two }) + + 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 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"]') - - await gotoSession() - await open(page) - - 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") - - 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 }) - }) +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 project.gotoSession() + await open(page) + + 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") + + 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 }) }) -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() - - await gotoSession() - await open(page) - - 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 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) - }) +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 project.gotoSession() + await open(page) + + 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 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) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ba299fe3650d..06b6c1e35108 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 e11462f30137..5102ed825bf8 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 2bca39b31cdf..db8001ddf91b 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 77a609c45dbe..7b1941319e86 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 bc182a6953c1..1ffe4219d161 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 16caa3d496b9..b8a072040183 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 1b151b606630..3327bf311454 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 8f7646c3e733..05a129a6139f 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 7b1941319e86..38b445a2058f 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 b3dd220c22f4..7bc3066e3361 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 1b44138784a5..63935d8b4fae 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 b8a072040183..aea4c80686a1 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 b7695cc0b5cf..6e7df88eed79 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 63935d8b4fae..32e3d6ba7ccd 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 38b445a2058f..bb3ea4a6c4eb 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 aea4c80686a1..206baa47ce65 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 8801e410f209..c107cc518707 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 6e7df88eed79..8693f1c30e54 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 5d8b7c4e3d8e..4b93997805b8 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 cac58f3174e1..82f797945a94 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 9c58be30abf5..03c0741b5274 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 8693f1c30e54..6c07de0a40e9 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 7bc3066e3361..fbad6ac1456b 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 000000000000..1160e98ad84b --- /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 1160e98ad84b..411e88f6250d 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 f263e49a023c..bdd6ba185bfd 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 32e3d6ba7ccd..f62c0555d173 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 bb3ea4a6c4eb..e6f9a6280446 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 6c07de0a40e9..d8876a0ff985 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 e6f9a6280446..067272c7ee15 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 f62c0555d173..7056295910c8 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 067272c7ee15..a24602982d9b 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 7056295910c8..fb9eb208bc27 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 bd09af266578..c7eb54b526e1 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 32e4ecd8a4ef..0a850c744079 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 a24602982d9b..fe2eb9c1a05b 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 e997f29f8415..8eeac5b1a18a 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 3327bf311454..6455892ccae8 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 c3ca1b385259..81479dcd75c2 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 287d04ed3188..2cc5092029be 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 d8876a0ff985..8693f1c30e54 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 8693f1c30e54..c0a98cb2e359 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 4b93997805b8..5d8b7c4e3d8e 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 82f797945a94..cac58f3174e1 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 133a5018ad43..2620405144ac 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 411e88f6250d..744c7bf0bcfe 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 744c7bf0bcfe..000000000000 --- 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" } } }, - }), - }, - ), -)