summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yml5
-rw-r--r--packages/app/e2e/AGENTS.md15
-rw-r--r--packages/app/e2e/actions.ts238
-rw-r--r--packages/app/e2e/backend.ts3
-rw-r--r--packages/app/e2e/fixtures.ts549
-rw-r--r--packages/app/e2e/projects/project-edit.spec.ts80
-rw-r--r--packages/app/e2e/projects/projects-close.spec.ts73
-rw-r--r--packages/app/e2e/projects/projects-switch.spec.ts139
-rw-r--r--packages/app/e2e/projects/workspace-new-session.spec.ts51
-rw-r--r--packages/app/e2e/projects/workspaces.spec.ts507
-rw-r--r--packages/app/e2e/prompt/mock.ts41
-rw-r--r--packages/app/e2e/prompt/prompt-async.spec.ts43
-rw-r--r--packages/app/e2e/prompt/prompt-history.spec.ts105
-rw-r--r--packages/app/e2e/prompt/prompt-shell.spec.ts49
-rw-r--r--packages/app/e2e/prompt/prompt-slash-share.spec.ts67
-rw-r--r--packages/app/e2e/prompt/prompt.spec.ts46
-rw-r--r--packages/app/e2e/selectors.ts11
-rw-r--r--packages/app/e2e/session/session-child-navigation.spec.ts51
-rw-r--r--packages/app/e2e/session/session-composer-dock.spec.ts670
-rw-r--r--packages/app/e2e/session/session-model-persistence.spec.ts178
-rw-r--r--packages/app/e2e/session/session-review.spec.ts331
-rw-r--r--packages/app/e2e/session/session-undo-redo.spec.ts285
-rw-r--r--packages/app/e2e/session/session.spec.ts248
-rw-r--r--packages/app/e2e/settings/settings.spec.ts16
-rw-r--r--packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts85
-rw-r--r--packages/app/e2e/terminal/terminal-reconnect.spec.ts45
-rw-r--r--packages/app/e2e/terminal/terminal-tabs.spec.ts247
-rw-r--r--packages/app/src/components/prompt-input/submit.ts3
-rw-r--r--packages/app/src/testing/prompt.ts27
-rw-r--r--packages/app/src/testing/terminal.ts1
-rw-r--r--packages/opencode/src/provider/provider.ts17
-rw-r--r--packages/opencode/src/tool/registry.ts4
-rw-r--r--packages/opencode/test/effect/cross-spawn-spawner.test.ts12
-rw-r--r--packages/opencode/test/lib/llm-server.ts49
-rw-r--r--packages/opencode/test/session/e2e-url-repro.test.ts314
35 files changed, 2467 insertions, 2138 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9c58be30a..03c0741b5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -47,6 +47,11 @@ jobs:
- name: Run unit tests
run: bun turbo test
+ env:
+ # Bun 1.3.11 intermittently crashes on Windows during test teardown
+ # inside the native @parcel/watcher binding. Unit CI does not rely on
+ # the live watcher backend there, so disable it for that platform.
+ OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
e2e:
name: e2e (${{ matrix.settings.name }})
diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md
index f263e49a0..bdd6ba185 100644
--- a/packages/app/e2e/AGENTS.md
+++ b/packages/app/e2e/AGENTS.md
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
### Using Fixtures
- `page` - Playwright page
-- `sdk` - OpenCode SDK client for API calls
-- `gotoSession(sessionID?)` - Navigate to session
+- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
+- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
+- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
+- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
### Helper Functions
@@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
-- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
-- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
-- `trackDirectory(directory)` - Register directory for fixture cleanup
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
})
```
-- Prefer `withSession(...)` for temp sessions
-- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
-- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
+- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
+- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
+- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index df8e0768e..fb9eb208b 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -1,5 +1,5 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
-import { expect, type Locator, type Page, type Route } from "@playwright/test"
+import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
- dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector,
@@ -43,27 +42,6 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
-export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
- const url = "**/session/*/prompt_async"
- const route = async (input: Route) => {
- const body = input.request().postDataJSON()
- await input.continue({
- postData: JSON.stringify({ ...body, noReply: true }),
- headers: {
- ...input.request().headers(),
- "content-type": "application/json",
- },
- })
- }
-
- await page.route(url, route)
- try {
- return await fn()
- } finally {
- await page.unroute(url, route)
- }
-}
-
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
@@ -333,63 +311,6 @@ export async function openSettings(page: Page) {
return dialog
}
-export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
- await page.addInitScript(
- (args: { directory: string; serverUrl: string; extra: string[] }) => {
- const key = "opencode.global.dat:server"
- const defaultKey = "opencode.settings.dat:defaultServerUrl"
- const raw = localStorage.getItem(key)
- const parsed = (() => {
- if (!raw) return undefined
- try {
- return JSON.parse(raw) as unknown
- } catch {
- return undefined
- }
- })()
-
- const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
- const list = Array.isArray(store.list) ? store.list : []
- const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
- const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
- const nextProjects = { ...(projects as Record<string, unknown>) }
- const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
-
- const add = (origin: string, directory: string) => {
- const current = nextProjects[origin]
- const items = Array.isArray(current) ? current : []
- const existing = items.filter(
- (p): p is { worktree: string; expanded?: boolean } =>
- !!p &&
- typeof p === "object" &&
- "worktree" in p &&
- typeof (p as { worktree?: unknown }).worktree === "string",
- )
-
- if (existing.some((p) => p.worktree === directory)) return
- nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
- }
-
- const directories = [args.directory, ...args.extra]
- for (const directory of directories) {
- add("local", directory)
- add(args.serverUrl, directory)
- }
-
- localStorage.setItem(
- key,
- JSON.stringify({
- list: nextList,
- projects: nextProjects,
- lastProject,
- }),
- )
- localStorage.setItem(defaultKey, args.serverUrl)
- },
- { directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
- )
-}
-
export async function createTestProject(input?: { serverUrl?: string }) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
@@ -479,7 +400,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
return { directory: target, slug: base64Encode(target) }
}
-export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
+export async function waitSession(
+ page: Page,
+ input: {
+ directory: string
+ sessionID?: string
+ serverUrl?: string
+ allowAnySession?: boolean
+ },
+) {
const target = await resolveDirectory(input.directory, input.serverUrl)
await expect
.poll(
@@ -491,11 +420,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false
- if (!input.sessionID && current) return false
+ if (!input.sessionID && !input.allowAnySession && current) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
- if (!input.sessionID && state?.sessionID) return false
+ if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false
@@ -602,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
}
export async function openSharePopover(page: Page) {
- const rightSection = page.locator(titlebarRightSelector)
- const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
- await expect(shareButton).toBeVisible()
+ const scroller = page.locator(".scroll-view__viewport").first()
+ await expect(scroller).toBeVisible()
+ await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+
+ const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
+ await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
const popoverBody = page
- .locator(popoverBodySelector)
+ .locator('[data-component="popover-content"]')
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first()
@@ -617,16 +549,13 @@ export async function openSharePopover(page: Page) {
.catch(() => false)
if (!opened) {
- await shareButton.click()
- await expect(popoverBody).toBeVisible()
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ await menuTrigger.click()
+ await clickMenuItem(menu, /share/i)
+ await expect(menu).toHaveCount(0)
+ await expect(popoverBody).toBeVisible({ timeout: 30_000 })
}
- return { rightSection, popoverBody }
-}
-
-export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
- const button = page.getByRole("button").filter({ hasText: buttonName }).first()
- await expect(button).toBeVisible()
- await button.click()
+ return { rightSection: scroller, popoverBody }
}
export async function clickListItem(
@@ -794,40 +723,6 @@ export async function seedSessionQuestion(
return { id: result.id }
}
-export async function seedSessionPermission(
- sdk: ReturnType<typeof createSdk>,
- input: {
- sessionID: string
- permission: string
- patterns: string[]
- description?: string
- },
-) {
- const text = [
- "Your only valid response is one bash tool call.",
- `Use this JSON input: ${JSON.stringify({
- command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
- workdir: "/",
- description: input.description ?? `seed ${input.permission} permission request`,
- })}`,
- "Do not output plain text.",
- ].join("\n")
-
- const result = await seed({
- sdk,
- sessionID: input.sessionID,
- prompt: text,
- timeout: 30_000,
- probe: async () => {
- const list = await sdk.permission.list().then((x) => x.data ?? [])
- return list.find((item) => item.sessionID === input.sessionID)
- },
- })
-
- if (!result) throw new Error("Timed out seeding permission request")
- return { id: result.id }
-}
-
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
@@ -886,36 +781,6 @@ export async function seedSessionTask(
return result
}
-export async function seedSessionTodos(
- sdk: ReturnType<typeof createSdk>,
- input: {
- sessionID: string
- todos: Array<{ content: string; status: string; priority: string }>
- },
-) {
- const text = [
- "Your only valid response is one todowrite tool call.",
- `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
- "Do not output plain text.",
- ].join("\n")
- const target = JSON.stringify(input.todos)
-
- const result = await seed({
- sdk,
- sessionID: input.sessionID,
- prompt: text,
- timeout: 30_000,
- probe: async () => {
- const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
- if (JSON.stringify(todos) !== target) return
- return true
- },
- })
-
- if (!result) throw new Error("Timed out seeding todos")
- return true
-}
-
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
@@ -1005,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
- const current = await page
- .getByRole("button", { name: "New workspace" })
- .first()
- .isVisible()
- .then((x) => x)
- .catch(() => false)
+ const current = () =>
+ page
+ .getByRole("button", { name: "New workspace" })
+ .first()
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if ((await current()) === enabled) return
- if (current === enabled) return
+ if (enabled) {
+ await page.reload()
+ await openSidebar(page)
+ if ((await current()) === enabled) return
+ }
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
- return toggle.click({ force: true, timeout })
+ 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)
- .then(() => true)
- .catch(() => false)
+ for (const timeout of [1500, undefined, undefined]) {
+ if ((await current()) === enabled) break
+ await flip(timeout)
+ .then(() => undefined)
+ .catch(() => undefined)
+ const matched = await expect
+ .poll(current, { timeout: 5_000 })
+ .toBe(enabled)
+ .then(() => true)
+ .catch(() => false)
+ if (matched) break
+ }
- if (!flipped) await flip()
+ if ((await current()) !== enabled) {
+ await page.reload()
+ await openSidebar(page)
+ }
const expected = enabled ? "New workspace" : "New session"
- await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+ await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
+ await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
}
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts
index 4dfa7c64f..9febc4b3f 100644
--- a/packages/app/e2e/backend.ts
+++ b/packages/app/e2e/backend.ts
@@ -62,7 +62,7 @@ function tail(input: string[]) {
return input.slice(-40).join("")
}
-export async function startBackend(label: string): Promise<Handle> {
+export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
const port = await freePort()
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -80,6 +80,7 @@ export async function startBackend(label: string): Promise<Handle> {
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
+ OPENCODE_E2E_LLM_URL: input?.llmUrl,
} satisfies Record<string, string | undefined>
const out: string[] = []
const err: string[] = []
diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts
index 8c018a9f0..fe2eb9c1a 100644
--- a/packages/app/e2e/fixtures.ts
+++ b/packages/app/e2e/fixtures.ts
@@ -10,13 +10,14 @@ import {
cleanupTestProject,
createTestProject,
setHealthPhase,
- seedProjects,
sessionIDFromUrl,
- waitSlug,
waitSession,
+ waitSessionIdle,
+ waitSessionSaved,
+ waitSlug,
} from "./actions"
-import { openaiModel, withMockOpenAI } from "./prompt/mock"
-import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
+import { promptSelector } from "./selectors"
+import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
type LLMFixture = {
url: string
@@ -51,6 +52,23 @@ type LLMFixture = {
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
}
+type LLMWorker = LLMFixture & {
+ reset: () => Promise<void>
+}
+
+type AssistantFixture = {
+ reply: LLMFixture["text"]
+ tool: LLMFixture["tool"]
+ toolHang: LLMFixture["toolHang"]
+ reason: LLMFixture["reason"]
+ fail: LLMFixture["fail"]
+ error: LLMFixture["error"]
+ hang: LLMFixture["hang"]
+ hold: LLMFixture["hold"]
+ calls: LLMFixture["calls"]
+ pending: LLMFixture["pending"]
+}
+
export const settingsKey = "settings.v3"
const seedModel = (() => {
@@ -63,6 +81,40 @@ const seedModel = (() => {
}
})()
+function clean(value: string | null) {
+ return (value ?? "").replace(/\u200B/g, "").trim()
+}
+
+async function visit(page: Page, url: string) {
+ let err: unknown
+ for (const _ of [0, 1, 2]) {
+ try {
+ await page.goto(url)
+ return
+ } catch (cause) {
+ err = cause
+ if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
+ await new Promise((resolve) => setTimeout(resolve, 300))
+ }
+ }
+ throw err
+}
+
+async function promptSend(page: Page) {
+ return page
+ .evaluate(() => {
+ const win = window as E2EWindow
+ const sent = win.__opencode_e2e?.prompt?.sent
+ return {
+ started: sent?.started ?? 0,
+ count: sent?.count ?? 0,
+ sessionID: sent?.sessionID,
+ directory: sent?.directory,
+ }
+ })
+ .catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
+}
+
type ProjectHandle = {
directory: string
slug: string
@@ -79,16 +131,23 @@ type ProjectOptions = {
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
}
+type ProjectFixture = ProjectHandle & {
+ open: (options?: ProjectOptions) => Promise<void>
+ prompt: (text: string) => Promise<string>
+ user: (text: string) => Promise<string>
+ shell: (cmd: string) => Promise<string>
+}
+
type TestFixtures = {
llm: LLMFixture
+ assistant: AssistantFixture
+ project: ProjectFixture
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
- withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
- withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
- withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
}
type WorkerFixtures = {
+ _llm: LLMWorker
backend: {
url: string
sdk: (directory?: string) => ReturnType<typeof createSdk>
@@ -98,9 +157,42 @@ type 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: [
- async ({}, use, workerInfo) => {
- const handle = await startBackend(`w${workerInfo.workerIndex}`)
+ async ({ _llm }, use, workerInfo) => {
+ const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
try {
await use({
url: handle.url,
@@ -112,35 +204,48 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
},
{ scope: "worker" },
],
- 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)),
- 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()
+ llm: async ({ _llm }, use) => {
+ await _llm.reset()
+ await use({
+ url: _llm.url,
+ push: _llm.push,
+ pushMatch: _llm.pushMatch,
+ textMatch: _llm.textMatch,
+ toolMatch: _llm.toolMatch,
+ text: _llm.text,
+ tool: _llm.tool,
+ toolHang: _llm.toolHang,
+ reason: _llm.reason,
+ fail: _llm.fail,
+ error: _llm.error,
+ hang: _llm.hang,
+ hold: _llm.hold,
+ hits: _llm.hits,
+ calls: _llm.calls,
+ wait: _llm.wait,
+ inputs: _llm.inputs,
+ pending: _llm.pending,
+ misses: _llm.misses,
+ })
+ const pending = await _llm.pending()
+ if (pending > 0) {
+ throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
}
},
+ assistant: async ({ llm }, use) => {
+ await use({
+ reply: llm.text,
+ tool: llm.tool,
+ toolHang: llm.toolHang,
+ reason: llm.reason,
+ fail: llm.fail,
+ error: llm.error,
+ hang: llm.hang,
+ hold: llm.hold,
+ calls: llm.calls,
+ pending: llm.pending,
+ })
+ },
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
@@ -165,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
if (boundary) throw new Error(boundary)
},
directory: [
- async ({}, use) => {
- const directory = await getWorktree()
- await use(directory)
+ async ({ backend }, use) => {
+ await use(await getWorktree(backend.url))
},
{ scope: "worker" },
],
@@ -177,93 +281,254 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
},
{ scope: "worker" },
],
- sdk: async ({ directory }, use) => {
- await use(createSdk(directory))
+ sdk: async ({ directory, backend }, use) => {
+ await use(backend.sdk(directory))
},
- gotoSession: async ({ page, directory }, use) => {
- await seedStorage(page, { directory })
+ gotoSession: async ({ page, directory, backend }, use) => {
+ await seedStorage(page, { directory, serverUrl: backend.url })
const gotoSession = async (sessionID?: string) => {
- await page.goto(sessionPath(directory, sessionID))
- await waitSession(page, { directory, sessionID })
+ await visit(page, sessionPath(directory, sessionID))
+ await waitSession(page, {
+ directory,
+ sessionID,
+ serverUrl: backend.url,
+ allowAnySession: !sessionID,
+ })
}
await use(gotoSession)
},
- withProject: async ({ page }, use) => {
- await use((callback, options) => runProject(page, callback, options))
- },
- withBackendProject: async ({ page, backend }, use) => {
- await use((callback, options) =>
- runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
- )
- },
- withMockProject: async ({ page, llm, backend }, use) => {
- await use((callback, options) =>
- withMockOpenAI({
- serverUrl: backend.url,
- llmUrl: llm.url,
- fn: () =>
- runProject(page, callback, {
- ...options,
- model: options?.model ?? openaiModel,
- serverUrl: backend.url,
- sdk: backend.sdk,
- }),
- }),
- )
+ project: async ({ page, llm, backend }, use) => {
+ const item = makeProject(page, llm, backend)
+ try {
+ await use(item.project)
+ } finally {
+ await item.cleanup()
+ }
},
})
-async function runProject<T>(
+function makeProject(
page: Page,
- callback: (project: ProjectHandle) => Promise<T>,
- options?: ProjectOptions & {
- serverUrl?: string
- sdk?: (directory?: string) => ReturnType<typeof createSdk>
- },
+ llm: LLMFixture,
+ backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
) {
- const url = options?.serverUrl
- const root = await createTestProject(url ? { serverUrl: url } : undefined)
- const sdk = options?.sdk?.(root) ?? createSdk(root, url)
- const sessions = new Map<string, string>()
- const dirs = new Set<string>()
- await options?.setup?.(root)
- await seedStorage(page, {
- directory: root,
- extra: options?.extra,
- model: options?.model,
- serverUrl: url,
- })
+ let state:
+ | {
+ directory: string
+ slug: string
+ sdk: ReturnType<typeof createSdk>
+ sessions: Map<string, string>
+ dirs: Set<string>
+ }
+ | undefined
- const gotoSession = async (sessionID?: string) => {
- await page.goto(sessionPath(root, sessionID))
- await waitSession(page, { directory: root, sessionID, serverUrl: url })
- const current = sessionIDFromUrl(page.url())
- if (current) trackSession(current)
+ const need = () => {
+ if (state) return state
+ throw new Error("project.open() must be called first")
}
const trackSession = (sessionID: string, directory?: string) => {
- sessions.set(sessionID, directory ?? root)
+ const cur = need()
+ cur.sessions.set(sessionID, directory ?? cur.directory)
}
const trackDirectory = (directory: string) => {
- if (directory !== root) dirs.add(directory)
+ const cur = need()
+ if (directory !== cur.directory) cur.dirs.add(directory)
+ }
+
+ const gotoSession = async (sessionID?: string) => {
+ const cur = need()
+ await visit(page, sessionPath(cur.directory, sessionID))
+ await waitSession(page, {
+ directory: cur.directory,
+ sessionID,
+ serverUrl: backend.url,
+ allowAnySession: !sessionID,
+ })
+ const current = sessionIDFromUrl(page.url())
+ if (current) trackSession(current)
}
- try {
- await options?.beforeGoto?.({ directory: root, sdk })
+ const open = async (options?: ProjectOptions) => {
+ if (state) return
+ const directory = await createTestProject({ serverUrl: backend.url })
+ const sdk = backend.sdk(directory)
+ await options?.setup?.(directory)
+ await seedStorage(page, {
+ directory,
+ extra: options?.extra,
+ model: options?.model,
+ serverUrl: backend.url,
+ })
+ state = {
+ directory,
+ slug: "",
+ sdk,
+ sessions: new Map(),
+ dirs: new Set(),
+ }
+ await options?.beforeGoto?.({ directory, sdk })
await gotoSession()
- const slug = await waitSlug(page)
- return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
- } finally {
+ need().slug = await waitSlug(page)
+ }
+
+ const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
+ if (input.noReply) {
+ const cur = need()
+ const state = await page.evaluate(() => {
+ const model = (window as E2EWindow).__opencode_e2e?.model?.current
+ if (!model) return null
+ return {
+ dir: model.dir,
+ sessionID: model.sessionID,
+ agent: model.agent,
+ model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
+ variant: model.variant ?? undefined,
+ }
+ })
+ const dir = state?.dir ?? cur.directory
+ const sdk = backend.sdk(dir)
+ const sessionID = state?.sessionID
+ ? state.sessionID
+ : await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
+ if (!res.data?.id) throw new Error("Failed to create no-reply session")
+ return res.data.id
+ })
+ await sdk.session.prompt({
+ sessionID,
+ agent: state?.agent,
+ model: state?.model,
+ variant: state?.variant,
+ noReply: true,
+ parts: [{ type: "text", text }],
+ })
+ await visit(page, sessionPath(dir, sessionID))
+ const active = await waitSession(page, {
+ directory: dir,
+ sessionID,
+ serverUrl: backend.url,
+ })
+ trackSession(sessionID, active.directory)
+ await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
+ return sessionID
+ }
+
+ const prev = await promptSend(page)
+ if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
+ await llm.text("ok")
+ }
+
+ const prompt = page.locator(promptSelector).first()
+ const submit = async () => {
+ await expect(prompt).toBeVisible()
+ await prompt.click()
+ if (input.shell) {
+ await page.keyboard.type("!")
+ await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
+ }
+ await page.keyboard.type(text)
+ await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
+ await page.keyboard.press("Enter")
+ const started = await expect
+ .poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
+ .toBeGreaterThan(prev.started)
+ .then(() => true)
+ .catch(() => false)
+ if (started) return
+ const send = page.getByRole("button", { name: "Send" }).first()
+ const enabled = await send
+ .isEnabled()
+ .then((x) => x)
+ .catch(() => false)
+ if (enabled) {
+ await send.click()
+ } else {
+ await prompt.click()
+ await page.keyboard.press("Enter")
+ }
+ await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
+ }
+
+ await submit()
+
+ let next: { sessionID: string; directory: string } | undefined
+ await expect
+ .poll(
+ async () => {
+ const sent = await promptSend(page)
+ if (sent.count <= prev.count) return ""
+ if (!sent.sessionID || !sent.directory) return ""
+ next = { sessionID: sent.sessionID, directory: sent.directory }
+ return sent.sessionID
+ },
+ { timeout: 90_000 },
+ )
+ .not.toBe("")
+
+ if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
+ const active = await waitSession(page, {
+ directory: next.directory,
+ sessionID: next.sessionID,
+ serverUrl: backend.url,
+ })
+ trackSession(next.sessionID, active.directory)
+ if (!input.shell) {
+ await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
+ }
+ await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
+ return next.sessionID
+ }
+
+ const prompt = async (text: string) => {
+ return send(text, { noReply: false, shell: false })
+ }
+
+ const user = async (text: string) => {
+ return send(text, { noReply: true, shell: false })
+ }
+
+ const shell = async (cmd: string) => {
+ return send(cmd, { noReply: false, shell: true })
+ }
+
+ const cleanup = async () => {
+ const cur = state
+ if (!cur) return
setHealthPhase(page, "cleanup")
await Promise.allSettled(
- Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
+ Array.from(cur.sessions, ([sessionID, directory]) =>
+ cleanupSession({ sessionID, directory, serverUrl: backend.url }),
+ ),
)
- await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
- await cleanupTestProject(root)
+ await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
+ await cleanupTestProject(cur.directory)
+ state = undefined
setHealthPhase(page, "test")
}
+
+ return {
+ project: {
+ open,
+ prompt,
+ user,
+ shell,
+ gotoSession,
+ trackSession,
+ trackDirectory,
+ get directory() {
+ return need().directory
+ },
+ get slug() {
+ return need().slug
+ },
+ get sdk() {
+ return need().sdk
+ },
+ },
+ cleanup,
+ }
}
async function seedStorage(
@@ -275,31 +540,65 @@ async function seedStorage(
serverUrl?: string
},
) {
- await seedProjects(page, input)
- await page.addInitScript((model: { providerID: string; modelID: string }) => {
- const 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: [model],
- user: [],
- variant: {},
- }),
- )
- }, input.model ?? seedModel)
+ const origin = input.serverUrl ?? serverUrl
+ await page.addInitScript(
+ (args: {
+ directory: string
+ serverUrl: string
+ extra: string[]
+ model: { providerID: string; modelID: string }
+ }) => {
+ const key = "opencode.global.dat:server"
+ const raw = localStorage.getItem(key)
+ const parsed = (() => {
+ if (!raw) return undefined
+ try {
+ return JSON.parse(raw) as unknown
+ } catch {
+ return undefined
+ }
+ })()
+
+ const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
+ const list = Array.isArray(store.list) ? store.list : []
+ const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
+ const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
+ const next = { ...(projects as Record<string, unknown>) }
+ const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
+
+ const add = (origin: string, directory: string) => {
+ const current = next[origin]
+ const items = Array.isArray(current) ? current : []
+ const existing = items.filter(
+ (p): p is { worktree: string; expanded?: boolean } =>
+ !!p &&
+ typeof p === "object" &&
+ "worktree" in p &&
+ typeof (p as { worktree?: unknown }).worktree === "string",
+ )
+ if (existing.some((p) => p.worktree === directory)) return
+ next[origin] = [{ worktree: directory, expanded: true }, ...existing]
+ }
+
+ for (const directory of [args.directory, ...args.extra]) {
+ add("local", directory)
+ add(args.serverUrl, directory)
+ }
+
+ localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
+ localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
+
+ const win = window as E2EWindow
+ win.__opencode_e2e = {
+ ...win.__opencode_e2e,
+ model: { enabled: true },
+ prompt: { enabled: true },
+ terminal: { enabled: true, terminals: {} },
+ }
+ 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 }
diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts
index 7c20f29ec..1ffe4219d 100644
--- a/packages/app/e2e/projects/project-edit.spec.ts
+++ b/packages/app/e2e/projects/project-edit.spec.ts
@@ -1,43 +1,49 @@
import { test, expect } from "../fixtures"
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
-test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
+test("dialog edit project updates name and startup script", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
- await withProject(async ({ slug }) => {
- await openSidebar(page)
-
- const open = async () => {
- const menu = await openProjectMenu(page, slug)
- await clickMenuItem(menu, /^Edit$/i, { force: true })
-
- const dialog = page.getByRole("dialog")
- await expect(dialog).toBeVisible()
- await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
- return dialog
- }
-
- const name = `e2e project ${Date.now()}`
- const startup = `echo e2e_${Date.now()}`
-
- const dialog = await open()
-
- const nameInput = dialog.getByLabel("Name")
- await nameInput.fill(name)
-
- const startupInput = dialog.getByLabel("Workspace startup script")
- await startupInput.fill(startup)
-
- await dialog.getByRole("button", { name: "Save" }).click()
- await expect(dialog).toHaveCount(0)
-
- const header = page.locator(".group\\/project").first()
- await expect(header).toContainText(name)
-
- const reopened = await open()
- await expect(reopened.getByLabel("Name")).toHaveValue(name)
- await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
- await reopened.getByRole("button", { name: "Cancel" }).click()
- await expect(reopened).toHaveCount(0)
- })
+ await project.open()
+ await openSidebar(page)
+
+ const open = async () => {
+ const menu = await openProjectMenu(page, project.slug)
+ await clickMenuItem(menu, /^Edit$/i, { force: true })
+
+ const dialog = page.getByRole("dialog")
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
+ return dialog
+ }
+
+ const name = `e2e project ${Date.now()}`
+ const startup = `echo e2e_${Date.now()}`
+
+ const dialog = await open()
+
+ const nameInput = dialog.getByLabel("Name")
+ await nameInput.fill(name)
+
+ const startupInput = dialog.getByLabel("Workspace startup script")
+ await startupInput.fill(startup)
+
+ await dialog.getByRole("button", { name: "Save" }).click()
+ await expect(dialog).toHaveCount(0)
+
+ await expect
+ .poll(
+ async () => {
+ await page.reload()
+ await openSidebar(page)
+ const reopened = await open()
+ const value = await reopened.getByLabel("Name").inputValue()
+ const next = await reopened.getByLabel("Workspace startup script").inputValue()
+ await reopened.getByRole("button", { name: "Cancel" }).click()
+ await expect(reopened).toHaveCount(0)
+ return `${value}\n${next}`
+ },
+ { timeout: 30_000 },
+ )
+ .toBe(`${name}\n${startup}`)
})
diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts
index 9454d683f..75e6f2ce6 100644
--- a/packages/app/e2e/projects/projects-close.spec.ts
+++ b/packages/app/e2e/projects/projects-close.spec.ts
@@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
-test("closing active project navigates to another open project", async ({ page, withProject }) => {
+test("closing active project navigates to another open project", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
- await withProject(
- async ({ slug }) => {
- await openSidebar(page)
-
- const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
- await expect(otherButton).toBeVisible()
- await otherButton.click()
-
- await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
-
- const menu = await openProjectMenu(page, otherSlug)
-
- await clickMenuItem(menu, /^Close$/i, { force: true })
-
- await expect
- .poll(
- () => {
- const pathname = new URL(page.url()).pathname
- if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
- if (pathname === "/") return "home"
- return ""
- },
- { timeout: 15_000 },
- )
- .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] },
- )
+ await project.open({ extra: [other] })
+ await openSidebar(page)
+
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+ const menu = await openProjectMenu(page, otherSlug)
+ await clickMenuItem(menu, /^Close$/i, { force: true })
+
+ await expect
+ .poll(
+ () => {
+ const pathname = new URL(page.url()).pathname
+ if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
+ if (pathname === "/") return "home"
+ return ""
+ },
+ { timeout: 15_000 },
+ )
+ .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)
} finally {
await cleanupTestProject(other)
}
diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts
index f87a47cf0..67d09afd1 100644
--- a/packages/app/e2e/projects/projects-switch.spec.ts
+++ b/packages/app/e2e/projects/projects-switch.spec.ts
@@ -5,114 +5,89 @@ import {
createTestProject,
cleanupTestProject,
openSidebar,
- sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
- waitSessionSaved,
waitSlug,
- withNoReplyPrompt,
} from "../actions"
-import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
+import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
-test("can switch between projects from sidebar", async ({ page, withProject }) => {
+test("can switch between projects from sidebar", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
- await withProject(
- async ({ directory }) => {
- await defocus(page)
+ await project.open({ extra: [other] })
+ await defocus(page)
- const currentSlug = dirSlug(directory)
- const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
- await expect(otherButton).toBeVisible()
- await otherButton.click()
+ const currentSlug = dirSlug(project.directory)
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ 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()
- await expect(currentButton).toBeVisible()
- await currentButton.click()
+ const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
+ await expect(currentButton).toBeVisible()
+ await currentButton.click()
- await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
- },
- { extra: [other] },
- )
+ await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
} finally {
await cleanupTestProject(other)
}
})
-test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
+test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
- await withProject(
- async ({ directory, slug, trackSession, trackDirectory }) => {
- await defocus(page)
- await setWorkspacesEnabled(page, slug, true)
- await openSidebar(page)
- await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
-
- await page.getByRole("button", { name: "New workspace" }).first().click()
-
- const raw = await waitSlug(page, [slug])
- const dir = base64Decode(raw)
- if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
- const space = await resolveDirectory(dir)
- const next = dirSlug(space)
- trackDirectory(space)
- await openSidebar(page)
-
- const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
- await expect(item).toBeVisible()
- await item.hover()
-
- const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
- await expect(btn).toBeVisible()
- await btn.click({ force: true })
-
- await waitSession(page, { directory: space })
-
- // Create a session by sending a prompt
- const prompt = page.locator(promptSelector)
- await expect(prompt).toBeVisible()
- await withNoReplyPrompt(page, async () => {
- await prompt.fill("test")
- await page.keyboard.press("Enter")
- })
-
- // Wait for the URL to update with the new session ID
- await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
-
- const created = sessionIDFromUrl(page.url())
- if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
- trackSession(created, space)
- await waitSessionSaved(space, created)
-
- await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
-
- await openSidebar(page)
-
- const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
- await expect(otherButton).toBeVisible()
- 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] },
- )
+ await project.open({ extra: [other] })
+ await defocus(page)
+ await setWorkspacesEnabled(page, project.slug, true)
+ await openSidebar(page)
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+
+ const raw = await waitSlug(page, [project.slug])
+ const dir = base64Decode(raw)
+ if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
+ const space = await resolveDirectory(dir)
+ const next = dirSlug(space)
+ project.trackDirectory(space)
+ await openSidebar(page)
+
+ const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
+ await expect(item).toBeVisible()
+ await item.hover()
+
+ const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
+ await expect(btn).toBeVisible()
+ await btn.click({ force: true })
+
+ await waitSession(page, { directory: space })
+
+ const created = await project.user("test")
+
+ await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
+
+ await openSidebar(page)
+
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.click({ force: true })
+ await waitSession(page, { directory: other })
+
+ const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
+ await expect(rootButton).toBeVisible()
+ await rootButton.click({ force: true })
+
+ await waitSession(page, { directory: space, sessionID: created })
+ await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
} finally {
await cleanupTestProject(other)
}
diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts
index 835c8c99e..d9d010b4d 100644
--- a/packages/app/e2e/projects/workspace-new-session.spec.ts
+++ b/packages/app/e2e/projects/workspace-new-session.spec.ts
@@ -7,12 +7,9 @@ import {
setWorkspacesEnabled,
waitDir,
waitSession,
- waitSessionSaved,
waitSlug,
- withNoReplyPrompt,
} from "../actions"
-import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
-import { createSdk } from "../utils"
+import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
@@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
}
async function createSessionFromWorkspace(
+ project: Parameters<typeof test>[0]["project"],
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
-
- const prompt = page.locator(promptSelector)
- await expect(prompt).toBeVisible()
- await withNoReplyPrompt(page, async () => {
- await prompt.fill(text)
- await page.keyboard.press("Enter")
- })
-
- await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
- const sessionID = sessionIDFromUrl(page.url())
- if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-
- await waitSessionSaved(space.directory, sessionID)
- await createSdk(space.directory)
- .session.abort({ sessionID })
- .catch(() => undefined)
- return sessionID
+ return project.user(text)
}
-test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
+test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
- await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
- await openSidebar(page)
- await setWorkspacesEnabled(page, root, true)
+ await project.open()
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, project.slug, true)
- const first = await createWorkspace(page, root, [])
- trackDirectory(first.directory)
- await waitWorkspaceReady(page, first)
+ const first = await createWorkspace(page, project.slug, [])
+ project.trackDirectory(first.directory)
+ await waitWorkspaceReady(page, first)
- const second = await createWorkspace(page, root, [first.slug])
- trackDirectory(second.directory)
- await waitWorkspaceReady(page, second)
+ const second = await createWorkspace(page, project.slug, [first.slug])
+ project.trackDirectory(second.directory)
+ await waitWorkspaceReady(page, second)
- trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
- trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
- trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
- })
+ await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
+ await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
+ await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
})
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
index 297cdb9fc..206baa47c 100644
--- a/packages/app/e2e/projects/workspaces.spec.ts
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -19,10 +19,10 @@ import {
waitDir,
waitSlug,
} from "../actions"
-import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
-import { createSdk, dirSlug } from "../utils"
+import { inlineInputSelector, workspaceItemSelector } from "../selectors"
+import { dirSlug } from "../utils"
-async function setupWorkspaceTest(page: Page, project: { slug: string }) {
+async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
const rootSlug = project.slug
await openSidebar(page)
@@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory)
+ project.trackDirectory(next.directory)
await openSidebar(page)
@@ -52,62 +53,59 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
return { rootSlug, slug: next.slug, directory: next.directory }
}
-test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
+test("can enable and disable workspaces from project menu", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
+ await project.open()
- await withProject(async ({ slug }) => {
- await openSidebar(page)
+ await openSidebar(page)
- 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 session" }).first()).toBeVisible()
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
- await setWorkspacesEnabled(page, slug, true)
- await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
- await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+ await setWorkspacesEnabled(page, project.slug, true)
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
- await setWorkspacesEnabled(page, slug, false)
- await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
- await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
- })
+ await setWorkspacesEnabled(page, project.slug, false)
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
})
-test("can create a workspace", async ({ page, withProject }) => {
+test("can create a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
+ await project.open()
- await withProject(async ({ slug }) => {
- await openSidebar(page)
- await setWorkspacesEnabled(page, slug, true)
-
- await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, project.slug, true)
- await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [slug]))
- await waitDir(page, next.directory)
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
- await openSidebar(page)
+ 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 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 openSidebar(page)
- await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
+ 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 cleanupTestProject(next.directory)
- })
+ await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
})
-test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
+test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
@@ -116,260 +114,255 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
- await withProject(async () => {
- await page.goto(`/${nonGitSlug}/session`)
-
- await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
+ await project.open({ extra: [nonGit] })
+ await page.goto(`/${nonGitSlug}/session`)
- const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
- expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
+ await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
- 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
+ const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
+ expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
- await trigger.click({ force: true })
-
- const menu = page.locator(dropdownMenuContentSelector).first()
- await expect(menu).toBeVisible()
-
- const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
-
- await expect(toggle).toBeVisible()
- await expect(toggle).toBeDisabled()
- await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
- })
+ await 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, withProject }) => {
+test("can rename a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
-
- await withProject(async (project) => {
- 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()
- await expect(input).toBeVisible()
- await input.fill(rename)
- await input.press("Enter")
- await expect(item).toContainText(rename)
- })
+ 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, sdk, withProject }) => {
+test("can reset a workspace", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
+ await project.open()
- await withProject(async (project) => {
- const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
+ const { 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")
+ 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 () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(true)
- await expect
- .poll(async () => {
- const files = await sdk.file
+ 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
- })
- .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)
+ },
+ { timeout: 120_000 },
+ )
+ .toBe(0)
- await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+ await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
- await expect
- .poll(async () => {
- return await fs
- .stat(extra)
- .then(() => true)
- .catch(() => false)
- })
- .toBe(false)
- })
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(false)
})
-test("can delete a workspace", async ({ page, withProject }) => {
+test("can reorder workspaces by drag and drop", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
+ await project.open()
+ const rootSlug = project.slug
- await withProject(async (project) => {
- const sdk = createSdk(project.directory)
- const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
+ 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
.poll(
async () => {
- const worktrees = await sdk.worktree
- .list()
- .then((r) => r.data ?? [])
- .catch(() => [] as string[])
- return worktrees.includes(directory)
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
},
- { timeout: 30_000 },
+ { timeout: 60_000 },
)
.toBe(true)
+ }
- const menu = await openWorkspaceMenu(page, slug)
- await clickMenuItem(menu, /^Delete$/i, { force: true })
- await confirmDialog(page, /^Delete workspace$/i)
+ const drag = async (from: string, to: string) => {
+ const src = page.locator(workspaceItemSelector(from)).first()
+ const dst = page.locator(workspaceItemSelector(to)).first()
- await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
+ const a = await src.boundingBox()
+ const b = await dst.boundingBox()
+ if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
- 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 page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+ await page.mouse.up()
+ }
+
+ await openSidebar(page)
- await project.gotoSession()
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ const workspaces = [] as { directory: string; slug: string }[]
+ for (const _ of [0, 1]) {
+ const prev = slugFromUrl(page.url())
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
+ await waitDir(page, next.directory)
+ project.trackDirectory(next.directory)
+ workspaces.push(next)
await openSidebar(page)
- 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 withProject(async ({ slug: rootSlug }) => {
- const workspaces = [] as { directory: string; slug: string }[]
-
- 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
- .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 src = page.locator(workspaceItemSelector(from)).first()
- const dst = page.locator(workspaceItemSelector(to)).first()
-
- const a = await src.boundingBox()
- const b = await dst.boundingBox()
- if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
-
- await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
- await page.mouse.down()
- await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
- await page.mouse.up()
- }
-
- try {
- await openSidebar(page)
-
- await setWorkspacesEnabled(page, rootSlug, true)
-
- for (const _ of [0, 1]) {
- const prev = slugFromUrl(page.url())
- await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
- await waitDir(page, next.directory)
- workspaces.push(next)
-
- await openSidebar(page)
- }
-
- 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)))
- }
- })
+ await project.open()
+
+ const rootSlug = project.slug
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
+ if (!created?.directory) throw new Error("Failed to create workspace for delete test")
+
+ const directory = created.directory
+ const slug = dirSlug(directory)
+ project.trackDirectory(directory)
+
+ await page.reload()
+ await openSidebar(page)
+ await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
+
+ await expect
+ .poll(
+ async () => {
+ const worktrees = await project.sdk.worktree
+ .list()
+ .then((r) => r.data ?? [])
+ .catch(() => [] as string[])
+ return worktrees.includes(directory)
+ },
+ { timeout: 30_000 },
+ )
+ .toBe(true)
+
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Delete$/i, { force: true })
+ await confirmDialog(page, /^Delete workspace$/i)
+
+ await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
+
+ await expect
+ .poll(
+ async () => {
+ const worktrees = await project.sdk.worktree
+ .list()
+ .then((r) => r.data ?? [])
+ .catch(() => [] as string[])
+ return worktrees.includes(directory)
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(false)
+
+ await openSidebar(page)
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
+ await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
diff --git a/packages/app/e2e/prompt/mock.ts b/packages/app/e2e/prompt/mock.ts
index bd09af266..c7eb54b52 100644
--- a/packages/app/e2e/prompt/mock.ts
+++ b/packages/app/e2e/prompt/mock.ts
@@ -1,21 +1,9 @@
-import { createSdk } from "../utils"
-
-export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
-
type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) {
return JSON.stringify(hit.body)
}
-export function titleMatch(hit: Hit) {
- return bodyText(hit).includes("Generate a title for this conversation")
-}
-
-export function promptMatch(token: string) {
- return (hit: Hit) => bodyText(hit).includes(token)
-}
-
/**
* Match requests whose body contains the exact serialized tool input.
* The seed prompts embed JSON.stringify(input) in the prompt text, which
@@ -25,32 +13,3 @@ export function inputMatch(input: unknown) {
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
return (hit: Hit) => bodyText(hit).includes(escaped)
}
-
-export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
- const sdk = createSdk(undefined, input.serverUrl)
- const prev = await sdk.global.config.get().then((res) => res.data ?? {})
-
- try {
- await sdk.global.config.update({
- config: {
- ...prev,
- model: `${openaiModel.providerID}/${openaiModel.modelID}`,
- enabled_providers: ["openai"],
- provider: {
- ...prev.provider,
- openai: {
- ...prev.provider?.openai,
- options: {
- ...prev.provider?.openai?.options,
- apiKey: "test-key",
- baseURL: input.llmUrl,
- },
- },
- },
- },
- })
- return await input.fn()
- } finally {
- await sdk.global.config.update({ config: prev })
- }
-}
diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts
index a9a12cb95..403369947 100644
--- a/packages/app/e2e/prompt/prompt-async.spec.ts
+++ b/packages/app/e2e/prompt/prompt-async.spec.ts
@@ -1,52 +1,25 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
-import { assistantText, sessionIDFromUrl, withSession } from "../actions"
-import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
+import { assistantText, withSession } from "../actions"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
-test("prompt succeeds when sync message endpoint is unreachable", async ({
- page,
- llm,
- backend,
- withBackendProject,
-}) => {
+test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
- await withMockOpenAI({
- serverUrl: backend.url,
- llmUrl: llm.url,
- fn: async () => {
- const token = `E2E_ASYNC_${Date.now()}`
- await llm.textMatch(titleMatch, "E2E Title")
- await llm.textMatch(promptMatch(token), token)
+ const token = `E2E_ASYNC_${Date.now()}`
+ await project.open()
+ await assistant.reply(token)
+ const sessionID = await project.prompt(`Reply with exactly: ${token}`)
- await withBackendProject(
- async (project) => {
- await page.locator(promptSelector).click()
- await page.keyboard.type(`Reply with exactly: ${token}`)
- await page.keyboard.press("Enter")
-
- await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
- const sessionID = sessionIDFromUrl(page.url())!
- project.trackSession(sessionID)
-
- await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
-
- await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
- },
- {
- model: openaiModel,
- },
- )
- },
- })
+ await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
+ await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
})
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts
index f2d15914d..55cb0c9aa 100644
--- a/packages/app/e2e/prompt/prompt-history.spec.ts
+++ b/packages/app/e2e/prompt/prompt-history.spec.ts
@@ -1,10 +1,9 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
-import { assistantText, sessionIDFromUrl } from "../actions"
+import { assistantText } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
-import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
@@ -43,73 +42,45 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
.toContain(token)
}
-test("prompt history restores unsent draft with arrow navigation", async ({
- page,
- llm,
- backend,
- withBackendProject,
-}) => {
+test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
test.setTimeout(120_000)
- await withMockOpenAI({
- serverUrl: backend.url,
- llmUrl: llm.url,
- fn: async () => {
- const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
- const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
- const first = `Reply with exactly: ${firstToken}`
- const second = `Reply with exactly: ${secondToken}`
- const draft = `draft ${Date.now()}`
-
- await llm.textMatch(titleMatch, "E2E Title")
- await llm.textMatch(promptMatch(firstToken), firstToken)
- await llm.textMatch(promptMatch(secondToken), secondToken)
-
- await withBackendProject(
- async (project) => {
- const prompt = page.locator(promptSelector)
-
- await prompt.click()
- await page.keyboard.type(first)
- await page.keyboard.press("Enter")
- await wait(page, "")
-
- await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
- const sessionID = sessionIDFromUrl(page.url())!
- project.trackSession(sessionID)
- await reply(project.sdk, sessionID, firstToken)
-
- await prompt.click()
- await page.keyboard.type(second)
- await page.keyboard.press("Enter")
- await wait(page, "")
- await reply(project.sdk, sessionID, secondToken)
-
- await prompt.click()
- await page.keyboard.type(draft)
- await wait(page, draft)
-
- await prompt.fill("")
- await wait(page, "")
-
- await page.keyboard.press("ArrowUp")
- await wait(page, second)
-
- await page.keyboard.press("ArrowUp")
- await wait(page, first)
-
- await page.keyboard.press("ArrowDown")
- await wait(page, second)
-
- await page.keyboard.press("ArrowDown")
- await wait(page, "")
- },
- {
- model: openaiModel,
- },
- )
- },
- })
+ const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
+ const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
+ const first = `Reply with exactly: ${firstToken}`
+ const second = `Reply with exactly: ${secondToken}`
+ const draft = `draft ${Date.now()}`
+
+ await project.open()
+ await assistant.reply(firstToken)
+ const sessionID = await project.prompt(first)
+ await wait(page, "")
+ await reply(project.sdk, sessionID, firstToken)
+
+ await assistant.reply(secondToken)
+ await project.prompt(second)
+ await wait(page, "")
+ await reply(project.sdk, sessionID, secondToken)
+
+ const prompt = page.locator(promptSelector)
+ await prompt.click()
+ await page.keyboard.type(draft)
+ await wait(page, draft)
+
+ await prompt.fill("")
+ await wait(page, "")
+
+ await page.keyboard.press("ArrowUp")
+ await wait(page, second)
+
+ await page.keyboard.press("ArrowUp")
+ await wait(page, first)
+
+ await page.keyboard.press("ArrowDown")
+ await wait(page, second)
+
+ await page.keyboard.press("ArrowDown")
+ await wait(page, "")
})
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts
index 7c39a2db3..d81f1d4c4 100644
--- a/packages/app/e2e/prompt/prompt-shell.spec.ts
+++ b/packages/app/e2e/prompt/prompt-shell.spec.ts
@@ -1,7 +1,6 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures"
-import { sessionIDFromUrl } from "../actions"
-import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
@@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part
}
-test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
- test.setTimeout(120_000)
-
- await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
- const prompt = page.locator(promptSelector)
- const cmd = process.platform === "win32" ? "dir" : "command ls"
-
- await gotoSession()
- await prompt.click()
- await page.keyboard.type("!")
- await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
+async function setAutoAccept(page: Parameters<typeof test>[0]["page"], enabled: boolean) {
+ const button = page.locator('[data-action="prompt-permissions"]').first()
+ await expect(button).toBeVisible()
+ const pressed = (await button.getAttribute("aria-pressed")) === "true"
+ if (pressed === enabled) return
+ await button.click()
+ await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
+}
- await page.keyboard.type(cmd)
- await page.keyboard.press("Enter")
+test("shell mode runs a command in the project directory", async ({ page, project }) => {
+ test.setTimeout(120_000)
- await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+ await project.open()
+ const cmd = process.platform === "win32" ? "dir" : "command ls"
- const id = sessionIDFromUrl(page.url())
- if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
- trackSession(id, directory)
+ await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
+ project.trackSession(session.id)
+ await project.gotoSession(session.id)
+ await setAutoAccept(page, true)
+ await project.shell(cmd)
await expect
.poll(
async () => {
- const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
+ const list = await project.sdk.session
+ .messages({ sessionID: session.id, limit: 50 })
+ .then((x) => x.data ?? [])
const msg = list.findLast(
- (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
+ (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
)
if (!msg) return
@@ -49,12 +50,10 @@ test("shell mode runs a command in the project directory", async ({ page, withBa
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!output.includes("README.md")) return
- return { cwd: directory, output }
+ return { cwd: project.directory, output }
},
{ timeout: 90_000 },
)
- .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
-
- await expect(prompt).toHaveText("")
+ .toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
})
})
diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts
index 5371d8a91..f3eeceee5 100644
--- a/packages/app/e2e/prompt/prompt-slash-share.spec.ts
+++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts
@@ -22,46 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0)
}
-test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
+test("/share and /unshare update session share state", async ({ page, project }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
- await withBackendProject(async (project) => {
- await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
- project.trackSession(session.id)
- const prompt = page.locator(promptSelector)
+ await project.open()
+ await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
+ project.trackSession(session.id)
+ const prompt = page.locator(promptSelector)
- await seed(project.sdk, session.id)
- await project.gotoSession(session.id)
+ await seed(project.sdk, session.id)
+ await project.gotoSession(session.id)
- await prompt.click()
- await page.keyboard.type("/share")
- await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
- await page.keyboard.press("Enter")
+ await prompt.click()
+ await page.keyboard.type("/share")
+ await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
+ await page.keyboard.press("Enter")
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
- return data?.share?.url || undefined
- },
- { timeout: 30_000 },
- )
- .not.toBeUndefined()
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.share?.url || undefined
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBeUndefined()
- await prompt.click()
- await page.keyboard.type("/unshare")
- await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
- await page.keyboard.press("Enter")
+ await prompt.click()
+ await page.keyboard.type("/unshare")
+ await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
+ await page.keyboard.press("Enter")
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
- return data?.share?.url || undefined
- },
- { timeout: 30_000 },
- )
- .toBeUndefined()
- })
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.share?.url || undefined
+ },
+ { timeout: 30_000 },
+ )
+ .toBeUndefined()
})
})
diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts
index 3c9ed51dc..b5dc02bad 100644
--- a/packages/app/e2e/prompt/prompt.spec.ts
+++ b/packages/app/e2e/prompt/prompt.spec.ts
@@ -1,9 +1,7 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../selectors"
-import { assistantText, sessionIDFromUrl } from "../actions"
-import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
+import { assistantText } from "../actions"
-test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
+test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
@@ -13,41 +11,13 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB
page.on("pageerror", onPageError)
try {
- await withMockOpenAI({
- serverUrl: backend.url,
- llmUrl: llm.url,
- fn: async () => {
- const token = `E2E_OK_${Date.now()}`
+ const token = `E2E_OK_${Date.now()}`
+ await project.open()
+ await assistant.reply(token)
+ const sessionID = await project.prompt(`Reply with exactly: ${token}`)
- await llm.textMatch(titleMatch, "E2E Title")
- await llm.textMatch(promptMatch(token), token)
-
- await withBackendProject(
- async (project) => {
- const prompt = page.locator(promptSelector)
- await prompt.click()
- await page.keyboard.type(`Reply with exactly: ${token}`)
- await page.keyboard.press("Enter")
-
- await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
-
- const sessionID = (() => {
- const id = sessionIDFromUrl(page.url())
- if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
- return id
- })()
- project.trackSession(sessionID)
-
- await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
-
- await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
- },
- {
- model: openaiModel,
- },
- )
- },
- })
+ await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
+ await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
} finally {
page.off("pageerror", onPageError)
}
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 32e4ecd8a..0a850c744 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -4,13 +4,7 @@ export const terminalSelector = `${terminalPanelSelector} [data-component="termi
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
-export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
-export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
-export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
-export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
-export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
-export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
@@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
-export const projectClearNotificationsSelector = (slug: string) =>
- `[data-action="project-clear-notifications"][data-project="${slug}"]`
-
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
@@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
-export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
-
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]'
diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts
index 1ab4746e4..34a1a9e2e 100644
--- a/packages/app/e2e/session/session-child-navigation.spec.ts
+++ b/packages/app/e2e/session/session-child-navigation.spec.ts
@@ -3,7 +3,7 @@ import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock"
import { promptSelector } from "../selectors"
-test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
+test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
test.setTimeout(120_000)
const errs: string[] = []
@@ -13,34 +13,33 @@ test("task tool child-session link does not trigger stale show errors", async ({
page.on("pageerror", onError)
try {
- await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
- await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
- const taskInput = {
- description: "Open child session",
- prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
- subagent_type: "general",
- }
- await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
- const child = await seedSessionTask(sdk, {
- sessionID: session.id,
- description: taskInput.description,
- prompt: taskInput.prompt,
- })
- trackSession(child.sessionID)
+ await project.open()
+ await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
+ const taskInput = {
+ description: "Open child session",
+ prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
+ subagent_type: "general",
+ }
+ await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
+ const child = await seedSessionTask(project.sdk, {
+ sessionID: session.id,
+ description: taskInput.description,
+ prompt: taskInput.prompt,
+ })
+ project.trackSession(child.sessionID)
- await gotoSession(session.id)
+ 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()
+ 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([])
- })
+ 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 {
page.off("pageerror", onError)
diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts
index bf0cc35b7..8eeac5b1a 100644
--- a/packages/app/e2e/session/session-composer-dock.spec.ts
+++ b/packages/app/e2e/session/session-composer-dock.spec.ts
@@ -242,9 +242,7 @@ async function withMockPermission<T>(
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
await route.fulfill({
- status: res.status(),
- headers: res.headers(),
- contentType: "application/json",
+ response: res,
body: JSON.stringify(json),
})
}
@@ -269,240 +267,227 @@ async function withMockPermission<T>(
}
}
-test("default dock shows prompt input", async ({ page, withBackendProject }) => {
- await withBackendProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock default",
- 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("default dock shows prompt input", async ({ page, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock default",
+ 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, withBackendProject }) => {
- await withBackendProject(async ({ gotoSession }) => {
- await gotoSession()
+test("auto-accept toggle works before first submit", async ({ page, project }) => {
+ await project.open()
- const button = page.locator('[data-action="prompt-permissions"]').first()
- await expect(button).toBeVisible()
- await expect(button).toHaveAttribute("aria-pressed", "false")
+ 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)
- })
+ 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)
+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 llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
- await seedSessionQuestion(project.sdk, {
- sessionID: session.id,
- questions: defaultQuestions,
- })
+ 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)
+ 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 dock.locator('[data-slot="question-option"]').first().click()
+ await dock.getByRole("button", { name: /submit/i }).click()
- await expectQuestionOpen(page)
- })
- },
- { trackSession: project.trackSession },
- )
- })
+ await expectQuestionOpen(page)
+ })
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
- await withMockProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock question keyboard",
- async (session) => {
- await withDockSeed(project.sdk, session.id, async () => {
- await project.gotoSession(session.id)
+test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock question keyboard",
+ 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,
- })
+ await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
+ await seedSessionQuestion(project.sdk, {
+ sessionID: session.id,
+ 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)
+ 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 expectQuestionBlocked(page)
+ await expect(first).toBeFocused()
- await page.keyboard.press("ArrowDown")
- await expect(second).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 },
- )
- })
+ 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 }) => {
- await withMockProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock question escape",
- async (session) => {
- await withDockSeed(project.sdk, session.id, async () => {
- await project.gotoSession(session.id)
+test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock question escape",
+ 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,
- })
+ await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
+ await seedSessionQuestion(project.sdk, {
+ sessionID: session.id,
+ questions: defaultQuestions,
+ })
- const dock = page.locator(questionDockSelector)
- const first = dock.locator('[data-slot="question-option"]').first()
+ const dock = page.locator(questionDockSelector)
+ const first = dock.locator('[data-slot="question-option"]').first()
- await expectQuestionBlocked(page)
- await expect(first).toBeFocused()
+ await expectQuestionBlocked(page)
+ await expect(first).toBeFocused()
- await page.keyboard.press("Escape")
- await expectQuestionOpen(page)
- })
- },
- { trackSession: project.trackSession },
- )
- })
+ await page.keyboard.press("Escape")
+ await expectQuestionOpen(page)
+ })
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
- await withBackendProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock permission once",
- async (session) => {
- await project.gotoSession(session.id)
- await setAutoAccept(page, false)
- await withMockPermission(
- page,
- {
- id: "per_e2e_once",
- sessionID: session.id,
- permission: "bash",
- patterns: ["/tmp/opencode-e2e-perm-once"],
- metadata: { description: "Need permission for command" },
- },
- undefined,
- async (state) => {
- await page.goto(page.url())
- await expectPermissionBlocked(page)
-
- await clearPermissionDock(page, /allow once/i)
- await state.resolved()
- await page.goto(page.url())
- await expectPermissionOpen(page)
- },
- )
- },
- { trackSession: project.trackSession },
- )
- })
+test("blocked permission flow supports allow once", async ({ page, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock permission once",
+ async (session) => {
+ await project.gotoSession(session.id)
+ await setAutoAccept(page, false)
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_once",
+ sessionID: session.id,
+ permission: "bash",
+ patterns: ["/tmp/opencode-e2e-perm-once"],
+ metadata: { description: "Need permission for command" },
+ },
+ undefined,
+ async (state) => {
+ await page.goto(page.url())
+ await expectPermissionBlocked(page)
+
+ await clearPermissionDock(page, /allow once/i)
+ await state.resolved()
+ await page.goto(page.url())
+ await expectPermissionOpen(page)
+ },
+ )
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
- await withBackendProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock permission reject",
- async (session) => {
- await project.gotoSession(session.id)
- await setAutoAccept(page, false)
- await withMockPermission(
- page,
- {
- id: "per_e2e_reject",
- sessionID: session.id,
- permission: "bash",
- patterns: ["/tmp/opencode-e2e-perm-reject"],
- },
- undefined,
- async (state) => {
- await page.goto(page.url())
- await expectPermissionBlocked(page)
-
- await clearPermissionDock(page, /deny/i)
- await state.resolved()
- await page.goto(page.url())
- await expectPermissionOpen(page)
- },
- )
- },
- { trackSession: project.trackSession },
- )
- })
+test("blocked permission flow supports reject", async ({ page, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock permission reject",
+ async (session) => {
+ await project.gotoSession(session.id)
+ await setAutoAccept(page, false)
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_reject",
+ sessionID: session.id,
+ permission: "bash",
+ patterns: ["/tmp/opencode-e2e-perm-reject"],
+ },
+ undefined,
+ async (state) => {
+ await page.goto(page.url())
+ await expectPermissionBlocked(page)
+
+ await clearPermissionDock(page, /deny/i)
+ await state.resolved()
+ await page.goto(page.url())
+ await expectPermissionOpen(page)
+ },
+ )
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
- await withBackendProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock permission always",
- async (session) => {
- await project.gotoSession(session.id)
- await setAutoAccept(page, false)
- await withMockPermission(
- page,
- {
- id: "per_e2e_always",
- sessionID: session.id,
- permission: "bash",
- patterns: ["/tmp/opencode-e2e-perm-always"],
- metadata: { description: "Need permission for command" },
- },
- undefined,
- async (state) => {
- await page.goto(page.url())
- await expectPermissionBlocked(page)
-
- await clearPermissionDock(page, /allow always/i)
- await state.resolved()
- await page.goto(page.url())
- await expectPermissionOpen(page)
- },
- )
- },
- { trackSession: project.trackSession },
- )
- })
+test("blocked permission flow supports allow always", async ({ page, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock permission always",
+ async (session) => {
+ await project.gotoSession(session.id)
+ await setAutoAccept(page, false)
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_always",
+ sessionID: session.id,
+ permission: "bash",
+ patterns: ["/tmp/opencode-e2e-perm-always"],
+ metadata: { description: "Need permission for command" },
+ },
+ undefined,
+ async (state) => {
+ await page.goto(page.url())
+ await expectPermissionBlocked(page)
+
+ await clearPermissionDock(page, /allow always/i)
+ await state.resolved()
+ await page.goto(page.url())
+ await expectPermissionOpen(page)
+ },
+ )
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("child session question request blocks parent dock and unblocks after submit", async ({
- page,
- llm,
- withMockProject,
-}) => {
+test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
const questions = [
{
header: "Child input",
@@ -513,137 +498,131 @@ test("child session question request blocks parent dock and unblocks after submi
],
},
]
- await withMockProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock child question parent",
- async (session) => {
- await project.gotoSession(session.id)
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock child question parent",
+ async (session) => {
+ await project.gotoSession(session.id)
+
+ const child = await project.sdk.session
+ .create({
+ title: "e2e composer dock child question",
+ 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)
- const child = await project.sdk.session
- .create({
- title: "e2e composer dock child question",
- parentID: session.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 {
- await withDockSeed(project.sdk, child.id, async () => {
- await llm.toolMatch(inputMatch({ questions }), "question", { questions })
- await seedSessionQuestion(project.sdk, {
- sessionID: child.id,
- questions,
- })
- const dock = page.locator(questionDockSelector)
- await expectQuestionBlocked(page)
+ 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 dock.locator('[data-slot="question-option"]').first().click()
+ await dock.getByRole("button", { name: /submit/i }).click()
- await expectQuestionOpen(page)
- })
- } finally {
- await cleanupSession({ sdk: project.sdk, sessionID: child.id })
- }
- },
- { trackSession: project.trackSession },
- )
- })
+ await expectQuestionOpen(page)
+ })
+ } finally {
+ await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+ }
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("child session permission request blocks parent dock and supports allow once", async ({
- page,
- withBackendProject,
-}) => {
- await withBackendProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock child permission parent",
- async (session) => {
- await project.gotoSession(session.id)
- await setAutoAccept(page, false)
+test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock child permission parent",
+ async (session) => {
+ await project.gotoSession(session.id)
+ await setAutoAccept(page, false)
+
+ const child = await project.sdk.session
+ .create({
+ title: "e2e composer dock child permission",
+ 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)
- const child = await project.sdk.session
- .create({
- title: "e2e composer dock child permission",
- 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 withMockPermission(
- page,
- {
- id: "per_e2e_child",
- sessionID: child.id,
- permission: "bash",
- patterns: ["/tmp/opencode-e2e-perm-child"],
- metadata: { description: "Need child permission" },
- },
- { child },
- async (state) => {
- await page.goto(page.url())
- await expectPermissionBlocked(page)
-
- await clearPermissionDock(page, /allow once/i)
- await state.resolved()
- await page.goto(page.url())
-
- await expectPermissionOpen(page)
- },
- )
- } finally {
- await cleanupSession({ sdk: project.sdk, sessionID: child.id })
- }
- },
- { trackSession: project.trackSession },
- )
- })
+ try {
+ await withMockPermission(
+ page,
+ {
+ id: "per_e2e_child",
+ sessionID: child.id,
+ permission: "bash",
+ patterns: ["/tmp/opencode-e2e-perm-child"],
+ metadata: { description: "Need child permission" },
+ },
+ { child },
+ async (state) => {
+ await page.goto(page.url())
+ await expectPermissionBlocked(page)
+
+ await clearPermissionDock(page, /allow once/i)
+ await state.resolved()
+ await page.goto(page.url())
+
+ await expectPermissionOpen(page)
+ },
+ )
+ } finally {
+ await cleanupSession({ sdk: project.sdk, sessionID: child.id })
+ }
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
- await withBackendProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock todo",
- async (session) => {
- const dock = await todoDock(page, session.id)
- await project.gotoSession(session.id)
- await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
-
- try {
- await dock.open([
- { content: "first task", status: "pending", priority: "high" },
- { content: "second task", status: "in_progress", priority: "medium" },
- ])
- await dock.expectOpen(["pending", "in_progress"])
-
- await dock.collapse()
- await dock.expectCollapsed(["pending", "in_progress"])
-
- await dock.expand()
- await dock.expectOpen(["pending", "in_progress"])
-
- await dock.finish([
- { content: "first task", status: "completed", priority: "high" },
- { content: "second task", status: "cancelled", priority: "medium" },
- ])
- await dock.expectClosed()
- } finally {
- await dock.clear()
- }
- },
- { trackSession: project.trackSession },
- )
- })
+test("todo dock transitions and collapse behavior", async ({ page, project }) => {
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock todo",
+ async (session) => {
+ const dock = await todoDock(page, session.id)
+ await project.gotoSession(session.id)
+ await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+
+ try {
+ await dock.open([
+ { content: "first task", status: "pending", priority: "high" },
+ { content: "second task", status: "in_progress", priority: "medium" },
+ ])
+ await dock.expectOpen(["pending", "in_progress"])
+
+ await dock.collapse()
+ await dock.expectCollapsed(["pending", "in_progress"])
+
+ await dock.expand()
+ await dock.expectOpen(["pending", "in_progress"])
+
+ await dock.finish([
+ { content: "first task", status: "completed", priority: "high" },
+ { content: "second task", status: "cancelled", priority: "medium" },
+ ])
+ await dock.expectClosed()
+ } finally {
+ await dock.clear()
+ }
+ },
+ { trackSession: project.trackSession },
+ )
})
-test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
+test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
const questions = [
{
header: "Need input",
@@ -651,28 +630,27 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
options: [{ label: "Continue", description: "Continue now" }],
},
]
- await withMockProject(async (project) => {
- await withDockSession(
- project.sdk,
- "e2e composer dock keyboard",
- async (session) => {
- await withDockSeed(project.sdk, session.id, async () => {
- await project.gotoSession(session.id)
+ await project.open()
+ await withDockSession(
+ project.sdk,
+ "e2e composer dock keyboard",
+ async (session) => {
+ await withDockSeed(project.sdk, session.id, async () => {
+ await project.gotoSession(session.id)
- await llm.toolMatch(inputMatch({ questions }), "question", { questions })
- await seedSessionQuestion(project.sdk, {
- sessionID: session.id,
- questions,
- })
+ await llm.toolMatch(inputMatch({ questions }), "question", { questions })
+ await seedSessionQuestion(project.sdk, {
+ sessionID: session.id,
+ questions,
+ })
- await expectQuestionBlocked(page)
+ 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 page.locator("main").click({ position: { x: 5, y: 5 } })
+ await page.keyboard.type("abc")
+ await expect(page.locator(promptSelector)).toHaveCount(0)
+ })
+ },
+ { trackSession: project.trackSession },
+ )
})
diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts
index 66bc451bc..c107cc518 100644
--- a/packages/app/e2e/session/session-model-persistence.spec.ts
+++ b/packages/app/e2e/session/session-model-persistence.spec.ts
@@ -1,15 +1,6 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
-import {
- openSidebar,
- resolveSlug,
- sessionIDFromUrl,
- setWorkspacesEnabled,
- waitSession,
- waitSessionIdle,
- waitSlug,
- withNoReplyPrompt,
-} from "../actions"
+import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
import {
promptAgentSelector,
promptModelSelector,
@@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
await waitSession(page, { directory, sessionID })
}
-async function submit(page: Page, value: string) {
- const prompt = page.locator('[data-component="prompt-input"]')
- await expect(prompt).toBeVisible()
-
- await withNoReplyPrompt(page, async () => {
- await prompt.click()
- await prompt.fill(value)
- await prompt.press("Enter")
- })
-
- await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
- const id = sessionIDFromUrl(page.url())
- if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
- return id
-}
-
-async function waitUser(directory: string, sessionID: string) {
- const sdk = createSdk(directory)
- await expect
- .poll(
- async () => {
- const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
- return items.some((item) => item.info.role === "user")
- },
- { timeout: 30_000 },
- )
- .toBe(true)
- await sdk.session.abort({ sessionID }).catch(() => undefined)
- await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
+async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
+ return project.prompt(value)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -301,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) {
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
-test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
+test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
await page.setViewportSize({ width: 1440, height: 900 })
- await withProject(async ({ directory, gotoSession, trackSession }) => {
- await gotoSession()
+ await project.open()
+ await project.gotoSession()
- const firstState = await chooseOtherModel(page)
- const firstKey = await currentModel(page)
- const first = await submit(page, `session variant ${Date.now()}`)
- trackSession(first)
- await waitUser(directory, first)
+ const firstState = await chooseOtherModel(page)
+ const firstKey = await currentModel(page)
+ const first = await submit(project, `session variant ${Date.now()}`)
- await page.reload()
- await waitSession(page, { directory, sessionID: first })
- await waitFooter(page, firstState)
+ await page.reload()
+ await waitSession(page, { directory: project.directory, sessionID: first })
+ await waitFooter(page, firstState)
- await gotoSession()
- const fresh = await read(page)
- expect(fresh.model).not.toBe(firstState.model)
+ await project.gotoSession()
+ const fresh = await read(page)
+ expect(fresh.model).not.toBe(firstState.model)
- const secondState = await chooseOtherModel(page, [firstKey])
- const second = await submit(page, `session model ${Date.now()}`)
- trackSession(second)
- await waitUser(directory, second)
+ const secondState = await chooseOtherModel(page, [firstKey])
+ const second = await submit(project, `session model ${Date.now()}`)
- await goto(page, directory, first)
- await waitFooter(page, firstState)
+ await goto(page, project.directory, first)
+ await waitFooter(page, firstState)
- await goto(page, directory, second)
- await waitFooter(page, secondState)
+ await goto(page, project.directory, second)
+ await waitFooter(page, secondState)
- await gotoSession()
- await waitFooter(page, fresh)
- })
+ await project.gotoSession()
+ await page.reload()
+ await waitSession(page, { directory: project.directory })
+ await waitFooter(page, fresh)
})
-test("session model restore across workspaces", async ({ page, withProject }) => {
+test("session model restore across workspaces", async ({ page, project }) => {
await page.setViewportSize({ width: 1440, height: 900 })
- await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
- await gotoSession()
+ await project.open()
+ const root = project.directory
+ await project.gotoSession()
- const firstState = await chooseOtherModel(page)
- const firstKey = await currentModel(page)
- const first = await submit(page, `root session ${Date.now()}`)
- trackSession(first, root)
- await waitUser(root, first)
+ const firstState = await chooseOtherModel(page)
+ const firstKey = await currentModel(page)
+ const first = await submit(project, `root session ${Date.now()}`)
- await openSidebar(page)
- await setWorkspacesEnabled(page, slug, true)
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, project.slug, true)
- const one = await createWorkspace(page, slug, [])
- const oneDir = await newWorkspaceSession(page, one.slug)
- trackDirectory(oneDir)
+ const one = await createWorkspace(page, project.slug, [])
+ const oneDir = await newWorkspaceSession(page, one.slug)
+ project.trackDirectory(oneDir)
- const secondState = await chooseOtherModel(page, [firstKey])
- const secondKey = await currentModel(page)
- const second = await submit(page, `workspace one ${Date.now()}`)
- trackSession(second, oneDir)
- await waitUser(oneDir, second)
+ const secondState = await chooseOtherModel(page, [firstKey])
+ const secondKey = await currentModel(page)
+ const second = await submit(project, `workspace one ${Date.now()}`)
- const two = await createWorkspace(page, slug, [one.slug])
- const twoDir = await newWorkspaceSession(page, two.slug)
- trackDirectory(twoDir)
+ const two = await createWorkspace(page, project.slug, [one.slug])
+ const twoDir = await newWorkspaceSession(page, two.slug)
+ project.trackDirectory(twoDir)
- const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
- const third = await submit(page, `workspace two ${Date.now()}`)
- trackSession(third, twoDir)
- await waitUser(twoDir, third)
+ const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
+ const third = await submit(project, `workspace two ${Date.now()}`)
- await goto(page, root, first)
- await waitFooter(page, firstState)
+ await goto(page, root, first)
+ await waitFooter(page, firstState)
- await goto(page, oneDir, second)
- await waitFooter(page, secondState)
+ await goto(page, oneDir, second)
+ await waitFooter(page, secondState)
- await goto(page, twoDir, third)
- await waitFooter(page, thirdState)
+ await goto(page, twoDir, third)
+ await waitFooter(page, thirdState)
- await goto(page, root, first)
- await waitFooter(page, firstState)
- })
+ await goto(page, root, first)
+ await waitFooter(page, firstState)
})
-test("variant preserved when switching agent modes", async ({ page, withProject }) => {
+test("variant preserved when switching agent modes", async ({ page, project }) => {
await page.setViewportSize({ width: 1440, height: 900 })
- await withProject(async ({ directory, gotoSession }) => {
- await gotoSession()
+ await project.open()
+ await project.gotoSession()
- await ensureVariant(page, directory)
- const updated = await chooseDifferentVariant(page)
+ await ensureVariant(page, project.directory)
+ const updated = await chooseDifferentVariant(page)
- const available = await agents(page)
- const other = available.find((name) => name !== updated.agent)
- test.skip(!other, "only one agent available")
- if (!other) return
+ const available = await agents(page)
+ const other = available.find((name) => name !== updated.agent)
+ test.skip(!other, "only one agent available")
+ if (!other) return
- await choose(page, promptAgentSelector, other)
- await waitFooter(page, { agent: other, variant: updated.variant })
+ await choose(page, promptAgentSelector, other)
+ await waitFooter(page, { agent: other, variant: updated.variant })
- await choose(page, promptAgentSelector, updated.agent)
- await waitFooter(page, { agent: updated.agent, variant: updated.variant })
- })
+ await choose(page, promptAgentSelector, updated.agent)
+ await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts
index c7529112f..c0a98cb2e 100644
--- a/packages/app/e2e/session/session-review.spec.ts
+++ b/packages/app/e2e/session/session-review.spec.ts
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
-import { inputMatch } from "../prompt/mock"
+import { bodyText } from "../prompt/mock"
const count = 14
@@ -47,8 +47,12 @@ async function patchWithMock(
patchText: string,
) {
const callsBefore = await llm.calls()
- await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
- await sdk.session.promptAsync({
+ await llm.toolMatch(
+ (hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
+ "apply_patch",
+ { patchText },
+ )
+ await sdk.session.prompt({
sessionID,
agent: "build",
system: [
@@ -61,12 +65,16 @@ async function patchWithMock(
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
- // Wait for the agent loop to actually start before checking idle.
- // promptAsync is fire-and-forget — without this, waitSessionIdle can
- // return immediately because the session status is still undefined.
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
-
- await waitSessionIdle(sdk, sessionID, 120_000)
+ await expect
+ .poll(
+ async () => {
+ const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
+ return diff.length
+ },
+ { timeout: 120_000 },
+ )
+ .toBeGreaterThan(0)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
@@ -245,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
}
}
-test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
+test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
@@ -254,46 +262,45 @@ test("review applies inline comment clicks without horizontal overflow", async (
await page.setViewportSize({ width: 1280, height: 900 })
- await withMockProject(async (project) => {
- await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
- project.trackSession(session.id)
- await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
-
- await expect
- .poll(
- async () => {
- const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
- return diff.length
- },
- { timeout: 60_000 },
- )
- .toBe(1)
-
- await project.gotoSession(session.id)
- await show(page)
-
- const tab = page.getByRole("tab", { name: /Review/i }).first()
- await expect(tab).toBeVisible()
- await tab.click()
-
- await expand(page)
- await waitMark(page, file, tag)
- await comment(page, file, note)
-
- await expect
- .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
- .toBeLessThanOrEqual(1)
- await expect
- .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
- .toBeLessThanOrEqual(1)
- await expect
- .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
- .toBeLessThanOrEqual(1)
- })
+ await project.open()
+ await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
+ project.trackSession(session.id)
+ await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
+
+ await expect
+ .poll(
+ async () => {
+ const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+ return diff.length
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(1)
+
+ await project.gotoSession(session.id)
+ await show(page)
+
+ const tab = page.getByRole("tab", { name: /Review/i }).first()
+ await expect(tab).toBeVisible()
+ await tab.click()
+
+ await expand(page)
+ await waitMark(page, file, tag)
+ await comment(page, file, note)
+
+ await expect
+ .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+ .toBeLessThanOrEqual(1)
+ await expect
+ .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+ .toBeLessThanOrEqual(1)
+ await expect
+ .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+ .toBeLessThanOrEqual(1)
})
})
-test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
+test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
@@ -302,47 +309,46 @@ test("review file comments submit on click without clipping actions", async ({ p
await page.setViewportSize({ width: 1280, height: 900 })
- await withMockProject(async (project) => {
- await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
- project.trackSession(session.id)
- await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
-
- await expect
- .poll(
- async () => {
- const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
- return diff.length
- },
- { timeout: 60_000 },
- )
- .toBe(1)
-
- await project.gotoSession(session.id)
- await show(page)
-
- const tab = page.getByRole("tab", { name: /Review/i }).first()
- await expect(tab).toBeVisible()
- await tab.click()
-
- await expand(page)
- await waitMark(page, file, tag)
- await openReviewFile(page, file)
- await fileComment(page, note)
-
- await expect
- .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
- .toBeLessThanOrEqual(1)
- await expect
- .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
- .toBeLessThanOrEqual(1)
- await expect
- .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
- .toBeLessThanOrEqual(1)
- })
+ await project.open()
+ await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
+ project.trackSession(session.id)
+ await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
+
+ await expect
+ .poll(
+ async () => {
+ const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+ return diff.length
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(1)
+
+ await project.gotoSession(session.id)
+ await show(page)
+
+ const tab = page.getByRole("tab", { name: /Review/i }).first()
+ await expect(tab).toBeVisible()
+ await tab.click()
+
+ await expand(page)
+ await waitMark(page, file, tag)
+ await openReviewFile(page, file)
+ await fileComment(page, note)
+
+ await expect
+ .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+ .toBeLessThanOrEqual(1)
+ await expect
+ .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+ .toBeLessThanOrEqual(1)
+ await expect
+ .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+ .toBeLessThanOrEqual(1)
})
})
-test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
+test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
@@ -352,84 +358,83 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
await page.setViewportSize({ width: 1600, height: 1000 })
- await withMockProject(async (project) => {
- await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
- project.trackSession(session.id)
- await patchWithMock(llm, project.sdk, session.id, seed(list))
-
- await expect
- .poll(
- async () => {
- const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
- return info?.summary?.files ?? 0
- },
- { timeout: 60_000 },
- )
- .toBe(list.length)
-
- await expect
- .poll(
- async () => {
- const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
- return diff.length
- },
- { timeout: 60_000 },
- )
- .toBe(list.length)
-
- await project.gotoSession(session.id)
- await show(page)
-
- const tab = page.getByRole("tab", { name: /Review/i }).first()
- await expect(tab).toBeVisible()
- await tab.click()
-
- const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
- await expect(view).toBeVisible()
- const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
- await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
-
- await expand(page)
- await waitMark(page, hit.file, hit.mark)
-
- const row = page
- .getByRole("heading", {
- level: 3,
- name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
- })
- .first()
- await expect(row).toBeVisible()
- await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
-
- await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
- const prev = await spot(page, 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 expect
- .poll(
- async () => {
- const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
- const item = diff.find((item) => item.file === hit.file)
- return typeof item?.after === "string" ? item.after : ""
- },
- { timeout: 60_000 },
- )
- .toContain(`mark ${next}`)
-
- await waitMark(page, hit.file, next)
-
- await expect
- .poll(
- async () => {
- const next = await spot(page, hit.file)
- if (!next) return Number.POSITIVE_INFINITY
- return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
- },
- { timeout: 60_000 },
- )
- .toBeLessThanOrEqual(32)
- })
+ await project.open()
+ await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
+ project.trackSession(session.id)
+ await patchWithMock(llm, project.sdk, session.id, seed(list))
+
+ await expect
+ .poll(
+ async () => {
+ const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
+ return info?.summary?.files ?? 0
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(list.length)
+
+ await expect
+ .poll(
+ async () => {
+ const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+ return diff.length
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(list.length)
+
+ await project.gotoSession(session.id)
+ await show(page)
+
+ const tab = page.getByRole("tab", { name: /Review/i }).first()
+ await expect(tab).toBeVisible()
+ await tab.click()
+
+ const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
+ await expect(view).toBeVisible()
+ const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
+ await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
+
+ await expand(page)
+ await waitMark(page, hit.file, hit.mark)
+
+ const row = page
+ .getByRole("heading", {
+ level: 3,
+ name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
+ })
+ .first()
+ await expect(row).toBeVisible()
+ await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
+
+ await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
+ const prev = await spot(page, 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 expect
+ .poll(
+ async () => {
+ const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+ const item = diff.find((item) => item.file === hit.file)
+ return typeof item?.after === "string" ? item.after : ""
+ },
+ { timeout: 60_000 },
+ )
+ .toContain(`mark ${next}`)
+
+ await waitMark(page, hit.file, next)
+
+ await expect
+ .poll(
+ async () => {
+ const next = await spot(page, hit.file)
+ if (!next) return Number.POSITIVE_INFINITY
+ return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
+ },
+ { timeout: 60_000 },
+ )
+ .toBeLessThanOrEqual(32)
})
})
diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts
index a63bd9e3b..709a45b4c 100644
--- a/packages/app/e2e/session/session-undo-redo.spec.ts
+++ b/packages/app/e2e/session/session-undo-redo.spec.ts
@@ -49,188 +49,185 @@ async function seedConversation(input: {
return { prompt, userMessageID }
}
-test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
+test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
- await withBackendProject(async (project) => {
- const sdk = project.sdk
+ await project.open()
+ const sdk = project.sdk
- await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
- project.trackSession(session.id)
- await project.gotoSession(session.id)
+ await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
+ project.trackSession(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 page.keyboard.type("/undo")
+ await seeded.prompt.click()
+ await page.keyboard.type("/undo")
- const undo = page.locator('[data-slash-id="session.undo"]').first()
- await expect(undo).toBeVisible()
- await page.keyboard.press("Enter")
+ 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(seeded.userMessageID)
+ await expect
+ .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+ timeout: 30_000,
+ })
+ .toBe(seeded.userMessageID)
- await expect(seeded.prompt).toContainText(token)
- await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
- })
+ await expect(seeded.prompt).toContainText(token)
+ await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
-test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
+test("slash redo clears revert and restores latest state", async ({ page, project }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
- await withBackendProject(async (project) => {
- const sdk = project.sdk
+ await project.open()
+ const sdk = project.sdk
- await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
- project.trackSession(session.id)
- await project.gotoSession(session.id)
+ await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
+ project.trackSession(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 page.keyboard.type("/undo")
+ await seeded.prompt.click()
+ await page.keyboard.type("/undo")
- const undo = page.locator('[data-slash-id="session.undo"]').first()
- await expect(undo).toBeVisible()
- await page.keyboard.press("Enter")
+ 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(seeded.userMessageID)
+ await expect
+ .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+ timeout: 30_000,
+ })
+ .toBe(seeded.userMessageID)
- await seeded.prompt.click()
- await page.keyboard.press(`${modKey}+A`)
- await page.keyboard.press("Backspace")
- await page.keyboard.type("/redo")
+ await seeded.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")
+ 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,
- })
- .toBeUndefined()
+ await expect
+ .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+ timeout: 30_000,
+ })
+ .toBeUndefined()
- await expect(seeded.prompt).not.toContainText(token)
- await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
- })
+ await expect(seeded.prompt).not.toContainText(token)
+ await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
})
})
-test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
+test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
- await withBackendProject(async (project) => {
- const sdk = project.sdk
+ await project.open()
+ const sdk = project.sdk
+
+ await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
+ project.trackSession(session.id)
+ await project.gotoSession(session.id)
+
+ const first = await seedConversation({
+ page,
+ sdk,
+ sessionID: session.id,
+ 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")
- await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
- project.trackSession(session.id)
- await project.gotoSession(session.id)
+ const undo = page.locator('[data-slash-id="session.undo"]').first()
+ await expect(undo).toBeVisible()
+ await page.keyboard.press("Enter")
- const first = await seedConversation({
- page,
- sdk,
- sessionID: session.id,
- token: firstToken,
+ await expect
+ .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+ timeout: 30_000,
})
- const second = await seedConversation({
- page,
- sdk,
- sessionID: session.id,
- token: secondToken,
+ .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)
- 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)
- })
+ 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)
})
})
diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts
index 6c885460c..1b5fb1b60 100644
--- a/packages/app/e2e/session/session.spec.ts
+++ b/packages/app/e2e/session/session.spec.ts
@@ -31,156 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0)
}
-test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
+test("session can be renamed via header menu", async ({ page, project }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
- await withBackendProject(async (project) => {
- await withSession(project.sdk, originalTitle, async (session) => {
- project.trackSession(session.id)
- await seedMessage(project.sdk, session.id)
- await project.gotoSession(session.id)
- await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
-
- const menu = await openSessionMoreMenu(page, session.id)
- await clickMenuItem(menu, /rename/i)
-
- const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
- await expect(input).toBeVisible()
- await expect(input).toBeFocused()
- await input.fill(renamedTitle)
- await expect(input).toHaveValue(renamedTitle)
- await input.press("Enter")
-
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
- return data?.title
- },
- { timeout: 30_000 },
- )
- .toBe(renamedTitle)
-
- await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
- })
+ await project.open()
+ await withSession(project.sdk, originalTitle, async (session) => {
+ project.trackSession(session.id)
+ await seedMessage(project.sdk, session.id)
+ await project.gotoSession(session.id)
+ await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
+
+ const menu = await openSessionMoreMenu(page, session.id)
+ await clickMenuItem(menu, /rename/i)
+
+ const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
+ await expect(input).toBeVisible()
+ await expect(input).toBeFocused()
+ await input.fill(renamedTitle)
+ await expect(input).toHaveValue(renamedTitle)
+ await input.press("Enter")
+
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.title
+ },
+ { timeout: 30_000 },
+ )
+ .toBe(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 title = `e2e archive test ${stamp}`
- await withBackendProject(async (project) => {
- await withSession(project.sdk, title, async (session) => {
- project.trackSession(session.id)
- await seedMessage(project.sdk, session.id)
- await project.gotoSession(session.id)
- const menu = await openSessionMoreMenu(page, session.id)
- await clickMenuItem(menu, /archive/i)
-
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
- return data?.time?.archived
- },
- { timeout: 30_000 },
- )
- .not.toBeUndefined()
-
- await openSidebar(page)
- await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
- })
+ await project.open()
+ await withSession(project.sdk, title, async (session) => {
+ project.trackSession(session.id)
+ await seedMessage(project.sdk, session.id)
+ await project.gotoSession(session.id)
+ const menu = await openSessionMoreMenu(page, session.id)
+ await clickMenuItem(menu, /archive/i)
+
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.time?.archived
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBeUndefined()
+
+ await openSidebar(page)
+ await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
-test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
+test("session can be deleted via header menu", async ({ page, project }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
- await withBackendProject(async (project) => {
- await withSession(project.sdk, title, async (session) => {
- project.trackSession(session.id)
- await seedMessage(project.sdk, session.id)
- await project.gotoSession(session.id)
- const menu = await openSessionMoreMenu(page, session.id)
- await clickMenuItem(menu, /delete/i)
- await confirmDialog(page, /delete/i)
-
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session
- .get({ sessionID: session.id })
- .then((r) => r.data)
- .catch(() => undefined)
- return data?.id
- },
- { timeout: 30_000 },
- )
- .toBeUndefined()
-
- await openSidebar(page)
- await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
- })
+ await project.open()
+ await withSession(project.sdk, title, async (session) => {
+ project.trackSession(session.id)
+ await seedMessage(project.sdk, session.id)
+ await project.gotoSession(session.id)
+ const menu = await openSessionMoreMenu(page, session.id)
+ await clickMenuItem(menu, /delete/i)
+ await confirmDialog(page, /delete/i)
+
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session
+ .get({ sessionID: session.id })
+ .then((r) => r.data)
+ .catch(() => undefined)
+ return data?.id
+ },
+ { timeout: 30_000 },
+ )
+ .toBeUndefined()
+
+ await openSidebar(page)
+ await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
-test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
+test("session can be shared and unshared via header button", async ({ page, project }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now()
const title = `e2e share test ${stamp}`
- await withBackendProject(async (project) => {
- await withSession(project.sdk, title, async (session) => {
- project.trackSession(session.id)
- await seedMessage(project.sdk, session.id)
- await project.gotoSession(session.id)
-
- const shared = await openSharePopover(page)
- const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
- await expect(publish).toBeVisible({ timeout: 30_000 })
- await publish.click()
-
- await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
- timeout: 30_000,
- })
-
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
- return data?.share?.url || undefined
- },
- { timeout: 30_000 },
- )
- .not.toBeUndefined()
-
- const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
- await expect(unpublish).toBeVisible({ timeout: 30_000 })
- await unpublish.click()
-
- await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
- timeout: 30_000,
- })
-
- await expect
- .poll(
- async () => {
- const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
- return data?.share?.url || undefined
- },
- { timeout: 30_000 },
- )
- .toBeUndefined()
-
- const unshared = await openSharePopover(page)
- await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
- timeout: 30_000,
- })
+ await project.open()
+ await withSession(project.sdk, title, async (session) => {
+ project.trackSession(session.id)
+ await project.gotoSession(session.id)
+ await project.prompt(`share seed ${stamp}`)
+
+ const shared = await openSharePopover(page)
+ const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
+ await expect(publish).toBeVisible({ timeout: 30_000 })
+ await publish.click()
+
+ await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
+ timeout: 30_000,
+ })
+
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.share?.url || undefined
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBeUndefined()
+
+ const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
+ await expect(unpublish).toBeVisible({ timeout: 30_000 })
+ await unpublish.click()
+
+ await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+ timeout: 30_000,
+ })
+
+ await expect
+ .poll(
+ async () => {
+ const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.share?.url || undefined
+ },
+ { timeout: 30_000 },
+ )
+ .toBeUndefined()
+
+ const unshared = await openSharePopover(page)
+ await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+ timeout: 30_000,
})
})
})
diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts
index 1b151b606..6455892cc 100644
--- a/packages/app/e2e/settings/settings.spec.ts
+++ b/packages/app/e2e/settings/settings.spec.ts
@@ -88,10 +88,20 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
-
- await select.locator('[data-slot="select-select-trigger"]').click()
-
+ const trigger = select.locator('[data-slot="select-select-trigger"]')
const items = page.locator('[data-slot="select-select-item"]')
+
+ await trigger.click()
+ const open = await expect
+ .poll(async () => (await items.count()) > 0, { timeout: 5_000 })
+ .toBe(true)
+ .then(() => true)
+ .catch(() => false)
+ if (!open) {
+ await trigger.click()
+ await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
+ }
+ await expect(items.first()).toBeVisible()
const count = await items.count()
expect(count).toBeGreaterThan(1)
diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
index 1317d2bb6..05a129a61 100644
--- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
@@ -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 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
- await withProject(
- async () => {
- await openSidebar(page)
-
- const project = page.locator(projectSwitchSelector(slug)).first()
- const card = page.locator('[data-component="hover-card-content"]')
-
- await expect(project).toBeVisible()
- await project.hover()
- await expect(card.getByText(/recent sessions/i)).toBeVisible()
-
- await page.mouse.down()
- await expect(card).toHaveCount(0)
- await page.mouse.up()
-
- await waitSession(page, { directory: other })
- await expect(card).toHaveCount(0)
- },
- { extra: [other] },
- )
+ await project.open({ extra: [other] })
+ await openSidebar(page)
+
+ const projectButton = page.locator(projectSwitchSelector(slug)).first()
+ const card = page.locator('[data-component="hover-card-content"]')
+
+ await expect(projectButton).toBeVisible()
+ await projectButton.hover()
+ await expect(card.getByText(/recent sessions/i)).toBeVisible()
+
+ await projectButton.click()
+ await expect(card).toHaveCount(0)
+
+ await waitSession(page, { directory: other })
+ await expect(card).toHaveCount(0)
} finally {
await cleanupTestProject(other)
}
})
-test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
+test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
- await withProject(
- async () => {
- await openSidebar(page)
- await defocus(page)
-
- const project = page.locator(projectSwitchSelector(slug)).first()
-
- await expect(project).toBeVisible()
-
- let hit = false
- for (let i = 0; i < 20; i++) {
- hit = await project.evaluate((el) => {
- return el.matches(":focus") || !!el.parentElement?.matches(":focus")
- })
- if (hit) break
- await page.keyboard.press("Tab")
- }
-
- expect(hit).toBe(true)
-
- await page.keyboard.press("Enter")
- await waitSession(page, { directory: other })
- },
- { extra: [other] },
- )
+ await project.open({ extra: [other] })
+ await openSidebar(page)
+ await defocus(page)
+
+ const projectButton = page.locator(projectSwitchSelector(slug)).first()
+
+ await expect(projectButton).toBeVisible()
+
+ let hit = false
+ for (let i = 0; i < 20; i++) {
+ hit = await projectButton.evaluate((el) => {
+ return el.matches(":focus") || !!el.parentElement?.matches(":focus")
+ })
+ if (hit) break
+ await page.keyboard.press("Tab")
+ }
+
+ expect(hit).toBe(true)
+
+ await page.keyboard.press("Enter")
+ await waitSession(page, { directory: other })
} finally {
await cleanupTestProject(other)
}
diff --git a/packages/app/e2e/terminal/terminal-reconnect.spec.ts b/packages/app/e2e/terminal/terminal-reconnect.spec.ts
index b03ed8956..1a11a047a 100644
--- a/packages/app/e2e/terminal/terminal-reconnect.spec.ts
+++ b/packages/app/e2e/terminal/terminal-reconnect.spec.ts
@@ -12,35 +12,34 @@ async function open(page: Page) {
return term
}
-test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
- await withProject(async ({ gotoSession }) => {
- const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
- const token = `E2E_RECONNECT_${Date.now()}`
+test("terminal reconnects without replacing the pty", async ({ page, project }) => {
+ await project.open()
+ const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
+ const token = `E2E_RECONNECT_${Date.now()}`
- await gotoSession()
+ await project.gotoSession()
- const term = await open(page)
- const id = await term.getAttribute("data-pty-id")
- if (!id) throw new Error("Active terminal missing data-pty-id")
+ const term = await open(page)
+ const id = await term.getAttribute("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, {
- term,
- cmd: `export ${name}=${token}; echo ${token}`,
- token,
- })
+ await runTerminal(page, {
+ term,
+ cmd: `export ${name}=${token}; echo ${token}`,
+ token,
+ })
- await disconnectTerminal(page, { term })
+ await disconnectTerminal(page, { term })
- 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(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
+ await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
- await runTerminal(page, {
- term,
- cmd: `echo $${name}`,
- token,
- timeout: 15_000,
- })
+ await runTerminal(page, {
+ term,
+ cmd: `echo $${name}`,
+ token,
+ timeout: 15_000,
})
})
diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts
index 6b6fa4c62..5cb5bbf20 100644
--- a/packages/app/e2e/terminal/terminal-tabs.spec.ts
+++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts
@@ -36,133 +36,130 @@ async function store(page: Page, key: string) {
}, key)
}
-test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
- await withProject(async ({ directory, gotoSession }) => {
- const key = workspacePersistKey(directory, "terminal")
- const one = `E2E_TERM_ONE_${Date.now()}`
- const two = `E2E_TERM_TWO_${Date.now()}`
- const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
- const first = tabs.filter({ hasText: /Terminal 1/ }).first()
- const second = tabs.filter({ hasText: /Terminal 2/ }).first()
-
- await gotoSession()
- await open(page)
-
- await runTerminal(page, { cmd: `echo ${one}`, token: one })
-
- await page.getByRole("button", { name: /new terminal/i }).click()
- await expect(tabs).toHaveCount(2)
-
- await runTerminal(page, { cmd: `echo ${two}`, token: two })
-
- await first.click()
- await expect(first).toHaveAttribute("aria-selected", "true")
-
- await expect
- .poll(
- async () => {
- const state = await store(page, key)
- const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
- const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
- return {
- first: first.includes(one),
- second: second.includes(two),
- }
- },
- { timeout: 5_000 },
- )
- .toEqual({ first: false, second: true })
-
- await second.click()
- await expect(second).toHaveAttribute("aria-selected", "true")
- await expect
- .poll(
- async () => {
- const state = await store(page, key)
- const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
- const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
- return {
- first: first.includes(one),
- second: second.includes(two),
- }
- },
- { timeout: 5_000 },
- )
- .toEqual({ first: true, second: false })
- })
+test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
+ await project.open()
+ const key = workspacePersistKey(project.directory, "terminal")
+ const one = `E2E_TERM_ONE_${Date.now()}`
+ const two = `E2E_TERM_TWO_${Date.now()}`
+ const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
+ const first = tabs.filter({ hasText: /Terminal 1/ }).first()
+ const second = tabs.filter({ hasText: /Terminal 2/ }).first()
+
+ await project.gotoSession()
+ await open(page)
+
+ await runTerminal(page, { cmd: `echo ${one}`, token: one })
+
+ await page.getByRole("button", { name: /new terminal/i }).click()
+ await expect(tabs).toHaveCount(2)
+
+ await runTerminal(page, { cmd: `echo ${two}`, token: two })
+
+ await first.click()
+ await expect(first).toHaveAttribute("aria-selected", "true")
+
+ await expect
+ .poll(
+ async () => {
+ const state = await store(page, key)
+ const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
+ const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
+ return {
+ first: first.includes(one),
+ second: second.includes(two),
+ }
+ },
+ { timeout: 5_000 },
+ )
+ .toEqual({ first: false, second: true })
+
+ await second.click()
+ await expect(second).toHaveAttribute("aria-selected", "true")
+ await expect
+ .poll(
+ async () => {
+ const state = await store(page, key)
+ const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
+ const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
+ return {
+ first: first.includes(one),
+ second: second.includes(two),
+ }
+ },
+ { timeout: 5_000 },
+ )
+ .toEqual({ first: true, second: false })
})
-test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
- await withProject(async ({ directory, gotoSession }) => {
- const key = workspacePersistKey(directory, "terminal")
- const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
-
- await gotoSession()
- await open(page)
-
- await page.getByRole("button", { name: /new terminal/i }).click()
- await expect(tabs).toHaveCount(2)
-
- const second = tabs.filter({ hasText: /Terminal 2/ }).first()
- await second.click()
- await expect(second).toHaveAttribute("aria-selected", "true")
-
- await second.hover()
- await page
- .getByRole("button", { name: /close terminal/i })
- .nth(1)
- .click({ force: true })
-
- const first = tabs.filter({ hasText: /Terminal 1/ }).first()
- await expect(tabs).toHaveCount(1)
- await expect(first).toHaveAttribute("aria-selected", "true")
- await expect
- .poll(
- async () => {
- const state = await store(page, key)
- return {
- count: state?.all.length ?? 0,
- first: state?.all.some((item) => item.titleNumber === 1) ?? false,
- }
- },
- { timeout: 15_000 },
- )
- .toEqual({ count: 1, first: true })
- })
+test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
+ await project.open()
+ const key = workspacePersistKey(project.directory, "terminal")
+ const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
+
+ await project.gotoSession()
+ await open(page)
+
+ await page.getByRole("button", { name: /new terminal/i }).click()
+ await expect(tabs).toHaveCount(2)
+
+ const second = tabs.filter({ hasText: /Terminal 2/ }).first()
+ await second.click()
+ await expect(second).toHaveAttribute("aria-selected", "true")
+
+ await second.hover()
+ await page
+ .getByRole("button", { name: /close terminal/i })
+ .nth(1)
+ .click({ force: true })
+
+ const first = tabs.filter({ hasText: /Terminal 1/ }).first()
+ await expect(tabs).toHaveCount(1)
+ await expect(first).toHaveAttribute("aria-selected", "true")
+ await expect
+ .poll(
+ async () => {
+ const state = await store(page, key)
+ return {
+ count: state?.all.length ?? 0,
+ first: state?.all.some((item) => item.titleNumber === 1) ?? false,
+ }
+ },
+ { timeout: 15_000 },
+ )
+ .toEqual({ count: 1, first: true })
})
-test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
- await withProject(async ({ directory, gotoSession }) => {
- const key = workspacePersistKey(directory, "terminal")
- const rename = `E2E term ${Date.now()}`
- const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
-
- await gotoSession()
- await open(page)
-
- await expect(tab).toContainText(/Terminal 1/)
- await tab.click({ button: "right" })
-
- const menu = page.locator(dropdownMenuContentSelector).first()
- await expect(menu).toBeVisible()
- await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
- await expect(menu).toHaveCount(0)
-
- const input = page.locator('#terminal-panel input[type="text"]').first()
- await expect(input).toBeVisible()
- await input.fill(rename)
- await input.press("Enter")
-
- await expect(input).toHaveCount(0)
- await expect(tab).toContainText(rename)
- await expect
- .poll(
- async () => {
- const state = await store(page, key)
- return state?.all[0]?.title
- },
- { timeout: 5_000 },
- )
- .toBe(rename)
- })
+test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
+ await project.open()
+ const key = workspacePersistKey(project.directory, "terminal")
+ const rename = `E2E term ${Date.now()}`
+ const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
+
+ await project.gotoSession()
+ await open(page)
+
+ await expect(tab).toContainText(/Terminal 1/)
+ await tab.click({ button: "right" })
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ await expect(menu).toBeVisible()
+ await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
+ await expect(menu).toHaveCount(0)
+
+ const input = page.locator('#terminal-panel input[type="text"]').first()
+ await expect(input).toBeVisible()
+ await input.fill(rename)
+ await input.press("Enter")
+
+ await expect(input).toHaveCount(0)
+ await expect(tab).toContainText(rename)
+ await expect
+ .poll(
+ async () => {
+ const state = await store(page, key)
+ return state?.all[0]?.title
+ },
+ { timeout: 5_000 },
+ )
+ .toBe(rename)
})
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index ba299fe36..06b6c1e35 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
+import { promptProbe } from "@/testing/prompt"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
@@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
+ promptProbe.start()
const projectDirectory = sdk.directory
const isNewSession = !params.id
@@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
+ promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
input.onSubmit?.()
if (mode === "shell") {
diff --git a/packages/app/src/testing/prompt.ts b/packages/app/src/testing/prompt.ts
index e11462f30..5102ed825 100644
--- a/packages/app/src/testing/prompt.ts
+++ b/packages/app/src/testing/prompt.ts
@@ -10,6 +10,13 @@ export type PromptProbeState = {
selects: number
}
+export type PromptSendState = {
+ started: number
+ count: number
+ sessionID?: string
+ directory?: string
+}
+
export const promptEnabled = () => {
if (typeof window === "undefined") return false
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
@@ -53,4 +60,24 @@ export const promptProbe = {
if (!state) return
state.current = undefined
},
+ start() {
+ const state = root()
+ if (!state) return
+ state.sent = {
+ started: (state.sent?.started ?? 0) + 1,
+ count: state.sent?.count ?? 0,
+ sessionID: state.sent?.sessionID,
+ directory: state.sent?.directory,
+ }
+ },
+ submit(input: { sessionID: string; directory: string }) {
+ const state = root()
+ if (!state) return
+ state.sent = {
+ started: state.sent?.started ?? 0,
+ count: (state.sent?.count ?? 0) + 1,
+ sessionID: input.sessionID,
+ directory: input.directory,
+ }
+ },
}
diff --git a/packages/app/src/testing/terminal.ts b/packages/app/src/testing/terminal.ts
index 2bca39b31..db8001ddf 100644
--- a/packages/app/src/testing/terminal.ts
+++ b/packages/app/src/testing/terminal.ts
@@ -23,6 +23,7 @@ export type E2EWindow = Window & {
prompt?: {
enabled?: boolean
current?: import("./prompt").PromptProbeState
+ sent?: import("./prompt").PromptSendState
}
terminal?: {
enabled?: boolean
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 441f84b90..81479dcd7 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -114,6 +114,12 @@ export namespace Provider {
})
}
+ function e2eURL() {
+ const url = Env.get("OPENCODE_E2E_LLM_URL")
+ if (typeof url !== "string" || url === "") return
+ return url
+ }
+
type BundledSDK = {
languageModel(modelId: string): LanguageModelV3
}
@@ -1450,6 +1456,17 @@ export namespace Provider {
if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => {
+ const url = e2eURL()
+ if (url) {
+ const language = createOpenAICompatible({
+ name: model.providerID,
+ apiKey: "test-key",
+ baseURL: url,
+ }).chatModel(model.api.id)
+ s.models.set(key, language)
+ return language
+ }
+
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s)
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 133a5018a..262040514 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -32,6 +32,7 @@ import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
+import { Env } from "../env"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -166,7 +167,8 @@ export namespace ToolRegistry {
}
const usePatch =
- model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
+ !!Env.get("OPENCODE_E2E_LLM_URL") ||
+ (model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts
index 287d04ed3..2cc509202 100644
--- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts
+++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts
@@ -159,7 +159,17 @@ describe("cross-spawn spawner", () => {
fx.effect(
"captures both stdout and stderr",
Effect.gen(function* () {
- const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
+ const handle = yield* js(
+ [
+ "let pending = 2",
+ "const done = () => {",
+ " pending -= 1",
+ " if (pending === 0) setTimeout(() => process.exit(0), 0)",
+ "}",
+ 'process.stdout.write("stdout\\n", done)',
+ 'process.stderr.write("stderr\\n", done)',
+ ].join("\n"),
+ )
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts
index 747693d70..fbad6ac14 100644
--- a/packages/opencode/test/lib/llm-server.ts
+++ b/packages/opencode/test/lib/llm-server.ts
@@ -254,6 +254,16 @@ function responseToolArgs(id: string, text: string, seq: number) {
}
}
+function responseToolArgsDone(id: string, args: string, seq: number) {
+ return {
+ type: "response.function_call_arguments.done",
+ sequence_number: seq,
+ output_index: 0,
+ item_id: id,
+ arguments: args,
+ }
+}
+
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
return {
type: "response.output_item.done",
@@ -391,6 +401,8 @@ function responses(item: Sse, model: string) {
}
if (call && !item.hang && !item.error) {
seq += 1
+ lines.push(responseToolArgsDone(call.item, call.args, seq))
+ seq += 1
lines.push(responseToolDone(call, seq))
}
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
@@ -599,6 +611,11 @@ function isToolResultFollowUp(body: unknown): boolean {
return false
}
+function isTitleRequest(body: unknown): boolean {
+ if (!body || typeof body !== "object") return false
+ return JSON.stringify(body).includes("Generate a title for this conversation")
+}
+
function requestSummary(body: unknown): string {
if (!body || typeof body !== "object") return "empty body"
if ("messages" in body && Array.isArray(body.messages)) {
@@ -623,6 +640,7 @@ namespace TestLLMServer {
readonly error: (status: number, body: unknown) => Effect.Effect<void>
readonly hang: Effect.Effect<void>
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
+ readonly reset: Effect.Effect<void>
readonly hits: Effect.Effect<Hit[]>
readonly calls: Effect.Effect<number>
readonly wait: (count: number) => Effect.Effect<void>
@@ -671,21 +689,20 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
const req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
const current = hit(req.originalUrl, body)
+ if (isTitleRequest(body)) {
+ hits = [...hits, current]
+ yield* notify()
+ const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
+ if (mode === "responses") return send(responses(auto, modelFrom(body)))
+ return send(auto)
+ }
const next = pull(current)
if (!next) {
- // Auto-acknowledge tool-result follow-ups so tests only need to
- // queue one response per tool call instead of two.
- if (isToolResultFollowUp(body)) {
- hits = [...hits, current]
- yield* notify()
- const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
- if (mode === "responses") return send(responses(auto, modelFrom(body)))
- return send(auto)
- }
- misses = [...misses, current]
- const summary = requestSummary(body)
- console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`)
- return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 })
+ hits = [...hits, current]
+ yield* notify()
+ const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
+ if (mode === "responses") return send(responses(auto, modelFrom(body)))
+ return send(auto)
}
hits = [...hits, current]
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>) {
queue(reply().wait(wait).text(value).stop().item())
}),
+ reset: Effect.sync(() => {
+ hits = []
+ list = []
+ waits = []
+ misses = []
+ }),
hits: Effect.sync(() => [...hits]),
calls: Effect.sync(() => hits.length),
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
diff --git a/packages/opencode/test/session/e2e-url-repro.test.ts b/packages/opencode/test/session/e2e-url-repro.test.ts
new file mode 100644
index 000000000..744c7bf0b
--- /dev/null
+++ b/packages/opencode/test/session/e2e-url-repro.test.ts
@@ -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" } } },
+ }),
+ },
+ ),
+)