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