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="
}
}