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

This commit is contained in:
Kit Langton 2026-04-02 14:17:28 -04:00 committed by GitHub
commit c3ef69c866
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2467 additions and 2138 deletions

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 }})

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

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 = () =>
.getByRole("button", { name: "New workspace" }) page
.first() .getByRole("button", { name: "New workspace" })
.isVisible() .first()
.then((x) => x) .isVisible()
.catch(() => false) .then((x) => x)
.catch(() => false)
if (current === enabled) return if ((await current()) === enabled) return
if (enabled) {
await page.reload()
await openSidebar(page)
if ((await current()) === enabled) return
}
const flip = async (timeout?: number) => { const 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 })
.then(() => true)
.catch(() => false)
if (clicked) return
await toggle.focus()
await page.keyboard.press("Enter")
} }
const flipped = await flip(1500) for (const timeout of [1500, undefined, undefined]) {
.then(() => true) if ((await current()) === enabled) break
.catch(() => false) 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 (!flipped) await flip() 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) {

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[] = []

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,9 +157,42 @@ type WorkerFixtures = {
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
_llm: [
async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
await use({
url: svc.url,
push: (...input) => rt.runPromise(svc.push(...input)),
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
fail: (message) => rt.runPromise(svc.fail(message)),
error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
reset: () => rt.runPromise(svc.reset),
hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
misses: () => rt.runPromise(svc.misses),
})
} finally {
await rt.dispose()
}
},
{ scope: "worker" },
],
backend: [ backend: [
async ({}, use, workerInfo) => { async ({ _llm }, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`) const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
try { try {
await use({ await use({
url: handle.url, url: handle.url,
@ -112,35 +204,48 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}, },
{ scope: "worker" }, { scope: "worker" },
], ],
llm: async ({}, use) => { llm: async ({ _llm }, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer) await _llm.reset()
try { await use({
const svc = await rt.runPromise(TestLLMServer.asEffect()) url: _llm.url,
await use({ push: _llm.push,
url: svc.url, pushMatch: _llm.pushMatch,
push: (...input) => rt.runPromise(svc.push(...input)), textMatch: _llm.textMatch,
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)), toolMatch: _llm.toolMatch,
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)), text: _llm.text,
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)), tool: _llm.tool,
text: (value, opts) => rt.runPromise(svc.text(value, opts)), toolHang: _llm.toolHang,
tool: (name, input) => rt.runPromise(svc.tool(name, input)), reason: _llm.reason,
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)), fail: _llm.fail,
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)), error: _llm.error,
fail: (message) => rt.runPromise(svc.fail(message)), hang: _llm.hang,
error: (status, body) => rt.runPromise(svc.error(status, body)), hold: _llm.hold,
hang: () => rt.runPromise(svc.hang), hits: _llm.hits,
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)), calls: _llm.calls,
hits: () => rt.runPromise(svc.hits), wait: _llm.wait,
calls: () => rt.runPromise(svc.calls), inputs: _llm.inputs,
wait: (count) => rt.runPromise(svc.wait(count)), pending: _llm.pending,
inputs: () => rt.runPromise(svc.inputs), misses: _llm.misses,
pending: () => rt.runPromise(svc.pending), })
misses: () => rt.runPromise(svc.misses), const pending = await _llm.pending()
}) if (pending > 0) {
} finally { throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
await rt.dispose()
} }
}, },
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)
const trackDirectory = (directory: string) => { await options?.setup?.(directory)
if (directory !== root) dirs.add(directory) await seedStorage(page, {
} directory,
extra: options?.extra,
try { model: options?.model,
await options?.beforeGoto?.({ directory: root, sdk }) serverUrl: backend.url,
})
state = {
directory,
slug: "",
sdk,
sessions: new Map(),
dirs: new Set(),
}
await options?.beforeGoto?.({ directory, 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(
const win = window as E2EWindow (args: {
win.__opencode_e2e = { directory: string
...win.__opencode_e2e, serverUrl: string
model: { extra: string[]
enabled: true, model: { providerID: string; modelID: string }
}, }) => {
prompt: { const key = "opencode.global.dat:server"
enabled: true, const raw = localStorage.getItem(key)
}, const parsed = (() => {
terminal: { if (!raw) return undefined
enabled: true, try {
terminals: {}, return JSON.parse(raw) as unknown
}, } catch {
} return undefined
localStorage.setItem( }
"opencode.global.dat:model", })()
JSON.stringify({
recent: [model], const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
user: [], const list = Array.isArray(store.list) ? store.list : []
variant: {}, 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>) }
}, input.model ?? seedModel) const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
const add = (origin: string, directory: string) => {
const current = next[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
for (const directory of [args.directory, ...args.extra]) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
model: { enabled: true },
prompt: { enabled: true },
terminal: { enabled: true, terminals: {} },
}
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
},
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
)
} }
export { expect } export { expect }

View file

@ -1,43 +1,49 @@
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")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog return dialog
} }
const name = `e2e project ${Date.now()}` const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}` const startup = `echo e2e_${Date.now()}`
const dialog = await open() const dialog = await open()
const nameInput = dialog.getByLabel("Name") const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name) await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script") const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup) await startupInput.fill(startup)
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 () => {
const reopened = await open() await page.reload()
await expect(reopened.getByLabel("Name")).toHaveValue(name) await openSidebar(page)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) const reopened = await open()
await reopened.getByRole("button", { name: "Cancel" }).click() const value = await reopened.getByLabel("Name").inputValue()
await expect(reopened).toHaveCount(0) const next = await reopened.getByLabel("Workspace startup script").inputValue()
}) await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
return `${value}\n${next}`
},
{ timeout: 30_000 },
)
.toBe(`${name}\n${startup}`)
}) })

View file

@ -3,51 +3,46 @@ 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()
await expect(otherButton).toBeVisible() await expect(otherButton).toBeVisible()
await otherButton.click() await otherButton.click()
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
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
.poll( await expect
() => { .poll(
const pathname = new URL(page.url()).pathname async () => {
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" return await page.locator(projectSwitchSelector(otherSlug)).count()
if (pathname === "/") return "home" },
return "" { timeout: 15_000 },
}, )
{ timeout: 15_000 }, .toBe(0)
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }

View file

@ -5,114 +5,89 @@ 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()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible() await expect(currentButton).toBeVisible()
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, project.slug, true)
await setWorkspacesEnabled(page, 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()
await expect(item).toBeVisible() await expect(item).toBeVisible()
await item.hover() await item.hover()
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
await expect(btn).toBeVisible() await expect(btn).toBeVisible()
await btn.click({ force: true }) await btn.click({ force: true })
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(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url()) await openSidebar(page)
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}(?:[/?#]|$)`)) const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
await openSidebar(page) const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click({ force: true })
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() await waitSession(page, { directory: space, sessionID: created })
await expect(otherButton).toBeVisible() await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click({ force: true })
await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }

View file

@ -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()}`)
})
}) })

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,44 +53,192 @@ 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 project.open()
await openSidebar(page)
await setWorkspacesEnabled(page, project.slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [project.slug]))
await waitDir(page, next.directory)
project.trackDirectory(next.directory)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
})
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 })
await withProject(async ({ slug }) => { const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
await openSidebar(page) const nonGitSlug = dirSlug(nonGit)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
await page.getByRole("button", { name: "New workspace" }).first().click() try {
const next = await resolveSlug(await waitSlug(page, [slug])) await project.open({ extra: [nonGit] })
await waitDir(page, next.directory) await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
const shown = await input
.isVisible()
.then((x) => x)
.catch(() => false)
if (!shown) {
const retry = await openWorkspaceMenu(page, slug)
await clickMenuItem(retry, /^Rename$/i, { force: true })
await expect(retry).toHaveCount(0)
}
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
test("can reset a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await project.sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await project.sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 120_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await project.open()
const rootSlug = project.slug
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect await expect
.poll( .poll(
async () => { async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first() const item = page.locator(workspaceItemSelector(slug)).first()
try { try {
await item.hover({ timeout: 500 }) await item.hover({ timeout: 500 })
return true return true
@ -100,276 +249,120 @@ test("can create a workspace", async ({ page, withProject }) => {
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(true) .toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await cleanupTestProject(next.directory)
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
} }
})
test("can rename a workspace", async ({ page, withProject }) => { const drag = async (from: string, to: string) => {
await page.setViewportSize({ width: 1400, height: 800 }) const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await withProject(async (project) => { const a = await src.boundingBox()
const { slug } = await setupWorkspaceTest(page, project) const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
const rename = `e2e workspace ${Date.now()}` await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
const menu = await openWorkspaceMenu(page, slug) await page.mouse.down()
await clickMenuItem(menu, /^Rename$/i, { force: true }) await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
await expect(menu).toHaveCount(0) await openSidebar(page)
const item = page.locator(workspaceItemSelector(slug)).first() await setWorkspacesEnabled(page, rootSlug, true)
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
})
test("can reset a workspace", async ({ page, sdk, withProject }) => { const workspaces = [] as { directory: string; slug: string }[]
await page.setViewportSize({ width: 1400, height: 800 }) for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await withProject(async (project) => { await page.getByRole("button", { name: "New workspace" }).first().click()
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
await waitDir(page, next.directory)
const readme = path.join(createdDir, "README.md") project.trackDirectory(next.directory)
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) workspaces.push(next)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
})
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page) await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) }
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
}) if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
}) })
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { test("can delete a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => { await project.open()
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => { const rootSlug = project.slug
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') await openSidebar(page)
const slugs = await nodes.evaluateAll((els) => { await setWorkspacesEnabled(page, rootSlug, true)
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => { const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
await expect if (!created?.directory) throw new Error("Failed to create workspace for delete test")
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => { const directory = created.directory
const src = page.locator(workspaceItemSelector(from)).first() const slug = dirSlug(directory)
const dst = page.locator(workspaceItemSelector(to)).first() project.trackDirectory(directory)
const a = await src.boundingBox() await page.reload()
const b = await dst.boundingBox() await openSidebar(page)
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) await expect
await page.mouse.down() .poll(
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) async () => {
await page.mouse.up() const worktrees = await project.sdk.worktree
} .list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
try { const menu = await openWorkspaceMenu(page, slug)
await openSidebar(page) await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await setWorkspacesEnabled(page, rootSlug, true) await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
for (const _ of [0, 1]) { await expect
const prev = slugFromUrl(page.url()) .poll(
await page.getByRole("button", { name: "New workspace" }).first().click() async () => {
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) const worktrees = await project.sdk.worktree
await waitDir(page, next.directory) .list()
workspaces.push(next) .then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await openSidebar(page) await openSidebar(page)
} await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
}) })

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 })
}
}

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({ const token = `E2E_ASYNC_${Date.now()}`
serverUrl: backend.url, await project.open()
llmUrl: llm.url, await assistant.reply(token)
fn: async () => { const sessionID = await project.prompt(`Reply with exactly: ${token}`)
const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
await withBackendProject( await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
async (project) => { await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
}) })
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {

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,73 +42,45 @@ 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({ const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
serverUrl: backend.url, const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
llmUrl: llm.url, const first = `Reply with exactly: ${firstToken}`
fn: async () => { const second = `Reply with exactly: ${secondToken}`
const firstToken = `E2E_HISTORY_ONE_${Date.now()}` const draft = `draft ${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title") await 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 wait(page, "")
await reply(project.sdk, sessionID, firstToken)
await withBackendProject( await assistant.reply(secondToken)
async (project) => { await project.prompt(second)
const prompt = page.locator(promptSelector) await wait(page, "")
await reply(project.sdk, sessionID, secondToken)
await prompt.click() const prompt = page.locator(promptSelector)
await page.keyboard.type(first) await prompt.click()
await page.keyboard.press("Enter") await page.keyboard.type(draft)
await wait(page, "") await wait(page, draft)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await prompt.fill("")
const sessionID = sessionIDFromUrl(page.url())! await wait(page, "")
project.trackSession(sessionID)
await reply(project.sdk, sessionID, firstToken)
await prompt.click() await page.keyboard.press("ArrowUp")
await page.keyboard.type(second) await wait(page, second)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, secondToken)
await prompt.click() await page.keyboard.press("ArrowUp")
await page.keyboard.type(draft) await wait(page, first)
await wait(page, draft)
await prompt.fill("") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, second)
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowDown")
await wait(page, second) await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
},
{
model: openaiModel,
},
)
},
})
}) })
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 }) => {

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("")
}) })
}) })

View file

@ -22,46 +22,45 @@ 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)
await seed(project.sdk, session.id) await seed(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await prompt.click() await prompt.click()
await page.keyboard.type("/share") await page.keyboard.type("/share")
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await prompt.click() await prompt.click()
await page.keyboard.type("/unshare") await page.keyboard.type("/unshare")
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
})
}) })
}) })

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({ const token = `E2E_OK_${Date.now()}`
serverUrl: backend.url, await project.open()
llmUrl: llm.url, await assistant.reply(token)
fn: async () => { const sessionID = await project.prompt(`Reply with exactly: ${token}`)
const token = `E2E_OK_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title") await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
await llm.textMatch(promptMatch(token), token) await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
project.trackSession(sessionID)
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
},
{
model: openaiModel,
},
)
},
})
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
} }

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"]'

View file

@ -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,34 +13,33 @@ 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)
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
}) })
project.trackSession(child.sessionID)
await project.gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
}) })
} finally { } finally {
page.off("pageerror", onError) page.off("pageerror", onError)

View file

@ -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,240 +267,227 @@ 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",
async (session) => { async (session) => {
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
},
{ trackSession: project.trackSession },
)
})
test("auto-accept toggle works before first submit", async ({ page, project }) => {
await project.open()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await expect(page.locator(promptSelector)).toBeVisible() await seedSessionQuestion(project.sdk, {
await expect(page.locator(questionDockSelector)).toHaveCount(0) sessionID: session.id,
await expect(page.locator(permissionDockSelector)).toHaveCount(0) questions: defaultQuestions,
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
},
{ trackSession: project.trackSession },
)
})
})
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
await withBackendProject(async ({ gotoSession }) => {
await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first()
await expect(button).toBeVisible()
await expect(button).toHaveAttribute("aria-pressed", "false")
await setAutoAccept(page, true)
await setAutoAccept(page, false)
})
})
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
}) })
},
{ trackSession: project.trackSession }, const dock = page.locator(questionDockSelector)
) await expectQuestionBlocked(page)
})
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
},
{ 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",
async (session) => { async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: defaultQuestions, questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
}) })
},
{ trackSession: project.trackSession }, const dock = page.locator(questionDockSelector)
) const first = dock.locator('[data-slot="question-option"]').first()
}) const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
})
},
{ 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",
async (session) => { async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: defaultQuestions, questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
}) })
},
{ trackSession: project.trackSession }, const dock = page.locator(questionDockSelector)
) const first = dock.locator('[data-slot="question-option"]').first()
})
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
},
{ 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",
async (session) => { async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
{ {
id: "per_e2e_once", id: "per_e2e_once",
sessionID: session.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"], patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" }, metadata: { description: "Need permission for command" },
}, },
undefined, undefined,
async (state) => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i) await clearPermissionDock(page, /allow once/i)
await state.resolved() await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, },
{ 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",
async (session) => { async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
{ {
id: "per_e2e_reject", id: "per_e2e_reject",
sessionID: session.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"], patterns: ["/tmp/opencode-e2e-perm-reject"],
}, },
undefined, undefined,
async (state) => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i) await clearPermissionDock(page, /deny/i)
await state.resolved() await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, },
{ 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",
async (session) => { async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
{ {
id: "per_e2e_always", id: "per_e2e_always",
sessionID: session.id, sessionID: session.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"], patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" }, metadata: { description: "Need permission for command" },
}, },
undefined, undefined,
async (state) => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i) await clearPermissionDock(page, /allow always/i)
await state.resolved() await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}, },
{ 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,137 +498,131 @@ 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",
async (session) => { async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
const child = await project.sdk.session const child = await project.sdk.session
.create({ .create({
title: "e2e composer dock child question", title: "e2e composer dock child question",
parentID: session.id, parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions,
}) })
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { const dock = page.locator(questionDockSelector)
await withDockSeed(project.sdk, child.id, async () => { await expectQuestionBlocked(page)
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions,
})
const dock = page.locator(questionDockSelector) await dock.locator('[data-slot="question-option"]').first().click()
await expectQuestionBlocked(page) await dock.getByRole("button", { name: /submit/i }).click()
await dock.locator('[data-slot="question-option"]').first().click() await expectQuestionOpen(page)
await dock.getByRole("button", { name: /submit/i }).click() })
} finally {
await expectQuestionOpen(page) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}) }
} finally { },
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) { 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 withDockSession(
}) => { project.sdk,
await withBackendProject(async (project) => { "e2e composer dock child permission parent",
await withDockSession( async (session) => {
project.sdk, await project.gotoSession(session.id)
"e2e composer dock child permission parent", await setAutoAccept(page, false)
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
const child = await project.sdk.session const child = await project.sdk.session
.create({ .create({
title: "e2e composer dock child permission", title: "e2e composer dock child permission",
parentID: session.id, parentID: session.id,
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id) project.trackSession(child.id)
try { try {
await withMockPermission( await withMockPermission(
page, page,
{ {
id: "per_e2e_child", id: "per_e2e_child",
sessionID: child.id, sessionID: child.id,
permission: "bash", permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"], patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" }, metadata: { description: "Need child permission" },
}, },
{ child }, { child },
async (state) => { async (state) => {
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionBlocked(page) await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i) await clearPermissionDock(page, /allow once/i)
await state.resolved() await state.resolved()
await page.goto(page.url()) await page.goto(page.url())
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}, },
{ 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",
async (session) => { async (session) => {
const dock = await todoDock(page, session.id) const dock = await todoDock(page, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try { try {
await dock.open([ await dock.open([
{ content: "first task", status: "pending", priority: "high" }, { content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" }, { content: "second task", status: "in_progress", priority: "medium" },
]) ])
await dock.expectOpen(["pending", "in_progress"]) await dock.expectOpen(["pending", "in_progress"])
await dock.collapse() await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"]) await dock.expectCollapsed(["pending", "in_progress"])
await dock.expand() await dock.expand()
await dock.expectOpen(["pending", "in_progress"]) await dock.expectOpen(["pending", "in_progress"])
await dock.finish([ await dock.finish([
{ content: "first task", status: "completed", priority: "high" }, { content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" }, { content: "second task", status: "cancelled", priority: "medium" },
]) ])
await dock.expectClosed() await dock.expectClosed()
} finally { } finally {
await dock.clear() await dock.clear()
} }
}, },
{ 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,28 +630,27 @@ 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",
async (session) => { async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions }), "question", { questions }) await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions, questions,
})
await expectQuestionBlocked(page)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
},
{ trackSession: project.trackSession }, await expectQuestionBlocked(page)
)
}) await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
},
{ trackSession: project.trackSession },
)
}) })

View file

@ -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,108 +265,98 @@ 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 waitFooter(page, fresh) await page.reload()
}) await waitSession(page, { directory: project.directory })
await waitFooter(page, fresh)
}) })
test("session model restore across workspaces", async ({ page, withProject }) => { test("session model restore across workspaces", async ({ page, project }) => {
await page.setViewportSize({ width: 1440, height: 900 }) await 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)
await goto(page, oneDir, second) await goto(page, oneDir, second)
await waitFooter(page, secondState) await waitFooter(page, secondState)
await goto(page, twoDir, third) await goto(page, twoDir, third)
await waitFooter(page, thirdState) await waitFooter(page, thirdState)
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)
const other = available.find((name) => name !== updated.agent) const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available") test.skip(!other, "only one agent available")
if (!other) return if (!other) return
await choose(page, promptAgentSelector, other) await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant }) await waitFooter(page, { agent: other, variant: updated.variant })
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 })
})
}) })

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,46 +262,45 @@ 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 }]))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(1) .toBe(1)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await show(page) await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first() const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
await expand(page) await expand(page)
await waitMark(page, file, tag) await waitMark(page, file, tag)
await comment(page, file, note) await comment(page, file, note)
await expect await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1) .toBeLessThanOrEqual(1)
await expect await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1) .toBeLessThanOrEqual(1)
await expect await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.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,47 +309,46 @@ 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 }]))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(1) .toBe(1)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await show(page) await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first() const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
await expand(page) await expand(page)
await waitMark(page, file, tag) await waitMark(page, file, tag)
await openReviewFile(page, file) await openReviewFile(page, file)
await fileComment(page, note) await fileComment(page, note)
await expect await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1) .toBeLessThanOrEqual(1)
await expect await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1) .toBeLessThanOrEqual(1)
await expect await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.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,84 +358,83 @@ 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))
await expect await expect
.poll( .poll(
async () => { async () => {
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0 return info?.summary?.files ?? 0
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(list.length) .toBe(list.length)
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBe(list.length) .toBe(list.length)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await show(page) await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first() const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible() await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, { timeout: 60_000 }) await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
await expand(page) await expand(page)
await waitMark(page, hit.file, hit.mark) await waitMark(page, hit.file, hit.mark)
const row = page const row = page
.getByRole("heading", { .getByRole("heading", {
level: 3, level: 3,
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
}) })
.first() .first()
await expect(row).toBeVisible() await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" })) await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200) await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file) const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`) if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file) const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : "" return typeof item?.after === "string" ? item.after : ""
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toContain(`mark ${next}`) .toContain(`mark ${next}`)
await waitMark(page, hit.file, next) await waitMark(page, hit.file, next)
await expect await expect
.poll( .poll(
async () => { async () => {
const next = await spot(page, hit.file) const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
) )
.toBeLessThanOrEqual(32) .toBeLessThanOrEqual(32)
})
}) })
}) })

View file

@ -49,188 +49,185 @@ 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) => {
project.trackSession(session.id) project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click() await seeded.prompt.click()
await page.keyboard.type("/undo") await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first() const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible() await expect(undo).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000, timeout: 30_000,
}) })
.toBe(seeded.userMessageID) .toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token) await expect(seeded.prompt).toContainText(token)
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) => {
project.trackSession(session.id) project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click() await seeded.prompt.click()
await page.keyboard.type("/undo") await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first() const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible() await expect(undo).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000, timeout: 30_000,
}) })
.toBe(seeded.userMessageID) .toBe(seeded.userMessageID)
await seeded.prompt.click() await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`) await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace") await page.keyboard.press("Backspace")
await page.keyboard.type("/redo") await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first() const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible() await expect(redo).toBeVisible()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000, timeout: 30_000,
}) })
.toBeUndefined() .toBeUndefined()
await expect(seeded.prompt).not.toContainText(token) await expect(seeded.prompt).not.toContainText(token)
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) => {
project.trackSession(session.id) project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const first = await seedConversation({ const first = await seedConversation({
page, page,
sdk, sdk,
sessionID: session.id, sessionID: session.id,
token: firstToken, token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
}) })
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage).toHaveCount(1)
await expect(secondMessage).toHaveCount(1)
}) })
}) })

View file

@ -31,156 +31,152 @@ 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)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await expect(input).toBeFocused()
await input.fill(renamedTitle) await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle) await expect(input).toHaveValue(renamedTitle)
await input.press("Enter") await input.press("Enter")
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title return data?.title
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBe(renamedTitle) .toBe(renamedTitle)
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)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i) await clickMenuItem(menu, /archive/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived return data?.time?.archived
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
await openSidebar(page) await openSidebar(page)
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)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i) await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i) await confirmDialog(page, /delete/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session const data = await project.sdk.session
.get({ sessionID: session.id }) .get({ sessionID: session.id })
.then((r) => r.data) .then((r) => r.data)
.catch(() => undefined) .catch(() => undefined)
return data?.id return data?.id
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
await openSidebar(page) await openSidebar(page)
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()
await expect(publish).toBeVisible({ timeout: 30_000 }) await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click() await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.not.toBeUndefined() .not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 }) await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click() await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
const unshared = await openSharePopover(page) const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
})
}) })
}) })
}) })

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)

View file

@ -48,70 +48,61 @@ 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
await page.keyboard.press("Tab") await page.keyboard.press("Tab")
} }
expect(hit).toBe(true) expect(hit).toBe(true)
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)
} }

View file

@ -12,35 +12,34 @@ 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")
if (!id) throw new Error("Active terminal missing data-pty-id") if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term }) const prev = await terminalConnects(page, { term })
await runTerminal(page, { await runTerminal(page, {
term, term,
cmd: `export ${name}=${token}; echo ${token}`, cmd: `export ${name}=${token}; echo ${token}`,
token, token,
}) })
await disconnectTerminal(page, { term }) await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev) await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id) await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, { await runTerminal(page, {
term, term,
cmd: `echo $${name}`, cmd: `echo $${name}`,
token, token,
timeout: 15_000, timeout: 15_000,
})
}) })
}) })

View file

@ -36,133 +36,130 @@ 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 })
await page.getByRole("button", { name: /new terminal/i }).click() await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two }) await runTerminal(page, { cmd: `echo ${two}`, token: two })
await first.click() await first.click()
await expect(first).toHaveAttribute("aria-selected", "true") await expect(first).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return { return {
first: first.includes(one), first: first.includes(one),
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 5_000 },
) )
.toEqual({ first: false, second: true }) .toEqual({ first: false, second: true })
await second.click() await second.click()
await expect(second).toHaveAttribute("aria-selected", "true") await expect(second).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
return { return {
first: first.includes(one), first: first.includes(one),
second: second.includes(two), second: second.includes(two),
} }
}, },
{ timeout: 5_000 }, { timeout: 5_000 },
) )
.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()
await expect(tabs).toHaveCount(2) await expect(tabs).toHaveCount(2)
const second = tabs.filter({ hasText: /Terminal 2/ }).first() const second = tabs.filter({ hasText: /Terminal 2/ }).first()
await second.click() await second.click()
await expect(second).toHaveAttribute("aria-selected", "true") await expect(second).toHaveAttribute("aria-selected", "true")
await second.hover() await second.hover()
await page await page
.getByRole("button", { name: /close terminal/i }) .getByRole("button", { name: /close terminal/i })
.nth(1) .nth(1)
.click({ force: true }) .click({ force: true })
const first = tabs.filter({ hasText: /Terminal 1/ }).first() const first = tabs.filter({ hasText: /Terminal 1/ }).first()
await expect(tabs).toHaveCount(1) await expect(tabs).toHaveCount(1)
await expect(first).toHaveAttribute("aria-selected", "true") await expect(first).toHaveAttribute("aria-selected", "true")
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
return { return {
count: state?.all.length ?? 0, count: state?.all.length ?? 0,
first: state?.all.some((item) => item.titleNumber === 1) ?? false, first: state?.all.some((item) => item.titleNumber === 1) ?? false,
} }
}, },
{ timeout: 15_000 }, { timeout: 15_000 },
) )
.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/)
await tab.click({ button: "right" }) await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first() const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible() await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click() await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0) await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first() const input = page.locator('#terminal-panel input[type="text"]').first()
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(input).toHaveCount(0) await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename) await expect(tab).toContainText(rename)
await expect await expect
.poll( .poll(
async () => { async () => {
const state = await store(page, key) const state = await store(page, key)
return state?.all[0]?.title return state?.all[0]?.title
}, },
{ timeout: 5_000 }, { timeout: 5_000 },
) )
.toBe(rename) .toBe(rename)
})
}) })

View file

@ -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") {

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,
}
},
} }

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

View file

@ -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)

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

View file

@ -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")

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,21 +689,20 @@ 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 hits = [...hits, current]
// queue one response per tool call instead of two. yield* notify()
if (isToolResultFollowUp(body)) { const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
hits = [...hits, current] if (mode === "responses") return send(responses(auto, modelFrom(body)))
yield* notify() return send(auto)
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
if (mode === "responses") return send(responses(auto, modelFrom(body)))
return send(auto)
}
misses = [...misses, current]
const summary = requestSummary(body)
console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`)
return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 })
} }
hits = [...hits, current] hits = [...hits, current]
yield* notify() yield* notify()
@ -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) {

View file

@ -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" } } },
}),
},
),
)