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