test(app): add a golden path for mocked e2e prompts (#20593)
This commit is contained in:
@@ -47,6 +47,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test
|
run: bun turbo test
|
||||||
|
env:
|
||||||
|
# Bun 1.3.11 intermittently crashes on Windows during test teardown
|
||||||
|
# inside the native @parcel/watcher binding. Unit CI does not rely on
|
||||||
|
# the live watcher backend there, so disable it for that platform.
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
### Using Fixtures
|
### Using Fixtures
|
||||||
|
|
||||||
- `page` - Playwright page
|
- `page` - Playwright page
|
||||||
- `sdk` - OpenCode SDK client for API calls
|
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
||||||
- `gotoSession(sessionID?)` - Navigate to session
|
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
||||||
|
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
||||||
|
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
||||||
|
|
||||||
### Helper Functions
|
### Helper Functions
|
||||||
|
|
||||||
@@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
|
||||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- Prefer `withSession(...)` for temp sessions
|
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
||||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
||||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
||||||
- Avoid calling `sdk.session.delete(...)` directly
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|||||||
+60
-168
@@ -1,5 +1,5 @@
|
|||||||
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { expect, type Locator, type Page, type Route } from "@playwright/test"
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
|
|||||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectSwitchSelector,
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
@@ -43,27 +42,6 @@ export async function defocus(page: Page) {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
|
|
||||||
const url = "**/session/*/prompt_async"
|
|
||||||
const route = async (input: Route) => {
|
|
||||||
const body = input.request().postDataJSON()
|
|
||||||
await input.continue({
|
|
||||||
postData: JSON.stringify({ ...body, noReply: true }),
|
|
||||||
headers: {
|
|
||||||
...input.request().headers(),
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.route(url, route)
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} finally {
|
|
||||||
await page.unroute(url, route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminalID(term: Locator) {
|
async function terminalID(term: Locator) {
|
||||||
const id = await term.getAttribute(terminalAttr)
|
const id = await term.getAttribute(terminalAttr)
|
||||||
if (id) return id
|
if (id) return id
|
||||||
@@ -333,63 +311,6 @@ export async function openSettings(page: Page) {
|
|||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
|
|
||||||
await page.addInitScript(
|
|
||||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
|
||||||
const key = "opencode.global.dat:server"
|
|
||||||
const defaultKey = "opencode.settings.dat:defaultServerUrl"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const parsed = (() => {
|
|
||||||
if (!raw) return undefined
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
||||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
|
||||||
const current = nextProjects[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existing.some((p) => p.worktree === directory)) return
|
|
||||||
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = [args.directory, ...args.extra]
|
|
||||||
for (const directory of directories) {
|
|
||||||
add("local", directory)
|
|
||||||
add(args.serverUrl, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
list: nextList,
|
|
||||||
projects: nextProjects,
|
|
||||||
lastProject,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
localStorage.setItem(defaultKey, args.serverUrl)
|
|
||||||
},
|
|
||||||
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTestProject(input?: { serverUrl?: string }) {
|
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
const id = `e2e-${path.basename(root)}`
|
||||||
@@ -479,7 +400,15 @@ export async function waitDir(page: Page, directory: string, input?: { serverUrl
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
|
export async function waitSession(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
sessionID?: string
|
||||||
|
serverUrl?: string
|
||||||
|
allowAnySession?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -491,11 +420,11 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
|||||||
if (!resolved || resolved.directory !== target) return false
|
if (!resolved || resolved.directory !== target) return false
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (input.sessionID && current !== input.sessionID) return false
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
if (!input.sessionID && current) return false
|
if (!input.sessionID && !input.allowAnySession && current) return false
|
||||||
|
|
||||||
const state = await probeSession(page)
|
const state = await probeSession(page)
|
||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
if (!input.sessionID && state?.sessionID) return false
|
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
||||||
if (state?.dir) {
|
if (state?.dir) {
|
||||||
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||||
if (dir !== target) return false
|
if (dir !== target) return false
|
||||||
@@ -602,12 +531,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSharePopover(page: Page) {
|
export async function openSharePopover(page: Page) {
|
||||||
const rightSection = page.locator(titlebarRightSelector)
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
await expect(scroller).toBeVisible()
|
||||||
await expect(shareButton).toBeVisible()
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
|
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
const popoverBody = page
|
const popoverBody = page
|
||||||
.locator(popoverBodySelector)
|
.locator('[data-component="popover-content"]')
|
||||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
@@ -617,16 +549,13 @@ export async function openSharePopover(page: Page) {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
await shareButton.click()
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
await expect(popoverBody).toBeVisible()
|
await menuTrigger.click()
|
||||||
|
await clickMenuItem(menu, /share/i)
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
return { rightSection, popoverBody }
|
return { rightSection: scroller, popoverBody }
|
||||||
}
|
|
||||||
|
|
||||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
|
||||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickListItem(
|
export async function clickListItem(
|
||||||
@@ -794,40 +723,6 @@ export async function seedSessionQuestion(
|
|||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionPermission(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
permission: string
|
|
||||||
patterns: string[]
|
|
||||||
description?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one bash tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({
|
|
||||||
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
|
||||||
workdir: "/",
|
|
||||||
description: input.description ?? `seed ${input.permission} permission request`,
|
|
||||||
})}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
|
||||||
return list.find((item) => item.sessionID === input.sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding permission request")
|
|
||||||
return { id: result.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionTask(
|
export async function seedSessionTask(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
@@ -886,36 +781,6 @@ export async function seedSessionTask(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<{ content: string; status: string; priority: string }>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one todowrite tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
const target = JSON.stringify(input.todos)
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
|
||||||
if (JSON.stringify(todos) !== target) return
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding todos")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
const [questions, permissions] = await Promise.all([
|
const [questions, permissions] = await Promise.all([
|
||||||
sdk.question.list().then((x) => x.data ?? []),
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
@@ -1005,30 +870,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
const current = await page
|
const current = () =>
|
||||||
|
page
|
||||||
.getByRole("button", { name: "New workspace" })
|
.getByRole("button", { name: "New workspace" })
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.then((x) => x)
|
.then((x) => x)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (current === enabled) return
|
if ((await current()) === enabled) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
if ((await current()) === enabled) return
|
||||||
|
}
|
||||||
|
|
||||||
const flip = async (timeout?: number) => {
|
const flip = async (timeout?: number) => {
|
||||||
const menu = await openProjectMenu(page, projectSlug)
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
return toggle.click({ force: true, timeout })
|
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
||||||
}
|
const clicked = await toggle
|
||||||
|
.click({ force: true, timeout })
|
||||||
const flipped = await flip(1500)
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
if (clicked) return
|
||||||
|
await toggle.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
|
||||||
if (!flipped) await flip()
|
for (const timeout of [1500, undefined, undefined]) {
|
||||||
|
if ((await current()) === enabled) break
|
||||||
|
await flip(timeout)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
const matched = await expect
|
||||||
|
.poll(current, { timeout: 5_000 })
|
||||||
|
.toBe(enabled)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (matched) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await current()) !== enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
||||||
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function tail(input: string[]) {
|
|||||||
return input.slice(-40).join("")
|
return input.slice(-40).join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startBackend(label: string): Promise<Handle> {
|
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||||
const port = await freePort()
|
const port = await freePort()
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
@@ -80,6 +80,7 @@ export async function startBackend(label: string): Promise<Handle> {
|
|||||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
|
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||||
} satisfies Record<string, string | undefined>
|
} satisfies Record<string, string | undefined>
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
const err: string[] = []
|
const err: string[] = []
|
||||||
|
|||||||
+407
-108
@@ -10,13 +10,14 @@ import {
|
|||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
createTestProject,
|
createTestProject,
|
||||||
setHealthPhase,
|
setHealthPhase,
|
||||||
seedProjects,
|
|
||||||
sessionIDFromUrl,
|
sessionIDFromUrl,
|
||||||
waitSlug,
|
|
||||||
waitSession,
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
} from "./actions"
|
} from "./actions"
|
||||||
import { openaiModel, withMockOpenAI } from "./prompt/mock"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
||||||
|
|
||||||
type LLMFixture = {
|
type LLMFixture = {
|
||||||
url: string
|
url: string
|
||||||
@@ -51,6 +52,23 @@ type LLMFixture = {
|
|||||||
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LLMWorker = LLMFixture & {
|
||||||
|
reset: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantFixture = {
|
||||||
|
reply: LLMFixture["text"]
|
||||||
|
tool: LLMFixture["tool"]
|
||||||
|
toolHang: LLMFixture["toolHang"]
|
||||||
|
reason: LLMFixture["reason"]
|
||||||
|
fail: LLMFixture["fail"]
|
||||||
|
error: LLMFixture["error"]
|
||||||
|
hang: LLMFixture["hang"]
|
||||||
|
hold: LLMFixture["hold"]
|
||||||
|
calls: LLMFixture["calls"]
|
||||||
|
pending: LLMFixture["pending"]
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
const seedModel = (() => {
|
const seedModel = (() => {
|
||||||
@@ -63,6 +81,40 @@ const seedModel = (() => {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
function clean(value: string | null) {
|
||||||
|
return (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visit(page: Page, url: string) {
|
||||||
|
let err: unknown
|
||||||
|
for (const _ of [0, 1, 2]) {
|
||||||
|
try {
|
||||||
|
await page.goto(url)
|
||||||
|
return
|
||||||
|
} catch (cause) {
|
||||||
|
err = cause
|
||||||
|
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSend(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const sent = win.__opencode_e2e?.prompt?.sent
|
||||||
|
return {
|
||||||
|
started: sent?.started ?? 0,
|
||||||
|
count: sent?.count ?? 0,
|
||||||
|
sessionID: sent?.sessionID,
|
||||||
|
directory: sent?.directory,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectHandle = {
|
type ProjectHandle = {
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
@@ -79,16 +131,23 @@ type ProjectOptions = {
|
|||||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectFixture = ProjectHandle & {
|
||||||
|
open: (options?: ProjectOptions) => Promise<void>
|
||||||
|
prompt: (text: string) => Promise<string>
|
||||||
|
user: (text: string) => Promise<string>
|
||||||
|
shell: (cmd: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
llm: LLMFixture
|
llm: LLMFixture
|
||||||
|
assistant: AssistantFixture
|
||||||
|
project: ProjectFixture
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
|
_llm: LLMWorker
|
||||||
backend: {
|
backend: {
|
||||||
url: string
|
url: string
|
||||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
@@ -98,21 +157,8 @@ type WorkerFixtures = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
backend: [
|
_llm: [
|
||||||
async ({}, use, workerInfo) => {
|
async ({}, use) => {
|
||||||
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
|
||||||
try {
|
|
||||||
await use({
|
|
||||||
url: handle.url,
|
|
||||||
sdk: (directory?: string) => createSdk(directory, handle.url),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await handle.stop()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
llm: async ({}, use) => {
|
|
||||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||||
try {
|
try {
|
||||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||||
@@ -130,6 +176,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||||
hang: () => rt.runPromise(svc.hang),
|
hang: () => rt.runPromise(svc.hang),
|
||||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||||
|
reset: () => rt.runPromise(svc.reset),
|
||||||
hits: () => rt.runPromise(svc.hits),
|
hits: () => rt.runPromise(svc.hits),
|
||||||
calls: () => rt.runPromise(svc.calls),
|
calls: () => rt.runPromise(svc.calls),
|
||||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||||
@@ -141,6 +188,64 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await rt.dispose()
|
await rt.dispose()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
backend: [
|
||||||
|
async ({ _llm }, use, workerInfo) => {
|
||||||
|
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||||
|
try {
|
||||||
|
await use({
|
||||||
|
url: handle.url,
|
||||||
|
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await handle.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
llm: async ({ _llm }, use) => {
|
||||||
|
await _llm.reset()
|
||||||
|
await use({
|
||||||
|
url: _llm.url,
|
||||||
|
push: _llm.push,
|
||||||
|
pushMatch: _llm.pushMatch,
|
||||||
|
textMatch: _llm.textMatch,
|
||||||
|
toolMatch: _llm.toolMatch,
|
||||||
|
text: _llm.text,
|
||||||
|
tool: _llm.tool,
|
||||||
|
toolHang: _llm.toolHang,
|
||||||
|
reason: _llm.reason,
|
||||||
|
fail: _llm.fail,
|
||||||
|
error: _llm.error,
|
||||||
|
hang: _llm.hang,
|
||||||
|
hold: _llm.hold,
|
||||||
|
hits: _llm.hits,
|
||||||
|
calls: _llm.calls,
|
||||||
|
wait: _llm.wait,
|
||||||
|
inputs: _llm.inputs,
|
||||||
|
pending: _llm.pending,
|
||||||
|
misses: _llm.misses,
|
||||||
|
})
|
||||||
|
const pending = await _llm.pending()
|
||||||
|
if (pending > 0) {
|
||||||
|
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
assistant: async ({ llm }, use) => {
|
||||||
|
await use({
|
||||||
|
reply: llm.text,
|
||||||
|
tool: llm.tool,
|
||||||
|
toolHang: llm.toolHang,
|
||||||
|
reason: llm.reason,
|
||||||
|
fail: llm.fail,
|
||||||
|
error: llm.error,
|
||||||
|
hang: llm.hang,
|
||||||
|
hold: llm.hold,
|
||||||
|
calls: llm.calls,
|
||||||
|
pending: llm.pending,
|
||||||
|
})
|
||||||
|
},
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
let boundary: string | undefined
|
let boundary: string | undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
@@ -165,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
if (boundary) throw new Error(boundary)
|
if (boundary) throw new Error(boundary)
|
||||||
},
|
},
|
||||||
directory: [
|
directory: [
|
||||||
async ({}, use) => {
|
async ({ backend }, use) => {
|
||||||
const directory = await getWorktree()
|
await use(await getWorktree(backend.url))
|
||||||
await use(directory)
|
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
@@ -177,93 +281,254 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
sdk: async ({ directory }, use) => {
|
sdk: async ({ directory, backend }, use) => {
|
||||||
await use(createSdk(directory))
|
await use(backend.sdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory, backend }, use) => {
|
||||||
await seedStorage(page, { directory })
|
await seedStorage(page, { directory, serverUrl: backend.url })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await visit(page, sessionPath(directory, sessionID))
|
||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, {
|
||||||
|
directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
project: async ({ page, llm, backend }, use) => {
|
||||||
await use((callback, options) => runProject(page, callback, options))
|
const item = makeProject(page, llm, backend)
|
||||||
},
|
try {
|
||||||
withBackendProject: async ({ page, backend }, use) => {
|
await use(item.project)
|
||||||
await use((callback, options) =>
|
} finally {
|
||||||
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
|
await item.cleanup()
|
||||||
)
|
}
|
||||||
},
|
|
||||||
withMockProject: async ({ page, llm, backend }, use) => {
|
|
||||||
await use((callback, options) =>
|
|
||||||
withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: () =>
|
|
||||||
runProject(page, callback, {
|
|
||||||
...options,
|
|
||||||
model: options?.model ?? openaiModel,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
sdk: backend.sdk,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function runProject<T>(
|
function makeProject(
|
||||||
page: Page,
|
page: Page,
|
||||||
callback: (project: ProjectHandle) => Promise<T>,
|
llm: LLMFixture,
|
||||||
options?: ProjectOptions & {
|
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||||
serverUrl?: string
|
|
||||||
sdk?: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const url = options?.serverUrl
|
let state:
|
||||||
const root = await createTestProject(url ? { serverUrl: url } : undefined)
|
| {
|
||||||
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
|
directory: string
|
||||||
const sessions = new Map<string, string>()
|
slug: string
|
||||||
const dirs = new Set<string>()
|
sdk: ReturnType<typeof createSdk>
|
||||||
await options?.setup?.(root)
|
sessions: Map<string, string>
|
||||||
await seedStorage(page, {
|
dirs: Set<string>
|
||||||
directory: root,
|
}
|
||||||
extra: options?.extra,
|
| undefined
|
||||||
model: options?.model,
|
|
||||||
serverUrl: url,
|
const need = () => {
|
||||||
})
|
if (state) return state
|
||||||
|
throw new Error("project.open() must be called first")
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
cur.sessions.set(sessionID, directory ?? cur.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
const cur = need()
|
||||||
|
if (directory !== cur.directory) cur.dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(root, sessionID))
|
const cur = need()
|
||||||
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
await visit(page, sessionPath(cur.directory, sessionID))
|
||||||
|
await waitSession(page, {
|
||||||
|
directory: cur.directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
const open = async (options?: ProjectOptions) => {
|
||||||
sessions.set(sessionID, directory ?? root)
|
if (state) return
|
||||||
|
const directory = await createTestProject({ serverUrl: backend.url })
|
||||||
|
const sdk = backend.sdk(directory)
|
||||||
|
await options?.setup?.(directory)
|
||||||
|
await seedStorage(page, {
|
||||||
|
directory,
|
||||||
|
extra: options?.extra,
|
||||||
|
model: options?.model,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
state = {
|
||||||
|
directory,
|
||||||
|
slug: "",
|
||||||
|
sdk,
|
||||||
|
sessions: new Map(),
|
||||||
|
dirs: new Set(),
|
||||||
}
|
}
|
||||||
|
await options?.beforeGoto?.({ directory, sdk })
|
||||||
const trackDirectory = (directory: string) => {
|
|
||||||
if (directory !== root) dirs.add(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await options?.beforeGoto?.({ directory: root, sdk })
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
const slug = await waitSlug(page)
|
need().slug = await waitSlug(page)
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
}
|
||||||
} finally {
|
|
||||||
|
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
|
||||||
|
if (input.noReply) {
|
||||||
|
const cur = need()
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const model = (window as E2EWindow).__opencode_e2e?.model?.current
|
||||||
|
if (!model) return null
|
||||||
|
return {
|
||||||
|
dir: model.dir,
|
||||||
|
sessionID: model.sessionID,
|
||||||
|
agent: model.agent,
|
||||||
|
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
|
||||||
|
variant: model.variant ?? undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dir = state?.dir ?? cur.directory
|
||||||
|
const sdk = backend.sdk(dir)
|
||||||
|
const sessionID = state?.sessionID
|
||||||
|
? state.sessionID
|
||||||
|
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
|
||||||
|
if (!res.data?.id) throw new Error("Failed to create no-reply session")
|
||||||
|
return res.data.id
|
||||||
|
})
|
||||||
|
await sdk.session.prompt({
|
||||||
|
sessionID,
|
||||||
|
agent: state?.agent,
|
||||||
|
model: state?.model,
|
||||||
|
variant: state?.variant,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
})
|
||||||
|
await visit(page, sessionPath(dir, sessionID))
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: dir,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(sessionID, active.directory)
|
||||||
|
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = await promptSend(page)
|
||||||
|
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
|
||||||
|
await llm.text("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
const submit = async () => {
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
if (input.shell) {
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
}
|
||||||
|
await page.keyboard.type(text)
|
||||||
|
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
const started = await expect
|
||||||
|
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(prev.started)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (started) return
|
||||||
|
const send = page.getByRole("button", { name: "Send" }).first()
|
||||||
|
const enabled = await send
|
||||||
|
.isEnabled()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (enabled) {
|
||||||
|
await send.click()
|
||||||
|
} else {
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
let next: { sessionID: string; directory: string } | undefined
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const sent = await promptSend(page)
|
||||||
|
if (sent.count <= prev.count) return ""
|
||||||
|
if (!sent.sessionID || !sent.directory) return ""
|
||||||
|
next = { sessionID: sent.sessionID, directory: sent.directory }
|
||||||
|
return sent.sessionID
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: next.directory,
|
||||||
|
sessionID: next.sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(next.sessionID, active.directory)
|
||||||
|
if (!input.shell) {
|
||||||
|
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
|
||||||
|
}
|
||||||
|
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
|
||||||
|
return next.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = async (text: string) => {
|
||||||
|
return send(text, { noReply: false, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = async (text: string) => {
|
||||||
|
return send(text, { noReply: true, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = async (cmd: string) => {
|
||||||
|
return send(cmd, { noReply: false, shell: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const cur = state
|
||||||
|
if (!cur) return
|
||||||
setHealthPhase(page, "cleanup")
|
setHealthPhase(page, "cleanup")
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
|
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||||
|
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(root)
|
await cleanupTestProject(cur.directory)
|
||||||
|
state = undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
open,
|
||||||
|
prompt,
|
||||||
|
user,
|
||||||
|
shell,
|
||||||
|
gotoSession,
|
||||||
|
trackSession,
|
||||||
|
trackDirectory,
|
||||||
|
get directory() {
|
||||||
|
return need().directory
|
||||||
|
},
|
||||||
|
get slug() {
|
||||||
|
return need().slug
|
||||||
|
},
|
||||||
|
get sdk() {
|
||||||
|
return need().sdk
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedStorage(
|
async function seedStorage(
|
||||||
@@ -275,31 +540,65 @@ async function seedStorage(
|
|||||||
serverUrl?: string
|
serverUrl?: string
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await seedProjects(page, input)
|
const origin = input.serverUrl ?? serverUrl
|
||||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
await page.addInitScript(
|
||||||
|
(args: {
|
||||||
|
directory: string
|
||||||
|
serverUrl: string
|
||||||
|
extra: string[]
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
}) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const next = { ...(projects as Record<string, unknown>) }
|
||||||
|
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = next[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of [args.directory, ...args.extra]) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
|
||||||
|
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
|
||||||
|
|
||||||
const win = window as E2EWindow
|
const win = window as E2EWindow
|
||||||
win.__opencode_e2e = {
|
win.__opencode_e2e = {
|
||||||
...win.__opencode_e2e,
|
...win.__opencode_e2e,
|
||||||
model: {
|
model: { enabled: true },
|
||||||
enabled: true,
|
prompt: { enabled: true },
|
||||||
},
|
terminal: { enabled: true, terminals: {} },
|
||||||
prompt: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
enabled: true,
|
|
||||||
terminals: {},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
localStorage.setItem(
|
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
|
||||||
"opencode.global.dat:model",
|
},
|
||||||
JSON.stringify({
|
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
|
||||||
recent: [model],
|
|
||||||
user: [],
|
|
||||||
variant: {},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}, input.model ?? seedModel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const menu = await openProjectMenu(page, slug)
|
const menu = await openProjectMenu(page, project.slug)
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
@@ -31,13 +31,19 @@ test("dialog edit project updates name and startup script", async ({ page, withP
|
|||||||
await dialog.getByRole("button", { name: "Save" }).click()
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const header = page.locator(".group\\/project").first()
|
await expect
|
||||||
await expect(header).toContainText(name)
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
const reopened = await open()
|
const reopened = await open()
|
||||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
const value = await reopened.getByLabel("Name").inputValue()
|
||||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
||||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
await expect(reopened).toHaveCount(0)
|
await expect(reopened).toHaveCount(0)
|
||||||
})
|
return `${value}\n${next}`
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${name}\n${startup}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
|
|||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
@@ -21,14 +20,13 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => {
|
() => {
|
||||||
const pathname = new URL(page.url()).pathname
|
const pathname = new URL(page.url()).pathname
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
if (pathname === "/") return "home"
|
if (pathname === "/") return "home"
|
||||||
return ""
|
return ""
|
||||||
},
|
},
|
||||||
@@ -45,9 +43,6 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
.toBe(0)
|
.toBe(0)
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,24 @@ import {
|
|||||||
createTestProject,
|
createTestProject,
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitSession,
|
waitSession,
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory }) => {
|
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const currentSlug = dirSlug(directory)
|
const currentSlug = dirSlug(project.directory)
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
@@ -38,35 +34,31 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
|
|||||||
await currentButton.click()
|
await currentButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const raw = await waitSlug(page, [slug])
|
const raw = await waitSlug(page, [project.slug])
|
||||||
const dir = base64Decode(raw)
|
const dir = base64Decode(raw)
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const space = await resolveDirectory(dir)
|
const space = await resolveDirectory(dir)
|
||||||
const next = dirSlug(space)
|
const next = dirSlug(space)
|
||||||
trackDirectory(space)
|
project.trackDirectory(space)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
@@ -79,21 +71,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
await waitSession(page, { directory: space })
|
await waitSession(page, { directory: space })
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
const created = await project.user("test")
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.fill("test")
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for the URL to update with the new session ID
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
|
||||||
trackSession(created, space)
|
|
||||||
await waitSessionSaved(space, created)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
@@ -104,15 +82,12 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await otherButton.click({ force: true })
|
await otherButton.click({ force: true })
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click({ force: true })
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
await waitSession(page, { directory: space, sessionID: created })
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ import {
|
|||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitDir,
|
waitDir,
|
||||||
waitSession,
|
waitSession,
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
function item(space: { slug: string; raw: string }) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
@@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
async function createSessionFromWorkspace(
|
||||||
|
project: Parameters<typeof test>[0]["project"],
|
||||||
page: Page,
|
page: Page,
|
||||||
space: { slug: string; raw: string; directory: string },
|
space: { slug: string; raw: string; directory: string },
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
await openWorkspaceNewSession(page, space)
|
await openWorkspaceNewSession(page, space)
|
||||||
|
return project.user(text)
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.fill(text)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
|
|
||||||
await waitSessionSaved(space.directory, sessionID)
|
|
||||||
await createSdk(space.directory)
|
|
||||||
.session.abort({ sessionID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, project.slug, [])
|
||||||
trackDirectory(first.directory)
|
project.trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, project.slug, [first.slug])
|
||||||
trackDirectory(second.directory)
|
project.trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
waitDir,
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -52,37 +53,37 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, false)
|
await setWorkspacesEnabled(page, project.slug, false)
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can create a workspace", async ({ page, withProject }) => {
|
test("can create a workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
const next = await resolveSlug(await waitSlug(page, [project.slug]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -102,12 +103,9 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await cleanupTestProject(next.directory)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
@@ -116,7 +114,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(async () => {
|
await project.open({ extra: [nonGit] })
|
||||||
await page.goto(`/${nonGitSlug}/session`)
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
@@ -126,34 +124,16 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
|
||||||
const trigger = page.locator('[data-action="project-menu"]').first()
|
|
||||||
const hasMenu = await trigger
|
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!hasMenu) return
|
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(toggle).toBeDisabled()
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(nonGit)
|
await cleanupTestProject(nonGit)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, withProject }) => {
|
test("can rename a workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const { slug } = await setupWorkspaceTest(page, project)
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
const rename = `e2e workspace ${Date.now()}`
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
@@ -165,17 +145,25 @@ test("can rename a workspace", async ({ page, withProject }) => {
|
|||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
await expect(item).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
const input = item.locator(inlineInputSelector).first()
|
const input = item.locator(inlineInputSelector).first()
|
||||||
|
const shown = await input
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!shown) {
|
||||||
|
const retry = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(retry, /^Rename$/i, { force: true })
|
||||||
|
await expect(retry).toHaveCount(0)
|
||||||
|
}
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(rename)
|
await input.fill(rename)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
await expect(item).toContainText(rename)
|
await expect(item).toContainText(rename)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
test("can reset a workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
const readme = path.join(createdDir, "README.md")
|
const readme = path.join(createdDir, "README.md")
|
||||||
@@ -196,7 +184,7 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const files = await sdk.file
|
const files = await project.sdk.file
|
||||||
.status({ directory: createdDir })
|
.status({ directory: createdDir })
|
||||||
.then((r) => r.data ?? [])
|
.then((r) => r.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -211,17 +199,17 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const files = await sdk.file
|
const files = await project.sdk.file
|
||||||
.status({ directory: createdDir })
|
.status({ directory: createdDir })
|
||||||
.then((r) => r.data ?? [])
|
.then((r) => r.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
return files.length
|
return files.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
)
|
)
|
||||||
.toBe(0)
|
.toBe(0)
|
||||||
|
|
||||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -232,59 +220,11 @@ test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
|||||||
})
|
})
|
||||||
.toBe(false)
|
.toBe(false)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("can delete a workspace", async ({ page, withProject }) => {
|
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
await withProject(async (project) => {
|
const rootSlug = project.slug
|
||||||
const sdk = createSdk(project.directory)
|
|
||||||
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
|
||||||
|
|
||||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(false)
|
|
||||||
|
|
||||||
await project.gotoSession()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
await withProject(async ({ slug: rootSlug }) => {
|
|
||||||
const workspaces = [] as { directory: string; slug: string }[]
|
|
||||||
|
|
||||||
const listSlugs = async () => {
|
const listSlugs = async () => {
|
||||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
@@ -325,16 +265,17 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
await page.mouse.up()
|
await page.mouse.up()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
workspaces.push(next)
|
workspaces.push(next)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -368,8 +309,60 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
await drag(from, to)
|
await drag(from, to)
|
||||||
|
|
||||||
await expect.poll(async () => await list()).toEqual([from, to])
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
} finally {
|
|
||||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("can delete a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const rootSlug = project.slug
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
||||||
|
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
||||||
|
|
||||||
|
const directory = created.directory
|
||||||
|
const slug = dirSlug(directory)
|
||||||
|
project.trackDirectory(directory)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await project.sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await project.sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
|
|
||||||
|
|
||||||
type Hit = { body: Record<string, unknown> }
|
type Hit = { body: Record<string, unknown> }
|
||||||
|
|
||||||
export function bodyText(hit: Hit) {
|
export function bodyText(hit: Hit) {
|
||||||
return JSON.stringify(hit.body)
|
return JSON.stringify(hit.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function titleMatch(hit: Hit) {
|
|
||||||
return bodyText(hit).includes("Generate a title for this conversation")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promptMatch(token: string) {
|
|
||||||
return (hit: Hit) => bodyText(hit).includes(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match requests whose body contains the exact serialized tool input.
|
* Match requests whose body contains the exact serialized tool input.
|
||||||
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
||||||
@@ -25,32 +13,3 @@ export function inputMatch(input: unknown) {
|
|||||||
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
||||||
return (hit: Hit) => bodyText(hit).includes(escaped)
|
return (hit: Hit) => bodyText(hit).includes(escaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
|
|
||||||
const sdk = createSdk(undefined, input.serverUrl)
|
|
||||||
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sdk.global.config.update({
|
|
||||||
config: {
|
|
||||||
...prev,
|
|
||||||
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
|
|
||||||
enabled_providers: ["openai"],
|
|
||||||
provider: {
|
|
||||||
...prev.provider,
|
|
||||||
openai: {
|
|
||||||
...prev.provider?.openai,
|
|
||||||
options: {
|
|
||||||
...prev.provider?.openai?.options,
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: input.llmUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return await input.fn()
|
|
||||||
} finally {
|
|
||||||
await sdk.global.config.update({ config: prev })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,52 +1,25 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
import { assistantText, withSession } from "../actions"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
await withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
await project.open()
|
||||||
await llm.textMatch(promptMatch(token), token)
|
await assistant.reply(token)
|
||||||
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
import { assistantText } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
type Sdk = ReturnType<typeof createSdk>
|
type Sdk = ReturnType<typeof createSdk>
|
||||||
@@ -43,48 +42,27 @@ async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
const draft = `draft ${Date.now()}`
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
await project.open()
|
||||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
await assistant.reply(firstToken)
|
||||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
const sessionID = await project.prompt(first)
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(first)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
await reply(project.sdk, sessionID, firstToken)
|
await reply(project.sdk, sessionID, firstToken)
|
||||||
|
|
||||||
await prompt.click()
|
await assistant.reply(secondToken)
|
||||||
await page.keyboard.type(second)
|
await project.prompt(second)
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await reply(project.sdk, sessionID, secondToken)
|
await reply(project.sdk, sessionID, secondToken)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(draft)
|
await page.keyboard.type(draft)
|
||||||
await wait(page, draft)
|
await wait(page, draft)
|
||||||
@@ -103,13 +81,6 @@ test("prompt history restores unsent draft with arrow navigation", async ({
|
|||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
|
async function setAutoAccept(page: Parameters<typeof test>[0]["page"], enabled: boolean) {
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
||||||
|
if (pressed === enabled) return
|
||||||
|
await button.click()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
await project.open()
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||||
|
|
||||||
await gotoSession()
|
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
||||||
await prompt.click()
|
project.trackSession(session.id)
|
||||||
await page.keyboard.type("!")
|
await project.gotoSession(session.id)
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
await setAutoAccept(page, true)
|
||||||
|
await project.shell(cmd)
|
||||||
await page.keyboard.type(cmd)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
trackSession(id, directory)
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
const list = await project.sdk.session
|
||||||
|
.messages({ sessionID: session.id, limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
const msg = list.findLast(
|
const msg = list.findLast(
|
||||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
||||||
)
|
)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
@@ -49,12 +50,10 @@ test("shell mode runs a command in the project directory", async ({ page, withBa
|
|||||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
if (!output.includes("README.md")) return
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
return { cwd: directory, output }
|
return { cwd: project.directory, output }
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
)
|
)
|
||||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
await expect(prompt).toHaveText("")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
|
test("/share and /unshare update session share state", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -64,4 +64,3 @@ test("/share and /unshare update session share state", async ({ page, withBacken
|
|||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { assistantText } from "../actions"
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
@@ -13,41 +11,13 @@ test("can send a prompt and receive a reply", async ({ page, llm, backend, withB
|
|||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const token = `E2E_OK_${Date.now()}`
|
const token = `E2E_OK_${Date.now()}`
|
||||||
|
await project.open()
|
||||||
|
await assistant.reply(token)
|
||||||
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
await llm.textMatch(promptMatch(token), token)
|
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const sessionID = (() => {
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
return id
|
|
||||||
})()
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ export const terminalSelector = `${terminalPanelSelector} [data-component="termi
|
|||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
|
||||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
|
||||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
|
||||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
|
||||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||||
@@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectClearNotificationsSelector = (slug: string) =>
|
|
||||||
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
@@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
|
|||||||
|
|
||||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
|
||||||
|
|
||||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from "../fixtures"
|
|||||||
import { inputMatch } from "../prompt/mock"
|
import { inputMatch } from "../prompt/mock"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
@@ -13,22 +13,22 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
|||||||
page.on("pageerror", onError)
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
|
await project.open()
|
||||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
const taskInput = {
|
const taskInput = {
|
||||||
description: "Open child session",
|
description: "Open child session",
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
subagent_type: "general",
|
subagent_type: "general",
|
||||||
}
|
}
|
||||||
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
||||||
const child = await seedSessionTask(sdk, {
|
const child = await seedSessionTask(project.sdk, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
description: taskInput.description,
|
description: taskInput.description,
|
||||||
prompt: taskInput.prompt,
|
prompt: taskInput.prompt,
|
||||||
})
|
})
|
||||||
trackSession(child.sessionID)
|
project.trackSession(child.sessionID)
|
||||||
|
|
||||||
await gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const link = page
|
const link = page
|
||||||
.locator("a.subagent-link")
|
.locator("a.subagent-link")
|
||||||
@@ -41,7 +41,6 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
|||||||
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
||||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onError)
|
page.off("pageerror", onError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,9 +242,7 @@ async function withMockPermission<T>(
|
|||||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: res.status(),
|
response: res,
|
||||||
headers: res.headers(),
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(json),
|
body: JSON.stringify(json),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -269,8 +267,8 @@ async function withMockPermission<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
|
test("default dock shows prompt input", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock default",
|
"e2e composer dock default",
|
||||||
@@ -288,11 +286,9 @@ test("default dock shows prompt input", async ({ page, withBackendProject }) =>
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
|
test("auto-accept toggle works before first submit", async ({ page, project }) => {
|
||||||
await withBackendProject(async ({ gotoSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
await expect(button).toBeVisible()
|
await expect(button).toBeVisible()
|
||||||
@@ -301,10 +297,9 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
|
|||||||
await setAutoAccept(page, true)
|
await setAutoAccept(page, true)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
|
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock question",
|
"e2e composer dock question",
|
||||||
@@ -330,10 +325,9 @@ test("blocked question flow unblocks after submit", async ({ page, llm, withMock
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
|
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock question keyboard",
|
"e2e composer dock question keyboard",
|
||||||
@@ -365,10 +359,9 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, llm, wi
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
|
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock question escape",
|
"e2e composer dock question escape",
|
||||||
@@ -395,10 +388,9 @@ test("blocked question flow supports escape dismiss", async ({ page, llm, withMo
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports allow once", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock permission once",
|
"e2e composer dock permission once",
|
||||||
@@ -429,10 +421,9 @@ test("blocked permission flow supports allow once", async ({ page, withBackendPr
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports reject", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock permission reject",
|
"e2e composer dock permission reject",
|
||||||
@@ -462,10 +453,9 @@ test("blocked permission flow supports reject", async ({ page, withBackendProjec
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
|
test("blocked permission flow supports allow always", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock permission always",
|
"e2e composer dock permission always",
|
||||||
@@ -496,13 +486,8 @@ test("blocked permission flow supports allow always", async ({ page, withBackend
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
withMockProject,
|
|
||||||
}) => {
|
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
header: "Child input",
|
header: "Child input",
|
||||||
@@ -513,7 +498,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock child question parent",
|
"e2e composer dock child question parent",
|
||||||
@@ -552,13 +537,9 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
|
||||||
page,
|
await project.open()
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock child permission parent",
|
"e2e composer dock child permission parent",
|
||||||
@@ -604,10 +585,9 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
|
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock todo",
|
"e2e composer dock todo",
|
||||||
@@ -641,9 +621,8 @@ test("todo dock transitions and collapse behavior", async ({ page, withBackendPr
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
|
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
header: "Need input",
|
header: "Need input",
|
||||||
@@ -651,7 +630,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
|
|||||||
options: [{ label: "Continue", description: "Continue now" }],
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withDockSession(
|
await withDockSession(
|
||||||
project.sdk,
|
project.sdk,
|
||||||
"e2e composer dock keyboard",
|
"e2e composer dock keyboard",
|
||||||
@@ -675,4 +654,3 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo
|
|||||||
{ trackSession: project.trackSession },
|
{ trackSession: project.trackSession },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitSession,
|
|
||||||
waitSessionIdle,
|
|
||||||
waitSlug,
|
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
@@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
|
|||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, { directory, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(page: Page, value: string) {
|
async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
|
||||||
const prompt = page.locator('[data-component="prompt-input"]')
|
return project.prompt(value)
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.click()
|
|
||||||
await prompt.fill(value)
|
|
||||||
await prompt.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUser(directory: string, sessionID: string) {
|
|
||||||
const sdk = createSdk(directory)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
|
||||||
return items.some((item) => item.info.role === "user")
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
|
||||||
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
@@ -301,75 +265,67 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
const firstState = await chooseOtherModel(page)
|
||||||
const firstKey = await currentModel(page)
|
const firstKey = await currentModel(page)
|
||||||
const first = await submit(page, `session variant ${Date.now()}`)
|
const first = await submit(project, `session variant ${Date.now()}`)
|
||||||
trackSession(first)
|
|
||||||
await waitUser(directory, first)
|
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await waitSession(page, { directory, sessionID: first })
|
await waitSession(page, { directory: project.directory, sessionID: first })
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
const fresh = await read(page)
|
const fresh = await read(page)
|
||||||
expect(fresh.model).not.toBe(firstState.model)
|
expect(fresh.model).not.toBe(firstState.model)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
const second = await submit(page, `session model ${Date.now()}`)
|
const second = await submit(project, `session model ${Date.now()}`)
|
||||||
trackSession(second)
|
|
||||||
await waitUser(directory, second)
|
|
||||||
|
|
||||||
await goto(page, directory, first)
|
await goto(page, project.directory, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await goto(page, directory, second)
|
await goto(page, project.directory, second)
|
||||||
await waitFooter(page, secondState)
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
await page.reload()
|
||||||
|
await waitSession(page, { directory: project.directory })
|
||||||
await waitFooter(page, fresh)
|
await waitFooter(page, fresh)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
test("session model restore across workspaces", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
const root = project.directory
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
const firstState = await chooseOtherModel(page)
|
||||||
const firstKey = await currentModel(page)
|
const firstKey = await currentModel(page)
|
||||||
const first = await submit(page, `root session ${Date.now()}`)
|
const first = await submit(project, `root session ${Date.now()}`)
|
||||||
trackSession(first, root)
|
|
||||||
await waitUser(root, first)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const one = await createWorkspace(page, slug, [])
|
const one = await createWorkspace(page, project.slug, [])
|
||||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||||
trackDirectory(oneDir)
|
project.trackDirectory(oneDir)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
const secondKey = await currentModel(page)
|
const secondKey = await currentModel(page)
|
||||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
const second = await submit(project, `workspace one ${Date.now()}`)
|
||||||
trackSession(second, oneDir)
|
|
||||||
await waitUser(oneDir, second)
|
|
||||||
|
|
||||||
const two = await createWorkspace(page, slug, [one.slug])
|
const two = await createWorkspace(page, project.slug, [one.slug])
|
||||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||||
trackDirectory(twoDir)
|
project.trackDirectory(twoDir)
|
||||||
|
|
||||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
const third = await submit(project, `workspace two ${Date.now()}`)
|
||||||
trackSession(third, twoDir)
|
|
||||||
await waitUser(twoDir, third)
|
|
||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
@@ -383,15 +339,14 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
|||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
test("variant preserved when switching agent modes", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
await ensureVariant(page, directory)
|
await ensureVariant(page, project.directory)
|
||||||
const updated = await chooseDifferentVariant(page)
|
const updated = await chooseDifferentVariant(page)
|
||||||
|
|
||||||
const available = await agents(page)
|
const available = await agents(page)
|
||||||
@@ -405,4 +360,3 @@ test("variant preserved when switching agent modes", async ({ page, withProject
|
|||||||
await choose(page, promptAgentSelector, updated.agent)
|
await choose(page, promptAgentSelector, updated.agent)
|
||||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { waitSessionIdle, withSession } from "../actions"
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { inputMatch } from "../prompt/mock"
|
import { bodyText } from "../prompt/mock"
|
||||||
|
|
||||||
const count = 14
|
const count = 14
|
||||||
|
|
||||||
@@ -47,8 +47,12 @@ async function patchWithMock(
|
|||||||
patchText: string,
|
patchText: string,
|
||||||
) {
|
) {
|
||||||
const callsBefore = await llm.calls()
|
const callsBefore = await llm.calls()
|
||||||
await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
|
await llm.toolMatch(
|
||||||
await sdk.session.promptAsync({
|
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
|
||||||
|
"apply_patch",
|
||||||
|
{ patchText },
|
||||||
|
)
|
||||||
|
await sdk.session.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
system: [
|
system: [
|
||||||
@@ -61,12 +65,16 @@ async function patchWithMock(
|
|||||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wait for the agent loop to actually start before checking idle.
|
|
||||||
// promptAsync is fire-and-forget — without this, waitSessionIdle can
|
|
||||||
// return immediately because the session status is still undefined.
|
|
||||||
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
||||||
|
await expect
|
||||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
@@ -245,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-comment-${Date.now()}`
|
const tag = `review-comment-${Date.now()}`
|
||||||
@@ -254,7 +262,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
@@ -291,9 +299,8 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
|
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
@@ -302,7 +309,7 @@ test("review file comments submit on click without clipping actions", async ({ p
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
@@ -340,9 +347,8 @@ test("review file comments submit on click without clipping actions", async ({ p
|
|||||||
.toBeLessThanOrEqual(1)
|
.toBeLessThanOrEqual(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
|
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-${Date.now()}`
|
const tag = `review-${Date.now()}`
|
||||||
@@ -352,7 +358,7 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
||||||
@@ -432,4 +438,3 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag
|
|||||||
.toBeLessThanOrEqual(32)
|
.toBeLessThanOrEqual(32)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ async function seedConversation(input: {
|
|||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
|
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
const sdk = project.sdk
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
@@ -80,14 +80,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
|
|||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
|
test("slash redo clears revert and restores latest state", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
const sdk = project.sdk
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
@@ -128,15 +127,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
|
|||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
const sdk = project.sdk
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
@@ -233,4 +231,3 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withBac
|
|||||||
await expect(secondMessage).toHaveCount(1)
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
|
test("session can be renamed via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, originalTitle, async (session) => {
|
await withSession(project.sdk, originalTitle, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await seedMessage(project.sdk, session.id)
|
||||||
@@ -66,13 +66,12 @@ test("session can be renamed via header menu", async ({ page, withBackendProject
|
|||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
|
test("session can be archived via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await withSession(project.sdk, title, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await seedMessage(project.sdk, session.id)
|
||||||
@@ -94,13 +93,12 @@ test("session can be archived via header menu", async ({ page, withBackendProjec
|
|||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
|
test("session can be deleted via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await withSession(project.sdk, title, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
await seedMessage(project.sdk, session.id)
|
||||||
@@ -126,19 +124,18 @@ test("session can be deleted via header menu", async ({ page, withBackendProject
|
|||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
|
test("session can be shared and unshared via header button", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e share test ${stamp}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
await project.open()
|
||||||
await withSession(project.sdk, title, async (session) => {
|
await withSession(project.sdk, title, async (session) => {
|
||||||
project.trackSession(session.id)
|
project.trackSession(session.id)
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
await project.prompt(`share seed ${stamp}`)
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
@@ -183,4 +180,3 @@ test("session can be shared and unshared via header button", async ({ page, with
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -88,10 +88,20 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
})
|
})
|
||||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
|
||||||
|
await trigger.click()
|
||||||
|
const open = await expect
|
||||||
|
.poll(async () => (await items.count()) > 0, { timeout: 5_000 })
|
||||||
|
.toBe(true)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!open) {
|
||||||
|
await trigger.click()
|
||||||
|
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
|
||||||
|
}
|
||||||
|
await expect(items.first()).toBeVisible()
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
|||||||
@@ -48,57 +48,51 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
const card = page.locator('[data-component="hover-card-content"]')
|
const card = page.locator('[data-component="hover-card-content"]')
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
await expect(projectButton).toBeVisible()
|
||||||
await project.hover()
|
await projectButton.hover()
|
||||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||||
|
|
||||||
await page.mouse.down()
|
await projectButton.click()
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
await expect(projectButton).toBeVisible()
|
||||||
|
|
||||||
let hit = false
|
let hit = false
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
hit = await project.evaluate((el) => {
|
hit = await projectButton.evaluate((el) => {
|
||||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||||
})
|
})
|
||||||
if (hit) break
|
if (hit) break
|
||||||
@@ -109,9 +103,6 @@ test("open sidebar project switch activates on first tabbed enter", async ({ pag
|
|||||||
|
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ async function open(page: Page) {
|
|||||||
return term
|
return term
|
||||||
}
|
}
|
||||||
|
|
||||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
test("terminal reconnects without replacing the pty", async ({ page, project }) => {
|
||||||
await withProject(async ({ gotoSession }) => {
|
await project.open()
|
||||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||||
const token = `E2E_RECONNECT_${Date.now()}`
|
const token = `E2E_RECONNECT_${Date.now()}`
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
const term = await open(page)
|
const term = await open(page)
|
||||||
const id = await term.getAttribute("data-pty-id")
|
const id = await term.getAttribute("data-pty-id")
|
||||||
@@ -43,4 +43,3 @@ test("terminal reconnects without replacing the pty", async ({ page, withProject
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ async function store(page: Page, key: string) {
|
|||||||
}, key)
|
}, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
@@ -90,14 +90,13 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
|||||||
)
|
)
|
||||||
.toEqual({ first: true, second: false })
|
.toEqual({ first: true, second: false })
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
@@ -129,15 +128,14 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
|||||||
)
|
)
|
||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const rename = `E2E term ${Date.now()}`
|
const rename = `E2E term ${Date.now()}`
|
||||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await expect(tab).toContainText(/Terminal 1/)
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
@@ -165,4 +163,3 @@ test("terminal tab can be renamed from the context menu", async ({ page, withPro
|
|||||||
)
|
)
|
||||||
.toBe(rename)
|
.toBe(rename)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { promptProbe } from "@/testing/prompt"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
@@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
promptProbe.start()
|
||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
@@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
|
||||||
input.onSubmit?.()
|
input.onSubmit?.()
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export type PromptProbeState = {
|
|||||||
selects: number
|
selects: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptSendState = {
|
||||||
|
started: number
|
||||||
|
count: number
|
||||||
|
sessionID?: string
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const promptEnabled = () => {
|
export const promptEnabled = () => {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
||||||
@@ -53,4 +60,24 @@ export const promptProbe = {
|
|||||||
if (!state) return
|
if (!state) return
|
||||||
state.current = undefined
|
state.current = undefined
|
||||||
},
|
},
|
||||||
|
start() {
|
||||||
|
const state = root()
|
||||||
|
if (!state) return
|
||||||
|
state.sent = {
|
||||||
|
started: (state.sent?.started ?? 0) + 1,
|
||||||
|
count: state.sent?.count ?? 0,
|
||||||
|
sessionID: state.sent?.sessionID,
|
||||||
|
directory: state.sent?.directory,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit(input: { sessionID: string; directory: string }) {
|
||||||
|
const state = root()
|
||||||
|
if (!state) return
|
||||||
|
state.sent = {
|
||||||
|
started: state.sent?.started ?? 0,
|
||||||
|
count: (state.sent?.count ?? 0) + 1,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
directory: input.directory,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type E2EWindow = Window & {
|
|||||||
prompt?: {
|
prompt?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
current?: import("./prompt").PromptProbeState
|
current?: import("./prompt").PromptProbeState
|
||||||
|
sent?: import("./prompt").PromptSendState
|
||||||
}
|
}
|
||||||
terminal?: {
|
terminal?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function e2eURL() {
|
||||||
|
const url = Env.get("OPENCODE_E2E_LLM_URL")
|
||||||
|
if (typeof url !== "string" || url === "") return
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
type BundledSDK = {
|
type BundledSDK = {
|
||||||
languageModel(modelId: string): LanguageModelV3
|
languageModel(modelId: string): LanguageModelV3
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1456,17 @@ export namespace Provider {
|
|||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
|
const url = e2eURL()
|
||||||
|
if (url) {
|
||||||
|
const language = createOpenAICompatible({
|
||||||
|
name: model.providerID,
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: url,
|
||||||
|
}).chatModel(model.api.id)
|
||||||
|
s.models.set(key, language)
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const sdk = await resolveSDK(model, s)
|
const sdk = await resolveSDK(model, s)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Env } from "../env"
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
@@ -166,7 +167,8 @@ export namespace ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const usePatch =
|
const usePatch =
|
||||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||||
|
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
||||||
if (tool.id === "apply_patch") return usePatch
|
if (tool.id === "apply_patch") return usePatch
|
||||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,17 @@ describe("cross-spawn spawner", () => {
|
|||||||
fx.effect(
|
fx.effect(
|
||||||
"captures both stdout and stderr",
|
"captures both stdout and stderr",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
|
const handle = yield* js(
|
||||||
|
[
|
||||||
|
"let pending = 2",
|
||||||
|
"const done = () => {",
|
||||||
|
" pending -= 1",
|
||||||
|
" if (pending === 0) setTimeout(() => process.exit(0), 0)",
|
||||||
|
"}",
|
||||||
|
'process.stdout.write("stdout\\n", done)',
|
||||||
|
'process.stderr.write("stderr\\n", done)',
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
|
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
|
||||||
expect(stdout).toBe("stdout")
|
expect(stdout).toBe("stdout")
|
||||||
expect(stderr).toBe("stderr")
|
expect(stderr).toBe("stderr")
|
||||||
|
|||||||
@@ -254,6 +254,16 @@ function responseToolArgs(id: string, text: string, seq: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function responseToolArgsDone(id: string, args: string, seq: number) {
|
||||||
|
return {
|
||||||
|
type: "response.function_call_arguments.done",
|
||||||
|
sequence_number: seq,
|
||||||
|
output_index: 0,
|
||||||
|
item_id: id,
|
||||||
|
arguments: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
||||||
return {
|
return {
|
||||||
type: "response.output_item.done",
|
type: "response.output_item.done",
|
||||||
@@ -390,6 +400,8 @@ function responses(item: Sse, model: string) {
|
|||||||
lines.push(responseReasonDone(reason, seq))
|
lines.push(responseReasonDone(reason, seq))
|
||||||
}
|
}
|
||||||
if (call && !item.hang && !item.error) {
|
if (call && !item.hang && !item.error) {
|
||||||
|
seq += 1
|
||||||
|
lines.push(responseToolArgsDone(call.item, call.args, seq))
|
||||||
seq += 1
|
seq += 1
|
||||||
lines.push(responseToolDone(call, seq))
|
lines.push(responseToolDone(call, seq))
|
||||||
}
|
}
|
||||||
@@ -599,6 +611,11 @@ function isToolResultFollowUp(body: unknown): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTitleRequest(body: unknown): boolean {
|
||||||
|
if (!body || typeof body !== "object") return false
|
||||||
|
return JSON.stringify(body).includes("Generate a title for this conversation")
|
||||||
|
}
|
||||||
|
|
||||||
function requestSummary(body: unknown): string {
|
function requestSummary(body: unknown): string {
|
||||||
if (!body || typeof body !== "object") return "empty body"
|
if (!body || typeof body !== "object") return "empty body"
|
||||||
if ("messages" in body && Array.isArray(body.messages)) {
|
if ("messages" in body && Array.isArray(body.messages)) {
|
||||||
@@ -623,6 +640,7 @@ namespace TestLLMServer {
|
|||||||
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
||||||
readonly hang: Effect.Effect<void>
|
readonly hang: Effect.Effect<void>
|
||||||
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||||
|
readonly reset: Effect.Effect<void>
|
||||||
readonly hits: Effect.Effect<Hit[]>
|
readonly hits: Effect.Effect<Hit[]>
|
||||||
readonly calls: Effect.Effect<number>
|
readonly calls: Effect.Effect<number>
|
||||||
readonly wait: (count: number) => Effect.Effect<void>
|
readonly wait: (count: number) => Effect.Effect<void>
|
||||||
@@ -671,22 +689,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
|||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||||
const current = hit(req.originalUrl, body)
|
const current = hit(req.originalUrl, body)
|
||||||
|
if (isTitleRequest(body)) {
|
||||||
|
hits = [...hits, current]
|
||||||
|
yield* notify()
|
||||||
|
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
|
||||||
|
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||||
|
return send(auto)
|
||||||
|
}
|
||||||
const next = pull(current)
|
const next = pull(current)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
// Auto-acknowledge tool-result follow-ups so tests only need to
|
|
||||||
// queue one response per tool call instead of two.
|
|
||||||
if (isToolResultFollowUp(body)) {
|
|
||||||
hits = [...hits, current]
|
hits = [...hits, current]
|
||||||
yield* notify()
|
yield* notify()
|
||||||
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
|
||||||
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
if (mode === "responses") return send(responses(auto, modelFrom(body)))
|
||||||
return send(auto)
|
return send(auto)
|
||||||
}
|
}
|
||||||
misses = [...misses, current]
|
|
||||||
const summary = requestSummary(body)
|
|
||||||
console.warn(`[TestLLMServer] unmatched request: ${req.originalUrl} (${summary}, pending=${list.length})`)
|
|
||||||
return HttpServerResponse.text(`unexpected request: ${summary}`, { status: 500 })
|
|
||||||
}
|
|
||||||
hits = [...hits, current]
|
hits = [...hits, current]
|
||||||
yield* notify()
|
yield* notify()
|
||||||
if (next.type !== "sse") return fail(next)
|
if (next.type !== "sse") return fail(next)
|
||||||
@@ -755,6 +772,12 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
|||||||
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
||||||
queue(reply().wait(wait).text(value).stop().item())
|
queue(reply().wait(wait).text(value).stop().item())
|
||||||
}),
|
}),
|
||||||
|
reset: Effect.sync(() => {
|
||||||
|
hits = []
|
||||||
|
list = []
|
||||||
|
waits = []
|
||||||
|
misses = []
|
||||||
|
}),
|
||||||
hits: Effect.sync(() => [...hits]),
|
hits: Effect.sync(() => [...hits]),
|
||||||
calls: Effect.sync(() => hits.length),
|
calls: Effect.sync(() => hits.length),
|
||||||
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* Reproduction test for e2e LLM URL routing.
|
||||||
|
*
|
||||||
|
* Tests whether OPENCODE_E2E_LLM_URL correctly routes LLM calls
|
||||||
|
* to the mock server when no explicit provider config is set.
|
||||||
|
* This mimics the e2e `project` fixture path (vs. withMockOpenAI).
|
||||||
|
*/
|
||||||
|
import { expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
|
import { SessionSummary } from "../../src/session/summary"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { provideTmpdirServer } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { TestLLMServer } from "../lib/llm-server"
|
||||||
|
|
||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
|
import { Command } from "../../src/command"
|
||||||
|
import { Config } from "../../src/config/config"
|
||||||
|
import { FileTime } from "../../src/file/time"
|
||||||
|
import { LSP } from "../../src/lsp"
|
||||||
|
import { MCP } from "../../src/mcp"
|
||||||
|
import { Permission } from "../../src/permission"
|
||||||
|
import { Plugin } from "../../src/plugin"
|
||||||
|
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||||
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
|
import { Server } from "../../src/server/server"
|
||||||
|
import { SessionCompaction } from "../../src/session/compaction"
|
||||||
|
import { Instruction } from "../../src/session/instruction"
|
||||||
|
import { SessionProcessor } from "../../src/session/processor"
|
||||||
|
import { SessionStatus } from "../../src/session/status"
|
||||||
|
import { LLM } from "../../src/session/llm"
|
||||||
|
import { Shell } from "../../src/shell/shell"
|
||||||
|
import { Snapshot } from "../../src/snapshot"
|
||||||
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
|
||||||
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
const mcp = Layer.succeed(
|
||||||
|
MCP.Service,
|
||||||
|
MCP.Service.of({
|
||||||
|
status: () => Effect.succeed({}),
|
||||||
|
clients: () => Effect.succeed({}),
|
||||||
|
tools: () => Effect.succeed({}),
|
||||||
|
prompts: () => Effect.succeed({}),
|
||||||
|
resources: () => Effect.succeed({}),
|
||||||
|
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
|
||||||
|
connect: () => Effect.void,
|
||||||
|
disconnect: () => Effect.void,
|
||||||
|
getPrompt: () => Effect.succeed(undefined),
|
||||||
|
readResource: () => Effect.succeed(undefined),
|
||||||
|
startAuth: () => Effect.die("unexpected MCP auth"),
|
||||||
|
authenticate: () => Effect.die("unexpected MCP auth"),
|
||||||
|
finishAuth: () => Effect.die("unexpected MCP auth"),
|
||||||
|
removeAuth: () => Effect.void,
|
||||||
|
supportsOAuth: () => Effect.succeed(false),
|
||||||
|
hasStoredTokens: () => Effect.succeed(false),
|
||||||
|
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const lsp = Layer.succeed(
|
||||||
|
LSP.Service,
|
||||||
|
LSP.Service.of({
|
||||||
|
init: () => Effect.void,
|
||||||
|
status: () => Effect.succeed([]),
|
||||||
|
hasClients: () => Effect.succeed(false),
|
||||||
|
touchFile: () => Effect.void,
|
||||||
|
diagnostics: () => Effect.succeed({}),
|
||||||
|
hover: () => Effect.succeed(undefined),
|
||||||
|
definition: () => Effect.succeed([]),
|
||||||
|
references: () => Effect.succeed([]),
|
||||||
|
implementation: () => Effect.succeed([]),
|
||||||
|
documentSymbol: () => Effect.succeed([]),
|
||||||
|
workspaceSymbol: () => Effect.succeed([]),
|
||||||
|
prepareCallHierarchy: () => Effect.succeed([]),
|
||||||
|
incomingCalls: () => Effect.succeed([]),
|
||||||
|
outgoingCalls: () => Effect.succeed([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const filetime = Layer.succeed(
|
||||||
|
FileTime.Service,
|
||||||
|
FileTime.Service.of({
|
||||||
|
read: () => Effect.void,
|
||||||
|
get: () => Effect.succeed(undefined),
|
||||||
|
assert: () => Effect.void,
|
||||||
|
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||||
|
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||||
|
const patchModel = { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-5.4") } as const
|
||||||
|
|
||||||
|
function makeHttp() {
|
||||||
|
const deps = Layer.mergeAll(
|
||||||
|
Session.defaultLayer,
|
||||||
|
Snapshot.defaultLayer,
|
||||||
|
LLM.defaultLayer,
|
||||||
|
AgentSvc.defaultLayer,
|
||||||
|
Command.defaultLayer,
|
||||||
|
Permission.layer,
|
||||||
|
Plugin.defaultLayer,
|
||||||
|
Config.defaultLayer,
|
||||||
|
ProviderSvc.defaultLayer,
|
||||||
|
filetime,
|
||||||
|
lsp,
|
||||||
|
mcp,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
status,
|
||||||
|
).pipe(Layer.provideMerge(infra))
|
||||||
|
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
||||||
|
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||||
|
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||||
|
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||||
|
return Layer.mergeAll(
|
||||||
|
TestLLMServer.layer,
|
||||||
|
SessionPrompt.layer.pipe(
|
||||||
|
Layer.provideMerge(compact),
|
||||||
|
Layer.provideMerge(proc),
|
||||||
|
Layer.provideMerge(registry),
|
||||||
|
Layer.provideMerge(trunc),
|
||||||
|
Layer.provide(Instruction.defaultLayer),
|
||||||
|
Layer.provideMerge(deps),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = testEffect(makeHttp())
|
||||||
|
|
||||||
|
it.live("e2eURL routes apply_patch through mock server", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
Effect.fnUntraced(function* ({ dir, llm }) {
|
||||||
|
// Set the env var to route all LLM calls through the mock
|
||||||
|
const prev = process.env.OPENCODE_E2E_LLM_URL
|
||||||
|
process.env.OPENCODE_E2E_LLM_URL = llm.url
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL
|
||||||
|
else process.env.OPENCODE_E2E_LLM_URL = prev
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
|
||||||
|
const session = yield* sessions.create({
|
||||||
|
title: "e2e url test",
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = ["*** Begin Patch", "*** Add File: e2e-test.txt", "+line 1", "+line 2", "*** End Patch"].join("\n")
|
||||||
|
|
||||||
|
// Queue mock response: match on system prompt, return apply_patch tool call
|
||||||
|
yield* llm.toolMatch(
|
||||||
|
(hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"),
|
||||||
|
"apply_patch",
|
||||||
|
{ patchText: patch },
|
||||||
|
)
|
||||||
|
// After tool execution, LLM gets called again with tool result — return "done"
|
||||||
|
yield* llm.text("done")
|
||||||
|
|
||||||
|
// Seed user message
|
||||||
|
yield* prompt.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
agent: "build",
|
||||||
|
model: patchModel,
|
||||||
|
noReply: true,
|
||||||
|
system: [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Your only valid response is one apply_patch tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ patchText: patch })}`,
|
||||||
|
"Do not call any other tools.",
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n"),
|
||||||
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run the agent loop
|
||||||
|
const result = yield* prompt.loop({ sessionID: session.id })
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
|
||||||
|
const calls = yield* llm.calls
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
|
||||||
|
const missed = yield* llm.misses
|
||||||
|
expect(missed.length).toBe(0)
|
||||||
|
|
||||||
|
const content = yield* Effect.promise(() =>
|
||||||
|
Bun.file(`${dir}/e2e-test.txt`)
|
||||||
|
.text()
|
||||||
|
.catch(() => "NOT FOUND"),
|
||||||
|
)
|
||||||
|
expect(content).toContain("line 1")
|
||||||
|
|
||||||
|
let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
|
||||||
|
if (diff.length > 0) break
|
||||||
|
yield* Effect.sleep("100 millis")
|
||||||
|
}
|
||||||
|
expect(diff.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: () => ({
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
agent: {
|
||||||
|
build: {
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
options: {
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("server message route produces diff through mock server", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
Effect.fnUntraced(function* ({ dir, llm }) {
|
||||||
|
const prev = process.env.OPENCODE_E2E_LLM_URL
|
||||||
|
process.env.OPENCODE_E2E_LLM_URL = llm.url
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (prev === undefined) delete process.env.OPENCODE_E2E_LLM_URL
|
||||||
|
else process.env.OPENCODE_E2E_LLM_URL = prev
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const session = yield* sessions.create({
|
||||||
|
title: "e2e route test",
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
})
|
||||||
|
const app = Server.Default()
|
||||||
|
const patch = ["*** Begin Patch", "*** Add File: route-test.txt", "+line 1", "+line 2", "*** End Patch"].join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* llm.toolMatch(
|
||||||
|
(hit) => JSON.stringify(hit.body).includes("Your only valid response is one apply_patch tool call"),
|
||||||
|
"apply_patch",
|
||||||
|
{ patchText: patch },
|
||||||
|
)
|
||||||
|
yield* llm.text("done")
|
||||||
|
|
||||||
|
const res = yield* Effect.promise(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
app.request(`/session/${session.id}/message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-opencode-directory": dir,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent: "build",
|
||||||
|
system: [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Your only valid response is one apply_patch tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ patchText: patch })}`,
|
||||||
|
"Do not call any other tools.",
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n"),
|
||||||
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
yield* Effect.promise(() => res.json())
|
||||||
|
|
||||||
|
const calls = yield* llm.calls
|
||||||
|
expect(calls).toBe(2)
|
||||||
|
|
||||||
|
const content = yield* Effect.promise(() =>
|
||||||
|
Bun.file(`${dir}/route-test.txt`)
|
||||||
|
.text()
|
||||||
|
.catch(() => "NOT FOUND"),
|
||||||
|
)
|
||||||
|
expect(content).toContain("line 1")
|
||||||
|
|
||||||
|
let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
|
||||||
|
if (diff.length > 0) break
|
||||||
|
yield* Effect.sleep("100 millis")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(diff.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: () => ({
|
||||||
|
model: "openai/gpt-5.4",
|
||||||
|
agent: { build: { model: "openai/gpt-5.4" } },
|
||||||
|
provider: { openai: { options: { apiKey: "test-openai-key" } } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user