diff options
| author | Kit Langton <[email protected]> | 2026-04-01 19:47:26 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-01 23:47:26 +0000 |
| commit | d9d4f895bcf5f95bd853fe2921de3e6a2798798f (patch) | |
| tree | a04d6bfc6f4c4bfdc7e94f4f55bb1acebeb07f7a /packages/app/e2e | |
| parent | 48db7cf07a651895341e019604d4ad485b97866d (diff) | |
| download | opencode-d9d4f895bcf5f95bd853fe2921de3e6a2798798f.tar.gz opencode-d9d4f895bcf5f95bd853fe2921de3e6a2798798f.zip | |
fix(test): auto-acknowledge tool-result follow-ups in mock LLM server (#20528)
Diffstat (limited to 'packages/app/e2e')
| -rw-r--r-- | packages/app/e2e/fixtures.ts | 19 | ||||
| -rw-r--r-- | packages/app/e2e/models/model-picker.spec.ts | 2 | ||||
| -rw-r--r-- | packages/app/e2e/prompt/mock.ts | 10 | ||||
| -rw-r--r-- | packages/app/e2e/session/session-child-navigation.spec.ts | 15 | ||||
| -rw-r--r-- | packages/app/e2e/session/session-composer-dock.spec.ts | 107 | ||||
| -rw-r--r-- | packages/app/e2e/session/session-review.spec.ts | 75 |
6 files changed, 133 insertions, 95 deletions
diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 592db60da..8c018a9f0 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -15,6 +15,7 @@ import { waitSlug, waitSession, } from "./actions" +import { openaiModel, withMockOpenAI } from "./prompt/mock" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" type LLMFixture = { @@ -47,6 +48,7 @@ type LLMFixture = { wait: (count: number) => Promise<void> inputs: () => Promise<Record<string, unknown>[]> pending: () => Promise<number> + misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>> } export const settingsKey = "settings.v3" @@ -83,6 +85,7 @@ type TestFixtures = { gotoSession: (sessionID?: string) => Promise<void> withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> + withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> } type WorkerFixtures = { @@ -132,6 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({ 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() @@ -193,6 +197,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({ 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, + }), + }), + ) + }, }) async function runProject<T>( diff --git a/packages/app/e2e/models/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts index 220a0baa1..d94c02652 100644 --- a/packages/app/e2e/models/model-picker.spec.ts +++ b/packages/app/e2e/models/model-picker.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" import { clickListItem } from "../actions" -test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { +test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { await gotoSession() await page.locator(promptSelector).click() diff --git a/packages/app/e2e/prompt/mock.ts b/packages/app/e2e/prompt/mock.ts index eb40a70cb..bd09af266 100644 --- a/packages/app/e2e/prompt/mock.ts +++ b/packages/app/e2e/prompt/mock.ts @@ -16,6 +16,16 @@ 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 + * gets escaped again inside the JSON body — so we double-escape to match. + */ +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<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) { const sdk = createSdk(undefined, input.serverUrl) const prev = await sdk.global.config.get().then((res) => res.data ?? {}) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index fa366e515..1ab4746e4 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -1,8 +1,9 @@ import { seedSessionTask, withSession } from "../actions" 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, withBackendProject }) => { +test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => { test.setTimeout(120_000) const errs: string[] = [] @@ -12,12 +13,18 @@ test("task tool child-session link does not trigger stale show errors", async ({ page.on("pageerror", onError) try { - await withBackendProject(async ({ gotoSession, trackSession, sdk }) => { + await withMockProject(async ({ gotoSession, trackSession, sdk }) => { await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { - const child = await seedSessionTask(sdk, { - sessionID: session.id, + 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) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 2c87a309d..bf0cc35b7 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -14,6 +14,7 @@ import { sessionTodoToggleButtonSelector, } from "../selectors" import { modKey } from "../utils" +import { inputMatch } from "../prompt/mock" type Sdk = Parameters<typeof clearSessionDockSeed>[0] type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } @@ -36,6 +37,17 @@ async function withDockSession<T>( } } +const defaultQuestions = [ + { + header: "Need input", + question: "Pick one option", + options: [ + { label: "Continue", description: "Continue now" }, + { label: "Stop", description: "Stop here" }, + ], + }, +] + test.setTimeout(120_000) async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) { @@ -291,8 +303,8 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP }) }) -test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { +test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => { + await withMockProject(async (project) => { await withDockSession( project.sdk, "e2e composer dock question", @@ -300,18 +312,10 @@ test("blocked question flow unblocks after submit", async ({ page, withBackendPr 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: [ - { - header: "Need input", - question: "Pick one option", - options: [ - { label: "Continue", description: "Continue now" }, - { label: "Stop", description: "Stop here" }, - ], - }, - ], + questions: defaultQuestions, }) const dock = page.locator(questionDockSelector) @@ -328,8 +332,8 @@ test("blocked question flow unblocks after submit", async ({ page, withBackendPr }) }) -test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { +test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => { + await withMockProject(async (project) => { await withDockSession( project.sdk, "e2e composer dock question keyboard", @@ -337,18 +341,10 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, withBac 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: [ - { - header: "Need input", - question: "Pick one option", - options: [ - { label: "Continue", description: "Continue now" }, - { label: "Stop", description: "Stop here" }, - ], - }, - ], + questions: defaultQuestions, }) const dock = page.locator(questionDockSelector) @@ -371,8 +367,8 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, withBac }) }) -test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { +test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => { + await withMockProject(async (project) => { await withDockSession( project.sdk, "e2e composer dock question escape", @@ -380,18 +376,10 @@ test("blocked question flow supports escape dismiss", async ({ page, withBackend 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: [ - { - header: "Need input", - question: "Pick one option", - options: [ - { label: "Continue", description: "Continue now" }, - { label: "Stop", description: "Stop here" }, - ], - }, - ], + questions: defaultQuestions, }) const dock = page.locator(questionDockSelector) @@ -512,9 +500,20 @@ test("blocked permission flow supports allow always", async ({ page, withBackend test("child session question request blocks parent dock and unblocks after submit", async ({ page, - withBackendProject, + llm, + withMockProject, }) => { - await withBackendProject(async (project) => { + const questions = [ + { + header: "Child input", + question: "Pick one child option", + options: [ + { label: "Continue", description: "Continue child" }, + { label: "Stop", description: "Stop child" }, + ], + }, + ] + await withMockProject(async (project) => { await withDockSession( project.sdk, "e2e composer dock child question parent", @@ -532,18 +531,10 @@ test("child session question request blocks parent dock and unblocks after submi try { await withDockSeed(project.sdk, child.id, async () => { + await llm.toolMatch(inputMatch({ questions }), "question", { questions }) await seedSessionQuestion(project.sdk, { sessionID: child.id, - questions: [ - { - header: "Child input", - question: "Pick one child option", - options: [ - { label: "Continue", description: "Continue child" }, - { label: "Stop", description: "Stop child" }, - ], - }, - ], + questions, }) const dock = page.locator(questionDockSelector) @@ -652,8 +643,15 @@ test("todo dock transitions and collapse behavior", async ({ page, withBackendPr }) }) -test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { +test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => { + const questions = [ + { + header: "Need input", + question: "Pick one option", + options: [{ label: "Continue", description: "Continue now" }], + }, + ] + await withMockProject(async (project) => { await withDockSession( project.sdk, "e2e composer dock keyboard", @@ -661,15 +659,10 @@ test("keyboard focus stays off prompt while blocked", async ({ page, withBackend 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: [ - { - header: "Need input", - question: "Pick one option", - options: [{ label: "Continue", description: "Continue now" }], - }, - ], + questions, }) await expectQuestionBlocked(page) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index 3137bd555..fb6a7ad1d 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 { createSdk } from "../utils" +import { inputMatch } from "../prompt/mock" const count = 14 @@ -40,7 +40,14 @@ function edit(file: string, prev: string, next: string) { ) } -async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) { +async function patchWithMock( + llm: Parameters<typeof test>[0]["llm"], + sdk: Parameters<typeof withSession>[0], + sessionID: string, + patchText: string, +) { + const callsBefore = await llm.calls() + await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText }) await sdk.session.promptAsync({ sessionID, agent: "build", @@ -54,6 +61,13 @@ async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patch 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) } @@ -233,8 +247,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) { } } -test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { - test.skip(true, "Flaky in CI for now.") +test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => { test.setTimeout(180_000) const tag = `review-comment-${Date.now()}` @@ -243,16 +256,15 @@ test("review applies inline comment clicks without horizontal overflow", async ( await page.setViewportSize({ width: 1280, height: 900 }) - await withProject(async (project) => { - const sdk = createSdk(project.directory) - - await withSession(sdk, `e2e review comment ${tag}`, async (session) => { - await patch(sdk, session.id, seed([{ file, mark: tag }])) + 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 sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) return diff.length }, { timeout: 60_000 }, @@ -283,8 +295,7 @@ test("review applies inline comment clicks without horizontal overflow", async ( }) }) -test("review file comments submit on click without clipping actions", async ({ page, withProject }) => { - test.skip(true, "Flaky in CI for now.") +test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => { test.setTimeout(180_000) const tag = `review-file-comment-${Date.now()}` @@ -293,16 +304,15 @@ test("review file comments submit on click without clipping actions", async ({ p await page.setViewportSize({ width: 1280, height: 900 }) - await withProject(async (project) => { - const sdk = createSdk(project.directory) - - await withSession(sdk, `e2e review file comment ${tag}`, async (session) => { - await patch(sdk, session.id, seed([{ file, mark: tag }])) + 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 sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) return diff.length }, { timeout: 60_000 }, @@ -334,8 +344,7 @@ test("review file comments submit on click without clipping actions", async ({ p }) }) -test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { - test.skip(Boolean(process.env.CI), "Flaky in CI for now.") +test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => { test.setTimeout(180_000) const tag = `review-${Date.now()}` @@ -345,16 +354,15 @@ test("review keeps scroll position after a live diff update", async ({ page, wit await page.setViewportSize({ width: 1600, height: 1000 }) - await withProject(async (project) => { - const sdk = createSdk(project.directory) - - await withSession(sdk, `e2e review ${tag}`, async (session) => { - await patch(sdk, session.id, seed(list)) + 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 sdk.session.get({ sessionID: session.id }).then((res) => res.data) + const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) return info?.summary?.files ?? 0 }, { timeout: 60_000 }, @@ -364,7 +372,7 @@ test("review keeps scroll position after a live diff update", async ({ page, wit await expect .poll( async () => { - const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) return diff.length }, { timeout: 60_000 }, @@ -381,15 +389,16 @@ test("review keeps scroll position after a live diff update", async ({ page, wit 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 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, "\\$&")) }) + .getByRole("heading", { + level: 3, + name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + }) .first() await expect(row).toBeVisible() await row.evaluate((el) => el.scrollIntoView({ block: "center" })) @@ -398,12 +407,12 @@ test("review keeps scroll position after a live diff update", async ({ page, wit const prev = await spot(page, hit.file) if (!prev) throw new Error(`missing review row for ${hit.file}`) - await patch(sdk, session.id, edit(hit.file, hit.mark, next)) + await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) await expect .poll( async () => { - const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + 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 : "" }, |
