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 - name: Run unit tests
run: bun turbo test 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: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
+7 -8
View File
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
### Using Fixtures ### Using Fixtures
- `page` - Playwright page - `page` - Playwright page
- `sdk` - OpenCode SDK client for API calls - `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
- `gotoSession(sessionID?)` - Navigate to session - `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 ### 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 - `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 - `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 - `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL - `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL - `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug - `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 - `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`): **Selectors** (`selectors.ts`):
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
}) })
``` ```
- Prefer `withSession(...)` for temp sessions - Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)` - Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency - 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 - Avoid calling `sdk.session.delete(...)` directly
### Timeouts ### Timeouts
+60 -168
View File
@@ -1,5 +1,5 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode" 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 fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
@@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal" import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import { import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectSwitchSelector, projectSwitchSelector,
projectMenuTriggerSelector, projectMenuTriggerSelector,
@@ -43,27 +42,6 @@ export async function defocus(page: Page) {
.catch(() => undefined) .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) { async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr) const id = await term.getAttribute(terminalAttr)
if (id) return id if (id) return id
@@ -333,63 +311,6 @@ export async function openSettings(page: Page) {
return dialog 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 }) { export async function createTestProject(input?: { serverUrl?: string }) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}` 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) } 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) const target = await resolveDirectory(input.directory, input.serverUrl)
await expect await expect
.poll( .poll(
@@ -491,11 +420,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
if (!resolved || resolved.directory !== target) return false if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false 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) const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false 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) { if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "") const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false if (dir !== target) return false
@@ -602,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
} }
export async function openSharePopover(page: Page) { export async function openSharePopover(page: Page) {
const rightSection = page.locator(titlebarRightSelector) const scroller = page.locator(".scroll-view__viewport").first()
const shareButton = rightSection.getByRole("button", { name: "Share" }).first() await expect(scroller).toBeVisible()
await expect(shareButton).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 const popoverBody = page
.locator(popoverBodySelector) .locator('[data-component="popover-content"]')
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first() .first()
@@ -617,16 +549,13 @@ export async function openSharePopover(page: Page) {
.catch(() => false) .catch(() => false)
if (!opened) { if (!opened) {
await shareButton.click() const menu = page.locator(dropdownMenuContentSelector).first()
await expect(popoverBody).toBeVisible() await menuTrigger.click()
await clickMenuItem(menu, /share/i)
await expect(menu).toHaveCount(0)
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
} }
return { rightSection, popoverBody } return { rightSection: scroller, 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()
} }
export async function clickListItem( export async function clickListItem(
@@ -794,40 +723,6 @@ export async function seedSessionQuestion(
return { id: result.id } 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( export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
input: { input: {
@@ -886,36 +781,6 @@ export async function seedSessionTask(
return result 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) { export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([ const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []), 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) { export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page const current = () =>
page
.getByRole("button", { name: "New workspace" }) .getByRole("button", { name: "New workspace" })
.first() .first()
.isVisible() .isVisible()
.then((x) => x) .then((x) => x)
.catch(() => false) .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 flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug) const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible() await expect(toggle).toBeVisible()
return toggle.click({ force: true, timeout }) await expect(toggle).toBeEnabled({ timeout: 30_000 })
} const clicked = await toggle
.click({ force: true, timeout })
const flipped = await flip(1500)
.then(() => true) .then(() => true)
.catch(() => false) .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" 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) { 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("") 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 port = await freePort()
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`)) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") 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"), XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app", OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true", OPENCODE_STRICT_CONFIG_DEPS: "true",
OPENCODE_E2E_LLM_URL: input?.llmUrl,
} satisfies Record<string, string | undefined> } satisfies Record<string, string | undefined>
const out: string[] = [] const out: string[] = []
const err: string[] = [] const err: string[] = []
+407 -108
View File
@@ -10,13 +10,14 @@ import {
cleanupTestProject, cleanupTestProject,
createTestProject, createTestProject,
setHealthPhase, setHealthPhase,
seedProjects,
sessionIDFromUrl, sessionIDFromUrl,
waitSlug,
waitSession, waitSession,
waitSessionIdle,
waitSessionSaved,
waitSlug,
} from "./actions" } from "./actions"
import { openaiModel, withMockOpenAI } from "./prompt/mock" import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
type LLMFixture = { type LLMFixture = {
url: string url: string
@@ -51,6 +52,23 @@ type LLMFixture = {
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>> 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" export const settingsKey = "settings.v3"
const seedModel = (() => { 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 = { type ProjectHandle = {
directory: string directory: string
slug: string slug: string
@@ -79,16 +131,23 @@ type ProjectOptions = {
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void> 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 = { type TestFixtures = {
llm: LLMFixture llm: LLMFixture
assistant: AssistantFixture
project: ProjectFixture
sdk: ReturnType<typeof createSdk> sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void> 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 = { type WorkerFixtures = {
_llm: LLMWorker
backend: { backend: {
url: string url: string
sdk: (directory?: string) => ReturnType<typeof createSdk> sdk: (directory?: string) => ReturnType<typeof createSdk>
@@ -98,21 +157,8 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
backend: [ _llm: [
async ({}, use, workerInfo) => { async ({}, use) => {
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) => {
const rt = ManagedRuntime.make(TestLLMServer.layer) const rt = ManagedRuntime.make(TestLLMServer.layer)
try { try {
const svc = await rt.runPromise(TestLLMServer.asEffect()) 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)), error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang), hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)), hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
reset: () => rt.runPromise(svc.reset),
hits: () => rt.runPromise(svc.hits), hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls), calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)), wait: (count) => rt.runPromise(svc.wait(count)),
@@ -141,6 +188,64 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await rt.dispose() 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) => { page: async ({ page }, use) => {
let boundary: string | undefined let boundary: string | undefined
setHealthPhase(page, "test") setHealthPhase(page, "test")
@@ -165,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
if (boundary) throw new Error(boundary) if (boundary) throw new Error(boundary)
}, },
directory: [ directory: [
async ({}, use) => { async ({ backend }, use) => {
const directory = await getWorktree() await use(await getWorktree(backend.url))
await use(directory)
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
@@ -177,93 +281,254 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
sdk: async ({ directory }, use) => { sdk: async ({ directory, backend }, use) => {
await use(createSdk(directory)) await use(backend.sdk(directory))
}, },
gotoSession: async ({ page, directory }, use) => { gotoSession: async ({ page, directory, backend }, use) => {
await seedStorage(page, { directory }) await seedStorage(page, { directory, serverUrl: backend.url })
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await visit(page, sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID }) await waitSession(page, {
directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { project: async ({ page, llm, backend }, use) => {
await use((callback, options) => runProject(page, callback, options)) const item = makeProject(page, llm, backend)
}, try {
withBackendProject: async ({ page, backend }, use) => { await use(item.project)
await use((callback, options) => } finally {
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }), await item.cleanup()
) }
},
withMockProject: async ({ page, llm, backend }, use) => {
await use((callback, options) =>
withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: () =>
runProject(page, callback, {
...options,
model: options?.model ?? openaiModel,
serverUrl: backend.url,
sdk: backend.sdk,
}),
}),
)
}, },
}) })
async function runProject<T>( function makeProject(
page: Page, page: Page,
callback: (project: ProjectHandle) => Promise<T>, llm: LLMFixture,
options?: ProjectOptions & { backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
serverUrl?: string
sdk?: (directory?: string) => ReturnType<typeof createSdk>
},
) { ) {
const url = options?.serverUrl let state:
const root = await createTestProject(url ? { serverUrl: url } : undefined) | {
const sdk = options?.sdk?.(root) ?? createSdk(root, url) directory: string
const sessions = new Map<string, string>() slug: string
const dirs = new Set<string>() sdk: ReturnType<typeof createSdk>
await options?.setup?.(root) sessions: Map<string, string>
await seedStorage(page, { dirs: Set<string>
directory: root, }
extra: options?.extra, | undefined
model: options?.model,
serverUrl: url, 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) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) const cur = need()
await waitSession(page, { directory: root, sessionID, serverUrl: url }) await visit(page, sessionPath(cur.directory, sessionID))
await waitSession(page, {
directory: cur.directory,
sessionID,
serverUrl: backend.url,
allowAnySession: !sessionID,
})
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
const trackSession = (sessionID: string, directory?: string) => { const open = async (options?: ProjectOptions) => {
sessions.set(sessionID, directory ?? root) 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(),
} }
await options?.beforeGoto?.({ directory, sdk })
const trackDirectory = (directory: string) => {
if (directory !== root) dirs.add(directory)
}
try {
await options?.beforeGoto?.({ directory: root, sdk })
await gotoSession() await gotoSession()
const slug = await waitSlug(page) need().slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk }) }
} finally {
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") setHealthPhase(page, "cleanup")
await Promise.allSettled( 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 Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root) await cleanupTestProject(cur.directory)
state = undefined
setHealthPhase(page, "test") 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( async function seedStorage(
@@ -275,31 +540,65 @@ async function seedStorage(
serverUrl?: string serverUrl?: string
}, },
) { ) {
await seedProjects(page, input) const origin = input.serverUrl ?? serverUrl
await page.addInitScript((model: { providerID: string; modelID: string }) => { 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 const win = window as E2EWindow
win.__opencode_e2e = { win.__opencode_e2e = {
...win.__opencode_e2e, ...win.__opencode_e2e,
model: { model: { enabled: true },
enabled: true, prompt: { enabled: true },
}, terminal: { enabled: true, terminals: {} },
prompt: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},
},
} }
localStorage.setItem( localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
"opencode.global.dat:model", },
JSON.stringify({ { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
recent: [model],
user: [],
variant: {},
}),
) )
}, input.model ?? seedModel)
} }
export { expect } export { expect }
+15 -9
View File
@@ -1,14 +1,14 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" 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 page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => { await project.open()
await openSidebar(page) await openSidebar(page)
const open = async () => { const open = async () => {
const menu = await openProjectMenu(page, slug) const menu = await openProjectMenu(page, project.slug)
await clickMenuItem(menu, /^Edit$/i, { force: true }) await clickMenuItem(menu, /^Edit$/i, { force: true })
const dialog = page.getByRole("dialog") 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 dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first() await expect
await expect(header).toContainText(name) .poll(
async () => {
await page.reload()
await openSidebar(page)
const reopened = await open() const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name) const value = await reopened.getByLabel("Name").inputValue()
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) const next = await reopened.getByLabel("Workspace startup script").inputValue()
await reopened.getByRole("button", { name: "Cancel" }).click() await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0) 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 { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils" 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 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await withProject( await project.open({ extra: [other] })
async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() 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`)) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const menu = await openProjectMenu(page, otherSlug) const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true }) await clickMenuItem(menu, /^Close$/i, { force: true })
await expect await expect
.poll( .poll(
() => { () => {
const pathname = new URL(page.url()).pathname 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" if (pathname === "/") return "home"
return "" return ""
}, },
@@ -45,9 +43,6 @@ test("closing active project navigates to another open project", async ({ page,
{ timeout: 15_000 }, { timeout: 15_000 },
) )
.toBe(0) .toBe(0)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -5,28 +5,24 @@ import {
createTestProject, createTestProject,
cleanupTestProject, cleanupTestProject,
openSidebar, openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled, setWorkspacesEnabled,
waitSession, waitSession,
waitSessionSaved,
waitSlug, waitSlug,
withNoReplyPrompt,
} from "../actions" } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" 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 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await withProject( await project.open({ extra: [other] })
async ({ directory }) => {
await defocus(page) await defocus(page)
const currentSlug = dirSlug(directory) const currentSlug = dirSlug(project.directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click()
@@ -38,35 +34,31 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
await currentButton.click() await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) 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 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
try { try {
await withProject( await project.open({ extra: [other] })
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page) await defocus(page)
await setWorkspacesEnabled(page, slug, true) await setWorkspacesEnabled(page, project.slug, true)
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() 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) const dir = base64Decode(raw)
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
const space = await resolveDirectory(dir) const space = await resolveDirectory(dir)
const next = dirSlug(space) const next = dirSlug(space)
trackDirectory(space) project.trackDirectory(space)
await openSidebar(page) await openSidebar(page)
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() 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 }) await waitSession(page, { directory: space })
// Create a session by sending a prompt const created = await project.user("test")
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)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) 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 otherButton.click({ force: true })
await waitSession(page, { directory: other }) 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 expect(rootButton).toBeVisible()
await rootButton.click({ force: true }) await rootButton.click({ force: true })
await waitSession(page, { directory: space, sessionID: created }) await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -7,12 +7,9 @@ import {
setWorkspacesEnabled, setWorkspacesEnabled,
waitDir, waitDir,
waitSession, waitSession,
waitSessionSaved,
waitSlug, waitSlug,
withNoReplyPrompt,
} from "../actions" } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function item(space: { slug: string; raw: string }) { function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
@@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
} }
async function createSessionFromWorkspace( async function createSessionFromWorkspace(
project: Parameters<typeof test>[0]["project"],
page: Page, page: Page,
space: { slug: string; raw: string; directory: string }, space: { slug: string; raw: string; directory: string },
text: string, text: string,
) { ) {
await openWorkspaceNewSession(page, space) await openWorkspaceNewSession(page, space)
return project.user(text)
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
} }
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 page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: root, trackDirectory, trackSession }) => { await project.open()
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, root, true) await setWorkspacesEnabled(page, project.slug, true)
const first = await createWorkspace(page, root, []) const first = await createWorkspace(page, project.slug, [])
trackDirectory(first.directory) project.trackDirectory(first.directory)
await waitWorkspaceReady(page, first) await waitWorkspaceReady(page, first)
const second = await createWorkspace(page, root, [first.slug]) const second = await createWorkspace(page, project.slug, [first.slug])
trackDirectory(second.directory) project.trackDirectory(second.directory)
await waitWorkspaceReady(page, second) await waitWorkspaceReady(page, second)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory) await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory) await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory) await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
})
}) })
+95 -102
View File
@@ -19,10 +19,10 @@ import {
waitDir, waitDir,
waitSlug, waitSlug,
} from "../actions" } from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import { inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils" 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 const rootSlug = project.slug
await openSidebar(page) await openSidebar(page)
@@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug])) const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page) await openSidebar(page)
@@ -52,37 +53,37 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
return { rootSlug, slug: next.slug, directory: next.directory } 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 page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async ({ slug }) => {
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) 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.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.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 page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async ({ slug }) => {
await openSidebar(page) 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 expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click() 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) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page) await openSidebar(page)
@@ -102,12 +103,9 @@ test("can create a workspace", async ({ page, withProject }) => {
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() 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 }) await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) 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") await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try { try {
await withProject(async () => { await project.open({ extra: [nonGit] })
await page.goto(`/${nonGitSlug}/session`) await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") 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 openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
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)
})
} finally { } finally {
await cleanupTestProject(nonGit) 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 page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project) const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}` 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() const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible() await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first() 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 expect(input).toBeVisible()
await input.fill(rename) await input.fill(rename)
await input.press("Enter") await input.press("Enter")
await expect(item).toContainText(rename) 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 page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md") const readme = path.join(createdDir, "README.md")
@@ -196,7 +184,7 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
await expect await expect
.poll(async () => { .poll(async () => {
const files = await sdk.file const files = await project.sdk.file
.status({ directory: createdDir }) .status({ directory: createdDir })
.then((r) => r.data ?? []) .then((r) => r.data ?? [])
.catch(() => []) .catch(() => [])
@@ -211,17 +199,17 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
await expect await expect
.poll( .poll(
async () => { async () => {
const files = await sdk.file const files = await project.sdk.file
.status({ directory: createdDir }) .status({ directory: createdDir })
.then((r) => r.data ?? []) .then((r) => r.data ?? [])
.catch(() => []) .catch(() => [])
return files.length return files.length
}, },
{ timeout: 60_000 }, { timeout: 120_000 },
) )
.toBe(0) .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 await expect
.poll(async () => { .poll(async () => {
@@ -232,59 +220,11 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
}) })
.toBe(false) .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 page.setViewportSize({ width: 1400, height: 800 })
await project.open()
await withProject(async (project) => { const rootSlug = project.slug
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 }[]
const listSlugs = async () => { const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') 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() await page.mouse.up()
} }
try {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
const workspaces = [] as { directory: string; slug: string }[]
for (const _ of [0, 1]) { for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url()) const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
await waitDir(page, next.directory) await waitDir(page, next.directory)
project.trackDirectory(next.directory)
workspaces.push(next) workspaces.push(next)
await openSidebar(page) await openSidebar(page)
@@ -368,8 +309,60 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
await drag(from, to) await drag(from, to)
await expect.poll(async () => await list()).toEqual([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> } type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) { export function bodyText(hit: Hit) {
return JSON.stringify(hit.body) 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. * Match requests whose body contains the exact serialized tool input.
* The seed prompts embed JSON.stringify(input) in the prompt text, which * 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) const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
return (hit: Hit) => bodyText(hit).includes(escaped) 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 { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { assistantText, sessionIDFromUrl, withSession } from "../actions" import { assistantText, withSession } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds // Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over // the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection // Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) 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()}` const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title") await project.open()
await llm.textMatch(promptMatch(token), token) await assistant.reply(token)
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
await withBackendProject(
async (project) => {
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) 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 }) => { 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 { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText, sessionIDFromUrl } from "../actions" import { assistantText } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk> type Sdk = ReturnType<typeof createSdk>
@@ -43,48 +42,27 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
.toContain(token) .toContain(token)
} }
test("prompt history restores unsent draft with arrow navigation", async ({ test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_HISTORY_ONE_${Date.now()}` const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}` const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}` const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}` const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}` const draft = `draft ${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title") await project.open()
await llm.textMatch(promptMatch(firstToken), firstToken) await assistant.reply(firstToken)
await llm.textMatch(promptMatch(secondToken), secondToken) const sessionID = await project.prompt(first)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "") await 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 reply(project.sdk, sessionID, firstToken)
await prompt.click() await assistant.reply(secondToken)
await page.keyboard.type(second) await project.prompt(second)
await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(project.sdk, sessionID, secondToken) await reply(project.sdk, sessionID, secondToken)
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type(draft) await page.keyboard.type(draft)
await wait(page, 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 page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, "")
},
{
model: openaiModel,
},
)
},
})
}) })
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { 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 type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions" import { withSession } from "../actions"
import { promptSelector } from "../selectors"
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part 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) test.setTimeout(120_000)
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => { await project.open()
const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "command ls" const cmd = process.platform === "win32" ? "dir" : "command ls"
await gotoSession() await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
await prompt.click() project.trackSession(session.id)
await page.keyboard.type("!") await project.gotoSession(session.id)
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) await setAutoAccept(page, true)
await project.shell(cmd)
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 expect await expect
.poll( .poll(
async () => { 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( 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 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 typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return if (!output.includes("README.md")) return
return { cwd: directory, output } return { cwd: project.directory, output }
}, },
{ timeout: 90_000 }, { timeout: 90_000 },
) )
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") })) .toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
await expect(prompt).toHaveText("")
}) })
}) })
@@ -22,10 +22,10 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0) .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).") 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) => { await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
@@ -64,4 +64,3 @@ test("/share and /unshare update session share state", async ({ page, withBacken
.toBeUndefined() .toBeUndefined()
}) })
}) })
})
+6 -36
View File
@@ -1,9 +1,7 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { assistantText } from "../actions"
import { assistantText, sessionIDFromUrl } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
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) test.setTimeout(120_000)
const pageErrors: string[] = [] 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) page.on("pageerror", onPageError)
try { try {
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const token = `E2E_OK_${Date.now()}` const token = `E2E_OK_${Date.now()}`
await project.open()
await assistant.reply(token)
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
await llm.textMatch(titleMatch, "E2E Title") await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
await llm.textMatch(promptMatch(token), token)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token) await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
} finally { } finally {
page.off("pageerror", onPageError) 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 sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' 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 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 modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]' 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 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) => export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]` `[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 popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]' export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]' export const inlineInputSelector = '[data-component="inline-input"]'
@@ -3,7 +3,7 @@ import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock" import { inputMatch } from "../prompt/mock"
import { promptSelector } from "../selectors" 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) test.setTimeout(120_000)
const errs: string[] = [] const errs: string[] = []
@@ -13,22 +13,22 @@ test("task tool child-session link does not trigger stale show errors", async ({
page.on("pageerror", onError) page.on("pageerror", onError)
try { try {
await withMockProject(async ({ gotoSession, trackSession, sdk }) => { await project.open()
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
const taskInput = { const taskInput = {
description: "Open child session", description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
subagent_type: "general", subagent_type: "general",
} }
await llm.toolMatch(inputMatch(taskInput), "task", taskInput) await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
const child = await seedSessionTask(sdk, { const child = await seedSessionTask(project.sdk, {
sessionID: session.id, sessionID: session.id,
description: taskInput.description, description: taskInput.description,
prompt: taskInput.prompt, prompt: taskInput.prompt,
}) })
trackSession(child.sessionID) project.trackSession(child.sessionID)
await gotoSession(session.id) await project.gotoSession(session.id)
const link = page const link = page
.locator("a.subagent-link") .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(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
}) })
})
} finally { } finally {
page.off("pageerror", onError) 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 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) if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({ await route.fulfill({
status: res.status(), response: res,
headers: res.headers(),
contentType: "application/json",
body: JSON.stringify(json), body: JSON.stringify(json),
}) })
} }
@@ -269,8 +267,8 @@ async function withMockPermission<T>(
} }
} }
test("default dock shows prompt input", async ({ page, withBackendProject }) => { test("default dock shows prompt input", async ({ page, project }) => {
await withBackendProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock default", "e2e composer dock default",
@@ -288,11 +286,9 @@ test("default dock shows prompt input", async ({ page, withBackendProject }) =>
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => { test("auto-accept toggle works before first submit", async ({ page, project }) => {
await withBackendProject(async ({ gotoSession }) => { await project.open()
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first() const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible() 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, true)
await setAutoAccept(page, false) await setAutoAccept(page, false)
}) })
})
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => { test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
await withMockProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock question", "e2e composer dock question",
@@ -330,10 +325,9 @@ test("blocked question flow unblocks after submit", async ({ page, llm, withMock
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => { test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
await withMockProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock question keyboard", "e2e composer dock question keyboard",
@@ -365,10 +359,9 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, llm, wi
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => { test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
await withMockProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock question escape", "e2e composer dock question escape",
@@ -395,10 +388,9 @@ test("blocked question flow supports escape dismiss", async ({ page, llm, withMo
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => { test("blocked permission flow supports allow once", async ({ page, project }) => {
await withBackendProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock permission once", "e2e composer dock permission once",
@@ -429,10 +421,9 @@ test("blocked permission flow supports allow once", async ({ page, withBackendPr
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => { test("blocked permission flow supports reject", async ({ page, project }) => {
await withBackendProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock permission reject", "e2e composer dock permission reject",
@@ -462,10 +453,9 @@ test("blocked permission flow supports reject", async ({ page, withBackendProjec
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => { test("blocked permission flow supports allow always", async ({ page, project }) => {
await withBackendProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock permission always", "e2e composer dock permission always",
@@ -496,13 +486,8 @@ test("blocked permission flow supports allow always", async ({ page, withBackend
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
test("child session question request blocks parent dock and unblocks after submit", async ({ test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
page,
llm,
withMockProject,
}) => {
const questions = [ const questions = [
{ {
header: "Child input", 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( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock child question parent", "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 }, { trackSession: project.trackSession },
) )
}) })
})
test("child session permission request blocks parent dock and supports allow once", async ({ test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
page, await project.open()
withBackendProject,
}) => {
await withBackendProject(async (project) => {
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock child permission parent", "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 }, { trackSession: project.trackSession },
) )
}) })
})
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => { test("todo dock transitions and collapse behavior", async ({ page, project }) => {
await withBackendProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock todo", "e2e composer dock todo",
@@ -641,9 +621,8 @@ test("todo dock transitions and collapse behavior", async ({ page, withBackendPr
{ trackSession: project.trackSession }, { 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 = [ const questions = [
{ {
header: "Need input", 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" }], options: [{ label: "Continue", description: "Continue now" }],
}, },
] ]
await withMockProject(async (project) => { await project.open()
await withDockSession( await withDockSession(
project.sdk, project.sdk,
"e2e composer dock keyboard", "e2e composer dock keyboard",
@@ -675,4 +654,3 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
{ trackSession: project.trackSession }, { trackSession: project.trackSession },
) )
}) })
})
@@ -1,15 +1,6 @@
import type { Locator, Page } from "@playwright/test" import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
@@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
await waitSession(page, { directory, sessionID }) await waitSession(page, { directory, sessionID })
} }
async function submit(page: Page, value: string) { async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
const prompt = page.locator('[data-component="prompt-input"]') return project.prompt(value)
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 createWorkspace(page: Page, root: string, seen: string[]) { 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) 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 page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => { await project.open()
await gotoSession() await project.gotoSession()
const firstState = await chooseOtherModel(page) const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page) const firstKey = await currentModel(page)
const first = await submit(page, `session variant ${Date.now()}`) const first = await submit(project, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload() await page.reload()
await waitSession(page, { directory, sessionID: first }) await waitSession(page, { directory: project.directory, sessionID: first })
await waitFooter(page, firstState) await waitFooter(page, firstState)
await gotoSession() await project.gotoSession()
const fresh = await read(page) const fresh = await read(page)
expect(fresh.model).not.toBe(firstState.model) expect(fresh.model).not.toBe(firstState.model)
const secondState = await chooseOtherModel(page, [firstKey]) const secondState = await chooseOtherModel(page, [firstKey])
const second = await submit(page, `session model ${Date.now()}`) const second = await submit(project, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first) await goto(page, project.directory, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
await goto(page, directory, second) await goto(page, project.directory, second)
await waitFooter(page, secondState) await waitFooter(page, secondState)
await gotoSession() await project.gotoSession()
await page.reload()
await waitSession(page, { directory: project.directory })
await waitFooter(page, fresh) 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 page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => { await project.open()
await gotoSession() const root = project.directory
await project.gotoSession()
const firstState = await chooseOtherModel(page) const firstState = await chooseOtherModel(page)
const firstKey = await currentModel(page) const firstKey = await currentModel(page)
const first = await submit(page, `root session ${Date.now()}`) const first = await submit(project, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page) 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) const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir) project.trackDirectory(oneDir)
const secondState = await chooseOtherModel(page, [firstKey]) const secondState = await chooseOtherModel(page, [firstKey])
const secondKey = await currentModel(page) const secondKey = await currentModel(page)
const second = await submit(page, `workspace one ${Date.now()}`) const second = await submit(project, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug]) const two = await createWorkspace(page, project.slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug) const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir) project.trackDirectory(twoDir)
const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
const third = await submit(page, `workspace two ${Date.now()}`) const third = await submit(project, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first) await goto(page, root, first)
await waitFooter(page, firstState) await waitFooter(page, firstState)
@@ -383,15 +339,14 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
await goto(page, root, first) await goto(page, root, first)
await waitFooter(page, firstState) 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 page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => { await project.open()
await gotoSession() await project.gotoSession()
await ensureVariant(page, directory) await ensureVariant(page, project.directory)
const updated = await chooseDifferentVariant(page) const updated = await chooseDifferentVariant(page)
const available = await agents(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 choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant }) await waitFooter(page, { agent: updated.agent, variant: updated.variant })
}) })
})
+22 -17
View File
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions" import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock" import { bodyText } from "../prompt/mock"
const count = 14 const count = 14
@@ -47,8 +47,12 @@ async function patchWithMock(
patchText: string, patchText: string,
) { ) {
const callsBefore = await llm.calls() const callsBefore = await llm.calls()
await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText }) await llm.toolMatch(
await sdk.session.promptAsync({ (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
"apply_patch",
{ patchText },
)
await sdk.session.prompt({
sessionID, sessionID,
agent: "build", agent: "build",
system: [ system: [
@@ -61,12 +65,16 @@ async function patchWithMock(
parts: [{ type: "text", text: "Apply the provided patch exactly once." }], 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 expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
await expect
await waitSessionIdle(sdk, sessionID, 120_000) .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"]) { 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) test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}` 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 page.setViewportSize({ width: 1280, height: 900 })
await withMockProject(async (project) => { await project.open()
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) 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) .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) test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}` 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 page.setViewportSize({ width: 1280, height: 900 })
await withMockProject(async (project) => { await project.open()
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) 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) .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) test.setTimeout(180_000)
const tag = `review-${Date.now()}` 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 page.setViewportSize({ width: 1600, height: 1000 })
await withMockProject(async (project) => { await project.open()
await withSession(project.sdk, `e2e review ${tag}`, async (session) => { await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed(list)) 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) .toBeLessThanOrEqual(32)
}) })
}) })
})
@@ -49,12 +49,12 @@ async function seedConversation(input: {
return { prompt, userMessageID } 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) test.setTimeout(120_000)
const token = `undo_${Date.now()}` const token = `undo_${Date.now()}`
await withBackendProject(async (project) => { await project.open()
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { 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) 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) test.setTimeout(120_000)
const token = `redo_${Date.now()}` const token = `redo_${Date.now()}`
await withBackendProject(async (project) => { await project.open()
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { 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) 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) test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}` const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}`
await withBackendProject(async (project) => { await project.open()
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { 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) await expect(secondMessage).toHaveCount(1)
}) })
}) })
})
+9 -13
View File
@@ -31,12 +31,12 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0) .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 stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}` const renamedTitle = `e2e renamed ${stamp}`
await withBackendProject(async (project) => { await project.open()
await withSession(project.sdk, originalTitle, async (session) => { await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await seedMessage(project.sdk, 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) 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 stamp = Date.now()
const title = `e2e archive test ${stamp}` const title = `e2e archive test ${stamp}`
await withBackendProject(async (project) => { await project.open()
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await seedMessage(project.sdk, 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) 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 stamp = Date.now()
const title = `e2e delete test ${stamp}` const title = `e2e delete test ${stamp}`
await withBackendProject(async (project) => { await project.open()
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await seedMessage(project.sdk, 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) 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).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now() const stamp = Date.now()
const title = `e2e share test ${stamp}` const title = `e2e share test ${stamp}`
await withBackendProject(async (project) => { await project.open()
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id) project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await project.prompt(`share seed ${stamp}`)
const shared = await openSharePopover(page) const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() 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") return document.documentElement.getAttribute("data-theme")
}) })
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
const trigger = select.locator('[data-slot="select-select-trigger"]')
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]') 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() const count = await items.count()
expect(count).toBeGreaterThan(1) 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 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const slug = dirSlug(other) const slug = dirSlug(other)
try { try {
await withProject( await project.open({ extra: [other] })
async () => {
await openSidebar(page) 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"]') const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible() await expect(projectButton).toBeVisible()
await project.hover() await projectButton.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible() await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down() await projectButton.click()
await expect(card).toHaveCount(0) await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
await expect(card).toHaveCount(0) await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) 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 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const slug = dirSlug(other) const slug = dirSlug(other)
try { try {
await withProject( await project.open({ extra: [other] })
async () => {
await openSidebar(page) await openSidebar(page)
await defocus(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 let hit = false
for (let i = 0; i < 20; i++) { 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") return el.matches(":focus") || !!el.parentElement?.matches(":focus")
}) })
if (hit) break 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 page.keyboard.press("Enter")
await waitSession(page, { directory: other }) await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -12,12 +12,12 @@ async function open(page: Page) {
return term return term
} }
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => { test("terminal reconnects without replacing the pty", async ({ page, project }) => {
await withProject(async ({ gotoSession }) => { await project.open()
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}` const token = `E2E_RECONNECT_${Date.now()}`
await gotoSession() await project.gotoSession()
const term = await open(page) const term = await open(page)
const id = await term.getAttribute("data-pty-id") 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, timeout: 15_000,
}) })
}) })
})
+12 -15
View File
@@ -36,16 +36,16 @@ async function store(page: Page, key: string) {
}, key) }, key)
} }
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => { test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
await withProject(async ({ directory, gotoSession }) => { await project.open()
const key = workspacePersistKey(directory, "terminal") const key = workspacePersistKey(project.directory, "terminal")
const one = `E2E_TERM_ONE_${Date.now()}` const one = `E2E_TERM_ONE_${Date.now()}`
const two = `E2E_TERM_TWO_${Date.now()}` const two = `E2E_TERM_TWO_${Date.now()}`
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const first = tabs.filter({ hasText: /Terminal 1/ }).first() const first = tabs.filter({ hasText: /Terminal 1/ }).first()
const second = tabs.filter({ hasText: /Terminal 2/ }).first() const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await gotoSession() await project.gotoSession()
await open(page) await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one }) 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 }) .toEqual({ first: true, second: false })
}) })
})
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => { test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
await withProject(async ({ directory, gotoSession }) => { await project.open()
const key = workspacePersistKey(directory, "terminal") const key = workspacePersistKey(project.directory, "terminal")
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
await gotoSession() await project.gotoSession()
await open(page) await open(page)
await page.getByRole("button", { name: /new terminal/i }).click() 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 }) .toEqual({ count: 1, first: true })
}) })
})
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => { test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
await withProject(async ({ directory, gotoSession }) => { await project.open()
const key = workspacePersistKey(directory, "terminal") const key = workspacePersistKey(project.directory, "terminal")
const rename = `E2E term ${Date.now()}` const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession() await project.gotoSession()
await open(page) await open(page)
await expect(tab).toContainText(/Terminal 1/) 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) .toBe(rename)
}) })
})
@@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { promptProbe } from "@/testing/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
@@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode) input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation() input.resetHistoryNavigation()
promptProbe.start()
const projectDirectory = sdk.directory const projectDirectory = sdk.directory
const isNewSession = !params.id const isNewSession = !params.id
@@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
input.onSubmit?.() input.onSubmit?.()
if (mode === "shell") { if (mode === "shell") {
+27
View File
@@ -10,6 +10,13 @@ export type PromptProbeState = {
selects: number selects: number
} }
export type PromptSendState = {
started: number
count: number
sessionID?: string
directory?: string
}
export const promptEnabled = () => { export const promptEnabled = () => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
@@ -53,4 +60,24 @@ export const promptProbe = {
if (!state) return if (!state) return
state.current = undefined 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?: { prompt?: {
enabled?: boolean enabled?: boolean
current?: import("./prompt").PromptProbeState current?: import("./prompt").PromptProbeState
sent?: import("./prompt").PromptSendState
} }
terminal?: { terminal?: {
enabled?: boolean 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 = { type BundledSDK = {
languageModel(modelId: string): LanguageModelV3 languageModel(modelId: string): LanguageModelV3
} }
@@ -1450,6 +1456,17 @@ export namespace Provider {
if (s.models.has(key)) return s.models.get(key)! if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => { 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 provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s) 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 { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
export namespace ToolRegistry { export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" }) const log = Log.create({ service: "tool.registry" })
@@ -166,7 +167,8 @@ export namespace ToolRegistry {
} }
const usePatch = 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 === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch if (tool.id === "edit" || tool.id === "write") return !usePatch
@@ -159,7 +159,17 @@ describe("cross-spawn spawner", () => {
fx.effect( fx.effect(
"captures both stdout and stderr", "captures both stdout and stderr",
Effect.gen(function* () { 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)]) const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
expect(stdout).toBe("stdout") expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr") 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) { function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
return { return {
type: "response.output_item.done", type: "response.output_item.done",
@@ -390,6 +400,8 @@ function responses(item: Sse, model: string) {
lines.push(responseReasonDone(reason, seq)) lines.push(responseReasonDone(reason, seq))
} }
if (call && !item.hang && !item.error) { if (call && !item.hang && !item.error) {
seq += 1
lines.push(responseToolArgsDone(call.item, call.args, seq))
seq += 1 seq += 1
lines.push(responseToolDone(call, seq)) lines.push(responseToolDone(call, seq))
} }
@@ -599,6 +611,11 @@ function isToolResultFollowUp(body: unknown): boolean {
return false 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 { function requestSummary(body: unknown): string {
if (!body || typeof body !== "object") return "empty body" if (!body || typeof body !== "object") return "empty body"
if ("messages" in body && Array.isArray(body.messages)) { if ("messages" in body && Array.isArray(body.messages)) {
@@ -623,6 +640,7 @@ namespace TestLLMServer {
readonly error: (status: number, body: unknown) => Effect.Effect<void> readonly error: (status: number, body: unknown) => Effect.Effect<void>
readonly hang: Effect.Effect<void> readonly hang: Effect.Effect<void>
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void> readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
readonly reset: Effect.Effect<void>
readonly hits: Effect.Effect<Hit[]> readonly hits: Effect.Effect<Hit[]>
readonly calls: Effect.Effect<number> readonly calls: Effect.Effect<number>
readonly wait: (count: number) => Effect.Effect<void> 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 req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({}))) const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
const current = hit(req.originalUrl, body) 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) const next = pull(current)
if (!next) { 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] hits = [...hits, current]
yield* notify() yield* notify()
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] } const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
if (mode === "responses") return send(responses(auto, modelFrom(body))) if (mode === "responses") return send(responses(auto, modelFrom(body)))
return send(auto) 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] hits = [...hits, current]
yield* notify() yield* notify()
if (next.type !== "sse") return fail(next) 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>) { hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
queue(reply().wait(wait).text(value).stop().item()) queue(reply().wait(wait).text(value).stop().item())
}), }),
reset: Effect.sync(() => {
hits = []
list = []
waits = []
misses = []
}),
hits: Effect.sync(() => [...hits]), hits: Effect.sync(() => [...hits]),
calls: Effect.sync(() => hits.length), calls: Effect.sync(() => hits.length),
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) { 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" } } },
}),
},
),
)