Explorar el Código

refactor(app): refactored tests + added project tests (#11349)

Filip hace 2 semanas
padre
commit
77fa8ddc88
Se han modificado 29 ficheros con 409 adiciones y 221 borrados
  1. 160 0
      packages/app/e2e/actions.ts
  2. 2 1
      packages/app/e2e/app/navigation.spec.ts
  3. 2 6
      packages/app/e2e/app/palette.spec.ts
  4. 1 1
      packages/app/e2e/app/session.spec.ts
  5. 3 7
      packages/app/e2e/app/titlebar-history.spec.ts
  6. 2 5
      packages/app/e2e/files/file-open.spec.ts
  7. 2 5
      packages/app/e2e/files/file-viewer.spec.ts
  8. 14 49
      packages/app/e2e/fixtures.ts
  9. 1 1
      packages/app/e2e/models/model-picker.spec.ts
  10. 4 29
      packages/app/e2e/models/models-visibility.spec.ts
  11. 47 0
      packages/app/e2e/projects/project-edit.spec.ts
  12. 77 0
      packages/app/e2e/projects/projects-close.spec.ts
  13. 34 0
      packages/app/e2e/projects/projects-switch.spec.ts
  14. 1 1
      packages/app/e2e/prompt/context.spec.ts
  15. 1 1
      packages/app/e2e/prompt/prompt-mention.spec.ts
  16. 1 1
      packages/app/e2e/prompt/prompt-slash-open.spec.ts
  17. 2 6
      packages/app/e2e/prompt/prompt.spec.ts
  18. 17 0
      packages/app/e2e/selectors.ts
  19. 3 14
      packages/app/e2e/settings/settings-language.spec.ts
  20. 4 30
      packages/app/e2e/settings/settings-providers.spec.ts
  21. 3 33
      packages/app/e2e/settings/settings.spec.ts
  22. 3 7
      packages/app/e2e/sidebar/sidebar-session-links.spec.ts
  23. 6 13
      packages/app/e2e/sidebar/sidebar.spec.ts
  24. 2 1
      packages/app/e2e/terminal/terminal-init.spec.ts
  25. 2 1
      packages/app/e2e/terminal/terminal.spec.ts
  26. 1 1
      packages/app/e2e/thinking-level.spec.ts
  27. 1 1
      packages/app/e2e/tsconfig.json
  28. 0 6
      packages/app/e2e/utils.ts
  29. 13 1
      packages/app/src/pages/layout.tsx

+ 160 - 0
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]
+}

+ 2 - 1
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))

+ 2 - 6
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)

+ 1 - 1
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()}`

+ 3 - 7
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()

+ 2 - 5
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")

+ 2 - 5
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)

+ 14 - 49
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))

+ 1 - 1
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()

+ 4 - 29
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")

+ 47 - 0
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)
+})

+ 77 - 0
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)
+  }
+})

+ 34 - 0
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)
+  }
+})

+ 1 - 1
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()}`

+ 1 - 1
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()

+ 1 - 1
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()

+ 2 - 6
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)

+ 17 - 0
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}"]`

+ 3 - 14
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")

+ 4 - 30
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)
 })

+ 3 - 33
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)
 })

+ 3 - 7
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()

+ 6 - 13
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/)
 })

+ 2 - 1
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()

+ 2 - 1
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()

+ 1 - 1
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()

+ 1 - 1
packages/app/e2e/tsconfig.json

@@ -2,7 +2,7 @@
   "extends": "../tsconfig.json",
   "compilerOptions": {
     "noEmit": true,
-    "types": ["node"]
+    "types": ["node", "bun"]
   },
   "include": ["./**/*.ts"]
 }

+ 0 - 6
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 })
 }

+ 13 - 1
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(),