From 55fe0e280d3cff0fb1061e68ea71cb0fe18f8d11 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:52:21 -0600 Subject: [PATCH 01/15] test(app): initial e2e test setup --- .github/workflows/test.yml | 63 +++++++++++++++++++++++ bun.lock | 10 ++++ package.json | 1 + packages/app/.gitignore | 2 + packages/app/README.md | 15 ++++++ packages/app/e2e/home.spec.ts | 6 +++ packages/app/package.json | 7 ++- packages/app/playwright.config.ts | 43 ++++++++++++++++ packages/opencode/script/seed-e2e.ts | 50 ++++++++++++++++++ packages/opencode/src/share/share-next.ts | 6 +++ packages/opencode/src/share/share.ts | 5 ++ 11 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/home.spec.ts create mode 100644 packages/app/playwright.config.ts create mode 100644 packages/opencode/script/seed-e2e.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c39710bee8f..98eac1dab2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,52 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Install Playwright browsers + run: bun --cwd packages/app x playwright install --with-deps + + - name: Seed opencode data + run: bun --cwd packages/opencode script/seed-e2e.ts + env: + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }} + OPENCODE_E2E_SESSION_TITLE: "E2E Session" + OPENCODE_E2E_MESSAGE: "Seeded for UI e2e" + OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" + + - name: Run opencode server + run: bun --cwd packages/opencode run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & + env: + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_CLIENT: "app" + + - name: Wait for opencode server + run: | + for i in {1..60}; do + curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0 + sleep 1 + done + exit 1 + - name: run run: | git config --global user.email "bot@opencode.ai" @@ -26,3 +72,20 @@ jobs: bun turbo test env: CI: true + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + PLAYWRIGHT_SERVER_HOST: "localhost" + PLAYWRIGHT_SERVER_PORT: "4096" + VITE_OPENCODE_SERVER_HOST: "localhost" + VITE_OPENCODE_SERVER_PORT: "4096" + OPENCODE_CLIENT: "app" + timeout-minutes: 30 diff --git a/bun.lock b/bun.lock index a9cabb31114..e5892a7745d 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -502,6 +503,7 @@ "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.0.2", + "@playwright/test": "1.51.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1355,6 +1357,8 @@ "@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=="], + "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -3291,6 +3295,10 @@ "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -4427,6 +4435,8 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/package.json b/package.json index f1d6c4fead1..ca9602174a2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "luxon": "3.6.1", "marked": "17.0.1", "marked-shiki": "1.2.1", + "@playwright/test": "1.51.0", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20251207.1", "zod": "4.1.8", diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 4a20d55a70d..d699efb38d2 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1 +1,3 @@ src/assets/theme.css +e2e/test-results +e2e/playwright-report diff --git a/packages/app/README.md b/packages/app/README.md index bd10e6c8ddf..42a68815090 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -29,6 +29,21 @@ It correctly bundles Solid in production mode and optimizes the build for the be The build is minified and the filenames include the hashes.
Your app is ready to be deployed! +## E2E Testing + +The Playwright runner expects the app already running at `http://localhost:3000`. + +```bash +bun add -D @playwright/test +bunx playwright install +bun run test:e2e +``` + +Environment options: + +- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`) +- `PLAYWRIGHT_PORT` (default: `3000`) + ## Deployment You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts new file mode 100644 index 00000000000..ff57923d58c --- /dev/null +++ b/packages/app/e2e/home.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from "@playwright/test" + +test("home shows recent projects header", async ({ page }) => { + await page.goto("/") + await expect(page.getByText("Recent projects")).toBeVisible() +}) diff --git a/packages/app/package.json b/packages/app/package.json index 38d9a25f50e..2a754c96735 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -12,11 +12,16 @@ "start": "vite", "dev": "vite", "build": "vite build", - "serve": "vite preview" + "serve": "vite preview", + "test": "playwright test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report e2e/playwright-report" }, "license": "MIT", "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts new file mode 100644 index 00000000000..10819e69ffe --- /dev/null +++ b/packages/app/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}` +const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const command = `bun run dev -- --host 0.0.0.0 --port ${port}` +const reuse = !process.env.CI + +export default defineConfig({ + testDir: "./e2e", + outputDir: "./e2e/test-results", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], + webServer: { + command, + url: baseURL, + reuseExistingServer: reuse, + timeout: 120_000, + env: { + VITE_OPENCODE_SERVER_HOST: serverHost, + VITE_OPENCODE_SERVER_PORT: serverPort, + }, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts new file mode 100644 index 00000000000..ba2155cb692 --- /dev/null +++ b/packages/opencode/script/seed-e2e.ts @@ -0,0 +1,50 @@ +const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() +const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" +const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" +const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano" +const parts = model.split("/") +const providerID = parts[0] ?? "opencode" +const modelID = parts[1] ?? "gpt-5-nano" +const now = Date.now() + +const seed = async () => { + const { Instance } = await import("../src/project/instance") + const { InstanceBootstrap } = await import("../src/project/bootstrap") + const { Session } = await import("../src/session") + const { Identifier } = await import("../src/id/id") + const { Project } = await import("../src/project/project") + + await Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn: async () => { + const session = await Session.create({ title }) + const messageID = Identifier.descending("message") + const partID = Identifier.descending("part") + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: now }, + agent: "build", + model: { + providerID, + modelID, + }, + } + const part = { + id: partID, + sessionID: session.id, + messageID, + type: "text" as const, + text, + time: { start: now }, + } + await Session.updateMessage(message) + await Session.updatePart(part) + await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) + }, + }) +} + +await seed() diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 95271f8c827..dddce95cb4f 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -15,7 +15,10 @@ export namespace ShareNext { return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") } + const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" + export async function init() { + if (disabled) return Bus.subscribe(Session.Event.Updated, async (evt) => { await sync(evt.properties.info.id, [ { @@ -63,6 +66,7 @@ export namespace ShareNext { } export async function create(sessionID: string) { + if (disabled) return { id: "", url: "", secret: "" } log.info("creating share", { sessionID }) const result = await fetch(`${await url()}/api/share`, { method: "POST", @@ -110,6 +114,7 @@ export namespace ShareNext { const queue = new Map }>() async function sync(sessionID: string, data: Data[]) { + if (disabled) return const existing = queue.get(sessionID) if (existing) { for (const item of data) { @@ -145,6 +150,7 @@ export namespace ShareNext { } export async function remove(sessionID: string) { + if (disabled) return log.info("removing share", { sessionID }) const share = await get(sessionID) if (!share) return diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts index 1006b23d556..f7bf4b3fa52 100644 --- a/packages/opencode/src/share/share.ts +++ b/packages/opencode/src/share/share.ts @@ -11,6 +11,7 @@ export namespace Share { const pending = new Map() export async function sync(key: string, content: any) { + if (disabled) return const [root, ...splits] = key.split("/") if (root !== "session") return const [sub, sessionID] = splits @@ -69,7 +70,10 @@ export namespace Share { process.env["OPENCODE_API"] ?? (Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai") + const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" + export async function create(sessionID: string) { + if (disabled) return { url: "", secret: "" } return fetch(`${URL}/share_create`, { method: "POST", body: JSON.stringify({ sessionID: sessionID }), @@ -79,6 +83,7 @@ export namespace Share { } export async function remove(sessionID: string, secret: string) { + if (disabled) return {} return fetch(`${URL}/share_delete`, { method: "POST", body: JSON.stringify({ sessionID, secret }), From 5bbf45d87cd92be34147d3211e754c6a4898dd44 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:15:34 -0600 Subject: [PATCH 02/15] test(app): more e2e tests --- packages/app/e2e/home.spec.ts | 22 ++++++++++++++++-- packages/app/e2e/session.spec.ts | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/session.spec.ts diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts index ff57923d58c..29a07060932 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/home.spec.ts @@ -1,6 +1,24 @@ import { test, expect } from "@playwright/test" -test("home shows recent projects header", async ({ page }) => { +test("home renders and shows an open project entrypoint", async ({ page }) => { await page.goto("/") - await expect(page.getByText("Recent projects")).toBeVisible() + + await expect(page.getByText("Recent projects").or(page.getByText("No recent projects"))).toBeVisible() + await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() +}) + +test("server picker dialog opens from home", async ({ page }) => { + const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" + const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + const name = `${host}:${port}` + + await page.goto("/") + + const trigger = page.getByRole("button", { name }) + await expect(trigger).toBeVisible() + await trigger.click() + + const dialog = page.getByRole("dialog", { name: "Servers" }) + await expect(dialog).toBeVisible() + await expect(dialog.getByPlaceholder("Search servers")).toBeVisible() }) diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts new file mode 100644 index 00000000000..e1ca12449bb --- /dev/null +++ b/packages/app/e2e/session.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +test("can open an existing session and type into the prompt", async ({ page }) => { + const directory = await getWorktree() + const sdk = createOpencodeClient({ baseUrl: url, directory, throwOnError: true }) + const title = `e2e smoke ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + + const prompt = page.locator('[data-component="prompt-input"]') + await expect(prompt).toBeVisible() + + await prompt.click() + await page.keyboard.type("hello from e2e") + await expect(prompt).toContainText("hello from e2e") + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) From 9fb0d14a270d790e8167c22ef3bcf2005930dc7c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:36:02 -0600 Subject: [PATCH 03/15] test(app): more e2e tests --- packages/app/e2e/navigation.spec.ts | 24 ++++++++++++++++++++ packages/app/e2e/palette.spec.ts | 34 +++++++++++++++++++++++++++++ packages/app/e2e/terminal.spec.ts | 33 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 packages/app/e2e/navigation.spec.ts create mode 100644 packages/app/e2e/palette.spec.ts create mode 100644 packages/app/e2e/terminal.spec.ts diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts new file mode 100644 index 00000000000..2783c5222cc --- /dev/null +++ b/packages/app/e2e/navigation.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +test("project route redirects to /session", async ({ page }) => { + const directory = await getWorktree() + const slug = base64Encode(directory) + + await page.goto(`/${slug}`) + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() +}) diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts new file mode 100644 index 00000000000..06294562945 --- /dev/null +++ b/packages/app/e2e/palette.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +const mod = process.platform === "darwin" ? "Meta" : "Control" + +test("search palette opens and closes", async ({ page }) => { + const directory = await getWorktree() + const slug = base64Encode(directory) + + await page.goto(`/${slug}/session`) + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + + await page.keyboard.press(`${mod}+P`) + + 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/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts new file mode 100644 index 00000000000..9bb9947fe31 --- /dev/null +++ b/packages/app/e2e/terminal.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +test("terminal panel can be toggled", async ({ page }) => { + const directory = await getWorktree() + const slug = base64Encode(directory) + + await page.goto(`/${slug}/session`) + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + + const terminal = page.locator('[data-component="terminal"]') + const initiallyOpen = await terminal.isVisible() + if (initiallyOpen) { + await page.keyboard.press("Control+Backquote") + await expect(terminal).toHaveCount(0) + } + + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() +}) From 32d6c5b1c45f80a9df7b65b07093eee73b3a2830 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 11:36:58 +0000 Subject: [PATCH 04/15] Update flake.lock --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 5ef276f0a08..87f95fb3eb7 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768302833, - "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", + "lastModified": 1768569498, + "narHash": "sha256-bB6Nt99Cj8Nu5nIUq0GLmpiErIT5KFshMQJGMZwgqUo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "61db79b0c6b838d9894923920b612048e1201926", + "rev": "be5afa0fcb31f0a96bf9ecba05a516c66fcd8114", "type": "github" }, "original": { From 65c62f8fa4765fbce1758389034a71c009d3a1f2 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 11:37:49 +0000 Subject: [PATCH 05/15] Update node_modules hash (x86_64-linux) --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 5bbdf921bbd..a7a7957e24d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=", - "aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=", - "aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=", - "x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c=" + "x86_64-linux": "sha256-DFsg3Dyt+cm8Z7CU6lpFd4gTAzydAewfiQW1T3qvDxA=", + "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", + "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", + "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" } } From 7e720613564953df57f3bed61a37760af8789987 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 11:43:58 +0000 Subject: [PATCH 06/15] Update node_modules hash (aarch64-darwin) --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index a7a7957e24d..e51ed90463a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -2,7 +2,7 @@ "nodeModules": { "x86_64-linux": "sha256-DFsg3Dyt+cm8Z7CU6lpFd4gTAzydAewfiQW1T3qvDxA=", "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", + "aarch64-darwin": "sha256-Rfd05h/BRLoM0SmKn+ui2RdXBNhkaVKkgBeT+uBs4J8=", "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" } } From 36c40195ff9d37c4156172916bcb1f6bb9d07565 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:43:34 -0600 Subject: [PATCH 07/15] test(app): e2e utilities --- packages/app/e2e/home.spec.ts | 15 ++++------ packages/app/e2e/navigation.spec.ts | 21 +++----------- packages/app/e2e/palette.spec.ts | 26 +++-------------- packages/app/e2e/session.spec.ts | 21 +++----------- packages/app/e2e/terminal.spec.ts | 28 ++++-------------- packages/app/e2e/utils.ts | 45 +++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 87 deletions(-) create mode 100644 packages/app/e2e/utils.ts diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts index 29a07060932..5bb701076e9 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/home.spec.ts @@ -1,24 +1,21 @@ import { test, expect } from "@playwright/test" +import { serverName } from "./utils" -test("home renders and shows an open project entrypoint", async ({ page }) => { +test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") - await expect(page.getByText("Recent projects").or(page.getByText("No recent projects"))).toBeVisible() await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: serverName })).toBeVisible() }) test("server picker dialog opens from home", async ({ page }) => { - const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" - const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - const name = `${host}:${port}` - await page.goto("/") - const trigger = page.getByRole("button", { name }) + const trigger = page.getByRole("button", { name: serverName }) await expect(trigger).toBeVisible() await trigger.click() - const dialog = page.getByRole("dialog", { name: "Servers" }) + const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() - await expect(dialog.getByPlaceholder("Search servers")).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() }) diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts index 2783c5222cc..4d0d3b2b9d4 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/navigation.spec.ts @@ -1,24 +1,11 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} +import { dirPath, dirSlug, getWorktree, promptSelector } from "./utils" test("project route redirects to /session", async ({ page }) => { const directory = await getWorktree() - const slug = base64Encode(directory) + const slug = dirSlug(directory) - await page.goto(`/${slug}`) + await page.goto(dirPath(directory)) await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + await expect(page.locator(promptSelector)).toBeVisible() }) diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts index 06294562945..bad09aab96b 100644 --- a/packages/app/e2e/palette.spec.ts +++ b/packages/app/e2e/palette.spec.ts @@ -1,29 +1,11 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} - -const mod = process.platform === "darwin" ? "Meta" : "Control" +import { gotoSession, modKey, promptSelector } from "./utils" test("search palette opens and closes", async ({ page }) => { - const directory = await getWorktree() - const slug = base64Encode(directory) - - await page.goto(`/${slug}/session`) - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + await gotoSession(page) + await expect(page.locator(promptSelector)).toBeVisible() - await page.keyboard.press(`${mod}+P`) + await page.keyboard.press(`${modKey}+P`) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts index e1ca12449bb..d44736a4f56 100644 --- a/packages/app/e2e/session.spec.ts +++ b/packages/app/e2e/session.spec.ts @@ -1,22 +1,9 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} +import { createSdk, getWorktree, promptSelector, sessionPath } from "./utils" test("can open an existing session and type into the prompt", async ({ page }) => { const directory = await getWorktree() - const sdk = createOpencodeClient({ baseUrl: url, directory, throwOnError: true }) + const sdk = createSdk(directory) const title = `e2e smoke ${Date.now()}` const created = await sdk.session.create({ title }).then((r) => r.data) @@ -24,9 +11,9 @@ test("can open an existing session and type into the prompt", async ({ page }) = const sessionID = created.id try { - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionPath(directory, sessionID)) - const prompt = page.locator('[data-component="prompt-input"]') + const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() await prompt.click() diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts index 9bb9947fe31..8f90a3c0c1b 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal.spec.ts @@ -1,33 +1,17 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} +import { gotoSession, promptSelector, terminalSelector, terminalToggleKey } from "./utils" test("terminal panel can be toggled", async ({ page }) => { - const directory = await getWorktree() - const slug = base64Encode(directory) - - await page.goto(`/${slug}/session`) - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + await gotoSession(page) + await expect(page.locator(promptSelector)).toBeVisible() - const terminal = page.locator('[data-component="terminal"]') + const terminal = page.locator(terminalSelector) const initiallyOpen = await terminal.isVisible() if (initiallyOpen) { - await page.keyboard.press("Control+Backquote") + await page.keyboard.press(terminalToggleKey) await expect(terminal).toHaveCount(0) } - await page.keyboard.press("Control+Backquote") + await page.keyboard.press(terminalToggleKey) await expect(terminal).toBeVisible() }) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts new file mode 100644 index 00000000000..c18b368029a --- /dev/null +++ b/packages/app/e2e/utils.ts @@ -0,0 +1,45 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" +import type { Page } from "@playwright/test" + +export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + +export const serverUrl = `http://${serverHost}:${serverPort}` +export const serverName = `${serverHost}:${serverPort}` + +export const modKey = process.platform === "darwin" ? "Meta" : "Control" +export const terminalToggleKey = "Control+Backquote" + +export const promptSelector = '[data-component="prompt-input"]' +export const terminalSelector = '[data-component="terminal"]' + +export function createSdk(directory?: string) { + return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) +} + +export async function getWorktree() { + const sdk = createSdk() + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`) + return data.worktree +} + +export function dirSlug(directory: string) { + return base64Encode(directory) +} + +export function dirPath(directory: string) { + return `/${dirSlug(directory)}` +} + +export function sessionPath(directory: string, sessionID?: string) { + return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` +} + +export async function gotoSession(page: Page, sessionID?: string) { + const directory = await getWorktree() + await page.goto(sessionPath(directory, sessionID)) + return { directory, slug: dirSlug(directory) } +} From fb60bc63e912c8ca69ca1a4f4ead730c16e67964 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:45:24 -0600 Subject: [PATCH 08/15] fix(app): tests in ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98eac1dab2f..fa93b61cf66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: uses: ./.github/actions/setup-bun - name: Install Playwright browsers - run: bun --cwd packages/app x playwright install --with-deps + run: bunx --cwd packages/app playwright install --with-deps - name: Seed opencode data run: bun --cwd packages/opencode script/seed-e2e.ts From 0958108719f3a8734e9e5e500114091080e5f56b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 05:22:35 -0600 Subject: [PATCH 09/15] chore: cleanup --- packages/app/e2e/fixtures.ts | 40 +++++++++++++++++++++++++++++ packages/app/e2e/home.spec.ts | 2 +- packages/app/e2e/navigation.spec.ts | 10 +++----- packages/app/e2e/palette.spec.ts | 9 +++---- packages/app/e2e/session.spec.ts | 12 +++------ packages/app/e2e/terminal.spec.ts | 9 +++---- packages/app/e2e/utils.ts | 7 ----- 7 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 packages/app/e2e/fixtures.ts diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts new file mode 100644 index 00000000000..721d60049ce --- /dev/null +++ b/packages/app/e2e/fixtures.ts @@ -0,0 +1,40 @@ +import { test as base, expect } from "@playwright/test" +import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils" + +type TestFixtures = { + sdk: ReturnType + gotoSession: (sessionID?: string) => Promise +} + +type WorkerFixtures = { + directory: string + slug: string +} + +export const test = base.extend({ + directory: [ + async ({}, use) => { + const directory = await getWorktree() + await use(directory) + }, + { scope: "worker" }, + ], + slug: [ + async ({ directory }, use) => { + await use(dirSlug(directory)) + }, + { scope: "worker" }, + ], + sdk: async ({ directory }, use) => { + await use(createSdk(directory)) + }, + gotoSession: async ({ page, directory }, use) => { + const gotoSession = async (sessionID?: string) => { + await page.goto(sessionPath(directory, sessionID)) + await expect(page.locator(promptSelector)).toBeVisible() + } + await use(gotoSession) + }, +}) + +export { expect } diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts index 5bb701076e9..c6fb0e3b074 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/home.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test" +import { test, expect } from "./fixtures" import { serverName } from "./utils" test("home renders and shows core entrypoints", async ({ page }) => { diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts index 4d0d3b2b9d4..76923af6ede 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/navigation.spec.ts @@ -1,11 +1,9 @@ -import { test, expect } from "@playwright/test" -import { dirPath, dirSlug, getWorktree, promptSelector } from "./utils" - -test("project route redirects to /session", async ({ page }) => { - const directory = await getWorktree() - const slug = dirSlug(directory) +import { test, expect } from "./fixtures" +import { dirPath, promptSelector } 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/palette.spec.ts b/packages/app/e2e/palette.spec.ts index bad09aab96b..617c55ac167 100644 --- a/packages/app/e2e/palette.spec.ts +++ b/packages/app/e2e/palette.spec.ts @@ -1,9 +1,8 @@ -import { test, expect } from "@playwright/test" -import { gotoSession, modKey, promptSelector } from "./utils" +import { test, expect } from "./fixtures" +import { modKey } from "./utils" -test("search palette opens and closes", async ({ page }) => { - await gotoSession(page) - await expect(page.locator(promptSelector)).toBeVisible() +test("search palette opens and closes", async ({ page, gotoSession }) => { + await gotoSession() await page.keyboard.press(`${modKey}+P`) diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts index d44736a4f56..19e25a42131 100644 --- a/packages/app/e2e/session.spec.ts +++ b/packages/app/e2e/session.spec.ts @@ -1,9 +1,7 @@ -import { test, expect } from "@playwright/test" -import { createSdk, getWorktree, promptSelector, sessionPath } from "./utils" +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" -test("can open an existing session and type into the prompt", async ({ page }) => { - const directory = await getWorktree() - const sdk = createSdk(directory) +test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke ${Date.now()}` const created = await sdk.session.create({ title }).then((r) => r.data) @@ -11,11 +9,9 @@ test("can open an existing session and type into the prompt", async ({ page }) = const sessionID = created.id try { - await page.goto(sessionPath(directory, sessionID)) + await gotoSession(sessionID) const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await prompt.click() await page.keyboard.type("hello from e2e") await expect(prompt).toContainText("hello from e2e") diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts index 8f90a3c0c1b..fc558b63259 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal.spec.ts @@ -1,9 +1,8 @@ -import { test, expect } from "@playwright/test" -import { gotoSession, promptSelector, terminalSelector, terminalToggleKey } from "./utils" +import { test, expect } from "./fixtures" +import { terminalSelector, terminalToggleKey } from "./utils" -test("terminal panel can be toggled", async ({ page }) => { - await gotoSession(page) - await expect(page.locator(promptSelector)).toBeVisible() +test("terminal panel can be toggled", async ({ page, gotoSession }) => { + await gotoSession() const terminal = page.locator(terminalSelector) const initiallyOpen = await terminal.isVisible() diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index c18b368029a..eb0395950ae 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,6 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" -import type { Page } from "@playwright/test" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -37,9 +36,3 @@ export function dirPath(directory: string) { export function sessionPath(directory: string, sessionID?: string) { return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` } - -export async function gotoSession(page: Page, sessionID?: string) { - const directory = await getWorktree() - await page.goto(sessionPath(directory, sessionID)) - return { directory, slug: dirSlug(directory) } -} From a1787180e99a63f3e7d504450252136890379796 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 05:28:15 -0600 Subject: [PATCH 10/15] chore: cleanup --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa93b61cf66..fda848762f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,8 @@ jobs: uses: ./.github/actions/setup-bun - name: Install Playwright browsers - run: bunx --cwd packages/app playwright install --with-deps + working-directory: packages/app + run: bunx playwright install --with-deps - name: Seed opencode data run: bun --cwd packages/opencode script/seed-e2e.ts From daa36cba512f056add1677fc1a963fcc0f07b21d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 06:54:47 -0600 Subject: [PATCH 11/15] test(app): more e2e tests --- packages/app/e2e/context.spec.ts | 28 ++++++++++++++++++++++++++++ packages/app/e2e/file-open.spec.ts | 24 ++++++++++++++++++++++++ packages/app/e2e/sidebar.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 packages/app/e2e/context.spec.ts create mode 100644 packages/app/e2e/file-open.spec.ts create mode 100644 packages/app/e2e/sidebar.spec.ts diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/context.spec.ts new file mode 100644 index 00000000000..dbfeda8d8c5 --- /dev/null +++ b/packages/app/e2e/context.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e smoke context ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await gotoSession(sessionID) + + const promptForm = page.locator("form").filter({ has: page.locator(promptSelector) }).first() + const contextButton = promptForm + .locator("button") + .filter({ has: promptForm.locator('[data-component="progress-circle"]').first() }) + .first() + + await expect(contextButton).toBeVisible() + await contextButton.click() + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/file-open.spec.ts new file mode 100644 index 00000000000..673caf9dc34 --- /dev/null +++ b/packages/app/e2e/file-open.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("can open a file tab from the search palette", async ({ page, gotoSession }) => { + await gotoSession() + + await page.keyboard.press(`${modKey}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + + const input = dialog.getByRole("textbox").first() + await input.fill("package.json") + + const firstItem = dialog.locator('[data-slot="list-item"]').first() + await expect(firstItem).toBeVisible() + await firstItem.click() + + await expect(dialog).toHaveCount(0) + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs).toBeVisible() + await expect(tabs.getByRole("tab").first()).toBeVisible() +}) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts new file mode 100644 index 00000000000..964b0a56c03 --- /dev/null +++ b/packages/app/e2e/sidebar.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { + await gotoSession() + + const createButton = page.getByRole("button", { name: /New (session|workspace)/ }).first() + const opened = (await createButton.count()) > 0 + + if (!opened) { + await page.keyboard.press(`${modKey}+B`) + await expect(createButton).toBeVisible() + } + + await page.keyboard.press(`${modKey}+B`) + await expect(createButton).toHaveCount(0) + + await page.keyboard.press(`${modKey}+B`) + await expect(createButton).toBeVisible() +}) From cb02f69418a57239084e2739bdb93e506194dda4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 07:00:00 -0600 Subject: [PATCH 12/15] chore: cleanup --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fda848762f5..9cf83ca8df0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,8 @@ jobs: run: bunx playwright install --with-deps - name: Seed opencode data - run: bun --cwd packages/opencode script/seed-e2e.ts + working-directory: packages/opencode + run: bun script/seed-e2e.ts env: MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json OPENCODE_DISABLE_MODELS_FETCH: "true" @@ -42,7 +43,7 @@ jobs: OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" - name: Run opencode server - run: bun --cwd packages/opencode run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & + run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & env: MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json OPENCODE_DISABLE_MODELS_FETCH: "true" From 5c43d3f3b7e6a2c9ea16d2142c2e10ecb4b1a62f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 07:15:57 -0600 Subject: [PATCH 13/15] chore: cleanup --- turbo.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/turbo.json b/turbo.json index 6b1c9b32425..5de1b8d7517 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,10 @@ "opencode#test": { "dependsOn": ["^build"], "outputs": [] + }, + "@opencode-ai/app#test": { + "dependsOn": ["^build"], + "outputs": [] } } } From 39054409f4c786d89fb131aceb0482799b0b6946 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:38:42 -0600 Subject: [PATCH 14/15] chore: cleanup --- packages/app/e2e/context.spec.ts | 25 +++++++++++++++++++++---- packages/app/e2e/file-open.spec.ts | 9 ++++----- packages/app/e2e/sidebar.spec.ts | 13 +++++++------ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/context.spec.ts index dbfeda8d8c5..beabd2eb7dd 100644 --- a/packages/app/e2e/context.spec.ts +++ b/packages/app/e2e/context.spec.ts @@ -9,12 +9,29 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess const sessionID = created.id try { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [ + { + type: "text", + text: "seed context", + }, + ], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + await gotoSession(sessionID) - const promptForm = page.locator("form").filter({ has: page.locator(promptSelector) }).first() - const contextButton = promptForm - .locator("button") - .filter({ has: promptForm.locator('[data-component="progress-circle"]').first() }) + const contextButton = page + .locator('[data-component="button"]') + .filter({ has: page.locator('[data-component="progress-circle"]').first() }) .first() await expect(contextButton).toBeVisible() diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/file-open.spec.ts index 673caf9dc34..fb7104b6b05 100644 --- a/packages/app/e2e/file-open.spec.ts +++ b/packages/app/e2e/file-open.spec.ts @@ -12,13 +12,12 @@ test("can open a file tab from the search palette", async ({ page, gotoSession } const input = dialog.getByRole("textbox").first() await input.fill("package.json") - const firstItem = dialog.locator('[data-slot="list-item"]').first() - await expect(firstItem).toBeVisible() - await firstItem.click() + const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() + await expect(fileItem).toBeVisible() + await fileItem.click() await expect(dialog).toHaveCount(0) const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - await expect(tabs).toBeVisible() - await expect(tabs.getByRole("tab").first()).toBeVisible() + await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible() }) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts index 964b0a56c03..925590f5106 100644 --- a/packages/app/e2e/sidebar.spec.ts +++ b/packages/app/e2e/sidebar.spec.ts @@ -4,17 +4,18 @@ import { modKey } from "./utils" test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { await gotoSession() - const createButton = page.getByRole("button", { name: /New (session|workspace)/ }).first() - const opened = (await createButton.count()) > 0 + const main = page.locator("main") + const closedClass = /xl:border-l/ + const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l")) - if (!opened) { + if (isClosed) { await page.keyboard.press(`${modKey}+B`) - await expect(createButton).toBeVisible() + await expect(main).not.toHaveClass(closedClass) } await page.keyboard.press(`${modKey}+B`) - await expect(createButton).toHaveCount(0) + await expect(main).toHaveClass(closedClass) await page.keyboard.press(`${modKey}+B`) - await expect(createButton).toBeVisible() + await expect(main).not.toHaveClass(closedClass) }) From e69a280a1921c96ca5ac8a414e5e9216617c24fe Mon Sep 17 00:00:00 2001 From: Github Action Date: Mon, 19 Jan 2026 14:59:54 +0000 Subject: [PATCH 15/15] Update node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index e51ed90463a..fa91b3b3102 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-DFsg3Dyt+cm8Z7CU6lpFd4gTAzydAewfiQW1T3qvDxA=", - "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-Rfd05h/BRLoM0SmKn+ui2RdXBNhkaVKkgBeT+uBs4J8=", - "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" + "x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=", + "aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=", + "aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=", + "x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg=" } }