summaryrefslogtreecommitdiffhomepage
path: root/packages/app/e2e/prompt
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-02 14:17:28 -0400
committerGitHub <[email protected]>2026-04-02 18:17:28 +0000
commitc3ef69c8664ab84464d720f75eb9bf35289c1847 (patch)
treed7b1aa78355c39472804bdfbbd9db87722d0587b /packages/app/e2e/prompt
parent363891126c9b2a909db0e724eb6377bd3d12b38b (diff)
downloadopencode-c3ef69c8664ab84464d720f75eb9bf35289c1847.tar.gz
opencode-c3ef69c8664ab84464d720f75eb9bf35289c1847.zip
test(app): add a golden path for mocked e2e prompts (#20593)
Diffstat (limited to 'packages/app/e2e/prompt')
-rw-r--r--packages/app/e2e/prompt/mock.ts41
-rw-r--r--packages/app/e2e/prompt/prompt-async.spec.ts43
-rw-r--r--packages/app/e2e/prompt/prompt-history.spec.ts105
-rw-r--r--packages/app/e2e/prompt/prompt-shell.spec.ts49
-rw-r--r--packages/app/e2e/prompt/prompt-slash-share.spec.ts67
-rw-r--r--packages/app/e2e/prompt/prompt.spec.ts46
6 files changed, 111 insertions, 240 deletions
diff --git a/packages/app/e2e/prompt/mock.ts b/packages/app/e2e/prompt/mock.ts
index bd09af266..c7eb54b52 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<string, unknown> }
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<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 ?? {})
-
- 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/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts
index a9a12cb95..403369947 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 f2d15914d..55cb0c9aa 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<typeof createSdk>
@@ -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-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts
index 7c39a2db3..d81f1d4c4 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<typeof test>[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 5371d8a91..f3eeceee5 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<typeof withSession>[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/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts
index 3c9ed51dc..b5dc02bad 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)
}