summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFilip <[email protected]>2026-01-30 21:59:37 +0100
committeropencode <[email protected]>2026-01-30 21:06:48 +0000
commit77fa8ddc8828b5ebcc306621e6669c192d1492fe (patch)
tree000f7d4194a86e0198d0047e24c527fffda55ecb
parent4a56491e42c07e30b95238a06c9b9175e4763444 (diff)
downloadopencode-77fa8ddc8828b5ebcc306621e6669c192d1492fe.tar.gz
opencode-77fa8ddc8828b5ebcc306621e6669c192d1492fe.zip
refactor(app): refactored tests + added project tests (#11349)
-rw-r--r--packages/app/e2e/actions.ts160
-rw-r--r--packages/app/e2e/app/navigation.spec.ts3
-rw-r--r--packages/app/e2e/app/palette.spec.ts8
-rw-r--r--packages/app/e2e/app/session.spec.ts2
-rw-r--r--packages/app/e2e/app/titlebar-history.spec.ts10
-rw-r--r--packages/app/e2e/files/file-open.spec.ts7
-rw-r--r--packages/app/e2e/files/file-viewer.spec.ts7
-rw-r--r--packages/app/e2e/fixtures.ts63
-rw-r--r--packages/app/e2e/models/model-picker.spec.ts2
-rw-r--r--packages/app/e2e/models/models-visibility.spec.ts33
-rw-r--r--packages/app/e2e/projects/project-edit.spec.ts47
-rw-r--r--packages/app/e2e/projects/projects-close.spec.ts77
-rw-r--r--packages/app/e2e/projects/projects-switch.spec.ts34
-rw-r--r--packages/app/e2e/prompt/context.spec.ts2
-rw-r--r--packages/app/e2e/prompt/prompt-mention.spec.ts2
-rw-r--r--packages/app/e2e/prompt/prompt-slash-open.spec.ts2
-rw-r--r--packages/app/e2e/prompt/prompt.spec.ts8
-rw-r--r--packages/app/e2e/selectors.ts17
-rw-r--r--packages/app/e2e/settings/settings-language.spec.ts17
-rw-r--r--packages/app/e2e/settings/settings-providers.spec.ts34
-rw-r--r--packages/app/e2e/settings/settings.spec.ts36
-rw-r--r--packages/app/e2e/sidebar/sidebar-session-links.spec.ts10
-rw-r--r--packages/app/e2e/sidebar/sidebar.spec.ts19
-rw-r--r--packages/app/e2e/terminal/terminal-init.spec.ts3
-rw-r--r--packages/app/e2e/terminal/terminal.spec.ts3
-rw-r--r--packages/app/e2e/thinking-level.spec.ts2
-rw-r--r--packages/app/e2e/tsconfig.json2
-rw-r--r--packages/app/e2e/utils.ts6
-rw-r--r--packages/app/src/pages/layout.tsx14
29 files changed, 409 insertions, 221 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
new file mode 100644
index 000000000..3da16d317
--- /dev/null
+++ b/packages/app/e2e/actions.ts
@@ -0,0 +1,160 @@
+import { expect, type Locator, type Page } from "@playwright/test"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { execSync } from "node:child_process"
+import { modKey, serverUrl } from "./utils"
+
+export async function defocus(page: Page) {
+ await page.mouse.click(5, 5)
+}
+
+export async function openPalette(page: Page) {
+ await defocus(page)
+ await page.keyboard.press(`${modKey}+P`)
+
+ const dialog = page.getByRole("dialog")
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByRole("textbox").first()).toBeVisible()
+ return dialog
+}
+
+export async function closeDialog(page: Page, dialog: Locator) {
+ await page.keyboard.press("Escape")
+ const closed = await dialog
+ .waitFor({ state: "detached", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (closed) return
+
+ await page.keyboard.press("Escape")
+ const closedSecond = await dialog
+ .waitFor({ state: "detached", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (closedSecond) return
+
+ await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
+ await expect(dialog).toHaveCount(0)
+}
+
+export async function isSidebarClosed(page: Page) {
+ const main = page.locator("main")
+ const classes = (await main.getAttribute("class")) ?? ""
+ return classes.includes("xl:border-l")
+}
+
+export async function toggleSidebar(page: Page) {
+ await defocus(page)
+ await page.keyboard.press(`${modKey}+B`)
+}
+
+export async function openSidebar(page: Page) {
+ if (!(await isSidebarClosed(page))) return
+ await toggleSidebar(page)
+ await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
+}
+
+export async function closeSidebar(page: Page) {
+ if (await isSidebarClosed(page)) return
+ await toggleSidebar(page)
+ await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+}
+
+export async function openSettings(page: Page) {
+ await defocus(page)
+
+ const dialog = page.getByRole("dialog")
+ await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
+
+ const opened = await dialog
+ .waitFor({ state: "visible", timeout: 3000 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (opened) return dialog
+
+ await page.getByRole("button", { name: "Settings" }).first().click()
+ await expect(dialog).toBeVisible()
+ return dialog
+}
+
+export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
+ await page.addInitScript(
+ (args: { directory: string; serverUrl: string; extra: string[] }) => {
+ const key = "opencode.global.dat:server"
+ const raw = localStorage.getItem(key)
+ const parsed = (() => {
+ if (!raw) return undefined
+ try {
+ return JSON.parse(raw) as unknown
+ } catch {
+ return undefined
+ }
+ })()
+
+ const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
+ const list = Array.isArray(store.list) ? store.list : []
+ const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
+ const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
+ const nextProjects = { ...(projects as Record<string, unknown>) }
+
+ const add = (origin: string, directory: string) => {
+ const current = nextProjects[origin]
+ const items = Array.isArray(current) ? current : []
+ const existing = items.filter(
+ (p): p is { worktree: string; expanded?: boolean } =>
+ !!p &&
+ typeof p === "object" &&
+ "worktree" in p &&
+ typeof (p as { worktree?: unknown }).worktree === "string",
+ )
+
+ if (existing.some((p) => p.worktree === directory)) return
+ nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
+ }
+
+ const directories = [args.directory, ...args.extra]
+ for (const directory of directories) {
+ add("local", directory)
+ add(args.serverUrl, directory)
+ }
+
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ list,
+ projects: nextProjects,
+ lastProject,
+ }),
+ )
+ },
+ { directory: input.directory, serverUrl, extra: input.extra ?? [] },
+ )
+}
+
+export async function createTestProject() {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
+
+ await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
+
+ execSync("git init", { cwd: root, stdio: "ignore" })
+ execSync("git add -A", { cwd: root, stdio: "ignore" })
+ execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
+ cwd: root,
+ stdio: "ignore",
+ })
+
+ return root
+}
+
+export async function cleanupTestProject(directory: string) {
+ await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
+}
+
+export function sessionIDFromUrl(url: string) {
+ const match = /\/session\/([^/?#]+)/.exec(url)
+ return match?.[1]
+}
diff --git a/packages/app/e2e/app/navigation.spec.ts b/packages/app/e2e/app/navigation.spec.ts
index 0812ea018..328c950df 100644
--- a/packages/app/e2e/app/navigation.spec.ts
+++ b/packages/app/e2e/app/navigation.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { dirPath, promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
+import { dirPath } from "../utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts
index 264b463bb..3ccfd7a92 100644
--- a/packages/app/e2e/app/palette.spec.ts
+++ b/packages/app/e2e/app/palette.spec.ts
@@ -1,14 +1,10 @@
import { test, expect } from "../fixtures"
-import { modKey } from "../utils"
+import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
- await page.keyboard.press(`${modKey}+P`)
-
- const dialog = page.getByRole("dialog")
- await expect(dialog).toBeVisible()
- await expect(dialog.getByRole("textbox").first()).toBeVisible()
+ const dialog = await openPalette(page)
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
diff --git a/packages/app/e2e/app/session.spec.ts b/packages/app/e2e/app/session.spec.ts
index 8d605f0c3..d35af7ef7 100644
--- a/packages/app/e2e/app/session.spec.ts
+++ b/packages/app/e2e/app/session.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts
index 649e5e0dc..c7ff6566c 100644
--- a/packages/app/e2e/app/titlebar-history.spec.ts
+++ b/packages/app/e2e/app/titlebar-history.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { modKey, promptSelector } from "../utils"
+import { openSidebar } from "../actions"
+import { promptSelector } from "../selectors"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -14,12 +15,7 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
try {
await gotoSession(one.id)
- const main = page.locator("main")
- const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
- if (collapsed) {
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(/xl:border-l/)
- }
+ await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
diff --git a/packages/app/e2e/files/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts
index e384f0b0d..dea35d25b 100644
--- a/packages/app/e2e/files/file-open.spec.ts
+++ b/packages/app/e2e/files/file-open.spec.ts
@@ -1,13 +1,10 @@
import { test, expect } from "../fixtures"
-import { modKey } from "../utils"
+import { openPalette } from "../actions"
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 dialog = await openPalette(page)
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts
index bed6d1d36..3dc0dead2 100644
--- a/packages/app/e2e/files/file-viewer.spec.ts
+++ b/packages/app/e2e/files/file-viewer.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { modKey } from "../utils"
+import { openPalette } from "../actions"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
@@ -7,10 +7,7 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
- await page.keyboard.press(`${modKey}+P`)
-
- const dialog = page.getByRole("dialog")
- await expect(dialog).toBeVisible()
+ const dialog = await openPalette(page)
const input = dialog.getByRole("textbox").first()
await input.fill(file)
diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts
index c5315ff19..0c3150609 100644
--- a/packages/app/e2e/fixtures.ts
+++ b/packages/app/e2e/fixtures.ts
@@ -1,5 +1,7 @@
import { test as base, expect } from "@playwright/test"
-import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
+import { seedProjects } from "./actions"
+import { promptSelector } from "./selectors"
+import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
@@ -29,54 +31,17 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
- await page.addInitScript(
- (input: { directory: string; serverUrl: string }) => {
- const key = "opencode.global.dat:server"
- const raw = localStorage.getItem(key)
- const parsed = (() => {
- if (!raw) return undefined
- try {
- return JSON.parse(raw) as unknown
- } catch {
- return undefined
- }
- })()
-
- const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
- const list = Array.isArray(store.list) ? store.list : []
- const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
- const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
- const nextProjects = { ...(projects as Record<string, unknown>) }
-
- const add = (origin: string) => {
- const current = nextProjects[origin]
- const items = Array.isArray(current) ? current : []
- const existing = items.filter(
- (p): p is { worktree: string; expanded?: boolean } =>
- !!p &&
- typeof p === "object" &&
- "worktree" in p &&
- typeof (p as { worktree?: unknown }).worktree === "string",
- )
-
- if (existing.some((p) => p.worktree === input.directory)) return
- nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
- }
-
- add("local")
- add(input.serverUrl)
-
- localStorage.setItem(
- key,
- JSON.stringify({
- list,
- projects: nextProjects,
- lastProject,
- }),
- )
- },
- { directory, serverUrl },
- )
+ await seedProjects(page, { directory })
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "opencode.global.dat:model",
+ JSON.stringify({
+ recent: [{ providerID: "opencode", modelID: "big-pickle" }],
+ user: [],
+ variant: {},
+ }),
+ )
+ })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
diff --git a/packages/app/e2e/models/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts
index a0c70aabe..df95e04d2 100644
--- a/packages/app/e2e/models/model-picker.spec.ts
+++ b/packages/app/e2e/models/model-picker.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/models/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts
index 0db7580c2..36f14596d 100644
--- a/packages/app/e2e/models/models-visibility.spec.ts
+++ b/packages/app/e2e/models/models-visibility.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { modKey, promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
- const settings = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
- const opened = await settings
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(settings).toBeVisible()
- }
+ const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
- await page.keyboard.press("Escape")
- const closed = await settings
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (!closed) {
- await page.keyboard.press("Escape")
- const closedSecond = await settings
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (!closedSecond) {
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(settings).toHaveCount(0)
- }
- }
+ await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts
new file mode 100644
index 000000000..22d053f3d
--- /dev/null
+++ b/packages/app/e2e/projects/project-edit.spec.ts
@@ -0,0 +1,47 @@
+import { test, expect } from "../fixtures"
+import { openSidebar } from "../actions"
+
+test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
+ await gotoSession()
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await openSidebar(page)
+
+ const open = async () => {
+ const header = page.locator(".group\\/project").first()
+ await header.hover()
+ const trigger = header.getByRole("button", { name: "More options" }).first()
+ await expect(trigger).toBeVisible()
+ await trigger.click({ force: true })
+
+ await page.getByRole("menuitem", { name: "Edit" }).click()
+
+ const dialog = page.getByRole("dialog")
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
+ return dialog
+ }
+
+ const name = `e2e project ${Date.now()}`
+ const startup = `echo e2e_${Date.now()}`
+
+ const dialog = await open()
+
+ const nameInput = dialog.getByLabel("Name")
+ await nameInput.fill(name)
+
+ const startupInput = dialog.getByLabel("Workspace startup script")
+ await startupInput.fill(startup)
+
+ await dialog.getByRole("button", { name: "Save" }).click()
+ await expect(dialog).toHaveCount(0)
+
+ const header = page.locator(".group\\/project").first()
+ await expect(header).toContainText(name)
+
+ const reopened = await open()
+ await expect(reopened.getByLabel("Name")).toHaveValue(name)
+ await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
+ await reopened.getByRole("button", { name: "Cancel" }).click()
+ await expect(reopened).toHaveCount(0)
+})
diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts
new file mode 100644
index 000000000..c3618740d
--- /dev/null
+++ b/packages/app/e2e/projects/projects-close.spec.ts
@@ -0,0 +1,77 @@
+import { test, expect } from "../fixtures"
+import { createTestProject, seedProjects, cleanupTestProject, openSidebar } from "../actions"
+import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const other = await createTestProject()
+ const otherSlug = dirSlug(other)
+ await seedProjects(page, { directory, extra: [other] })
+
+ try {
+ await gotoSession()
+
+ await openSidebar(page)
+
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.hover()
+
+ const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
+ await expect(close).toBeVisible()
+ await close.click()
+
+ await expect(otherButton).toHaveCount(0)
+ } finally {
+ await cleanupTestProject(other)
+ }
+})
+
+test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const other = await createTestProject()
+ const otherName = other.split("/").pop()
+ const otherSlug = dirSlug(other)
+ await seedProjects(page, { directory, extra: [other] })
+
+ try {
+ await gotoSession()
+
+ await openSidebar(page)
+
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+ const header = page
+ .locator(".group\\/project")
+ .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
+ .first()
+ await expect(header).toContainText(otherName)
+
+ const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
+ await expect(trigger).toHaveCount(1)
+ await trigger.focus()
+ await page.keyboard.press("Enter")
+
+ const close = page
+ .locator(projectCloseMenuSelector(otherSlug))
+ .or(page.getByRole("menuitem", { name: "Close" }))
+ .or(
+ page
+ .locator('[data-component="dropdown-menu-content"] [data-slot="dropdown-menu-item"]')
+ .filter({ hasText: "Close" }),
+ )
+ .first()
+ await expect(close).toBeVisible({ timeout: 10_000 })
+ await close.click({ force: true })
+ await expect(otherButton).toHaveCount(0)
+ } finally {
+ await cleanupTestProject(other)
+ }
+})
diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts
new file mode 100644
index 000000000..829ed8e57
--- /dev/null
+++ b/packages/app/e2e/projects/projects-switch.spec.ts
@@ -0,0 +1,34 @@
+import { test, expect } from "../fixtures"
+import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
+import { projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const other = await createTestProject()
+ const otherSlug = dirSlug(other)
+
+ await seedProjects(page, { directory, extra: [other] })
+
+ try {
+ await gotoSession()
+
+ await defocus(page)
+
+ const currentSlug = dirSlug(directory)
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+ const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
+ await expect(currentButton).toBeVisible()
+ await currentButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
+ } finally {
+ await cleanupTestProject(other)
+ }
+})
diff --git a/packages/app/e2e/prompt/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts
index f0f3f073a..9e8f998f2 100644
--- a/packages/app/e2e/prompt/context.spec.ts
+++ b/packages/app/e2e/prompt/context.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
diff --git a/packages/app/e2e/prompt/prompt-mention.spec.ts b/packages/app/e2e/prompt/prompt-mention.spec.ts
index 85acb4c28..5cc9f6e68 100644
--- a/packages/app/e2e/prompt/prompt-mention.spec.ts
+++ b/packages/app/e2e/prompt/prompt-mention.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/prompt/prompt-slash-open.spec.ts b/packages/app/e2e/prompt/prompt-slash-open.spec.ts
index 3e769e330..b4a93099d 100644
--- a/packages/app/e2e/prompt/prompt-slash-open.spec.ts
+++ b/packages/app/e2e/prompt/prompt-slash-open.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts
index b58e5e296..33f8d7ebc 100644
--- a/packages/app/e2e/prompt/prompt.spec.ts
+++ b/packages/app/e2e/prompt/prompt.spec.ts
@@ -1,10 +1,6 @@
import { test, expect } from "../fixtures"
-import { promptSelector } from "../utils"
-
-function sessionIDFromUrl(url: string) {
- const match = /\/session\/([^/?#]+)/.exec(url)
- return match?.[1]
-}
+import { promptSelector } from "../selectors"
+import { sessionIDFromUrl } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
new file mode 100644
index 000000000..9179a6fd5
--- /dev/null
+++ b/packages/app/e2e/selectors.ts
@@ -0,0 +1,17 @@
+export const promptSelector = '[data-component="prompt-input"]'
+export const terminalSelector = '[data-component="terminal"]'
+
+export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
+export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
+
+export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
+
+export const projectSwitchSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
+
+export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
+
+export const projectMenuTriggerSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
+
+export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
diff --git a/packages/app/e2e/settings/settings-language.spec.ts b/packages/app/e2e/settings/settings-language.spec.ts
index b2ef70bf8..b326a7d81 100644
--- a/packages/app/e2e/settings/settings-language.spec.ts
+++ b/packages/app/e2e/settings/settings-language.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { modKey, settingsLanguageSelectSelector } from "../utils"
+import { settingsLanguageSelectSelector } from "../selectors"
+import { openSettings } from "../actions"
test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
@@ -8,19 +9,7 @@ test("smoke changing language updates settings labels", async ({ page, gotoSessi
await gotoSession()
- const dialog = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
- const opened = await dialog
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(dialog).toBeVisible()
- }
+ const dialog = await openSettings(page)
const heading = dialog.getByRole("heading", { level: 2 })
await expect(heading).toHaveText("General")
diff --git a/packages/app/e2e/settings/settings-providers.spec.ts b/packages/app/e2e/settings/settings-providers.spec.ts
index 5b9325c2a..4b3b178cc 100644
--- a/packages/app/e2e/settings/settings-providers.spec.ts
+++ b/packages/app/e2e/settings/settings-providers.spec.ts
@@ -1,22 +1,11 @@
import { test, expect } from "../fixtures"
-import { modKey, promptSelector } from "../utils"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings } from "../actions"
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
await gotoSession()
- const dialog = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
- const opened = await dialog
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(dialog).toBeVisible()
- }
+ const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Providers" }).click()
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
@@ -37,20 +26,5 @@ test("smoke providers settings opens provider selector", async ({ page, gotoSess
const stillOpen = await dialog.isVisible().catch(() => false)
if (!stillOpen) return
- await page.keyboard.press("Escape")
- const closed = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (closed) return
-
- await page.keyboard.press("Escape")
- const closedSecond = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (closedSecond) return
-
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(dialog).toHaveCount(0)
+ await closeDialog(page, dialog)
})
diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts
index 293a4ba9a..55b767076 100644
--- a/packages/app/e2e/settings/settings.spec.ts
+++ b/packages/app/e2e/settings/settings.spec.ts
@@ -1,44 +1,14 @@
import { test, expect } from "../fixtures"
-import { modKey } from "../utils"
+import { closeDialog, openSettings } from "../actions"
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
await gotoSession()
- const dialog = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
- const opened = await dialog
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(dialog).toBeVisible()
- }
+ const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
- await page.keyboard.press("Escape")
-
- const closed = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
-
- if (closed) return
-
- await page.keyboard.press("Escape")
- const closedSecond = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
-
- if (closedSecond) return
-
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(dialog).toHaveCount(0)
+ await closeDialog(page, dialog)
})
diff --git a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts
index 8c3f69547..1c0f4fa71 100644
--- a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { modKey, promptSelector } from "../utils"
+import { openSidebar } from "../actions"
+import { promptSelector } from "../selectors"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@@ -13,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
try {
await gotoSession(one.id)
- const main = page.locator("main")
- const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
- if (collapsed) {
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(/xl:border-l/)
- }
+ await openSidebar(page)
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts
index ba58b1008..6239a04bd 100644
--- a/packages/app/e2e/sidebar/sidebar.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar.spec.ts
@@ -1,21 +1,14 @@
import { test, expect } from "../fixtures"
-import { modKey } from "../utils"
+import { openSidebar, toggleSidebar } from "../actions"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
- const main = page.locator("main")
- const closedClass = /xl:border-l/
- const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
+ await openSidebar(page)
- if (isClosed) {
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(closedClass)
- }
+ await toggleSidebar(page)
+ await expect(page.locator("main")).toHaveClass(/xl:border-l/)
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).toHaveClass(closedClass)
-
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(closedClass)
+ await toggleSidebar(page)
+ await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
})
diff --git a/packages/app/e2e/terminal/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts
index 6faa73a75..87934b66e 100644
--- a/packages/app/e2e/terminal/terminal-init.spec.ts
+++ b/packages/app/e2e/terminal/terminal-init.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { promptSelector, terminalSelector, terminalToggleKey } from "../utils"
+import { promptSelector, terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/terminal/terminal.spec.ts b/packages/app/e2e/terminal/terminal.spec.ts
index aaf5c2d75..ef88aa34e 100644
--- a/packages/app/e2e/terminal/terminal.spec.ts
+++ b/packages/app/e2e/terminal/terminal.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
-import { terminalSelector, terminalToggleKey } from "../utils"
+import { terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/thinking-level.spec.ts b/packages/app/e2e/thinking-level.spec.ts
index 564ef3c1f..92200933e 100644
--- a/packages/app/e2e/thinking-level.spec.ts
+++ b/packages/app/e2e/thinking-level.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "./fixtures"
-import { modelVariantCycleSelector } from "./utils"
+import { modelVariantCycleSelector } from "./selectors"
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json
index 76438a03c..18e88ddc9 100644
--- a/packages/app/e2e/tsconfig.json
+++ b/packages/app/e2e/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "types": ["node"]
+ "types": ["node", "bun"]
},
"include": ["./**/*.ts"]
}
diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts
index 3dec12592..ec6cdf830 100644
--- a/packages/app/e2e/utils.ts
+++ b/packages/app/e2e/utils.ts
@@ -10,12 +10,6 @@ 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 const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
-
-export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
-
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 73480e8f2..f049dc3bc 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -2285,6 +2285,8 @@ export default function Layout(props: ParentProps) {
<button
type="button"
aria-label={projectName()}
+ data-action="project-switch"
+ data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
@@ -2335,6 +2337,8 @@ export default function Layout(props: ParentProps) {
icon="circle-x"
variant="ghost"
class="shrink-0"
+ data-action="project-close-hover"
+ data-project={base64Encode(props.project.worktree)}
aria-label={language.t("common.close")}
onClick={(event) => {
event.stopPropagation()
@@ -2577,6 +2581,8 @@ export default function Layout(props: ParentProps) {
as={IconButton}
icon="dot-grid"
variant="ghost"
+ data-action="project-menu"
+ data-project={base64Encode(p.worktree)}
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
@@ -2604,7 +2610,11 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
- <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
+ <DropdownMenu.Item
+ data-action="project-close-menu"
+ data-project={base64Encode(p.worktree)}
+ onSelect={() => closeProject(p.worktree)}
+ >
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
@@ -2814,6 +2824,7 @@ export default function Layout(props: ParentProps) {
<div class="flex-1 min-h-0 flex">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
+ data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"relative shrink-0": true,
@@ -2873,6 +2884,7 @@ export default function Layout(props: ParentProps) {
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
+ data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),