diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 510f682549..69a3a1a2d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -121,7 +121,7 @@ jobs: - name: Read Playwright version id: playwright-version run: | - version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])') + version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])') echo "version=$version" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers diff --git a/bun.lock b/bun.lock index 91db9a57bb..4b160a0d2d 100644 --- a/bun.lock +++ b/bun.lock @@ -66,7 +66,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", - "@playwright/test": "1.57.0", + "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -663,7 +663,7 @@ "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.1.0-beta.18", - "@playwright/test": "1.51.0", + "@playwright/test": "1.59.1", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1698,7 +1698,7 @@ "@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="], - "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], @@ -4118,9 +4118,9 @@ "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], diff --git a/package.json b/package.json index d08bada052..f8093e7642 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "marked": "17.0.1", "marked-shiki": "1.2.1", "remend": "1.3.0", - "@playwright/test": "1.51.0", + "@playwright/test": "1.59.1", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20251207.1", "zod": "4.1.8", diff --git a/packages/app/README.md b/packages/app/README.md index 54d1b2861b..304e272cd0 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -31,11 +31,10 @@ Your app is ready to be deployed! ## E2E Testing -Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`). -Use the local runner to create a temp sandbox, seed data, and run the tests. +Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default. ```bash -bunx playwright install +bunx playwright install chromium bun run test:e2e:local bun run test:e2e:local -- --grep "settings" ``` diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md deleted file mode 100644 index bdd6ba185b..0000000000 --- a/packages/app/e2e/AGENTS.md +++ /dev/null @@ -1,225 +0,0 @@ -# E2E Testing Guide - -## Build/Lint/Test Commands - -```bash -# Run all e2e tests -bun test:e2e - -# Run specific test file -bun test:e2e -- app/home.spec.ts - -# Run single test by title -bun test:e2e -- -g "home renders and shows core entrypoints" - -# Run tests with UI mode (for debugging) -bun test:e2e:ui - -# Run tests locally with full server setup -bun test:e2e:local - -# View test report -bun test:e2e:report - -# Typecheck -bun typecheck -``` - -## Test Structure - -All tests live in `packages/app/e2e/`: - -``` -e2e/ -├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk) -├── actions.ts # Reusable action helpers -├── selectors.ts # DOM selectors -├── utils.ts # Utilities (serverUrl, modKey, path helpers) -└── [feature]/ - └── *.spec.ts # Test files -``` - -## Test Patterns - -### Basic Test Structure - -```typescript -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { withSession } from "../actions" - -test("test description", async ({ page, sdk, gotoSession }) => { - await gotoSession() // or gotoSession(sessionID) - - // Your test code - await expect(page.locator(promptSelector)).toBeVisible() -}) -``` - -### Using Fixtures - -- `page` - Playwright page -- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.) -- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`) -- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory) -- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory) - -### Helper Functions - -**Actions** (`actions.ts`): - -- `openPalette(page)` - Open command palette -- `openSettings(page)` - Open settings dialog -- `closeDialog(page, dialog)` - Close any dialog -- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar -- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output -- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output -- `withSession(sdk, title, callback)` - Create temp session -- `sessionIDFromUrl(url)` - Read session ID from URL -- `slugFromUrl(url)` - Read workspace slug from URL -- `waitSlug(page, skip?)` - Wait for resolved workspace slug -- `clickListItem(container, filter)` - Click list item by key/text - -**Selectors** (`selectors.ts`): - -- `promptSelector` - Prompt input -- `terminalSelector` - Terminal panel -- `sessionItemSelector(id)` - Session in sidebar -- `listItemSelector` - Generic list items - -**Utils** (`utils.ts`): - -- `modKey` - Meta (Mac) or Control (Linux/Win) -- `serverUrl` - Backend server URL -- `sessionPath(dir, id?)` - Build session URL - -## Code Style Guidelines - -### Imports - -Always import from `../fixtures`, not `@playwright/test`: - -```typescript -// ✅ Good -import { test, expect } from "../fixtures" - -// ❌ Bad -import { test, expect } from "@playwright/test" -``` - -### Naming Conventions - -- Test files: `feature-name.spec.ts` -- Test names: lowercase, descriptive: `"sidebar can be toggled"` -- Variables: camelCase -- Constants: SCREAMING_SNAKE_CASE - -### Error Handling - -Tests should clean up after themselves. Prefer fixture-managed cleanup: - -```typescript -test("test with cleanup", async ({ page, sdk, gotoSession }) => { - await withSession(sdk, "test session", async (session) => { - await gotoSession(session.id) - // Test code... - }) // Auto-deletes session -}) -``` - -- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc. -- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory -- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up -- Avoid calling `sdk.session.delete(...)` directly - -### Timeouts - -Default: 60s per test, 10s per assertion. Override when needed: - -```typescript -test.setTimeout(120_000) // For long LLM operations -test("slow test", async () => { - await expect.poll(() => check(), { timeout: 90_000 }).toBe(true) -}) -``` - -### Selectors - -Use `data-component`, `data-action`, or semantic roles: - -```typescript -// ✅ Good -await page.locator('[data-component="prompt-input"]').click() -await page.getByRole("button", { name: "Open settings" }).click() - -// ❌ Bad -await page.locator(".css-class-name").click() -await page.locator("#id-name").click() -``` - -### Keyboard Shortcuts - -Use `modKey` for cross-platform compatibility: - -```typescript -import { modKey } from "../utils" - -await page.keyboard.press(`${modKey}+B`) // Toggle sidebar -await page.keyboard.press(`${modKey}+Comma`) // Open settings -``` - -### Terminal Tests - -- In terminal tests, type through the browser. Do not write to the PTY through the SDK. -- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`. -- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. -- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters. -- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts. -- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. - -### Wait on state - -- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass -- Avoid race-prone flows that assume work is finished after an action -- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers -- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state -- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops -- Do not treat a visible element as proof that the app will route the next action to it -- When fixing a flake, validate with `--repeat-each` and multiple workers when practical - -### Add hooks - -- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks -- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts` -- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony -- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI -- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable -- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states - -### Prefer helpers - -- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise -- Use direct locators when the interaction is simple and a helper would not add clarity -- Prefer helpers that both perform an action and verify the app consumed it -- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state -- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions - -## Writing New Tests - -1. Choose appropriate folder or create new one -2. Import from `../fixtures` -3. Use helper functions from `../actions` and `../selectors` -4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs. -5. Clean up any created resources -6. Use specific selectors (avoid CSS classes) -7. Test one feature per test file - -## Local Development - -For UI debugging, use: - -```bash -bun test:e2e:ui -``` - -This opens Playwright's interactive UI for step-through debugging. diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts deleted file mode 100644 index ac9439360d..0000000000 --- a/packages/app/e2e/actions.ts +++ /dev/null @@ -1,949 +0,0 @@ -import { base64Decode, base64Encode } from "@opencode-ai/util/encode" -import { expect, type Locator, type Page } from "@playwright/test" -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { execSync } from "node:child_process" -import { terminalAttr, type E2EWindow } from "../src/testing/terminal" -import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" -import { - dropdownMenuContentSelector, - projectSwitchSelector, - projectMenuTriggerSelector, - projectCloseMenuSelector, - projectWorkspacesToggleSelector, - titlebarRightSelector, - popoverBodySelector, - listItemSelector, - listItemKeySelector, - listItemKeyStartsWithSelector, - promptSelector, - terminalSelector, - workspaceItemSelector, - workspaceMenuTriggerSelector, -} from "./selectors" - -const phase = new WeakMap() - -export function setHealthPhase(page: Page, value: "test" | "cleanup") { - phase.set(page, value) -} - -export function healthPhase(page: Page) { - return phase.get(page) ?? "test" -} - -export async function defocus(page: Page) { - await page - .evaluate(() => { - const el = document.activeElement - if (el instanceof HTMLElement) el.blur() - }) - .catch(() => undefined) -} - -async function terminalID(term: Locator) { - const id = await term.getAttribute(terminalAttr) - if (id) return id - throw new Error(`Active terminal missing ${terminalAttr}`) -} - -export async function terminalConnects(page: Page, input?: { term?: Locator }) { - const term = input?.term ?? page.locator(terminalSelector).first() - const id = await terminalID(term) - return page.evaluate((id) => { - return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0 - }, id) -} - -export async function disconnectTerminal(page: Page, input?: { term?: Locator }) { - const term = input?.term ?? page.locator(terminalSelector).first() - const id = await terminalID(term) - await page.evaluate((id) => { - ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.() - }, id) -} - -async function terminalReady(page: Page, term?: Locator) { - const next = term ?? page.locator(terminalSelector).first() - const id = await terminalID(next) - return page.evaluate((id) => { - const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id] - return !!state?.connected && (state.settled ?? 0) > 0 - }, id) -} - -async function terminalFocusIdle(page: Page, term?: Locator) { - const next = term ?? page.locator(terminalSelector).first() - const id = await terminalID(next) - return page.evaluate((id) => { - const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id] - return (state?.focusing ?? 0) === 0 - }, id) -} - -async function terminalHas(page: Page, input: { term?: Locator; token: string }) { - const next = input.term ?? page.locator(terminalSelector).first() - const id = await terminalID(next) - return page.evaluate( - (input) => { - const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id] - return state?.rendered.includes(input.token) ?? false - }, - { id, token: input.token }, - ) -} - -async function promptSlashActive(page: Page, id: string) { - return page.evaluate((id) => { - const state = (window as E2EWindow).__opencode_e2e?.prompt?.current - if (state?.popover !== "slash") return false - if (!state.slash.ids.includes(id)) return false - return state.slash.active === id - }, id) -} - -async function promptSlashSelects(page: Page) { - return page.evaluate(() => { - return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0 - }) -} - -async function promptSlashSelected(page: Page, input: { id: string; count: number }) { - return page.evaluate((input) => { - const state = (window as E2EWindow).__opencode_e2e?.prompt?.current - if (!state) return false - return state.selected === input.id && state.selects >= input.count - }, input) -} - -export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) { - const term = input?.term ?? page.locator(terminalSelector).first() - const timeout = input?.timeout ?? 10_000 - await expect(term).toBeVisible() - await expect(term.locator("textarea")).toHaveCount(1) - await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true) -} - -export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) { - const term = input?.term ?? page.locator(terminalSelector).first() - const timeout = input?.timeout ?? 10_000 - await waitTerminalReady(page, { term, timeout }) - await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true) -} - -export async function showPromptSlash( - page: Page, - input: { id: string; text: string; prompt?: Locator; timeout?: number }, -) { - const prompt = input.prompt ?? page.locator(promptSelector) - const timeout = input.timeout ?? 10_000 - await expect - .poll( - async () => { - await prompt.click().catch(() => false) - await prompt.fill(input.text).catch(() => false) - return promptSlashActive(page, input.id).catch(() => false) - }, - { timeout }, - ) - .toBe(true) -} - -export async function runPromptSlash( - page: Page, - input: { id: string; text: string; prompt?: Locator; timeout?: number }, -) { - const prompt = input.prompt ?? page.locator(promptSelector) - const timeout = input.timeout ?? 10_000 - const count = await promptSlashSelects(page) - await showPromptSlash(page, input) - await prompt.press("Enter") - await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true) -} - -export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) { - const term = input.term ?? page.locator(terminalSelector).first() - const timeout = input.timeout ?? 10_000 - await waitTerminalReady(page, { term, timeout }) - const textarea = term.locator("textarea") - await term.click() - await expect(textarea).toBeFocused() - await page.keyboard.type(input.cmd) - await page.keyboard.press("Enter") - await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true) -} - -export async function openPalette(page: Page, key = "K") { - await defocus(page) - await page.keyboard.press(`${modKey}+${key}`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("textbox").first()).toBeVisible() - return dialog -} - -export async function closeDialog(page: Page, dialog: Locator) { - await page.keyboard.press("Escape") - const closed = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closed) return - - await page.keyboard.press("Escape") - const closedSecond = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closedSecond) return - - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(dialog).toHaveCount(0) -} - -async function isSidebarClosed(page: Page) { - const button = await waitSidebarButton(page, "isSidebarClosed") - return (await button.getAttribute("aria-expanded")) !== "true" -} - -async function errorBoundaryText(page: Page) { - const title = page.getByRole("heading", { name: /something went wrong/i }).first() - if (!(await title.isVisible().catch(() => false))) return - - const description = await page - .getByText(/an error occurred while loading the application\./i) - .first() - .textContent() - .catch(() => "") - const detail = await page - .getByRole("textbox", { name: /error details/i }) - .first() - .inputValue() - .catch(async () => - ( - (await page - .getByRole("textbox", { name: /error details/i }) - .first() - .textContent() - .catch(() => "")) ?? "" - ).trim(), - ) - - return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n") -} - -async function assertHealthy(page: Page, context: string) { - const text = await errorBoundaryText(page) - if (!text) return - console.log(`[e2e:error-boundary][${context}]\n${text}`) - throw new Error(`Error boundary during ${context}\n${text}`) -} - -async function waitSidebarButton(page: Page, context: string) { - const button = page.getByRole("button", { name: /toggle sidebar/i }).first() - const boundary = page.getByRole("heading", { name: /something went wrong/i }).first() - await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 }) - await assertHealthy(page, context) - return button -} - -export async function toggleSidebar(page: Page) { - await defocus(page) - await page.keyboard.press(`${modKey}+B`) -} - -export async function openSidebar(page: Page) { - if (!(await isSidebarClosed(page))) return - - const button = await waitSidebarButton(page, "openSidebar") - await button.click() - - const opened = await expect(button) - .toHaveAttribute("aria-expanded", "true", { timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (opened) return - - await toggleSidebar(page) - await expect(button).toHaveAttribute("aria-expanded", "true") -} - -export async function closeSidebar(page: Page) { - if (await isSidebarClosed(page)) return - - const button = await waitSidebarButton(page, "closeSidebar") - await button.click() - - const closed = await expect(button) - .toHaveAttribute("aria-expanded", "false", { timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closed) return - - await toggleSidebar(page) - await expect(button).toHaveAttribute("aria-expanded", "false") -} - -export async function openSettings(page: Page) { - await assertHealthy(page, "openSettings") - await defocus(page) - - const dialog = page.getByRole("dialog") - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - - const opened = await dialog - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (opened) return dialog - - await assertHealthy(page, "openSettings") - - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(dialog).toBeVisible() - return dialog -} - -export async function createTestProject(input?: { serverUrl?: string }) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) - const id = `e2e-${path.basename(root)}` - - await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`) - - execSync("git init", { cwd: root, stdio: "ignore" }) - await fs.writeFile(path.join(root, ".git", "opencode"), id) - execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) - execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" }) - execSync("git add -A", { cwd: root, stdio: "ignore" }) - execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { - cwd: root, - stdio: "ignore", - }) - - return resolveDirectory(root, input?.serverUrl) -} - -export async function cleanupTestProject(directory: string) { - try { - execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" }) - } catch {} - await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined) -} - -export function slugFromUrl(url: string) { - return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? "" -} - -async function probeSession(page: Page) { - return page - .evaluate(() => { - const win = window as E2EWindow - const current = win.__opencode_e2e?.model?.current - if (!current) return null - return { dir: current.dir, sessionID: current.sessionID } - }) - .catch(() => null as { dir?: string; sessionID?: string } | null) -} - -export async function waitSlug(page: Page, skip: string[] = []) { - let prev = "" - let next = "" - await expect - .poll( - async () => { - await assertHealthy(page, "waitSlug") - const slug = slugFromUrl(page.url()) - if (!slug) return "" - if (skip.includes(slug)) return "" - if (slug !== prev) { - prev = slug - next = "" - return "" - } - next = slug - return slug - }, - { timeout: 45_000 }, - ) - .not.toBe("") - return next -} - -export async function resolveSlug(slug: string, input?: { serverUrl?: string }) { - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - const resolved = await resolveDirectory(directory, input?.serverUrl) - return { directory: resolved, slug: base64Encode(resolved), raw: slug } -} - -export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) { - const target = await resolveDirectory(directory, input?.serverUrl) - await expect - .poll( - async () => { - await assertHealthy(page, "waitDir") - const slug = slugFromUrl(page.url()) - if (!slug) return "" - return resolveSlug(slug, input) - .then((item) => item.directory) - .catch(() => "") - }, - { timeout: 45_000 }, - ) - .toBe(target) - return { directory: target, slug: base64Encode(target) } -} - -export async function waitSession( - page: Page, - input: { - directory: string - sessionID?: string - serverUrl?: string - allowAnySession?: boolean - }, -) { - const target = await resolveDirectory(input.directory, input.serverUrl) - await expect - .poll( - async () => { - await assertHealthy(page, "waitSession") - const slug = slugFromUrl(page.url()) - if (!slug) return false - const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined) - if (!resolved || resolved.directory !== target) return false - const current = sessionIDFromUrl(page.url()) - if (input.sessionID && current !== input.sessionID) return false - if (!input.sessionID && !input.allowAnySession && current) return false - - const state = await probeSession(page) - if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false - if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false - if (state?.dir) { - const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "") - if (dir !== target) return false - } - - return page - .locator(promptSelector) - .first() - .isVisible() - .catch(() => false) - }, - { timeout: 45_000 }, - ) - .toBe(true) - return { directory: target, slug: base64Encode(target) } -} - -export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) { - const sdk = createSdk(directory, serverUrl) - const target = await resolveDirectory(directory, serverUrl) - - await expect - .poll( - async () => { - const data = await sdk.session - .get({ sessionID }) - .then((x) => x.data) - .catch(() => undefined) - if (!data?.directory) return "" - return resolveDirectory(data.directory, serverUrl).catch(() => data.directory) - }, - { timeout }, - ) - .toBe(target) - - await expect - .poll( - async () => { - const items = await sdk.session - .messages({ sessionID, limit: 20 }) - .then((x) => x.data ?? []) - .catch(() => []) - return items.some((item) => item.info.role === "user") - }, - { timeout }, - ) - .toBe(true) -} - -export function sessionIDFromUrl(url: string) { - const match = /\/session\/([^/?#]+)/.exec(url) - return match?.[1] -} - -export async function hoverSessionItem(page: Page, sessionID: string) { - const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last() - await expect(sessionEl).toBeVisible() - await sessionEl.hover() - return sessionEl -} - -export async function openSessionMoreMenu(page: Page, sessionID: string) { - await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) - - const scroller = page.locator(".scroll-view__viewport").first() - await expect(scroller).toBeVisible() - await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) - - const menu = page - .locator(dropdownMenuContentSelector) - .filter({ has: page.getByRole("menuitem", { name: /rename/i }) }) - .filter({ has: page.getByRole("menuitem", { name: /archive/i }) }) - .filter({ has: page.getByRole("menuitem", { name: /delete/i }) }) - .first() - - const opened = await menu - .isVisible() - .then((x) => x) - .catch(() => false) - - if (opened) return menu - - const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() - await expect(menuTrigger).toBeVisible() - await menuTrigger.click() - - await expect(menu).toBeVisible() - return menu -} - -export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) { - const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first() - await expect(item).toBeVisible() - await item.click({ force: options?.force }) -} - -export async function confirmDialog(page: Page, buttonName: string | RegExp) { - const dialog = page.getByRole("dialog").first() - await expect(dialog).toBeVisible() - - const button = dialog.getByRole("button").filter({ hasText: buttonName }).first() - await expect(button).toBeVisible() - await button.click() -} - -export async function openSharePopover(page: Page) { - const scroller = page.locator(".scroll-view__viewport").first() - await expect(scroller).toBeVisible() - await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) - - const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() - await expect(menuTrigger).toBeVisible({ timeout: 30_000 }) - - const popoverBody = page - .locator('[data-component="popover-content"]') - .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) - .first() - - const opened = await popoverBody - .isVisible() - .then((x) => x) - .catch(() => false) - - if (!opened) { - const menu = page.locator(dropdownMenuContentSelector).first() - await menuTrigger.click() - await clickMenuItem(menu, /share/i) - await expect(menu).toHaveCount(0) - await expect(popoverBody).toBeVisible({ timeout: 30_000 }) - } - return { rightSection: scroller, popoverBody } -} - -export async function clickListItem( - container: Locator | Page, - filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string }, -): Promise { - let item: Locator - - if (typeof filter === "string" || filter instanceof RegExp) { - item = container.locator(listItemSelector).filter({ hasText: filter }).first() - } else if (filter.keyStartsWith) { - item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first() - } else if (filter.key) { - item = container.locator(listItemKeySelector(filter.key)).first() - } else if (filter.text) { - item = container.locator(listItemSelector).filter({ hasText: filter.text }).first() - } else { - throw new Error("Invalid filter provided to clickListItem") - } - - await expect(item).toBeVisible() - await item.click() - return item -} - -async function status(sdk: ReturnType, sessionID: string) { - const data = await sdk.session - .status() - .then((x) => x.data ?? {}) - .catch(() => undefined) - return data?.[sessionID] -} - -async function stable(sdk: ReturnType, sessionID: string, timeout = 10_000) { - let prev = "" - await expect - .poll( - async () => { - const info = await sdk.session - .get({ sessionID }) - .then((x) => x.data) - .catch(() => undefined) - if (!info) return true - const next = `${info.title}:${info.time.updated ?? info.time.created}` - if (next !== prev) { - prev = next - return false - } - return true - }, - { timeout }, - ) - .toBe(true) -} - -export async function waitSessionIdle(sdk: ReturnType, sessionID: string, timeout = 30_000) { - await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true) -} - -export async function cleanupSession(input: { - sessionID: string - directory?: string - sdk?: ReturnType - serverUrl?: string -}) { - const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined) - if (!sdk) throw new Error("cleanupSession requires sdk or directory") - await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined) - const current = await status(sdk, input.sessionID).catch(() => undefined) - if (current && current.type !== "idle") { - await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined) - await waitSessionIdle(sdk, input.sessionID).catch(() => undefined) - } - await stable(sdk, input.sessionID).catch(() => undefined) - await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined) -} - -export async function withSession( - sdk: ReturnType, - title: string, - callback: (session: { id: string; title: string }) => Promise, -): Promise { - const session = await sdk.session.create({ title }).then((r) => r.data) - if (!session?.id) throw new Error("Session create did not return an id") - - try { - return await callback(session) - } finally { - await cleanupSession({ sdk, sessionID: session.id }) - } -} - -const seedSystem = [ - "You are seeding deterministic e2e UI state.", - "Follow the user's instruction exactly.", - "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.", - "Do not call any extra tools.", -].join(" ") - -const wait = async (input: { probe: () => Promise; timeout?: number }) => { - const timeout = input.timeout ?? 30_000 - const end = Date.now() + timeout - while (Date.now() < end) { - const value = await input.probe() - if (value !== undefined) return value - await new Promise((resolve) => setTimeout(resolve, 250)) - } -} - -const seed = async (input: { - sessionID: string - prompt: string - sdk: ReturnType - probe: () => Promise - timeout?: number - attempts?: number -}) => { - for (let i = 0; i < (input.attempts ?? 2); i++) { - await input.sdk.session.promptAsync({ - sessionID: input.sessionID, - agent: "build", - system: seedSystem, - parts: [{ type: "text", text: input.prompt }], - }) - const value = await wait({ probe: input.probe, timeout: input.timeout }) - if (value !== undefined) return value - } -} - -export async function seedSessionQuestion( - sdk: ReturnType, - input: { - sessionID: string - questions: Array<{ - header: string - question: string - options: Array<{ label: string; description: string }> - multiple?: boolean - custom?: boolean - }> - }, -) { - const first = input.questions[0] - if (!first) throw new Error("Question seed requires at least one question") - - const text = [ - "Your only valid response is one question tool call.", - `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`, - "Do not output plain text.", - "After calling the tool, wait for the user response.", - ].join("\n") - - const result = await seed({ - sdk, - sessionID: input.sessionID, - prompt: text, - timeout: 30_000, - probe: async () => { - const list = await sdk.question.list().then((x) => x.data ?? []) - return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header) - }, - }) - - if (!result) throw new Error("Timed out seeding question request") - return { id: result.id } -} - -export async function seedSessionTask( - sdk: ReturnType, - input: { - sessionID: string - description: string - prompt: string - subagentType?: string - }, -) { - const text = [ - "Your only valid response is one task tool call.", - `Use this JSON input: ${JSON.stringify({ - description: input.description, - prompt: input.prompt, - subagent_type: input.subagentType ?? "general", - })}`, - "Do not output plain text.", - "Wait for the task to start and return the child session id.", - ].join("\n") - - const result = await seed({ - sdk, - sessionID: input.sessionID, - prompt: text, - timeout: 90_000, - probe: async () => { - const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? []) - const part = messages - .flatMap((message) => message.parts) - .find((part) => { - if (part.type !== "tool" || part.tool !== "task") return false - if (!("state" in part) || !part.state || typeof part.state !== "object") return false - if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false - if (!("description" in part.state.input) || part.state.input.description !== input.description) return false - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") - return false - if (!("sessionId" in part.state.metadata)) return false - return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0 - }) - - if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return - if (!("sessionId" in part.state.metadata)) return - const id = part.state.metadata.sessionId - if (typeof id !== "string" || !id) return - const child = await sdk.session - .get({ sessionID: id }) - .then((x) => x.data) - .catch(() => undefined) - if (!child?.id) return - return { sessionID: id } - }, - }) - - if (!result) throw new Error("Timed out seeding task tool") - return result -} - -export async function clearSessionDockSeed(sdk: ReturnType, sessionID: string) { - const [questions, permissions] = await Promise.all([ - sdk.question.list().then((x) => x.data ?? []), - sdk.permission.list().then((x) => x.data ?? []), - ]) - - await Promise.all([ - ...questions - .filter((item) => item.sessionID === sessionID) - .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)), - ...permissions - .filter((item) => item.sessionID === sessionID) - .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)), - ]) - - return true -} - -export async function openStatusPopover(page: Page) { - await defocus(page) - - const rightSection = page.locator(titlebarRightSelector) - const trigger = rightSection.getByRole("button", { name: /status/i }).first() - - const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') }) - - const opened = await popoverBody - .isVisible() - .then((x) => x) - .catch(() => false) - - if (!opened) { - await expect(trigger).toBeVisible() - await trigger.click() - await expect(popoverBody).toBeVisible() - } - - return { rightSection, popoverBody } -} - -export async function openProjectMenu(page: Page, projectSlug: string) { - await openSidebar(page) - const item = page.locator(projectSwitchSelector(projectSlug)).first() - await expect(item).toBeVisible() - await item.hover() - - const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() - await expect(trigger).toHaveCount(1) - await expect(trigger).toBeVisible() - - const menu = page - .locator(dropdownMenuContentSelector) - .filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) }) - .first() - const close = menu.locator(projectCloseMenuSelector(projectSlug)).first() - - const clicked = await trigger - .click({ force: true, timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (clicked) { - const opened = await menu - .waitFor({ state: "visible", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (opened) { - await expect(close).toBeVisible() - return menu - } - } - - await trigger.focus() - await page.keyboard.press("Enter") - - const opened = await menu - .waitFor({ state: "visible", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (opened) { - await expect(close).toBeVisible() - return menu - } - - throw new Error(`Failed to open project menu: ${projectSlug}`) -} - -export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { - const current = () => - page - .getByRole("button", { name: "New workspace" }) - .first() - .isVisible() - .then((x) => x) - .catch(() => false) - - if ((await current()) === enabled) return - - if (enabled) { - await page.reload() - await openSidebar(page) - if ((await current()) === enabled) return - } - - const flip = async (timeout?: number) => { - const menu = await openProjectMenu(page, projectSlug) - const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() - await expect(toggle).toBeVisible() - 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") - } - - for (const timeout of [1500, undefined, undefined]) { - if ((await current()) === enabled) break - await flip(timeout) - .then(() => undefined) - .catch(() => undefined) - const matched = await expect - .poll(current, { timeout: 5_000 }) - .toBe(enabled) - .then(() => true) - .catch(() => false) - if (matched) break - } - - if ((await current()) !== enabled) { - await page.reload() - await openSidebar(page) - } - - const expected = enabled ? "New workspace" : "New session" - 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) { - const item = page.locator(workspaceItemSelector(workspaceSlug)).first() - await expect(item).toBeVisible() - await item.hover() - - const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first() - await expect(trigger).toBeVisible() - await trigger.click({ force: true }) - - const menu = page.locator(dropdownMenuContentSelector).first() - await expect(menu).toBeVisible() - return menu -} - -export async function assistantText(sdk: ReturnType, sessionID: string) { - const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) - return messages - .filter((m) => m.info.role === "assistant") - .flatMap((m) => m.parts) - .filter((p) => p.type === "text") - .map((p) => p.text) - .join("\n") -} diff --git a/packages/app/e2e/app/home.spec.ts b/packages/app/e2e/app/home.spec.ts deleted file mode 100644 index 5deba4300c..0000000000 --- a/packages/app/e2e/app/home.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from "../fixtures" -import { serverNamePattern } from "../utils" - -test("home renders and shows core entrypoints", async ({ page }) => { - await page.goto("/") - const nav = page.locator('[data-component="sidebar-nav-desktop"]') - - await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() - await expect(nav.getByText("No projects open")).toBeVisible() - await expect(nav.getByText("Open a project to get started")).toBeVisible() - await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() -}) - -test("server picker dialog opens from home", async ({ page }) => { - await page.goto("/") - - const trigger = page.getByRole("button", { name: serverNamePattern }) - await expect(trigger).toBeVisible() - await trigger.click() - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("textbox").first()).toBeVisible() -}) diff --git a/packages/app/e2e/app/navigation.spec.ts b/packages/app/e2e/app/navigation.spec.ts deleted file mode 100644 index 328c950df3..0000000000 --- a/packages/app/e2e/app/navigation.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { dirPath } from "../utils" - -test("project route redirects to /session", async ({ page, directory, slug }) => { - await page.goto(dirPath(directory)) - - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - await expect(page.locator(promptSelector)).toBeVisible() -}) diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts deleted file mode 100644 index 4c701fab27..0000000000 --- a/packages/app/e2e/app/palette.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test, expect } from "../fixtures" -import { closeDialog, openPalette } from "../actions" - -test("search palette opens and closes", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openPalette(page) - - await page.keyboard.press("Escape") - await expect(dialog).toHaveCount(0) -}) - -test("search palette also opens with cmd+p", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openPalette(page, "P") - - await closeDialog(page, dialog) - await expect(dialog).toHaveCount(0) -}) diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts deleted file mode 100644 index 2c63130f67..0000000000 --- a/packages/app/e2e/app/server-default.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { test, expect } from "../fixtures" -import { serverNamePattern, serverUrls } from "../utils" -import { closeDialog, clickMenuItem } from "../actions" - -const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" - -test("can set a default server on web", async ({ page, gotoSession }) => { - await page.addInitScript((key: string) => { - try { - localStorage.removeItem(key) - } catch { - return - } - }, DEFAULT_SERVER_URL_KEY) - - await gotoSession() - - const status = page.getByRole("button", { name: "Status" }) - await expect(status).toBeVisible() - const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" }) - - const ensurePopoverOpen = async () => { - if (await popover.isVisible()) return - await status.click() - await expect(popover).toBeVisible() - } - - await ensurePopoverOpen() - await popover.getByRole("button", { name: "Manage servers" }).click() - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - - await expect(dialog.getByText(serverNamePattern).first()).toBeVisible() - - const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first() - await expect(menuTrigger).toBeVisible() - await menuTrigger.click({ force: true }) - - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible() - await clickMenuItem(menu, /set as default/i) - - await expect - .poll(async () => - serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""), - ) - .toBe(true) - await expect(dialog.getByText("Default", { exact: true })).toBeVisible() - - await closeDialog(page, dialog) - - await ensurePopoverOpen() - - const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first() - await expect(serverRow).toBeVisible() - await expect(serverRow.getByText("Default", { exact: true })).toBeVisible() -}) diff --git a/packages/app/e2e/app/session.spec.ts b/packages/app/e2e/app/session.spec.ts deleted file mode 100644 index c7fdfdc542..0000000000 --- a/packages/app/e2e/app/session.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { withSession } from "../actions" - -test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { - const title = `e2e smoke ${Date.now()}` - - await withSession(sdk, title, async (session) => { - await gotoSession(session.id) - - const prompt = page.locator(promptSelector) - await prompt.click() - await page.keyboard.type("hello from e2e") - await expect(prompt).toContainText("hello from e2e") - }) -}) diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts deleted file mode 100644 index a4592ff1db..0000000000 --- a/packages/app/e2e/app/titlebar-history.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { test, expect } from "../fixtures" -import { defocus, openSidebar, withSession } from "../actions" -import { promptSelector } from "../selectors" -import { modKey } from "../utils" - -test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const stamp = Date.now() - - await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => { - await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => { - await gotoSession(one.id) - - await openSidebar(page) - - const link = page.locator(`[data-session-id="${two.id}"] a`).first() - await expect(link).toBeVisible() - await link.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - const back = page.getByRole("button", { name: "Back" }) - const forward = page.getByRole("button", { name: "Forward" }) - - await expect(back).toBeVisible() - await expect(back).toBeEnabled() - await back.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - await expect(forward).toBeVisible() - await expect(forward).toBeEnabled() - await forward.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - }) - }) -}) - -test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const stamp = Date.now() - - await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => { - await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => { - await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => { - await gotoSession(a.id) - - await openSidebar(page) - - const second = page.locator(`[data-session-id="${b.id}"] a`).first() - await expect(second).toBeVisible() - await second.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - const back = page.getByRole("button", { name: "Back" }) - const forward = page.getByRole("button", { name: "Forward" }) - - await expect(back).toBeVisible() - await expect(back).toBeEnabled() - await back.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - await openSidebar(page) - - const third = page.locator(`[data-session-id="${c.id}"] a`).first() - await expect(third).toBeVisible() - await third.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - await expect(forward).toBeVisible() - await expect(forward).toBeDisabled() - }) - }) - }) -}) - -test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const stamp = Date.now() - - await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => { - await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => { - await gotoSession(one.id) - - await openSidebar(page) - - const link = page.locator(`[data-session-id="${two.id}"] a`).first() - await expect(link).toBeVisible() - await link.click() - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - await defocus(page) - await page.keyboard.press(`${modKey}+[`) - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - - await defocus(page) - await page.keyboard.press(`${modKey}+]`) - - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) - await expect(page.locator(promptSelector)).toBeVisible() - }) - }) -}) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts deleted file mode 100644 index a03d1d4375..0000000000 --- a/packages/app/e2e/backend.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { spawn } from "node:child_process" -import fs from "node:fs/promises" -import net from "node:net" -import os from "node:os" -import path from "node:path" -import { fileURLToPath } from "node:url" - -type Handle = { - url: string - stop: () => Promise -} - -function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer() - server.once("error", reject) - server.listen(0, () => { - const address = server.address() - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to acquire a free port"))) - return - } - server.close((err) => { - if (err) reject(err) - else resolve(address.port) - }) - }) - }) -} - -async function waitForHealth(url: string, probe = "/global/health") { - const end = Date.now() + 120_000 - let last = "" - while (Date.now() < end) { - try { - const res = await fetch(`${url}${probe}`) - if (res.ok) return - last = `status ${res.status}` - } catch (err) { - last = err instanceof Error ? err.message : String(err) - } - await new Promise((resolve) => setTimeout(resolve, 250)) - } - throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) -} - -function done(proc: ReturnType) { - return proc.exitCode !== null || proc.signalCode !== null -} - -async function waitExit(proc: ReturnType, timeout = 10_000) { - if (done(proc)) return - await Promise.race([ - new Promise((resolve) => proc.once("exit", () => resolve())), - new Promise((resolve) => setTimeout(resolve, timeout)), - ]) -} - -const LOG_CAP = 100 - -function cap(input: string[]) { - if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP) -} - -function tail(input: string[]) { - return input.slice(-40).join("") -} - -export async function startBackend(label: string, input?: { llmUrl?: string }): Promise { - const port = await freePort() - const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`)) - const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") - const repoDir = path.resolve(appDir, "../..") - const opencodeDir = path.join(repoDir, "packages", "opencode") - const env = { - ...process.env, - OPENCODE_DISABLE_LSP_DOWNLOAD: "true", - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", - OPENCODE_TEST_HOME: path.join(sandbox, "home"), - XDG_DATA_HOME: path.join(sandbox, "share"), - XDG_CACHE_HOME: path.join(sandbox, "cache"), - XDG_CONFIG_HOME: path.join(sandbox, "config"), - XDG_STATE_HOME: path.join(sandbox, "state"), - OPENCODE_CLIENT: "app", - OPENCODE_STRICT_CONFIG_DEPS: "true", - OPENCODE_E2E_LLM_URL: input?.llmUrl, - } satisfies Record - const out: string[] = [] - const err: string[] = [] - const proc = spawn( - "bun", - ["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"], - { - cwd: opencodeDir, - env, - stdio: ["ignore", "pipe", "pipe"], - }, - ) - proc.stdout?.on("data", (chunk) => { - out.push(String(chunk)) - cap(out) - }) - proc.stderr?.on("data", (chunk) => { - err.push(String(chunk)) - cap(err) - }) - - const url = `http://127.0.0.1:${port}` - try { - await waitForHealth(url) - } catch (error) { - proc.kill("SIGTERM") - await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined) - throw new Error( - [ - `Failed to start isolated e2e backend for ${label}`, - error instanceof Error ? error.message : String(error), - tail(out), - tail(err), - ] - .filter(Boolean) - .join("\n"), - ) - } - - return { - url, - async stop() { - if (!done(proc)) { - proc.kill("SIGTERM") - await waitExit(proc) - } - if (!done(proc)) { - proc.kill("SIGKILL") - await waitExit(proc) - } - await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined) - }, - } -} diff --git a/packages/app/e2e/commands/input-focus.spec.ts b/packages/app/e2e/commands/input-focus.spec.ts deleted file mode 100644 index 4ba1aa3e69..0000000000 --- a/packages/app/e2e/commands/input-focus.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => { - await gotoSession() - - const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - - await page.locator("main").click({ position: { x: 5, y: 5 } }) - await expect(prompt).not.toBeFocused() - - await page.keyboard.press("Control+L") - await expect(prompt).toBeFocused() -}) diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts deleted file mode 100644 index 7e5d7bd6e7..0000000000 --- a/packages/app/e2e/commands/panels.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test, expect } from "../fixtures" -import { modKey } from "../utils" - -const expanded = async (el: { getAttribute: (name: string) => Promise }) => { - const value = await el.getAttribute("aria-expanded") - if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`) - return value === "true" -} - -test("review panel can be toggled via keybind", async ({ page, gotoSession }) => { - await gotoSession() - - const reviewPanel = page.locator("#review-panel") - - const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first() - await expect(treeToggle).toBeVisible() - if (await expanded(treeToggle)) await treeToggle.click() - await expect(treeToggle).toHaveAttribute("aria-expanded", "false") - - const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first() - await expect(reviewToggle).toBeVisible() - if (await expanded(reviewToggle)) await reviewToggle.click() - await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(reviewPanel).toHaveAttribute("aria-hidden", "true") - - await page.keyboard.press(`${modKey}+Shift+R`) - await expect(reviewToggle).toHaveAttribute("aria-expanded", "true") - await expect(reviewPanel).toHaveAttribute("aria-hidden", "false") - - await page.keyboard.press(`${modKey}+Shift+R`) - await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(reviewPanel).toHaveAttribute("aria-hidden", "true") -}) diff --git a/packages/app/e2e/commands/tab-close.spec.ts b/packages/app/e2e/commands/tab-close.spec.ts deleted file mode 100644 index 981ee561e2..0000000000 --- a/packages/app/e2e/commands/tab-close.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { modKey } from "../utils" - -test("mod+w closes the active file tab", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/open") - await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible() - await page.keyboard.press("Enter") - - const dialog = page - .getByRole("dialog") - .filter({ has: page.getByPlaceholder(/search files/i) }) - .first() - await expect(dialog).toBeVisible() - - await dialog.getByRole("textbox").first().fill("package.json") - const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() - await expect(item).toBeVisible({ timeout: 30_000 }) - await item.click() - await expect(dialog).toHaveCount(0) - - const tab = page.getByRole("tab", { name: "package.json" }).first() - await expect(tab).toBeVisible() - await tab.click() - await expect(tab).toHaveAttribute("aria-selected", "true") - - await page.keyboard.press(`${modKey}+W`) - await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0) -}) diff --git a/packages/app/e2e/files/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts deleted file mode 100644 index abb28242da..0000000000 --- a/packages/app/e2e/files/file-open.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("can open a file tab from the search palette", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/open") - - const command = page.locator('[data-slash-id="file.open"]').first() - await expect(command).toBeVisible() - await page.keyboard.press("Enter") - - const dialog = page - .getByRole("dialog") - .filter({ has: page.getByPlaceholder(/search files/i) }) - .first() - await expect(dialog).toBeVisible() - - const input = dialog.getByRole("textbox").first() - await input.fill("package.json") - - const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() - await expect(item).toBeVisible({ timeout: 30_000 }) - await item.click() - - await expect(dialog).toHaveCount(0) - - const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible() -}) diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts deleted file mode 100644 index a5872bdf87..0000000000 --- a/packages/app/e2e/files/file-tree.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, expect } from "../fixtures" - -test("file tree can expand folders and open a file", async ({ page, gotoSession }) => { - await gotoSession() - - const toggle = page.getByRole("button", { name: "Toggle file tree" }) - const panel = page.locator("#file-tree-panel") - const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]') - - await expect(toggle).toBeVisible() - if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click() - await expect(toggle).toHaveAttribute("aria-expanded", "true") - await expect(panel).toBeVisible() - await expect(treeTabs).toBeVisible() - - const allTab = treeTabs.getByRole("tab", { name: /^all files$/i }) - await expect(allTab).toBeVisible() - await allTab.click() - await expect(allTab).toHaveAttribute("aria-selected", "true") - - const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])') - await expect(tree).toBeVisible() - - const expand = async (name: string) => { - const folder = tree.getByRole("button", { name, exact: true }).first() - await expect(folder).toBeVisible() - await expect(folder).toHaveAttribute("aria-expanded", /true|false/) - if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click() - await expect(folder).toHaveAttribute("aria-expanded", "true") - } - - await expand("packages") - await expand("app") - await expand("src") - await expand("components") - - const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first() - await expect(file).toBeVisible() - await file.click() - - const tab = page.getByRole("tab", { name: "file-tree.tsx" }) - await expect(tab).toBeVisible() - await tab.click() - await expect(tab).toHaveAttribute("aria-selected", "true") - - await toggle.click() - await expect(toggle).toHaveAttribute("aria-expanded", "false") - - await toggle.click() - await expect(toggle).toHaveAttribute("aria-expanded", "true") - await expect(allTab).toHaveAttribute("aria-selected", "true") - - const viewer = page.locator('[data-component="file"][data-mode="text"]').first() - await expect(viewer).toBeVisible() - await expect(viewer).toContainText("export default function FileTree") -}) diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts deleted file mode 100644 index 49fe1baa13..0000000000 --- a/packages/app/e2e/files/file-viewer.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { modKey } from "../utils" - -test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/open") - - const command = page.locator('[data-slash-id="file.open"]').first() - await expect(command).toBeVisible() - await page.keyboard.press("Enter") - - const dialog = page - .getByRole("dialog") - .filter({ has: page.getByPlaceholder(/search files/i) }) - .first() - await expect(dialog).toBeVisible() - - const input = dialog.getByRole("textbox").first() - await input.fill("package.json") - - const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') - let index = -1 - await expect - .poll( - async () => { - const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? "")) - index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, ""))) - return index >= 0 - }, - { timeout: 30_000 }, - ) - .toBe(true) - - const item = items.nth(index) - await expect(item).toBeVisible() - await item.click() - - await expect(dialog).toHaveCount(0) - - const tab = page.getByRole("tab", { name: "package.json" }) - await expect(tab).toBeVisible() - await tab.click() - - const viewer = page.locator('[data-component="file"][data-mode="text"]').first() - await expect(viewer).toBeVisible() - await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() -}) - -test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/open") - - const command = page.locator('[data-slash-id="file.open"]').first() - await expect(command).toBeVisible() - await page.keyboard.press("Enter") - - const dialog = page - .getByRole("dialog") - .filter({ has: page.getByPlaceholder(/search files/i) }) - .first() - await expect(dialog).toBeVisible() - - const input = dialog.getByRole("textbox").first() - await input.fill("package.json") - - const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') - let index = -1 - await expect - .poll( - async () => { - const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? "")) - index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, ""))) - return index >= 0 - }, - { timeout: 30_000 }, - ) - .toBe(true) - - const item = items.nth(index) - await expect(item).toBeVisible() - await item.click() - - await expect(dialog).toHaveCount(0) - - const tab = page.getByRole("tab", { name: "package.json" }) - await expect(tab).toBeVisible() - await tab.click() - - const viewer = page.locator('[data-component="file"][data-mode="text"]').first() - await expect(viewer).toBeVisible() - - await page.locator(promptSelector).click() - await page.keyboard.press(`${modKey}+f`) - - const findInput = page.getByPlaceholder("Find") - await expect(findInput).toBeVisible() - await expect(findInput).toBeFocused() -}) - -test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/open") - - const command = page.locator('[data-slash-id="file.open"]').first() - await expect(command).toBeVisible() - await page.keyboard.press("Enter") - - const dialog = page - .getByRole("dialog") - .filter({ has: page.getByPlaceholder(/search files/i) }) - .first() - await expect(dialog).toBeVisible() - - const input = dialog.getByRole("textbox").first() - await input.fill("package.json") - - const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') - let index = -1 - await expect - .poll( - async () => { - const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? "")) - index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, ""))) - return index >= 0 - }, - { timeout: 30_000 }, - ) - .toBe(true) - - const item = items.nth(index) - await expect(item).toBeVisible() - await item.click() - - await expect(dialog).toHaveCount(0) - - const tab = page.getByRole("tab", { name: "package.json" }) - await expect(tab).toBeVisible() - await tab.click() - - const viewer = page.locator('[data-component="file"][data-mode="text"]').first() - await expect(viewer).toBeVisible() - - await viewer.click() - await page.keyboard.press(`${modKey}+f`) - - const findInput = page.getByPlaceholder("Find") - await expect(findInput).toBeVisible() - await expect(findInput).toBeFocused() -}) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts deleted file mode 100644 index fe2eb9c1a0..0000000000 --- a/packages/app/e2e/fixtures.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { test as base, expect, type Page } from "@playwright/test" -import { ManagedRuntime } from "effect" -import type { E2EWindow } from "../src/testing/terminal" -import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server" -import { TestLLMServer } from "../../opencode/test/lib/llm-server" -import { startBackend } from "./backend" -import { - healthPhase, - cleanupSession, - cleanupTestProject, - createTestProject, - setHealthPhase, - sessionIDFromUrl, - waitSession, - waitSessionIdle, - waitSessionSaved, - waitSlug, -} from "./actions" -import { promptSelector } from "./selectors" -import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils" - -type LLMFixture = { - url: string - push: (...input: (Item | Reply)[]) => Promise - pushMatch: ( - match: (hit: { url: URL; body: Record }) => boolean, - ...input: (Item | Reply)[] - ) => Promise - textMatch: ( - match: (hit: { url: URL; body: Record }) => boolean, - value: string, - opts?: { usage?: Usage }, - ) => Promise - toolMatch: ( - match: (hit: { url: URL; body: Record }) => boolean, - name: string, - input: unknown, - ) => Promise - text: (value: string, opts?: { usage?: Usage }) => Promise - tool: (name: string, input: unknown) => Promise - toolHang: (name: string, input: unknown) => Promise - reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise - fail: (message?: unknown) => Promise - error: (status: number, body: unknown) => Promise - hang: () => Promise - hold: (value: string, wait: PromiseLike) => Promise - hits: () => Promise }>> - calls: () => Promise - wait: (count: number) => Promise - inputs: () => Promise[]> - pending: () => Promise - misses: () => Promise }>> -} - -type LLMWorker = LLMFixture & { - reset: () => Promise -} - -type AssistantFixture = { - reply: LLMFixture["text"] - tool: LLMFixture["tool"] - toolHang: LLMFixture["toolHang"] - reason: LLMFixture["reason"] - fail: LLMFixture["fail"] - error: LLMFixture["error"] - hang: LLMFixture["hang"] - hold: LLMFixture["hold"] - calls: LLMFixture["calls"] - pending: LLMFixture["pending"] -} - -export const settingsKey = "settings.v3" - -const seedModel = (() => { - const [providerID = "opencode", modelID = "big-pickle"] = ( - process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle" - ).split("/") - return { - providerID: providerID || "opencode", - modelID: modelID || "big-pickle", - } -})() - -function clean(value: string | null) { - return (value ?? "").replace(/\u200B/g, "").trim() -} - -async function visit(page: Page, url: string) { - let err: unknown - for (const _ of [0, 1, 2]) { - try { - await page.goto(url) - return - } catch (cause) { - err = cause - if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause - await new Promise((resolve) => setTimeout(resolve, 300)) - } - } - throw err -} - -async function promptSend(page: Page) { - return page - .evaluate(() => { - const win = window as E2EWindow - const sent = win.__opencode_e2e?.prompt?.sent - return { - started: sent?.started ?? 0, - count: sent?.count ?? 0, - sessionID: sent?.sessionID, - directory: sent?.directory, - } - }) - .catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined })) -} - -type ProjectHandle = { - directory: string - slug: string - gotoSession: (sessionID?: string) => Promise - trackSession: (sessionID: string, directory?: string) => void - trackDirectory: (directory: string) => void - sdk: ReturnType -} - -type ProjectOptions = { - extra?: string[] - model?: { providerID: string; modelID: string } - setup?: (directory: string) => Promise - beforeGoto?: (project: { directory: string; sdk: ReturnType }) => Promise -} - -type ProjectFixture = ProjectHandle & { - open: (options?: ProjectOptions) => Promise - prompt: (text: string) => Promise - user: (text: string) => Promise - shell: (cmd: string) => Promise -} - -type TestFixtures = { - llm: LLMFixture - assistant: AssistantFixture - project: ProjectFixture - sdk: ReturnType - gotoSession: (sessionID?: string) => Promise -} - -type WorkerFixtures = { - _llm: LLMWorker - backend: { - url: string - sdk: (directory?: string) => ReturnType - } - directory: string - slug: string -} - -export const test = base.extend({ - _llm: [ - async ({}, use) => { - const rt = ManagedRuntime.make(TestLLMServer.layer) - try { - const svc = await rt.runPromise(TestLLMServer.asEffect()) - await use({ - url: svc.url, - push: (...input) => rt.runPromise(svc.push(...input)), - pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)), - textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)), - toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)), - text: (value, opts) => rt.runPromise(svc.text(value, opts)), - tool: (name, input) => rt.runPromise(svc.tool(name, input)), - toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)), - reason: (value, opts) => rt.runPromise(svc.reason(value, opts)), - fail: (message) => rt.runPromise(svc.fail(message)), - error: (status, body) => rt.runPromise(svc.error(status, body)), - hang: () => rt.runPromise(svc.hang), - hold: (value, wait) => rt.runPromise(svc.hold(value, wait)), - reset: () => rt.runPromise(svc.reset), - hits: () => rt.runPromise(svc.hits), - calls: () => rt.runPromise(svc.calls), - wait: (count) => rt.runPromise(svc.wait(count)), - inputs: () => rt.runPromise(svc.inputs), - pending: () => rt.runPromise(svc.pending), - misses: () => rt.runPromise(svc.misses), - }) - } finally { - await rt.dispose() - } - }, - { scope: "worker" }, - ], - backend: [ - async ({ _llm }, use, workerInfo) => { - const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url }) - try { - await use({ - url: handle.url, - sdk: (directory?: string) => createSdk(directory, handle.url), - }) - } finally { - await handle.stop() - } - }, - { scope: "worker" }, - ], - llm: async ({ _llm }, use) => { - await _llm.reset() - await use({ - url: _llm.url, - push: _llm.push, - pushMatch: _llm.pushMatch, - textMatch: _llm.textMatch, - toolMatch: _llm.toolMatch, - text: _llm.text, - tool: _llm.tool, - toolHang: _llm.toolHang, - reason: _llm.reason, - fail: _llm.fail, - error: _llm.error, - hang: _llm.hang, - hold: _llm.hold, - hits: _llm.hits, - calls: _llm.calls, - wait: _llm.wait, - inputs: _llm.inputs, - pending: _llm.pending, - misses: _llm.misses, - }) - const pending = await _llm.pending() - if (pending > 0) { - throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`) - } - }, - assistant: async ({ llm }, use) => { - await use({ - reply: llm.text, - tool: llm.tool, - toolHang: llm.toolHang, - reason: llm.reason, - fail: llm.fail, - error: llm.error, - hang: llm.hang, - hold: llm.hold, - calls: llm.calls, - pending: llm.pending, - }) - }, - page: async ({ page }, use) => { - let boundary: string | undefined - setHealthPhase(page, "test") - const consoleHandler = (msg: { text(): string }) => { - const text = msg.text() - if (!text.includes("[e2e:error-boundary]")) return - if (healthPhase(page) === "cleanup") { - console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`) - return - } - boundary ||= text - console.log(text) - } - const pageErrorHandler = (err: Error) => { - console.log(`[e2e:pageerror] ${err.stack || err.message}`) - } - page.on("console", consoleHandler) - page.on("pageerror", pageErrorHandler) - await use(page) - page.off("console", consoleHandler) - page.off("pageerror", pageErrorHandler) - if (boundary) throw new Error(boundary) - }, - directory: [ - async ({ backend }, use) => { - await use(await getWorktree(backend.url)) - }, - { scope: "worker" }, - ], - slug: [ - async ({ directory }, use) => { - await use(dirSlug(directory)) - }, - { scope: "worker" }, - ], - sdk: async ({ directory, backend }, use) => { - await use(backend.sdk(directory)) - }, - gotoSession: async ({ page, directory, backend }, use) => { - await seedStorage(page, { directory, serverUrl: backend.url }) - - const gotoSession = async (sessionID?: string) => { - await visit(page, sessionPath(directory, sessionID)) - await waitSession(page, { - directory, - sessionID, - serverUrl: backend.url, - allowAnySession: !sessionID, - }) - } - await use(gotoSession) - }, - project: async ({ page, llm, backend }, use) => { - const item = makeProject(page, llm, backend) - try { - await use(item.project) - } finally { - await item.cleanup() - } - }, -}) - -function makeProject( - page: Page, - llm: LLMFixture, - backend: { url: string; sdk: (directory?: string) => ReturnType }, -) { - let state: - | { - directory: string - slug: string - sdk: ReturnType - sessions: Map - dirs: Set - } - | undefined - - const need = () => { - if (state) return state - throw new Error("project.open() must be called first") - } - - const trackSession = (sessionID: string, directory?: string) => { - const cur = need() - cur.sessions.set(sessionID, directory ?? cur.directory) - } - - const trackDirectory = (directory: string) => { - const cur = need() - if (directory !== cur.directory) cur.dirs.add(directory) - } - - const gotoSession = async (sessionID?: string) => { - const cur = need() - await visit(page, sessionPath(cur.directory, sessionID)) - await waitSession(page, { - directory: cur.directory, - sessionID, - serverUrl: backend.url, - allowAnySession: !sessionID, - }) - const current = sessionIDFromUrl(page.url()) - if (current) trackSession(current) - } - - const open = async (options?: ProjectOptions) => { - if (state) return - const directory = await createTestProject({ serverUrl: backend.url }) - const sdk = backend.sdk(directory) - await options?.setup?.(directory) - await seedStorage(page, { - directory, - extra: options?.extra, - model: options?.model, - serverUrl: backend.url, - }) - state = { - directory, - slug: "", - sdk, - sessions: new Map(), - dirs: new Set(), - } - await options?.beforeGoto?.({ directory, sdk }) - await gotoSession() - need().slug = await waitSlug(page) - } - - const send = async (text: string, input: { noReply: boolean; shell: boolean }) => { - if (input.noReply) { - const cur = need() - const state = await page.evaluate(() => { - const model = (window as E2EWindow).__opencode_e2e?.model?.current - if (!model) return null - return { - dir: model.dir, - sessionID: model.sessionID, - agent: model.agent, - model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined, - variant: model.variant ?? undefined, - } - }) - const dir = state?.dir ?? cur.directory - const sdk = backend.sdk(dir) - const sessionID = state?.sessionID - ? state.sessionID - : await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => { - if (!res.data?.id) throw new Error("Failed to create no-reply session") - return res.data.id - }) - await sdk.session.prompt({ - sessionID, - agent: state?.agent, - model: state?.model, - variant: state?.variant, - noReply: true, - parts: [{ type: "text", text }], - }) - await visit(page, sessionPath(dir, sessionID)) - const active = await waitSession(page, { - directory: dir, - sessionID, - serverUrl: backend.url, - }) - trackSession(sessionID, active.directory) - await waitSessionSaved(active.directory, sessionID, 90_000, backend.url) - return sessionID - } - - const prev = await promptSend(page) - if (!input.noReply && !input.shell && (await llm.pending()) === 0) { - await llm.text("ok") - } - - const prompt = page.locator(promptSelector).first() - const submit = async () => { - await expect(prompt).toBeVisible() - await prompt.click() - if (input.shell) { - await page.keyboard.type("!") - await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) - } - await page.keyboard.type(text) - await expect.poll(async () => clean(await prompt.textContent())).toBe(text) - await page.keyboard.press("Enter") - const started = await expect - .poll(async () => (await promptSend(page)).started, { timeout: 5_000 }) - .toBeGreaterThan(prev.started) - .then(() => true) - .catch(() => false) - if (started) return - const send = page.getByRole("button", { name: "Send" }).first() - const enabled = await send - .isEnabled() - .then((x) => x) - .catch(() => false) - if (enabled) { - await send.click() - } else { - await prompt.click() - await page.keyboard.press("Enter") - } - await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started) - } - - await submit() - - let next: { sessionID: string; directory: string } | undefined - await expect - .poll( - async () => { - const sent = await promptSend(page) - if (sent.count <= prev.count) return "" - if (!sent.sessionID || !sent.directory) return "" - next = { sessionID: sent.sessionID, directory: sent.directory } - return sent.sessionID - }, - { timeout: 90_000 }, - ) - .not.toBe("") - - if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe") - const active = await waitSession(page, { - directory: next.directory, - sessionID: next.sessionID, - serverUrl: backend.url, - }) - trackSession(next.sessionID, active.directory) - if (!input.shell) { - await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url) - } - await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined) - return next.sessionID - } - - const prompt = async (text: string) => { - return send(text, { noReply: false, shell: false }) - } - - const user = async (text: string) => { - return send(text, { noReply: true, shell: false }) - } - - const shell = async (cmd: string) => { - return send(cmd, { noReply: false, shell: true }) - } - - const cleanup = async () => { - const cur = state - if (!cur) return - setHealthPhase(page, "cleanup") - await Promise.allSettled( - Array.from(cur.sessions, ([sessionID, directory]) => - cleanupSession({ sessionID, directory, serverUrl: backend.url }), - ), - ) - await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory))) - await cleanupTestProject(cur.directory) - state = undefined - setHealthPhase(page, "test") - } - - return { - project: { - open, - prompt, - user, - shell, - gotoSession, - trackSession, - trackDirectory, - get directory() { - return need().directory - }, - get slug() { - return need().slug - }, - get sdk() { - return need().sdk - }, - }, - cleanup, - } -} - -async function seedStorage( - page: Page, - input: { - directory: string - extra?: string[] - model?: { providerID: string; modelID: string } - serverUrl?: string - }, -) { - const origin = input.serverUrl ?? serverUrl - await page.addInitScript( - (args: { - directory: string - serverUrl: string - extra: string[] - model: { providerID: string; modelID: string } - }) => { - const key = "opencode.global.dat:server" - const raw = localStorage.getItem(key) - const parsed = (() => { - if (!raw) return undefined - try { - return JSON.parse(raw) as unknown - } catch { - return undefined - } - })() - - const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} - const list = Array.isArray(store.list) ? store.list : [] - const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} - const projects = store.projects && typeof store.projects === "object" ? store.projects : {} - const next = { ...(projects as Record) } - const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list] - - const add = (origin: string, directory: string) => { - const current = next[origin] - const items = Array.isArray(current) ? current : [] - const existing = items.filter( - (p): p is { worktree: string; expanded?: boolean } => - !!p && - typeof p === "object" && - "worktree" in p && - typeof (p as { worktree?: unknown }).worktree === "string", - ) - if (existing.some((p) => p.worktree === directory)) return - next[origin] = [{ worktree: directory, expanded: true }, ...existing] - } - - for (const directory of [args.directory, ...args.extra]) { - add("local", directory) - add(args.serverUrl, directory) - } - - localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject })) - localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl) - - const win = window as E2EWindow - win.__opencode_e2e = { - ...win.__opencode_e2e, - model: { enabled: true }, - prompt: { enabled: true }, - terminal: { enabled: true, terminals: {} }, - } - localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} })) - }, - { directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel }, - ) -} - -export { expect } diff --git a/packages/app/e2e/models/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts deleted file mode 100644 index d94c026521..0000000000 --- a/packages/app/e2e/models/model-picker.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { clickListItem } from "../actions" - -test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/model") - - const command = page.locator('[data-slash-id="model.choose"]') - await expect(command).toBeVisible() - await command.hover() - - await page.keyboard.press("Enter") - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - - const input = dialog.getByRole("textbox").first() - - const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first() - await expect(selected).toBeVisible() - - const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first() - const target = (await other.count()) > 0 ? other : selected - - const key = await target.getAttribute("data-key") - if (!key) throw new Error("Failed to resolve model key from list item") - - const model = key.split(":").slice(1).join(":") - - await input.fill(model) - - await clickListItem(dialog, { key }) - - await expect(dialog).toHaveCount(0) - - await page.locator(promptSelector).click() - await page.keyboard.type("/model") - await expect(command).toBeVisible() - await command.hover() - await page.keyboard.press("Enter") - - const dialogAgain = page.getByRole("dialog") - await expect(dialogAgain).toBeVisible() - await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible() -}) diff --git a/packages/app/e2e/models/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts deleted file mode 100644 index c699111793..0000000000 --- a/packages/app/e2e/models/models-visibility.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { closeDialog, openSettings, clickListItem } from "../actions" - -test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/model") - - const command = page.locator('[data-slash-id="model.choose"]') - await expect(command).toBeVisible() - await command.hover() - await page.keyboard.press("Enter") - - const picker = page.getByRole("dialog") - await expect(picker).toBeVisible() - - const target = picker.locator('[data-slot="list-item"]').first() - await expect(target).toBeVisible() - - const key = await target.getAttribute("data-key") - if (!key) throw new Error("Failed to resolve model key from list item") - - const name = (await target.locator("span").first().innerText()).trim() - if (!name) throw new Error("Failed to resolve model name from list item") - - await page.keyboard.press("Escape") - await expect(picker).toHaveCount(0) - - const settings = await openSettings(page) - - await settings.getByRole("tab", { name: "Models" }).click() - const search = settings.getByPlaceholder("Search models") - await expect(search).toBeVisible() - await search.fill(name) - - const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first() - const input = toggle.locator('[data-slot="switch-input"]') - await expect(toggle).toBeVisible() - await expect(input).toHaveAttribute("aria-checked", "true") - await toggle.locator('[data-slot="switch-control"]').click() - await expect(input).toHaveAttribute("aria-checked", "false") - - await closeDialog(page, settings) - - await page.locator(promptSelector).click() - await page.keyboard.type("/model") - await expect(command).toBeVisible() - await command.hover() - await page.keyboard.press("Enter") - - const pickerAgain = page.getByRole("dialog") - await expect(pickerAgain).toBeVisible() - await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible() - - await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0) - - await page.keyboard.press("Escape") - await expect(pickerAgain).toHaveCount(0) -}) diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts deleted file mode 100644 index 1ffe4219d1..0000000000 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { test, expect } from "../fixtures" -import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" - -test("dialog edit project updates name and startup script", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - await project.open() - await openSidebar(page) - - const open = async () => { - const menu = await openProjectMenu(page, project.slug) - await clickMenuItem(menu, /^Edit$/i, { force: true }) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") - return dialog - } - - const name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` - - const dialog = await open() - - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) - - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) - - await dialog.getByRole("button", { name: "Save" }).click() - await expect(dialog).toHaveCount(0) - - await expect - .poll( - async () => { - await page.reload() - await openSidebar(page) - const reopened = await open() - const value = await reopened.getByLabel("Name").inputValue() - const next = await reopened.getByLabel("Workspace startup script").inputValue() - await reopened.getByRole("button", { name: "Cancel" }).click() - await expect(reopened).toHaveCount(0) - return `${value}\n${next}` - }, - { timeout: 30_000 }, - ) - .toBe(`${name}\n${startup}`) -}) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts deleted file mode 100644 index 75e6f2ce68..0000000000 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { test, expect } from "../fixtures" -import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions" -import { projectSwitchSelector } from "../selectors" -import { dirSlug } from "../utils" - -test("closing active project navigates to another open project", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const other = await createTestProject() - const otherSlug = dirSlug(other) - - try { - await project.open({ extra: [other] }) - await openSidebar(page) - - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() - - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - - const menu = await openProjectMenu(page, otherSlug) - await clickMenuItem(menu, /^Close$/i, { force: true }) - - await expect - .poll( - () => { - const pathname = new URL(page.url()).pathname - if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" - if (pathname === "/") return "home" - return "" - }, - { timeout: 15_000 }, - ) - .toMatch(/^(project|home)$/) - - await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) - await expect - .poll( - async () => { - return await page.locator(projectSwitchSelector(otherSlug)).count() - }, - { timeout: 15_000 }, - ) - .toBe(0) - } finally { - await cleanupTestProject(other) - } -}) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts deleted file mode 100644 index 67d09afd15..0000000000 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { base64Decode } from "@opencode-ai/util/encode" -import { test, expect } from "../fixtures" -import { - defocus, - createTestProject, - cleanupTestProject, - openSidebar, - setWorkspacesEnabled, - waitSession, - waitSlug, -} from "../actions" -import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" -import { dirSlug, resolveDirectory } from "../utils" - -test("can switch between projects from sidebar", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const other = await createTestProject() - const otherSlug = dirSlug(other) - - try { - await project.open({ extra: [other] }) - await defocus(page) - - const currentSlug = dirSlug(project.directory) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() - - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - - const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() - await expect(currentButton).toBeVisible() - await currentButton.click() - - await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) - } finally { - await cleanupTestProject(other) - } -}) - -test("switching back to a project opens the latest workspace session", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const other = await createTestProject() - const otherSlug = dirSlug(other) - try { - await project.open({ extra: [other] }) - await defocus(page) - await setWorkspacesEnabled(page, project.slug, true) - await openSidebar(page) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - - await page.getByRole("button", { name: "New workspace" }).first().click() - - const raw = await waitSlug(page, [project.slug]) - const dir = base64Decode(raw) - if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) - const space = await resolveDirectory(dir) - const next = dirSlug(space) - project.trackDirectory(space) - await openSidebar(page) - - const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() - await expect(item).toBeVisible() - await item.hover() - - const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() - await expect(btn).toBeVisible() - await btn.click({ force: true }) - - await waitSession(page, { directory: space }) - - const created = await project.user("test") - - await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) - - await openSidebar(page) - - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click({ force: true }) - await waitSession(page, { directory: other }) - - const rootButton = page.locator(projectSwitchSelector(project.slug)).first() - await expect(rootButton).toBeVisible() - await rootButton.click({ force: true }) - - await waitSession(page, { directory: space, sessionID: created }) - await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) - } finally { - await cleanupTestProject(other) - } -}) diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts deleted file mode 100644 index d9d010b4dc..0000000000 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Page } from "@playwright/test" -import { test, expect } from "../fixtures" -import { - openSidebar, - resolveSlug, - sessionIDFromUrl, - setWorkspacesEnabled, - waitDir, - waitSession, - waitSlug, -} from "../actions" -import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" - -function item(space: { slug: string; raw: string }) { - return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` -} - -function button(space: { slug: string; raw: string }) { - return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}` -} - -async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { - await openSidebar(page) - await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 }) -} - -async function createWorkspace(page: Page, root: string, seen: string[]) { - await openSidebar(page) - await page.getByRole("button", { name: "New workspace" }).first().click() - - const next = await resolveSlug(await waitSlug(page, [root, ...seen])) - await waitDir(page, next.directory) - return next -} - -async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) { - await waitWorkspaceReady(page, space) - - const row = page.locator(item(space)).first() - await row.hover() - - const next = page.locator(button(space)).first() - await expect(next).toBeVisible() - await next.click({ force: true }) - - await waitSession(page, { directory: space.directory }) - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("") -} - -async function createSessionFromWorkspace( - project: Parameters[0]["project"], - page: Page, - space: { slug: string; raw: string; directory: string }, - text: string, -) { - await openWorkspaceNewSession(page, space) - return project.user(text) -} - -test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - await project.open() - await openSidebar(page) - await setWorkspacesEnabled(page, project.slug, true) - - const first = await createWorkspace(page, project.slug, []) - project.trackDirectory(first.directory) - await waitWorkspaceReady(page, first) - - const second = await createWorkspace(page, project.slug, [first.slug]) - project.trackDirectory(second.directory) - await waitWorkspaceReady(page, second) - - await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`) - await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`) - await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`) -}) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts deleted file mode 100644 index 206baa47ce..0000000000 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ /dev/null @@ -1,368 +0,0 @@ -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { base64Decode } from "@opencode-ai/util/encode" -import type { Page } from "@playwright/test" - -import { test, expect } from "../fixtures" - -test.describe.configure({ mode: "serial" }) -import { - cleanupTestProject, - clickMenuItem, - confirmDialog, - openSidebar, - openWorkspaceMenu, - resolveSlug, - setWorkspacesEnabled, - slugFromUrl, - waitDir, - waitSlug, -} from "../actions" -import { inlineInputSelector, workspaceItemSelector } from "../selectors" -import { dirSlug } from "../utils" - -async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) { - const rootSlug = project.slug - await openSidebar(page) - - await setWorkspacesEnabled(page, rootSlug, true) - - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [rootSlug])) - await waitDir(page, next.directory) - project.trackDirectory(next.directory) - - await openSidebar(page) - - 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) - - return { rootSlug, slug: next.slug, directory: next.directory } -} - -test("can enable and disable workspaces from project menu", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - await project.open() - - await openSidebar(page) - - await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - - await setWorkspacesEnabled(page, project.slug, true) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible() - - await setWorkspacesEnabled(page, project.slug, false) - await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() - await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0) -}) - -test("can create a workspace", async ({ page, 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 }) - - 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 project.open({ extra: [nonGit] }) - await page.goto(`/${nonGitSlug}/session`) - - await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - - const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) - expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") - - await openSidebar(page) - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible() - } finally { - await cleanupTestProject(nonGit) - } -}) - -test("can rename a workspace", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - await project.open() - - const { slug } = await setupWorkspaceTest(page, project) - - const rename = `e2e workspace ${Date.now()}` - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Rename$/i, { force: true }) - - await expect(menu).toHaveCount(0) - - const item = page.locator(workspaceItemSelector(slug)).first() - await expect(item).toBeVisible() - const input = item.locator(inlineInputSelector).first() - const shown = await input - .isVisible() - .then((x) => x) - .catch(() => false) - if (!shown) { - const retry = await openWorkspaceMenu(page, slug) - await clickMenuItem(retry, /^Rename$/i, { force: true }) - await expect(retry).toHaveCount(0) - } - await expect(input).toBeVisible() - await input.fill(rename) - await input.press("Enter") - await expect(item).toContainText(rename) -}) - -test("can reset a workspace", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - await project.open() - - const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) - - const readme = path.join(createdDir, "README.md") - const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) - const original = await fs.readFile(readme, "utf8") - const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` - await fs.writeFile(readme, dirty, "utf8") - await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") - - await expect - .poll(async () => { - return await fs - .stat(extra) - .then(() => true) - .catch(() => false) - }) - .toBe(true) - - await expect - .poll(async () => { - const files = await project.sdk.file - .status({ directory: createdDir }) - .then((r) => r.data ?? []) - .catch(() => []) - return files.length - }) - .toBeGreaterThan(0) - - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Reset$/i, { force: true }) - await confirmDialog(page, /^Reset workspace$/i) - - await expect - .poll( - async () => { - const files = await project.sdk.file - .status({ directory: createdDir }) - .then((r) => r.data ?? []) - .catch(() => []) - return files.length - }, - { timeout: 120_000 }, - ) - .toBe(0) - - await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original) - - await expect - .poll(async () => { - return await fs - .stat(extra) - .then(() => true) - .catch(() => false) - }) - .toBe(false) -}) - -test("can reorder workspaces by drag and drop", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - await project.open() - const rootSlug = project.slug - - const listSlugs = async () => { - const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') - const slugs = await nodes.evaluateAll((els) => { - return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) - }) - return slugs - } - - const waitReady = async (slug: string) => { - await expect - .poll( - async () => { - const item = page.locator(workspaceItemSelector(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) - } - - const drag = async (from: string, to: string) => { - const src = page.locator(workspaceItemSelector(from)).first() - const dst = page.locator(workspaceItemSelector(to)).first() - - const a = await src.boundingBox() - const b = await dst.boundingBox() - if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") - - await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) - await page.mouse.down() - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) - await page.mouse.up() - } - - await openSidebar(page) - - await setWorkspacesEnabled(page, rootSlug, true) - - const workspaces = [] as { directory: string; slug: string }[] - for (const _ of [0, 1]) { - const prev = slugFromUrl(page.url()) - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) - await waitDir(page, next.directory) - project.trackDirectory(next.directory) - workspaces.push(next) - - await openSidebar(page) - } - - 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 delete a workspace", async ({ page, project }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - await project.open() - - const rootSlug = project.slug - await openSidebar(page) - await setWorkspacesEnabled(page, rootSlug, true) - - const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data) - if (!created?.directory) throw new Error("Failed to create workspace for delete test") - - const directory = created.directory - const slug = dirSlug(directory) - project.trackDirectory(directory) - - await page.reload() - await openSidebar(page) - await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 }) - - await expect - .poll( - async () => { - const worktrees = await project.sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 30_000 }, - ) - .toBe(true) - - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Delete$/i, { force: true }) - await confirmDialog(page, /^Delete workspace$/i) - - await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) - - await expect - .poll( - async () => { - const worktrees = await project.sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 60_000 }, - ) - .toBe(false) - - await openSidebar(page) - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) - await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() -}) diff --git a/packages/app/e2e/prompt/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts deleted file mode 100644 index 366191fd70..0000000000 --- a/packages/app/e2e/prompt/context.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { test, expect } from "../fixtures" -import type { Page } from "@playwright/test" -import { promptSelector } from "../selectors" -import { withSession } from "../actions" - -function contextButton(page: Page) { - return page - .locator('[data-component="button"]') - .filter({ has: page.locator('[data-component="progress-circle"]').first() }) - .first() -} - -async function seedContextSession(input: { sessionID: string; sdk: Parameters[0] }) { - await input.sdk.session.promptAsync({ - sessionID: input.sessionID, - noReply: true, - parts: [ - { - type: "text", - text: "seed context", - }, - ], - }) - - await expect - .poll(async () => { - const messages = await input.sdk.session - .messages({ sessionID: input.sessionID, limit: 1 }) - .then((r) => r.data ?? []) - return messages.length - }) - .toBeGreaterThan(0) -} - -test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { - const title = `e2e smoke context ${Date.now()}` - - await withSession(sdk, title, async (session) => { - await seedContextSession({ sessionID: session.id, sdk }) - - await gotoSession(session.id) - - const trigger = contextButton(page) - await expect(trigger).toBeVisible() - await trigger.click() - - const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() - }) -}) - -test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => { - await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => { - await seedContextSession({ sessionID: session.id, sdk }) - await gotoSession(session.id) - - await page.locator(promptSelector).click() - - const trigger = contextButton(page) - await expect(trigger).toBeVisible() - await trigger.click() - - const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - const context = tabs.getByRole("tab", { name: "Context" }) - await expect(context).toBeVisible() - - await page.getByRole("button", { name: "Close tab" }).first().click() - await expect(context).toHaveCount(0) - }) -}) - -test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => { - await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => { - await seedContextSession({ sessionID: session.id, sdk }) - await gotoSession(session.id) - - await page.locator(promptSelector).click() - - const trigger = contextButton(page) - await expect(trigger).toBeVisible() - await trigger.click() - - await expect(page.getByRole("tab", { name: "Context" })).toBeVisible() - await page.getByRole("button", { name: "Open file" }).first().click() - - const dialog = page - .getByRole("dialog") - .filter({ has: page.getByPlaceholder(/search files/i) }) - .first() - await expect(dialog).toBeVisible() - - await page.keyboard.press("Escape") - await expect(dialog).toHaveCount(0) - }) -}) diff --git a/packages/app/e2e/prompt/mock.ts b/packages/app/e2e/prompt/mock.ts deleted file mode 100644 index c7eb54b526..0000000000 --- a/packages/app/e2e/prompt/mock.ts +++ /dev/null @@ -1,15 +0,0 @@ -type Hit = { body: Record } - -export function bodyText(hit: Hit) { - return JSON.stringify(hit.body) -} - -/** - * Match requests whose body contains the exact serialized tool input. - * The seed prompts embed JSON.stringify(input) in the prompt text, which - * gets escaped again inside the JSON body — so we double-escape to match. - */ -export function inputMatch(input: unknown) { - const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1) - return (hit: Hit) => bodyText(hit).includes(escaped) -} diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts deleted file mode 100644 index 403369947b..0000000000 --- a/packages/app/e2e/prompt/prompt-async.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { assistantText, withSession } from "../actions" - -const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() - -// Regression test for Issue #12453: the synchronous POST /message endpoint holds -// the connection open while the agent works, causing "Failed to fetch" over -// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. -test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => { - test.setTimeout(120_000) - - // Simulate Tailscale/VPN killing the long-lived sync connection - await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) - - const token = `E2E_ASYNC_${Date.now()}` - await project.open() - await assistant.reply(token) - const sessionID = await project.prompt(`Reply with exactly: ${token}`) - - await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) - await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token) -}) - -test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { - await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => { - const prompt = page.locator(promptSelector) - const value = `restore ${Date.now()}` - - await page.route(`**/session/${session.id}/prompt_async`, (route) => - route.fulfill({ - status: 500, - contentType: "application/json", - body: JSON.stringify({ message: "e2e prompt failure" }), - }), - ) - - await gotoSession(session.id) - await prompt.click() - await page.keyboard.type(value) - await page.keyboard.press("Enter") - - await expect.poll(async () => text(await prompt.textContent())).toBe(value) - await expect - .poll( - async () => { - const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? []) - return messages.length - }, - { timeout: 15_000 }, - ) - .toBe(0) - }) -}) diff --git a/packages/app/e2e/prompt/prompt-drop-file-uri.spec.ts b/packages/app/e2e/prompt/prompt-drop-file-uri.spec.ts deleted file mode 100644 index add2d8d8bc..0000000000 --- a/packages/app/e2e/prompt/prompt-drop-file-uri.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => { - await gotoSession() - - const prompt = page.locator(promptSelector) - await prompt.click() - - const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt" - const dt = await page.evaluateHandle((text) => { - const dt = new DataTransfer() - dt.setData("text/plain", text) - return dt - }, `file:${path}`) - - await page.dispatchEvent("body", "drop", { dataTransfer: dt }) - - const pill = page.locator(`${promptSelector} [data-type="file"]`).first() - await expect(pill).toBeVisible() - await expect(pill).toHaveAttribute("data-path", path) -}) diff --git a/packages/app/e2e/prompt/prompt-drop-file.spec.ts b/packages/app/e2e/prompt/prompt-drop-file.spec.ts deleted file mode 100644 index 0a138de997..0000000000 --- a/packages/app/e2e/prompt/prompt-drop-file.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("dropping an image file adds an attachment", async ({ page, gotoSession }) => { - await gotoSession() - - const prompt = page.locator(promptSelector) - await prompt.click() - - const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII=" - const dt = await page.evaluateHandle((b64) => { - const dt = new DataTransfer() - const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)) - const file = new File([bytes], "drop.png", { type: "image/png" }) - dt.items.add(file) - return dt - }, png) - - await page.dispatchEvent("body", "drop", { dataTransfer: dt }) - - const img = page.locator('img[alt="drop.png"]').first() - await expect(img).toBeVisible() - - const remove = page.getByRole("button", { name: "Remove attachment" }).first() - await expect(remove).toBeVisible() - - await img.hover() - await remove.click() - await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0) -}) diff --git a/packages/app/e2e/prompt/prompt-footer-focus.spec.ts b/packages/app/e2e/prompt/prompt-footer-focus.spec.ts deleted file mode 100644 index 4609f4b3d9..0000000000 --- a/packages/app/e2e/prompt/prompt-footer-focus.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Locator, Page } from "@playwright/test" -import { test, expect } from "../fixtures" -import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors" - -type Probe = { - agent?: string - model?: { providerID: string; modelID: string; name?: string } - models?: Array<{ providerID: string; modelID: string; name: string }> - agents?: Array<{ name: string }> -} - -async function probe(page: Page): Promise { - return page.evaluate(() => { - const win = window as Window & { - __opencode_e2e?: { - model?: { - current?: Probe - } - } - } - return win.__opencode_e2e?.model?.current ?? null - }) -} - -async function state(page: Page) { - const value = await probe(page) - if (!value) throw new Error("Failed to resolve model selection probe") - return value -} - -async function ready(page: Page) { - const prompt = page.locator(promptSelector) - await prompt.click() - await expect(prompt).toBeFocused() - await prompt.pressSequentially("focus") - return prompt -} - -async function body(prompt: Locator) { - return prompt.evaluate((el) => (el as HTMLElement).innerText) -} - -test("agent select returns focus to the prompt", async ({ page, gotoSession }) => { - await gotoSession() - - const prompt = await ready(page) - - const info = await state(page) - const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent) - test.skip(!next, "only one agent available") - if (!next) return - - await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click() - - const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first() - await expect(item).toBeVisible() - await item.click({ force: true }) - - await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText( - next, - ) - await expect(prompt).toBeFocused() - await prompt.pressSequentially(" agent") - await expect.poll(() => body(prompt)).toContain("focus agent") -}) - -test("model select returns focus to the prompt", async ({ page, gotoSession }) => { - await gotoSession() - - const prompt = await ready(page) - - const info = await state(page) - const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null - const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key) - test.skip(!next, "only one model available") - if (!next) return - - await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click() - - const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first() - await expect(item).toBeVisible() - await item.click({ force: true }) - - await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name) - await expect(prompt).toBeFocused() - await prompt.pressSequentially(" model") - await expect.poll(() => body(prompt)).toContain("focus model") -}) diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts deleted file mode 100644 index 55cb0c9aa3..0000000000 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client" -import type { Page } from "@playwright/test" -import { test, expect } from "../fixtures" -import { assistantText } from "../actions" -import { promptSelector } from "../selectors" -import { createSdk } from "../utils" - -const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() -type Sdk = ReturnType - -const isBash = (part: unknown): part is ToolPart => { - if (!part || typeof part !== "object") return false - if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || part.tool !== "bash") return false - return "state" in part -} - -async function wait(page: Page, value: string) { - await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) -} - -async function reply(sdk: Sdk, sessionID: string, token: string) { - await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token) -} - -async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) { - await expect - .poll( - async () => { - const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) - const part = messages - .filter((item) => item.info.role === "assistant") - .flatMap((item) => item.parts) - .filter(isBash) - .find((item) => item.state.input?.command === cmd && item.state.status === "completed") - - if (!part || part.state.status !== "completed") return - return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output - }, - { timeout: 90_000 }, - ) - .toContain(token) -} - -test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => { - test.setTimeout(120_000) - - const firstToken = `E2E_HISTORY_ONE_${Date.now()}` - const secondToken = `E2E_HISTORY_TWO_${Date.now()}` - const first = `Reply with exactly: ${firstToken}` - const second = `Reply with exactly: ${secondToken}` - const draft = `draft ${Date.now()}` - - await project.open() - await assistant.reply(firstToken) - const sessionID = await project.prompt(first) - await wait(page, "") - await reply(project.sdk, sessionID, firstToken) - - await assistant.reply(secondToken) - await project.prompt(second) - await wait(page, "") - await reply(project.sdk, sessionID, secondToken) - - const prompt = page.locator(promptSelector) - await prompt.click() - await page.keyboard.type(draft) - await wait(page, draft) - - await prompt.fill("") - await wait(page, "") - - await page.keyboard.press("ArrowUp") - await wait(page, second) - - await page.keyboard.press("ArrowUp") - await wait(page, first) - - await page.keyboard.press("ArrowDown") - await wait(page, second) - - await page.keyboard.press("ArrowDown") - await wait(page, "") -}) - -test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { - test.setTimeout(120_000) - - const firstToken = `E2E_SHELL_ONE_${Date.now()}` - const secondToken = `E2E_SHELL_TWO_${Date.now()}` - const normalToken = `E2E_NORMAL_${Date.now()}` - const first = `echo ${firstToken}` - const second = `echo ${secondToken}` - const normal = `Reply with exactly: ${normalToken}` - - await gotoSession() - - const prompt = page.locator(promptSelector) - - await prompt.click() - await page.keyboard.type("!") - await page.keyboard.type(first) - await page.keyboard.press("Enter") - await wait(page, "") - - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - const sessionID = sessionIDFromUrl(page.url())! - await shell(sdk, sessionID, first, firstToken) - - await prompt.click() - await page.keyboard.type("!") - await page.keyboard.type(second) - await page.keyboard.press("Enter") - await wait(page, "") - await shell(sdk, sessionID, second, secondToken) - - await page.keyboard.press("Escape") - await wait(page, "") - - await prompt.click() - await page.keyboard.type("!") - await page.keyboard.press("ArrowUp") - await wait(page, second) - - await page.keyboard.press("ArrowUp") - await wait(page, first) - - await page.keyboard.press("ArrowDown") - await wait(page, second) - - await page.keyboard.press("ArrowDown") - await wait(page, "") - - await page.keyboard.press("Escape") - await wait(page, "") - - await prompt.click() - await page.keyboard.type(normal) - await page.keyboard.press("Enter") - await wait(page, "") - await reply(sdk, sessionID, normalToken) - - await prompt.click() - await page.keyboard.press("ArrowUp") - await wait(page, normal) -}) diff --git a/packages/app/e2e/prompt/prompt-mention.spec.ts b/packages/app/e2e/prompt/prompt-mention.spec.ts deleted file mode 100644 index 5cc9f6e685..0000000000 --- a/packages/app/e2e/prompt/prompt-mention.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - const sep = process.platform === "win32" ? "\\" : "/" - const file = ["packages", "app", "package.json"].join(sep) - const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/ - - await page.keyboard.type(`@${file}`) - - const suggestion = page.getByRole("button", { name: filePattern }).first() - await expect(suggestion).toBeVisible() - await suggestion.hover() - - await page.keyboard.press("Tab") - - const pill = page.locator(`${promptSelector} [data-type="file"]`).first() - await expect(pill).toBeVisible() - await expect(pill).toHaveAttribute("data-path", filePattern) - - await page.keyboard.type(" ok") - await expect(page.locator(promptSelector)).toContainText("ok") -}) diff --git a/packages/app/e2e/prompt/prompt-multiline.spec.ts b/packages/app/e2e/prompt/prompt-multiline.spec.ts deleted file mode 100644 index 3584773bb9..0000000000 --- a/packages/app/e2e/prompt/prompt-multiline.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => { - await gotoSession() - - await expect(page).toHaveURL(/\/session\/?$/) - - const prompt = page.locator(promptSelector) - await prompt.focus() - await expect(prompt).toBeFocused() - - await prompt.pressSequentially("line one") - await expect(prompt).toBeFocused() - - await prompt.press("Shift+Enter") - await expect(page).toHaveURL(/\/session\/?$/) - await expect(prompt).toBeFocused() - - await prompt.pressSequentially("line two") - - await expect(page).toHaveURL(/\/session\/?$/) - await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two") -}) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts deleted file mode 100644 index 81af4cb1bc..0000000000 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client" -import { test, expect } from "../fixtures" -import { closeDialog, openSettings, withSession } from "../actions" -import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors" - -const isBash = (part: unknown): part is ToolPart => { - if (!part || typeof part !== "object") return false - if (!("type" in part) || part.type !== "tool") return false - if (!("tool" in part) || part.tool !== "bash") return false - return "state" in part -} - -test("shell mode runs a command in the project directory", async ({ page, project }) => { - test.setTimeout(120_000) - - await project.open() - const cmd = process.platform === "win32" ? "dir" : "command ls" - - await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) - const dialog = await openSettings(page) - const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() - const input = toggle.locator('[data-slot="switch-input"]').first() - await expect(toggle).toBeVisible() - if ((await input.getAttribute("aria-checked")) !== "true") { - await toggle.locator('[data-slot="switch-control"]').click() - await expect(input).toHaveAttribute("aria-checked", "true") - } - await closeDialog(page, dialog) - await project.shell(cmd) - - await expect - .poll( - async () => { - const list = await project.sdk.session - .messages({ sessionID: session.id, limit: 50 }) - .then((x) => x.data ?? []) - const msg = list.findLast( - (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory, - ) - if (!msg) return - - const part = msg.parts - .filter(isBash) - .find((item) => item.state.input?.command === cmd && item.state.status === "completed") - - if (!part || part.state.status !== "completed") return - const output = - typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output - if (!output.includes("README.md")) return - - return { cwd: project.directory, output } - }, - { timeout: 90_000 }, - ) - .toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") })) - }) -}) - -test("shell mode unmounts model and variant controls", async ({ page, project }) => { - await project.open() - - const prompt = page.locator(promptSelector).first() - await expect(page.locator(promptModelSelector)).toHaveCount(1) - await expect(page.locator(promptVariantSelector)).toHaveCount(1) - - await prompt.click() - await page.keyboard.type("!") - - await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) - await expect(page.locator(promptModelSelector)).toHaveCount(0) - await expect(page.locator(promptVariantSelector)).toHaveCount(0) -}) diff --git a/packages/app/e2e/prompt/prompt-slash-open.spec.ts b/packages/app/e2e/prompt/prompt-slash-open.spec.ts deleted file mode 100644 index b4a93099d9..0000000000 --- a/packages/app/e2e/prompt/prompt-slash-open.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" - -test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => { - await gotoSession() - - await page.locator(promptSelector).click() - await page.keyboard.type("/open") - - const command = page.locator('[data-slash-id="file.open"]') - await expect(command).toBeVisible() - await command.hover() - - await page.keyboard.press("Enter") - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("textbox").first()).toBeVisible() - - await page.keyboard.press("Escape") - await expect(dialog).toHaveCount(0) -}) diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts deleted file mode 100644 index f3eeceee5f..0000000000 --- a/packages/app/e2e/prompt/prompt-slash-share.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test, expect } from "../fixtures" -import { promptSelector } from "../selectors" -import { withSession } from "../actions" - -const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" - -async function seed(sdk: Parameters[0], sessionID: string) { - await sdk.session.promptAsync({ - sessionID, - noReply: true, - parts: [{ type: "text", text: "e2e share seed" }], - }) - - await expect - .poll( - async () => { - const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) - return messages.length - }, - { timeout: 30_000 }, - ) - .toBeGreaterThan(0) -} - -test("/share and /unshare update session share state", async ({ page, project }) => { - test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") - - await project.open() - await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { - project.trackSession(session.id) - const prompt = page.locator(promptSelector) - - await seed(project.sdk, session.id) - await project.gotoSession(session.id) - - await prompt.click() - await page.keyboard.type("/share") - await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() - - await prompt.click() - await page.keyboard.type("/unshare") - await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .toBeUndefined() - }) -}) diff --git a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts deleted file mode 100644 index 466b3ba1bb..0000000000 --- a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from "../fixtures" -import { runPromptSlash, waitTerminalFocusIdle } from "../actions" -import { promptSelector, terminalSelector } from "../selectors" - -test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { - await gotoSession() - - const prompt = page.locator(promptSelector) - const terminal = page.locator(terminalSelector) - - await expect(terminal).not.toBeVisible() - - await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) - await waitTerminalFocusIdle(page, { term: terminal }) - - await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) - await expect(terminal).not.toBeVisible() -}) diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts deleted file mode 100644 index b5dc02badb..0000000000 --- a/packages/app/e2e/prompt/prompt.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { test, expect } from "../fixtures" -import { assistantText } from "../actions" - -test("can send a prompt and receive a reply", async ({ page, project, assistant }) => { - test.setTimeout(120_000) - - const pageErrors: string[] = [] - const onPageError = (err: Error) => { - pageErrors.push(err.message) - } - page.on("pageerror", onPageError) - - try { - const token = `E2E_OK_${Date.now()}` - await project.open() - await assistant.reply(token) - const sessionID = await project.prompt(`Reply with exactly: ${token}`) - - await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1) - await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token) - } finally { - page.off("pageerror", onPageError) - } - - if (pageErrors.length > 0) { - throw new Error(`Page error(s):\n${pageErrors.join("\n")}`) - } -}) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts deleted file mode 100644 index 461bb5c1b7..0000000000 --- a/packages/app/e2e/selectors.ts +++ /dev/null @@ -1,65 +0,0 @@ -export const promptSelector = '[data-component="prompt-input"]' -const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]' -export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]` -export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]' -export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' -export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' -export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]' - -export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' -export const promptAgentSelector = '[data-component="prompt-agent-control"]' -export const promptModelSelector = '[data-component="prompt-model-control"]' -export const promptVariantSelector = '[data-component="prompt-variant-control"]' -export const settingsLanguageSelectSelector = '[data-action="settings-language"]' -export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' -export const settingsThemeSelector = '[data-action="settings-theme"]' -export const settingsCodeFontSelector = '[data-action="settings-code-font"]' -export const settingsUIFontSelector = '[data-action="settings-ui-font"]' -export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]' -export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' -export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' -export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' -export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' -export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' -export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' -export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' - -const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]' - -export const projectSwitchSelector = (slug: string) => - `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]` - -export const projectMenuTriggerSelector = (slug: string) => - `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]` - -export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` - -export const projectWorkspacesToggleSelector = (slug: string) => - `[data-action="project-workspaces-toggle"][data-project="${slug}"]` - -export const titlebarRightSelector = "#opencode-titlebar-right" - -export const popoverBodySelector = '[data-slot="popover-body"]' - -export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]' - -export const inlineInputSelector = '[data-component="inline-input"]' - -export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` - -export const workspaceItemSelector = (slug: string) => - `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]` - -export const workspaceMenuTriggerSelector = (slug: string) => - `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` - -export const workspaceNewSessionSelector = (slug: string) => - `${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]` - -export const listItemSelector = '[data-slot="list-item"]' - -export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` - -export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]` - -export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]` diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts deleted file mode 100644 index c9fad1af85..0000000000 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { seedSessionTask, withSession } from "../actions" -import { test, expect } from "../fixtures" -import { inputMatch } from "../prompt/mock" - -test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { - test.setTimeout(120_000) - - const errs: string[] = [] - const onError = (err: Error) => { - errs.push(err.message) - } - page.on("pageerror", onError) - - try { - await project.open() - await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => { - const taskInput = { - description: "Open child session", - prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", - subagent_type: "general", - } - await llm.toolMatch(inputMatch(taskInput), "task", taskInput) - const child = await seedSessionTask(project.sdk, { - sessionID: session.id, - description: taskInput.description, - prompt: taskInput.prompt, - }) - project.trackSession(child.sessionID) - - await project.gotoSession(session.id) - - const header = page.locator("[data-session-title]") - await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 }) - - const card = page - .locator('[data-component="task-tool-card"]') - .filter({ hasText: /open child session/i }) - .first() - await expect(card).toBeVisible({ timeout: 30_000 }) - await card.click() - - await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) - await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title) - await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description) - await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/") - await expect - .poll( - () => - header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({ - left: getComputedStyle(el).paddingLeft, - right: getComputedStyle(el).paddingRight, - })), - { timeout: 30_000 }, - ) - .toEqual({ left: "8px", right: "8px" }) - await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0) - await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 }) - await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) - }) - } finally { - page.off("pageerror", onError) - } -}) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts deleted file mode 100644 index ecacea83dc..0000000000 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ /dev/null @@ -1,655 +0,0 @@ -import { test, expect } from "../fixtures" -import { - composerEvent, - type ComposerDriverState, - type ComposerProbeState, - type ComposerWindow, -} from "../../src/testing/session-composer" -import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions" -import { - permissionDockSelector, - promptSelector, - questionDockSelector, - sessionComposerDockSelector, - sessionTodoToggleButtonSelector, -} from "../selectors" -import { modKey } from "../utils" -import { inputMatch } from "../prompt/mock" - -type Sdk = Parameters[0] -type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } - -async function withDockSession( - sdk: Sdk, - title: string, - fn: (session: { id: string; title: string }) => Promise, - opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void }, -) { - const session = await sdk.session - .create(opts?.permission ? { title, permission: opts.permission } : { title }) - .then((r) => r.data) - if (!session?.id) throw new Error("Session create did not return an id") - opts?.trackSession?.(session.id) - try { - return await fn(session) - } finally { - await cleanupSession({ sdk, sessionID: session.id }) - } -} - -const defaultQuestions = [ - { - header: "Need input", - question: "Pick one option", - options: [ - { label: "Continue", description: "Continue now" }, - { label: "Stop", description: "Stop here" }, - ], - }, -] - -test.setTimeout(120_000) - -async function withDockSeed(sdk: Sdk, sessionID: string, fn: () => Promise) { - try { - return await fn() - } finally { - await clearSessionDockSeed(sdk, sessionID).catch(() => undefined) - } -} - -async function clearPermissionDock(page: any, label: RegExp) { - const dock = page.locator(permissionDockSelector) - await expect(dock).toBeVisible() - await dock.getByRole("button", { name: label }).click() -} - -async function setAutoAccept(page: any, enabled: boolean) { - const dialog = await openSettings(page) - const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() - const input = toggle.locator('[data-slot="switch-input"]').first() - await expect(toggle).toBeVisible() - const checked = (await input.getAttribute("aria-checked")) === "true" - if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click() - await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false") - await closeDialog(page, dialog) -} - -async function expectQuestionBlocked(page: any) { - await expect(page.locator(questionDockSelector)).toBeVisible() - await expect(page.locator(promptSelector)).toHaveCount(0) -} - -async function expectQuestionOpen(page: any) { - await expect(page.locator(questionDockSelector)).toHaveCount(0) - await expect(page.locator(promptSelector)).toBeVisible() -} - -async function expectPermissionBlocked(page: any) { - await expect(page.locator(permissionDockSelector)).toBeVisible() - await expect(page.locator(promptSelector)).toHaveCount(0) -} - -async function expectPermissionOpen(page: any) { - await expect(page.locator(permissionDockSelector)).toHaveCount(0) - await expect(page.locator(promptSelector)).toBeVisible() -} - -async function todoDock(page: any, sessionID: string) { - await page.addInitScript(() => { - const win = window as ComposerWindow - win.__opencode_e2e = { - ...win.__opencode_e2e, - composer: { - enabled: true, - sessions: {}, - }, - } - }) - - const write = async (driver: ComposerDriverState | undefined) => { - await page.evaluate( - (input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => { - const win = window as ComposerWindow - const composer = win.__opencode_e2e?.composer - if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled") - composer.sessions ??= {} - const prev = composer.sessions[input.sessionID] ?? {} - if (!input.driver) { - if (!prev.probe) { - delete composer.sessions[input.sessionID] - } else { - composer.sessions[input.sessionID] = { probe: prev.probe } - } - } else { - composer.sessions[input.sessionID] = { - ...prev, - driver: input.driver, - } - } - window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } })) - }, - { event: composerEvent, sessionID, driver }, - ) - } - - const read = () => - page.evaluate((sessionID: string) => { - const win = window as ComposerWindow - return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null - }, sessionID) as Promise - - const api = { - async clear() { - await write(undefined) - return api - }, - async open(todos: NonNullable) { - await write({ live: true, todos }) - return api - }, - async finish(todos: NonNullable) { - await write({ live: false, todos }) - return api - }, - async expectOpen(states: ComposerProbeState["states"]) { - await expect.poll(read, { timeout: 10_000 }).toMatchObject({ - mounted: true, - collapsed: false, - hidden: false, - count: states.length, - states, - }) - return api - }, - async expectCollapsed(states: ComposerProbeState["states"]) { - await expect.poll(read, { timeout: 10_000 }).toMatchObject({ - mounted: true, - collapsed: true, - hidden: true, - count: states.length, - states, - }) - return api - }, - async expectClosed() { - await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false }) - return api - }, - async collapse() { - await page.locator(sessionTodoToggleButtonSelector).click() - return api - }, - async expand() { - await page.locator(sessionTodoToggleButtonSelector).click() - return api - }, - } - - return api -} - -async function withMockPermission( - page: any, - request: { - id: string - sessionID: string - permission: string - patterns: string[] - metadata?: Record - always?: string[] - }, - opts: { child?: any } | undefined, - fn: (state: { resolved: () => Promise }) => Promise, -) { - const listUrl = /\/permission(?:\?.*)?$/ - const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/] - let pending = [ - { - ...request, - always: request.always ?? ["*"], - metadata: request.metadata ?? {}, - }, - ] - - const list = async (route: any) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(pending), - }) - } - - const reply = async (route: any) => { - const url = new URL(route.request().url()) - const parts = url.pathname.split("/").filter(Boolean) - const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1) - pending = pending.filter((item) => item.id !== id) - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(true), - }) - } - - await page.route(listUrl, list) - for (const item of replyUrls) { - await page.route(item, reply) - } - - const sessionList = opts?.child - ? async (route: any) => { - const res = await route.fetch() - const json = await res.json() - const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined - if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child) - await route.fulfill({ - response: res, - body: JSON.stringify(json), - }) - } - : undefined - - if (sessionList) await page.route("**/session?*", sessionList) - - const state = { - async resolved() { - await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0) - }, - } - - try { - return await fn(state) - } finally { - await page.unroute(listUrl, list) - for (const item of replyUrls) { - await page.unroute(item, reply) - } - if (sessionList) await page.unroute("**/session?*", sessionList) - } -} - -test("default dock shows prompt input", async ({ page, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock default", - async (session) => { - await project.gotoSession(session.id) - - await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - await expect(page.locator(promptSelector)).toBeVisible() - await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0) - 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() - - 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 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 }, - ) -}) - -test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock question keyboard", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) - - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - const first = dock.locator('[data-slot="question-option"]').first() - const second = dock.locator('[data-slot="question-option"]').nth(1) - - await expectQuestionBlocked(page) - await expect(first).toBeFocused() - - await page.keyboard.press("ArrowDown") - await expect(second).toBeFocused() - - await page.keyboard.press("Space") - await page.keyboard.press(`${modKey}+Enter`) - await expectQuestionOpen(page) - }) - }, - { trackSession: project.trackSession }, - ) -}) - -test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock question escape", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) - - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - const first = dock.locator('[data-slot="question-option"]').first() - - await expectQuestionBlocked(page) - await expect(first).toBeFocused() - - await page.keyboard.press("Escape") - await expectQuestionOpen(page) - }) - }, - { trackSession: project.trackSession }, - ) -}) - -test("blocked permission flow supports allow once", async ({ page, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock permission once", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_once", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-once"], - metadata: { description: "Need permission for command" }, - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) - - await clearPermissionDock(page, /allow once/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) -}) - -test("blocked permission flow supports reject", async ({ page, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock permission reject", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_reject", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-reject"], - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) - - await clearPermissionDock(page, /deny/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) -}) - -test("blocked permission flow supports allow always", async ({ page, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock permission always", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_always", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-always"], - metadata: { description: "Need permission for command" }, - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) - - await clearPermissionDock(page, /allow always/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) -}) - -test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => { - const questions = [ - { - header: "Child input", - question: "Pick one child option", - options: [ - { label: "Continue", description: "Continue child" }, - { label: "Stop", description: "Stop child" }, - ], - }, - ] - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock child question parent", - async (session) => { - await project.gotoSession(session.id) - - const child = await project.sdk.session - .create({ - title: "e2e composer dock child question", - parentID: session.id, - }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") - project.trackSession(child.id) - - try { - await withDockSeed(project.sdk, child.id, async () => { - await llm.toolMatch(inputMatch({ questions }), "question", { questions }) - await seedSessionQuestion(project.sdk, { - sessionID: child.id, - questions, - }) - - const dock = page.locator(questionDockSelector) - await expectQuestionBlocked(page) - - await dock.locator('[data-slot="question-option"]').first().click() - await dock.getByRole("button", { name: /submit/i }).click() - - await expectQuestionOpen(page) - }) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: child.id }) - } - }, - { trackSession: project.trackSession }, - ) -}) - -test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock child permission parent", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - - const child = await project.sdk.session - .create({ - title: "e2e composer dock child permission", - parentID: session.id, - }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") - project.trackSession(child.id) - - try { - await withMockPermission( - page, - { - id: "per_e2e_child", - sessionID: child.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-child"], - metadata: { description: "Need child permission" }, - }, - { child }, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) - - await clearPermissionDock(page, /allow once/i) - await state.resolved() - await page.goto(page.url()) - - await expectPermissionOpen(page) - }, - ) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: child.id }) - } - }, - { trackSession: project.trackSession }, - ) -}) - -test("todo dock transitions and collapse behavior", async ({ page, project }) => { - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock todo", - async (session) => { - const dock = await todoDock(page, session.id) - await project.gotoSession(session.id) - await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - - try { - await dock.open([ - { content: "first task", status: "pending", priority: "high" }, - { content: "second task", status: "in_progress", priority: "medium" }, - ]) - await dock.expectOpen(["pending", "in_progress"]) - - await dock.collapse() - await dock.expectCollapsed(["pending", "in_progress"]) - - await dock.expand() - await dock.expectOpen(["pending", "in_progress"]) - - await dock.finish([ - { content: "first task", status: "completed", priority: "high" }, - { content: "second task", status: "cancelled", priority: "medium" }, - ]) - await dock.expectClosed() - } finally { - await dock.clear() - } - }, - { trackSession: project.trackSession }, - ) -}) - -test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { - const questions = [ - { - header: "Need input", - question: "Pick one option", - options: [{ label: "Continue", description: "Continue now" }], - }, - ] - await project.open() - await withDockSession( - project.sdk, - "e2e composer dock keyboard", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) - - await llm.toolMatch(inputMatch({ questions }), "question", { questions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions, - }) - - await expectQuestionBlocked(page) - - await page.locator("main").click({ position: { x: 5, y: 5 } }) - await page.keyboard.type("abc") - await expect(page.locator(promptSelector)).toHaveCount(0) - }) - }, - { trackSession: project.trackSession }, - ) -}) diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts deleted file mode 100644 index c107cc5187..0000000000 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { Locator, Page } from "@playwright/test" -import { test, expect } from "../fixtures" -import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions" -import { - promptAgentSelector, - promptModelSelector, - promptVariantSelector, - workspaceItemSelector, - workspaceNewSessionSelector, -} from "../selectors" -import { createSdk, sessionPath } from "../utils" - -type Footer = { - agent: string - model: string - variant: string -} - -type Probe = { - dir?: string - sessionID?: string - agent?: string - model?: { providerID: string; modelID: string; name?: string } - variant?: string | null - pick?: { - agent?: string - model?: { providerID: string; modelID: string } - variant?: string | null - } - variants?: string[] - models?: Array<{ providerID: string; modelID: string; name: string }> - agents?: Array<{ name: string }> -} - -const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - -const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim() - -const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) - -async function probe(page: Page): Promise { - return page.evaluate(() => { - const win = window as Window & { - __opencode_e2e?: { - model?: { - current?: Probe - } - } - } - return win.__opencode_e2e?.model?.current ?? null - }) -} - -async function currentModel(page: Page) { - await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null) - const value = await probe(page).then(modelKey) - if (!value) throw new Error("Failed to resolve current model key") - return value -} - -async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") { - await expect - .poll( - () => - page.evaluate((key) => { - const win = window as Window & { - __opencode_e2e?: { - model?: { - controls?: Record - } - } - } - return !!win.__opencode_e2e?.model?.controls?.[key] - }, key), - { timeout: 30_000 }, - ) - .toBe(true) -} - -async function pickAgent(page: Page, value: string) { - await waitControl(page, "setAgent") - await page.evaluate((value) => { - const win = window as Window & { - __opencode_e2e?: { - model?: { - controls?: { - setAgent?: (value: string | undefined) => void - } - } - } - } - const fn = win.__opencode_e2e?.model?.controls?.setAgent - if (!fn) throw new Error("Model e2e agent control is not enabled") - fn(value) - }, value) -} - -async function pickModel(page: Page, value: { providerID: string; modelID: string }) { - await waitControl(page, "setModel") - await page.evaluate((value) => { - const win = window as Window & { - __opencode_e2e?: { - model?: { - controls?: { - setModel?: (value: { providerID: string; modelID: string } | undefined) => void - } - } - } - } - const fn = win.__opencode_e2e?.model?.controls?.setModel - if (!fn) throw new Error("Model e2e model control is not enabled") - fn(value) - }, value) -} - -async function pickVariant(page: Page, value: string) { - await waitControl(page, "setVariant") - await page.evaluate((value) => { - const win = window as Window & { - __opencode_e2e?: { - model?: { - controls?: { - setVariant?: (value: string | undefined) => void - } - } - } - } - const fn = win.__opencode_e2e?.model?.controls?.setVariant - if (!fn) throw new Error("Model e2e variant control is not enabled") - fn(value) - }, value) -} - -async function read(page: Page): Promise