summaryrefslogtreecommitdiffhomepage
path: root/packages/app/e2e
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-01 19:47:26 -0400
committerGitHub <[email protected]>2026-04-01 23:47:26 +0000
commitd9d4f895bcf5f95bd853fe2921de3e6a2798798f (patch)
treea04d6bfc6f4c4bfdc7e94f4f55bb1acebeb07f7a /packages/app/e2e
parent48db7cf07a651895341e019604d4ad485b97866d (diff)
downloadopencode-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.ts19
-rw-r--r--packages/app/e2e/models/model-picker.spec.ts2
-rw-r--r--packages/app/e2e/prompt/mock.ts10
-rw-r--r--packages/app/e2e/session/session-child-navigation.spec.ts15
-rw-r--r--packages/app/e2e/session/session-composer-dock.spec.ts107
-rw-r--r--packages/app/e2e/session/session-review.spec.ts75
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 : ""
},