Skip to content

Commit 6ebf801

Browse files
kitlangtonbalcsida
authored andcommitted
test(app): add a golden path for mocked e2e prompts (anomalyco#20593)
1 parent 40b6e53 commit 6ebf801

35 files changed

+2467
-2138
lines changed

.github/workflows/test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ jobs:
4747
4848
- name: Run unit tests
4949
run: bun turbo test
50+
env:
51+
# Bun 1.3.11 intermittently crashes on Windows during test teardown
52+
# inside the native @parcel/watcher binding. Unit CI does not rely on
53+
# the live watcher backend there, so disable it for that platform.
54+
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
5055

5156
e2e:
5257
name: e2e (${{ matrix.settings.name }})

packages/app/e2e/AGENTS.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
5959
### Using Fixtures
6060

6161
- `page` - Playwright page
62-
- `sdk` - OpenCode SDK client for API calls
63-
- `gotoSession(sessionID?)` - Navigate to session
62+
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
63+
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
64+
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
65+
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
6466

6567
### Helper Functions
6668

@@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
7375
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
7476
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
7577
- `withSession(sdk, title, callback)` - Create temp session
76-
- `withProject(...)` - Create temp project/workspace
7778
- `sessionIDFromUrl(url)` - Read session ID from URL
7879
- `slugFromUrl(url)` - Read workspace slug from URL
7980
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
80-
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
81-
- `trackDirectory(directory)` - Register directory for fixture cleanup
8281
- `clickListItem(container, filter)` - Click list item by key/text
8382

8483
**Selectors** (`selectors.ts`):
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
128127
})
129128
```
130129

131-
- Prefer `withSession(...)` for temp sessions
132-
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
133-
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
130+
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
131+
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
132+
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
134133
- Avoid calling `sdk.session.delete(...)` directly
135134

136135
### Timeouts

packages/app/e2e/actions.ts

Lines changed: 65 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
2-
import { expect, type Locator, type Page, type Route } from "@playwright/test"
2+
import { expect, type Locator, type Page } from "@playwright/test"
33
import fs from "node:fs/promises"
44
import os from "node:os"
55
import path from "node:path"
66
import { execSync } from "node:child_process"
77
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
88
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
99
import {
10-
dropdownMenuTriggerSelector,
1110
dropdownMenuContentSelector,
1211
projectSwitchSelector,
1312
projectMenuTriggerSelector,
@@ -43,27 +42,6 @@ export async function defocus(page: Page) {
4342
.catch(() => undefined)
4443
}
4544

46-
export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
47-
const url = "**/session/*/prompt_async"
48-
const route = async (input: Route) => {
49-
const body = input.request().postDataJSON()
50-
await input.continue({
51-
postData: JSON.stringify({ ...body, noReply: true }),
52-
headers: {
53-
...input.request().headers(),
54-
"content-type": "application/json",
55-
},
56-
})
57-
}
58-
59-
await page.route(url, route)
60-
try {
61-
return await fn()
62-
} finally {
63-
await page.unroute(url, route)
64-
}
65-
}
66-
6745
async function terminalID(term: Locator) {
6846
const id = await term.getAttribute(terminalAttr)
6947
if (id) return id
@@ -333,63 +311,6 @@ export async function openSettings(page: Page) {
333311
return dialog
334312
}
335313

336-
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
337-
await page.addInitScript(
338-
(args: { directory: string; serverUrl: string; extra: string[] }) => {
339-
const key = "opencode.global.dat:server"
340-
const defaultKey = "opencode.settings.dat:defaultServerUrl"
341-
const raw = localStorage.getItem(key)
342-
const parsed = (() => {
343-
if (!raw) return undefined
344-
try {
345-
return JSON.parse(raw) as unknown
346-
} catch {
347-
return undefined
348-
}
349-
})()
350-
351-
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
352-
const list = Array.isArray(store.list) ? store.list : []
353-
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
354-
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
355-
const nextProjects = { ...(projects as Record<string, unknown>) }
356-
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
357-
358-
const add = (origin: string, directory: string) => {
359-
const current = nextProjects[origin]
360-
const items = Array.isArray(current) ? current : []
361-
const existing = items.filter(
362-
(p): p is { worktree: string; expanded?: boolean } =>
363-
!!p &&
364-
typeof p === "object" &&
365-
"worktree" in p &&
366-
typeof (p as { worktree?: unknown }).worktree === "string",
367-
)
368-
369-
if (existing.some((p) => p.worktree === directory)) return
370-
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
371-
}
372-
373-
const directories = [args.directory, ...args.extra]
374-
for (const directory of directories) {
375-
add("local", directory)
376-
add(args.serverUrl, directory)
377-
}
378-
379-
localStorage.setItem(
380-
key,
381-
JSON.stringify({
382-
list: nextList,
383-
projects: nextProjects,
384-
lastProject,
385-
}),
386-
)
387-
localStorage.setItem(defaultKey, args.serverUrl)
388-
},
389-
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
390-
)
391-
}
392-
393314
export async function createTestProject(input?: { serverUrl?: string }) {
394315
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
395316
const id = `e2e-${path.basename(root)}`
@@ -479,7 +400,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
479400
return { directory: target, slug: base64Encode(target) }
480401
}
481402

482-
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
403+
export async function waitSession(
404+
page: Page,
405+
input: {
406+
directory: string
407+
sessionID?: string
408+
serverUrl?: string
409+
allowAnySession?: boolean
410+
},
411+
) {
483412
const target = await resolveDirectory(input.directory, input.serverUrl)
484413
await expect
485414
.poll(
@@ -491,11 +420,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
491420
if (!resolved || resolved.directory !== target) return false
492421
const current = sessionIDFromUrl(page.url())
493422
if (input.sessionID && current !== input.sessionID) return false
494-
if (!input.sessionID && current) return false
423+
if (!input.sessionID && !input.allowAnySession && current) return false
495424

496425
const state = await probeSession(page)
497426
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
498-
if (!input.sessionID && state?.sessionID) return false
427+
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
499428
if (state?.dir) {
500429
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
501430
if (dir !== target) return false
@@ -602,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
602531
}
603532

604533
export async function openSharePopover(page: Page) {
605-
const rightSection = page.locator(titlebarRightSelector)
606-
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
607-
await expect(shareButton).toBeVisible()
534+
const scroller = page.locator(".scroll-view__viewport").first()
535+
await expect(scroller).toBeVisible()
536+
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
537+
538+
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
539+
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
608540

609541
const popoverBody = page
610-
.locator(popoverBodySelector)
542+
.locator('[data-component="popover-content"]')
611543
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
612544
.first()
613545

@@ -617,16 +549,13 @@ export async function openSharePopover(page: Page) {
617549
.catch(() => false)
618550

619551
if (!opened) {
620-
await shareButton.click()
621-
await expect(popoverBody).toBeVisible()
552+
const menu = page.locator(dropdownMenuContentSelector).first()
553+
await menuTrigger.click()
554+
await clickMenuItem(menu, /share/i)
555+
await expect(menu).toHaveCount(0)
556+
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
622557
}
623-
return { rightSection, popoverBody }
624-
}
625-
626-
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
627-
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
628-
await expect(button).toBeVisible()
629-
await button.click()
558+
return { rightSection: scroller, popoverBody }
630559
}
631560

632561
export async function clickListItem(
@@ -794,40 +723,6 @@ export async function seedSessionQuestion(
794723
return { id: result.id }
795724
}
796725

797-
export async function seedSessionPermission(
798-
sdk: ReturnType<typeof createSdk>,
799-
input: {
800-
sessionID: string
801-
permission: string
802-
patterns: string[]
803-
description?: string
804-
},
805-
) {
806-
const text = [
807-
"Your only valid response is one bash tool call.",
808-
`Use this JSON input: ${JSON.stringify({
809-
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
810-
workdir: "/",
811-
description: input.description ?? `seed ${input.permission} permission request`,
812-
})}`,
813-
"Do not output plain text.",
814-
].join("\n")
815-
816-
const result = await seed({
817-
sdk,
818-
sessionID: input.sessionID,
819-
prompt: text,
820-
timeout: 30_000,
821-
probe: async () => {
822-
const list = await sdk.permission.list().then((x) => x.data ?? [])
823-
return list.find((item) => item.sessionID === input.sessionID)
824-
},
825-
})
826-
827-
if (!result) throw new Error("Timed out seeding permission request")
828-
return { id: result.id }
829-
}
830-
831726
export async function seedSessionTask(
832727
sdk: ReturnType<typeof createSdk>,
833728
input: {
@@ -886,36 +781,6 @@ export async function seedSessionTask(
886781
return result
887782
}
888783

889-
export async function seedSessionTodos(
890-
sdk: ReturnType<typeof createSdk>,
891-
input: {
892-
sessionID: string
893-
todos: Array<{ content: string; status: string; priority: string }>
894-
},
895-
) {
896-
const text = [
897-
"Your only valid response is one todowrite tool call.",
898-
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
899-
"Do not output plain text.",
900-
].join("\n")
901-
const target = JSON.stringify(input.todos)
902-
903-
const result = await seed({
904-
sdk,
905-
sessionID: input.sessionID,
906-
prompt: text,
907-
timeout: 30_000,
908-
probe: async () => {
909-
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
910-
if (JSON.stringify(todos) !== target) return
911-
return true
912-
},
913-
})
914-
915-
if (!result) throw new Error("Timed out seeding todos")
916-
return true
917-
}
918-
919784
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
920785
const [questions, permissions] = await Promise.all([
921786
sdk.question.list().then((x) => x.data ?? []),
@@ -1005,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
1005870
}
1006871

1007872
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
1008-
const current = await page
1009-
.getByRole("button", { name: "New workspace" })
1010-
.first()
1011-
.isVisible()
1012-
.then((x) => x)
1013-
.catch(() => false)
873+
const current = () =>
874+
page
875+
.getByRole("button", { name: "New workspace" })
876+
.first()
877+
.isVisible()
878+
.then((x) => x)
879+
.catch(() => false)
880+
881+
if ((await current()) === enabled) return
1014882

1015-
if (current === enabled) return
883+
if (enabled) {
884+
await page.reload()
885+
await openSidebar(page)
886+
if ((await current()) === enabled) return
887+
}
1016888

1017889
const flip = async (timeout?: number) => {
1018890
const menu = await openProjectMenu(page, projectSlug)
1019891
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
1020892
await expect(toggle).toBeVisible()
1021-
return toggle.click({ force: true, timeout })
893+
await expect(toggle).toBeEnabled({ timeout: 30_000 })
894+
const clicked = await toggle
895+
.click({ force: true, timeout })
896+
.then(() => true)
897+
.catch(() => false)
898+
if (clicked) return
899+
await toggle.focus()
900+
await page.keyboard.press("Enter")
1022901
}
1023902

1024-
const flipped = await flip(1500)
1025-
.then(() => true)
1026-
.catch(() => false)
903+
for (const timeout of [1500, undefined, undefined]) {
904+
if ((await current()) === enabled) break
905+
await flip(timeout)
906+
.then(() => undefined)
907+
.catch(() => undefined)
908+
const matched = await expect
909+
.poll(current, { timeout: 5_000 })
910+
.toBe(enabled)
911+
.then(() => true)
912+
.catch(() => false)
913+
if (matched) break
914+
}
1027915

1028-
if (!flipped) await flip()
916+
if ((await current()) !== enabled) {
917+
await page.reload()
918+
await openSidebar(page)
919+
}
1029920

1030921
const expected = enabled ? "New workspace" : "New session"
1031-
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
922+
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
923+
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
1032924
}
1033925

1034926
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {

packages/app/e2e/backend.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function tail(input: string[]) {
6262
return input.slice(-40).join("")
6363
}
6464

65-
export async function startBackend(label: string): Promise<Handle> {
65+
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
6666
const port = await freePort()
6767
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
6868
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -80,6 +80,7 @@ export async function startBackend(label: string): Promise<Handle> {
8080
XDG_STATE_HOME: path.join(sandbox, "state"),
8181
OPENCODE_CLIENT: "app",
8282
OPENCODE_STRICT_CONFIG_DEPS: "true",
83+
OPENCODE_E2E_LLM_URL: input?.llmUrl,
8384
} satisfies Record<string, string | undefined>
8485
const out: string[] = []
8586
const err: string[] = []

0 commit comments

Comments
 (0)