test(app): add a golden path for mocked e2e prompts (#20593)

This commit is contained in:
Kit Langton
2026-04-02 18:17:28 +00:00
committed by GitHub
parent 363891126c
commit c3ef69c866
35 changed files with 2400 additions and 2071 deletions
+5
View File
@@ -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 }})
+7 -8
View File
@@ -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
+60 -168
View File
@@ -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<T>(page: Page, fn: () => Promise<T>) {
const url = "**/session/*/prompt_async"
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 page.route(url, route)
try {
return await fn()
} finally {
await page.unroute(url, route)
}
}
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
@@ -333,63 +311,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<string, unknown>) : {}
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<string, unknown>) }
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)}`
@@ -479,7 +400,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,11 +420,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 && current) 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 && state?.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
@@ -602,12 +531,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,16 +549,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 }
}
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()
return { rightSection: scroller, popoverBody }
}
export async function clickListItem(
@@ -794,40 +723,6 @@ export async function seedSessionQuestion(
return { id: result.id }
}
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
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<typeof createSdk>,
input: {
@@ -886,36 +781,6 @@ export async function seedSessionTask(
return result
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
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<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
@@ -1005,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page
const current = () =>
page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (current === enabled) return
if ((await current()) === enabled) return
if (enabled) {
await page.reload()
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 })
}
const flipped = await flip(1500)
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")
}
if (!flipped) await flip()
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 ((await current()) !== enabled) {
await page.reload()
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) {
+2 -1
View File
@@ -62,7 +62,7 @@ function tail(input: string[]) {
return input.slice(-40).join("")
}
export async function startBackend(label: string): Promise<Handle> {
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
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<Handle> {
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
OPENCODE_E2E_LLM_URL: input?.llmUrl,
} satisfies Record<string, string | undefined>
const out: string[] = []
const err: string[] = []
+407 -108
View File
@@ -10,13 +10,14 @@ import {
cleanupTestProject,
createTestProject,
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, serverUrl, sessionPath } from "./utils"
type LLMFixture = {
url: string
@@ -51,6 +52,23 @@ type LLMFixture = {
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
}
type LLMWorker = LLMFixture & {
reset: () => Promise<void>
}
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 = (() => {
@@ -63,6 +81,40 @@ const seedModel = (() => {
}
})()
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(() => {
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
@@ -79,16 +131,23 @@ type ProjectOptions = {
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
}
type ProjectFixture = ProjectHandle & {
open: (options?: ProjectOptions) => Promise<void>
prompt: (text: string) => Promise<string>
user: (text: string) => Promise<string>
shell: (cmd: string) => Promise<string>
}
type TestFixtures = {
llm: LLMFixture
assistant: AssistantFixture
project: ProjectFixture
sdk: ReturnType<typeof createSdk>
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 = {
_llm: LLMWorker
backend: {
url: string
sdk: (directory?: string) => ReturnType<typeof createSdk>
@@ -98,21 +157,8 @@ type WorkerFixtures = {
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
backend: [
async ({}, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`)
try {
await use({
url: handle.url,
sdk: (directory?: string) => createSdk(directory, handle.url),
})
} finally {
await handle.stop()
}
},
{ scope: "worker" },
],
llm: async ({}, use) => {
_llm: [
async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
@@ -130,6 +176,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
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)),
@@ -141,6 +188,64 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await rt.dispose()
}
},
{ scope: "worker" },
],
backend: [
async ({ _llm }, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
try {
await use({
url: handle.url,
sdk: (directory?: string) => createSdk(directory, handle.url),
})
} finally {
await handle.stop()
}
},
{ scope: "worker" },
],
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")
@@ -165,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
if (boundary) throw new Error(boundary)
},
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
async ({ backend }, use) => {
await use(await getWorktree(backend.url))
},
{ scope: "worker" },
],
@@ -177,93 +281,254 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
},
{ 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 page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
await visit(page, sessionPath(directory, sessionID))
await waitSession(page, {
directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
}
await use(gotoSession)
},
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,
}),
}),
)
project: async ({ page, llm, backend }, use) => {
const item = makeProject(page, llm, backend)
try {
await use(item.project)
} finally {
await item.cleanup()
}
},
})
async function runProject<T>(
function makeProject(
page: Page,
callback: (project: ProjectHandle) => Promise<T>,
options?: ProjectOptions & {
serverUrl?: string
sdk?: (directory?: string) => ReturnType<typeof createSdk>
},
llm: LLMFixture,
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
) {
const url = options?.serverUrl
const root = await createTestProject(url ? { serverUrl: url } : undefined)
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await options?.setup?.(root)
await seedStorage(page, {
directory: root,
extra: options?.extra,
model: options?.model,
serverUrl: url,
})
let state:
| {
directory: string
slug: string
sdk: ReturnType<typeof createSdk>
sessions: Map<string, string>
dirs: Set<string>
}
| 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) => {
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID, serverUrl: url })
const cur = need()
await visit(page, sessionPath(cur.directory, sessionID))
await waitSession(page, {
directory: cur.directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
const trackSession = (sessionID: string, directory?: string) => {
sessions.set(sessionID, directory ?? root)
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(),
}
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await options?.beforeGoto?.({ directory: root, sdk })
await options?.beforeGoto?.({ directory, sdk })
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
} finally {
need().slug = await waitSlug(page)
}
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")
}
const prompt = page.locator(promptSelector).first()
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()
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 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 () => {
const cur = state
if (!cur) return
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
Array.from(cur.sessions, ([sessionID, directory]) =>
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(cur.directory)
state = undefined
setHealthPhase(page, "test")
}
return {
project: {
open,
prompt,
user,
shell,
gotoSession,
trackSession,
trackDirectory,
get directory() {
return need().directory
},
get slug() {
return need().slug
},
get sdk() {
return need().sdk
},
},
cleanup,
}
}
async function seedStorage(
@@ -275,31 +540,65 @@ async function seedStorage(
serverUrl?: string
},
) {
await seedProjects(page, input)
await page.addInitScript((model: { providerID: string; modelID: string }) => {
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<string, unknown>) : {}
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<string, unknown>) }
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: {},
},
model: { enabled: true },
prompt: { enabled: true },
terminal: { enabled: true, terminals: {} },
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [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 },
)
}, input.model ?? seedModel)
}
export { expect }
+15 -9
View File
@@ -1,14 +1,14 @@
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 project.open()
await openSidebar(page)
const open = async () => {
const menu = await openProjectMenu(page, slug)
const menu = await openProjectMenu(page, project.slug)
await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog")
@@ -31,13 +31,19 @@ test("dialog edit project updates name and startup script", async ({ page, withP
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
await expect
.poll(
async () => {
await page.reload()
await openSidebar(page)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
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}`)
})
@@ -3,15 +3,14 @@ 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 project.open({ extra: [other] })
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
@@ -21,14 +20,13 @@ test("closing active project navigates to another open project", async ({ page,
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 (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
@@ -45,9 +43,6 @@ test("closing active project navigates to another open project", async ({ page,
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
@@ -5,28 +5,24 @@ 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 project.open({ extra: [other] })
await defocus(page)
const currentSlug = dirSlug(directory)
const currentSlug = dirSlug(project.directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
@@ -38,35 +34,31 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} 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 project.open({ extra: [other] })
await defocus(page)
await setWorkspacesEnabled(page, slug, true)
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, [slug])
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)
trackDirectory(space)
project.trackDirectory(space)
await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
@@ -79,21 +71,7 @@ test("switching back to a project opens the latest workspace session", async ({
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)
const created = await project.user("test")
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -104,15 +82,12 @@ test("switching back to a project opens the latest workspace session", async ({
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
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}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
@@ -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<typeof test>[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 project.open()
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
await setWorkspacesEnabled(page, project.slug, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
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)
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()}`)
})
+95 -102
View File
@@ -19,10 +19,10 @@ import {
waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
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,37 +53,37 @@ 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 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 setWorkspacesEnabled(page, project.slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, false)
await setWorkspacesEnabled(page, project.slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
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 setWorkspacesEnabled(page, project.slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [slug]))
const next = await resolveSlug(await waitSlug(page, [project.slug]))
await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page)
@@ -102,12 +103,9 @@ test("can create a workspace", async ({ page, withProject }) => {
.toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(next.directory)
})
})
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,7 +114,7 @@ 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 project.open({ extra: [nonGit] })
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
@@ -126,34 +124,16 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
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 trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
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)
}
})
test("can rename a workspace", async ({ page, withProject }) => {
test("can rename a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
@@ -165,17 +145,25 @@ test("can rename a workspace", async ({ page, withProject }) => {
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 readme = path.join(createdDir, "README.md")
@@ -196,7 +184,7 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
await expect
.poll(async () => {
const files = await sdk.file
const files = await project.sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
@@ -211,17 +199,17 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
await expect
.poll(
async () => {
const files = await sdk.file
const files = await project.sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
{ 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 () => {
@@ -232,59 +220,11 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
})
.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 withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await 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 sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
await project.open()
const rootSlug = project.slug
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
@@ -325,16 +265,17 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
await page.mouse.up()
}
try {
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
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)
@@ -368,8 +309,60 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
test("can delete a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
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(
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()
})
-41
View File
@@ -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 })
}
}
+6 -33
View File
@@ -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)
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 project.open()
await assistant.reply(token)
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
+8 -37
View File
@@ -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,48 +42,27 @@ 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 project.open()
await assistant.reply(firstToken)
const sessionID = await project.prompt(first)
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 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)
@@ -103,13 +81,6 @@ test("prompt history restores unsent draft with arrow navigation", async ({
await page.keyboard.press("ArrowDown")
await wait(page, "")
},
{
model: openaiModel,
},
)
},
})
})
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
+23 -24
View File
@@ -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 }) => {
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")
}
test("shell mode runs a command in the project directory", async ({ page, project }) => {
test.setTimeout(120_000)
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
const prompt = page.locator(promptSelector)
await project.open()
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)
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
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") }))
})
})
@@ -22,10 +22,10 @@ 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 project.open()
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector)
@@ -64,4 +64,3 @@ test("/share and /unshare update session share state", async ({ page, withBacken
.toBeUndefined()
})
})
})
+6 -36
View File
@@ -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()}`
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(() => assistant.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
} finally {
page.off("pageerror", onPageError)
}
-11
View File
@@ -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"]'
@@ -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,22 +13,22 @@ 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) => {
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(sdk, {
const child = await seedSessionTask(project.sdk, {
sessionID: session.id,
description: taskInput.description,
prompt: taskInput.prompt,
})
trackSession(child.sessionID)
project.trackSession(child.sessionID)
await gotoSession(session.id)
await project.gotoSession(session.id)
const link = page
.locator("a.subagent-link")
@@ -41,7 +41,6 @@ test("task tool child-session link does not trigger stale show errors", async ({
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
})
} finally {
page.off("pageerror", onError)
}
@@ -242,9 +242,7 @@ async function withMockPermission<T>(
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),
})
}
@@ -269,8 +267,8 @@ async function withMockPermission<T>(
}
}
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
test("default dock shows prompt input", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock default",
@@ -288,11 +286,9 @@ test("default dock shows prompt input", async ({ page, withBackendProject }) =>
{ 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()
@@ -301,10 +297,9 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
})
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock question",
@@ -330,10 +325,9 @@ test("blocked question flow unblocks after submit", async ({ page, llm, withMock
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock question keyboard",
@@ -365,10 +359,9 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, llm, wi
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock question escape",
@@ -395,10 +388,9 @@ test("blocked question flow supports escape dismiss", async ({ page, llm, withMo
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
test("blocked permission flow supports allow once", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock permission once",
@@ -429,10 +421,9 @@ test("blocked permission flow supports allow once", async ({ page, withBackendPr
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
test("blocked permission flow supports reject", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock permission reject",
@@ -462,10 +453,9 @@ test("blocked permission flow supports reject", async ({ page, withBackendProjec
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
test("blocked permission flow supports allow always", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock permission always",
@@ -496,13 +486,8 @@ test("blocked permission flow supports allow always", async ({ page, withBackend
{ 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,7 +498,7 @@ test("child session question request blocks parent dock and unblocks after submi
],
},
]
await withMockProject(async (project) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock child question parent",
@@ -552,13 +537,9 @@ test("child session question request blocks parent dock and unblocks after submi
{ trackSession: project.trackSession },
)
})
})
test("child session permission request blocks parent dock and supports allow once", async ({
page,
withBackendProject,
}) => {
await withBackendProject(async (project) => {
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",
@@ -604,10 +585,9 @@ test("child session permission request blocks parent dock and supports allow onc
{ trackSession: project.trackSession },
)
})
})
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock todo",
@@ -641,9 +621,8 @@ test("todo dock transitions and collapse behavior", async ({ page, withBackendPr
{ 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,7 +630,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
options: [{ label: "Continue", description: "Continue now" }],
},
]
await withMockProject(async (project) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock keyboard",
@@ -675,4 +654,3 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
{ trackSession: project.trackSession },
)
})
})
@@ -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<typeof test>[0]["project"], value: string) {
return project.prompt(value)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -301,75 +265,67 @@ 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 first = await submit(project, `session variant ${Date.now()}`)
await page.reload()
await waitSession(page, { directory, sessionID: first })
await waitSession(page, { directory: project.directory, sessionID: first })
await waitFooter(page, firstState)
await gotoSession()
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 second = await submit(project, `session model ${Date.now()}`)
await goto(page, directory, first)
await goto(page, project.directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await goto(page, project.directory, second)
await waitFooter(page, secondState)
await gotoSession()
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 first = await submit(project, `root session ${Date.now()}`)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await setWorkspacesEnabled(page, project.slug, true)
const one = await createWorkspace(page, slug, [])
const one = await createWorkspace(page, project.slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
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 second = await submit(project, `workspace one ${Date.now()}`)
const two = await createWorkspace(page, slug, [one.slug])
const two = await createWorkspace(page, project.slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
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 third = await submit(project, `workspace two ${Date.now()}`)
await goto(page, root, first)
await waitFooter(page, firstState)
@@ -383,15 +339,14 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
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)
await ensureVariant(page, project.directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
@@ -405,4 +360,3 @@ test("variant preserved when switching agent modes", async ({ page, withProject
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
+22 -17
View File
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { inputMatch } 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(inputMatch({ patchText }), "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,16 @@ 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 () => {
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 120_000 },
)
.toBeGreaterThan(0)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
@@ -245,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[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,7 +262,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
await page.setViewportSize({ width: 1280, height: 900 })
await withMockProject(async (project) => {
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 }]))
@@ -291,9 +299,8 @@ test("review applies inline comment clicks without horizontal overflow", async (
.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,7 +309,7 @@ test("review file comments submit on click without clipping actions", async ({ p
await page.setViewportSize({ width: 1280, height: 900 })
await withMockProject(async (project) => {
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 }]))
@@ -340,9 +347,8 @@ test("review file comments submit on click without clipping actions", async ({ p
.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,7 +358,7 @@ 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 project.open()
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed(list))
@@ -432,4 +438,3 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
.toBeLessThanOrEqual(32)
})
})
})
@@ -49,12 +49,12 @@ 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) => {
await project.open()
const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
@@ -80,14 +80,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
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) => {
await project.open()
const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
@@ -128,15 +127,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
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) => {
await project.open()
const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
@@ -233,4 +231,3 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withBac
await expect(secondMessage).toHaveCount(1)
})
})
})
+9 -13
View File
@@ -31,12 +31,12 @@ 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 project.open()
await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
@@ -66,13 +66,12 @@ test("session can be renamed via header menu", async ({ page, withBackendProject
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 project.open()
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
@@ -94,13 +93,12 @@ test("session can be archived via header menu", async ({ page, withBackendProjec
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 project.open()
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
@@ -126,19 +124,18 @@ test("session can be deleted via header menu", async ({ page, withBackendProject
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 project.open()
await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, 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()
@@ -183,4 +180,3 @@ test("session can be shared and unshared via header button", async ({ page, with
})
})
})
})
+13 -3
View File
@@ -88,10 +88,20 @@ 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)
@@ -48,57 +48,51 @@ 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 project.open({ extra: [other] })
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const projectButton = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(projectButton).toBeVisible()
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)
},
{ extra: [other] },
)
} 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 project.open({ extra: [other] })
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const projectButton = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await expect(projectButton).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
hit = await projectButton.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
@@ -109,9 +103,6 @@ test("open sidebar project switch activates on first tabbed enter", async ({ pag
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
@@ -12,12 +12,12 @@ async function open(page: Page) {
return term
}
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await withProject(async ({ gotoSession }) => {
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")
@@ -43,4 +43,3 @@ test("terminal reconnects without replacing the pty", async ({ page, withProject
timeout: 15_000,
})
})
})
+12 -15
View File
@@ -36,16 +36,16 @@ 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")
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 gotoSession()
await project.gotoSession()
await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one })
@@ -90,14 +90,13 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
)
.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")
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 gotoSession()
await project.gotoSession()
await open(page)
await page.getByRole("button", { name: /new terminal/i }).click()
@@ -129,15 +128,14 @@ test("closing the active terminal tab falls back to the previous tab", async ({
)
.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")
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 gotoSession()
await project.gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
@@ -165,4 +163,3 @@ test("terminal tab can be renamed from the context menu", async ({ page, withPro
)
.toBe(rename)
})
})
@@ -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") {
+27
View File
@@ -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,
}
},
}
+1
View File
@@ -23,6 +23,7 @@ export type E2EWindow = Window & {
prompt?: {
enabled?: boolean
current?: import("./prompt").PromptProbeState
sent?: import("./prompt").PromptSendState
}
terminal?: {
enabled?: boolean
@@ -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 = createOpenAICompatible({
name: model.providerID,
apiKey: "test-key",
baseURL: url,
}).chatModel(model.api.id)
s.models.set(key, language)
return language
}
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s)
+3 -1
View File
@@ -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
@@ -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")
+31 -8
View File
@@ -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))
}
@@ -599,6 +611,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 +640,7 @@ namespace TestLLMServer {
readonly error: (status: number, body: unknown) => Effect.Effect<void>
readonly hang: Effect.Effect<void>
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
readonly reset: Effect.Effect<void>
readonly hits: Effect.Effect<Hit[]>
readonly calls: Effect.Effect<number>
readonly wait: (count: number) => Effect.Effect<void>
@@ -671,22 +689,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
const req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
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)
}
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()
if (next.type !== "sse") return fail(next)
@@ -755,6 +772,12 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
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) {
@@ -0,0 +1,314 @@
/**
* 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<ReturnType<typeof SessionSummary.diff>> = []
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<ReturnType<typeof SessionSummary.diff>> = []
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" } } },
}),
},
),
)