Browse Source

test(app): more e2e tests (#13162)

Adam 2 weeks ago
parent
commit
fc88dde63f

+ 83 - 1
packages/app/e2e/app/titlebar-history.spec.ts

@@ -1,6 +1,7 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { openSidebar, withSession } from "../actions"
+import { defocus, openSidebar, withSession } from "../actions"
 import { promptSelector } from "../selectors"
 import { promptSelector } from "../selectors"
+import { modKey } from "../utils"
 
 
 test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
 test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
   await page.setViewportSize({ width: 1400, height: 800 })
@@ -40,3 +41,84 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
     })
     })
   })
   })
 })
 })
+
+test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const stamp = Date.now()
+
+  await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
+    await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
+      await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
+        await gotoSession(a.id)
+
+        await openSidebar(page)
+
+        const second = page.locator(`[data-session-id="${b.id}"] a`).first()
+        await expect(second).toBeVisible()
+        await second.scrollIntoViewIfNeeded()
+        await second.click()
+
+        await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
+        await expect(page.locator(promptSelector)).toBeVisible()
+
+        const back = page.getByRole("button", { name: "Back" })
+        const forward = page.getByRole("button", { name: "Forward" })
+
+        await expect(back).toBeVisible()
+        await expect(back).toBeEnabled()
+        await back.click()
+
+        await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
+        await expect(page.locator(promptSelector)).toBeVisible()
+
+        await openSidebar(page)
+
+        const third = page.locator(`[data-session-id="${c.id}"] a`).first()
+        await expect(third).toBeVisible()
+        await third.scrollIntoViewIfNeeded()
+        await third.click()
+
+        await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
+        await expect(page.locator(promptSelector)).toBeVisible()
+
+        await expect(forward).toBeVisible()
+        await expect(forward).toBeDisabled()
+      })
+    })
+  })
+})
+
+test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const stamp = Date.now()
+
+  await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
+    await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
+      await gotoSession(one.id)
+
+      await openSidebar(page)
+
+      const link = page.locator(`[data-session-id="${two.id}"] a`).first()
+      await expect(link).toBeVisible()
+      await link.scrollIntoViewIfNeeded()
+      await link.click()
+
+      await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+      await expect(page.locator(promptSelector)).toBeVisible()
+
+      await defocus(page)
+      await page.keyboard.press(`${modKey}+[`)
+
+      await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
+      await expect(page.locator(promptSelector)).toBeVisible()
+
+      await defocus(page)
+      await page.keyboard.press(`${modKey}+]`)
+
+      await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+      await expect(page.locator(promptSelector)).toBeVisible()
+    })
+  })
+})

+ 29 - 17
packages/app/e2e/files/file-tree.spec.ts

@@ -1,37 +1,49 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
 
 
-test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
+test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 
   const toggle = page.getByRole("button", { name: "Toggle file tree" })
   const toggle = page.getByRole("button", { name: "Toggle file tree" })
-  const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
+  const panel = page.locator("#file-tree-panel")
+  const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
 
 
+  await expect(toggle).toBeVisible()
   if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
   if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
+  await expect(toggle).toHaveAttribute("aria-expanded", "true")
+  await expect(panel).toBeVisible()
   await expect(treeTabs).toBeVisible()
   await expect(treeTabs).toBeVisible()
 
 
-  await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
+  const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
+  await expect(allTab).toBeVisible()
+  await allTab.click()
+  await expect(allTab).toHaveAttribute("aria-selected", "true")
 
 
-  const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
+  const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
+  await expect(tree).toBeVisible()
 
 
-  await expect(node("packages")).toBeVisible()
-  await node("packages").click()
+  const expand = async (name: string) => {
+    const folder = tree.getByRole("button", { name, exact: true }).first()
+    await expect(folder).toBeVisible()
+    await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
+    if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
+    await expect(folder).toHaveAttribute("aria-expanded", "true")
+  }
 
 
-  await expect(node("app")).toBeVisible()
-  await node("app").click()
+  await expand("packages")
+  await expand("app")
+  await expand("src")
+  await expand("components")
 
 
-  await expect(node("src")).toBeVisible()
-  await node("src").click()
-
-  await expect(node("components")).toBeVisible()
-  await node("components").click()
-
-  await expect(node("file-tree.tsx")).toBeVisible()
-  await node("file-tree.tsx").click()
+  const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
+  await expect(file).toBeVisible()
+  await file.click()
 
 
   const tab = page.getByRole("tab", { name: "file-tree.tsx" })
   const tab = page.getByRole("tab", { name: "file-tree.tsx" })
   await expect(tab).toBeVisible()
   await expect(tab).toBeVisible()
   await tab.click()
   await tab.click()
+  await expect(tab).toHaveAttribute("aria-selected", "true")
 
 
   const code = page.locator('[data-component="code"]').first()
   const code = page.locator('[data-component="code"]').first()
-  await expect(code.getByText("export default function FileTree")).toBeVisible()
+  await expect(code).toBeVisible()
+  await expect(code).toContainText("export default function FileTree")
 })
 })

+ 15 - 17
packages/app/e2e/projects/projects-close.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
-import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
+import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
+import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
 import { dirSlug } from "../utils"
 import { dirSlug } from "../utils"
 
 
 test("can close a project via hover card close button", async ({ page, withProject }) => {
 test("can close a project via hover card close button", async ({ page, withProject }) => {
@@ -31,16 +31,15 @@ test("can close a project via hover card close button", async ({ page, withProje
   }
   }
 })
 })
 
 
-test("can close a project via project header more options menu", async ({ page, withProject }) => {
+test("closing active project navigates to another open project", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
   await page.setViewportSize({ width: 1400, height: 800 })
 
 
   const other = await createTestProject()
   const other = await createTestProject()
-  const otherName = other.split("/").pop() ?? other
   const otherSlug = dirSlug(other)
   const otherSlug = dirSlug(other)
 
 
   try {
   try {
     await withProject(
     await withProject(
-      async () => {
+      async ({ slug }) => {
         await openSidebar(page)
         await openSidebar(page)
 
 
         const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
         const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
@@ -49,21 +48,20 @@ test("can close a project via project header more options menu", async ({ page,
 
 
         await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
         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 menu = await openProjectMenu(page, otherSlug)
 
 
-        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")
+        await clickMenuItem(menu, /^Close$/i, { force: true })
 
 
-        const menu = page.locator('[data-component="dropdown-menu-content"]').first()
-        await expect(menu).toBeVisible({ timeout: 10_000 })
+        await expect
+          .poll(() => {
+            const pathname = new URL(page.url()).pathname
+            if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
+            if (pathname === "/") return "home"
+            return ""
+          })
+          .toMatch(/^(project|home)$/)
 
 
-        await clickMenuItem(menu, /^Close$/i, { force: true })
+        await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
         await expect(otherButton).toHaveCount(0)
         await expect(otherButton).toHaveCount(0)
       },
       },
       { extra: [other] },
       { extra: [other] },

+ 43 - 1
packages/app/e2e/projects/workspaces.spec.ts

@@ -1,5 +1,6 @@
 import { base64Decode } from "@opencode-ai/util/encode"
 import { base64Decode } from "@opencode-ai/util/encode"
 import fs from "node:fs/promises"
 import fs from "node:fs/promises"
+import os from "node:os"
 import path from "node:path"
 import path from "node:path"
 import type { Page } from "@playwright/test"
 import type { Page } from "@playwright/test"
 
 
@@ -10,11 +11,18 @@ import {
   cleanupTestProject,
   cleanupTestProject,
   clickMenuItem,
   clickMenuItem,
   confirmDialog,
   confirmDialog,
+  openProjectMenu,
   openSidebar,
   openSidebar,
   openWorkspaceMenu,
   openWorkspaceMenu,
   setWorkspacesEnabled,
   setWorkspacesEnabled,
 } from "../actions"
 } from "../actions"
-import { inlineInputSelector, workspaceItemSelector } from "../selectors"
+import {
+  inlineInputSelector,
+  projectSwitchSelector,
+  projectWorkspacesToggleSelector,
+  workspaceItemSelector,
+} from "../selectors"
+import { dirSlug } from "../utils"
 
 
 function slugFromUrl(url: string) {
 function slugFromUrl(url: string) {
   return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
   return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -126,6 +134,40 @@ test("can create a workspace", async ({ page, withProject }) => {
   })
   })
 })
 })
 
 
+test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
+  const nonGitSlug = dirSlug(nonGit)
+
+  await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
+
+  try {
+    await withProject(
+      async () => {
+        await openSidebar(page)
+
+        const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
+        await expect(nonGitButton).toBeVisible()
+        await nonGitButton.click()
+        await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
+
+        const menu = await openProjectMenu(page, nonGitSlug)
+        const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
+
+        await expect(toggle).toBeVisible()
+        await expect(toggle).toBeDisabled()
+
+        await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
+        await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+      },
+      { extra: [nonGit] },
+    )
+  } finally {
+    await cleanupTestProject(nonGit)
+  }
+})
+
 test("can rename a workspace", async ({ page, withProject }) => {
 test("can rename a workspace", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
   await page.setViewportSize({ width: 1400, height: 800 })
 
 

+ 111 - 2
packages/app/e2e/session/session-undo-redo.spec.ts

@@ -23,10 +23,15 @@ async function seedConversation(input: {
         const messages = await input.sdk.session
         const messages = await input.sdk.session
           .messages({ sessionID: input.sessionID, limit: 50 })
           .messages({ sessionID: input.sessionID, limit: 50 })
           .then((r) => r.data ?? [])
           .then((r) => r.data ?? [])
-        const users = messages.filter((m) => m.info.role === "user")
+        const users = messages.filter(
+          (m) =>
+            m.info.role === "user" &&
+            m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
+        )
         if (users.length === 0) return false
         if (users.length === 0) return false
 
 
-        const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
+        const user = users[users.length - 1]
+        if (!user) return false
         userMessageID = user.info.id
         userMessageID = user.info.id
 
 
         const assistantText = messages
         const assistantText = messages
@@ -124,3 +129,107 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
     })
     })
   })
   })
 })
 })
+
+test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
+  test.setTimeout(120_000)
+
+  const firstToken = `undo_redo_first_${Date.now()}`
+  const secondToken = `undo_redo_second_${Date.now()}`
+
+  await withProject(async (project) => {
+    const sdk = createSdk(project.directory)
+
+    await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
+      await project.gotoSession(session.id)
+
+      const first = await seedConversation({
+        page,
+        sdk,
+        sessionID: session.id,
+        token: firstToken,
+      })
+      const second = await seedConversation({
+        page,
+        sdk,
+        sessionID: session.id,
+        token: secondToken,
+      })
+
+      expect(first.userMessageID).not.toBe(second.userMessageID)
+
+      const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
+      const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
+
+      await expect(firstMessage.first()).toBeVisible()
+      await expect(secondMessage.first()).toBeVisible()
+
+      await second.prompt.click()
+      await page.keyboard.press(`${modKey}+A`)
+      await page.keyboard.press("Backspace")
+      await page.keyboard.type("/undo")
+
+      const undo = page.locator('[data-slash-id="session.undo"]').first()
+      await expect(undo).toBeVisible()
+      await page.keyboard.press("Enter")
+
+      await expect
+        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+          timeout: 30_000,
+        })
+        .toBe(second.userMessageID)
+
+      await expect(firstMessage.first()).toBeVisible()
+      await expect(secondMessage).toHaveCount(0)
+
+      await second.prompt.click()
+      await page.keyboard.press(`${modKey}+A`)
+      await page.keyboard.press("Backspace")
+      await page.keyboard.type("/undo")
+      await expect(undo).toBeVisible()
+      await page.keyboard.press("Enter")
+
+      await expect
+        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+          timeout: 30_000,
+        })
+        .toBe(first.userMessageID)
+
+      await expect(firstMessage).toHaveCount(0)
+      await expect(secondMessage).toHaveCount(0)
+
+      await second.prompt.click()
+      await page.keyboard.press(`${modKey}+A`)
+      await page.keyboard.press("Backspace")
+      await page.keyboard.type("/redo")
+
+      const redo = page.locator('[data-slash-id="session.redo"]').first()
+      await expect(redo).toBeVisible()
+      await page.keyboard.press("Enter")
+
+      await expect
+        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+          timeout: 30_000,
+        })
+        .toBe(second.userMessageID)
+
+      await expect(firstMessage.first()).toBeVisible()
+      await expect(secondMessage).toHaveCount(0)
+
+      await second.prompt.click()
+      await page.keyboard.press(`${modKey}+A`)
+      await page.keyboard.press("Backspace")
+      await page.keyboard.type("/redo")
+      await expect(redo).toBeVisible()
+      await page.keyboard.press("Enter")
+
+      await expect
+        .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
+          timeout: 30_000,
+        })
+        .toBeUndefined()
+
+      await expect(firstMessage.first()).toBeVisible()
+      await expect(secondMessage.first()).toBeVisible()
+    })
+  })
+})

+ 73 - 1
packages/app/e2e/settings/settings-keybinds.spec.ts

@@ -9,7 +9,7 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
   const dialog = await openSettings(page)
   const dialog = await openSettings(page)
   await dialog.getByRole("tab", { name: "Shortcuts" }).click()
   await dialog.getByRole("tab", { name: "Shortcuts" }).click()
 
 
-  const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+  const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
   await expect(keybindButton).toBeVisible()
   await expect(keybindButton).toBeVisible()
 
 
   const initialKeybind = await keybindButton.textContent()
   const initialKeybind = await keybindButton.textContent()
@@ -51,6 +51,40 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
   expect(finalClosed).toBe(initiallyClosed)
   expect(finalClosed).toBe(initiallyClosed)
 })
 })
 
 
+test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("B")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyP`)
+  await page.waitForTimeout(100)
+
+  const toast = page.locator('[data-component="toast"]').last()
+  await expect(toast).toBeVisible()
+  await expect(toast).toContainText(/already/i)
+
+  await keybindButton.click()
+  await expect(keybindButton).toContainText("B")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
+
+  await closeDialog(page, dialog)
+})
+
 test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
 test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
   await page.addInitScript(() => {
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
     localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
@@ -277,6 +311,44 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
   await expect(terminal).not.toBeVisible()
   await expect(terminal).not.toBeVisible()
 })
 })
 
 
+test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
+  await expect(keybindButton).toBeVisible()
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyY`)
+  await page.waitForTimeout(100)
+
+  await expect(keybindButton).toContainText("Y")
+  await closeDialog(page, dialog)
+
+  await page.reload()
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate(() => {
+        const raw = localStorage.getItem("settings.v3")
+        if (!raw) return
+        const parsed = JSON.parse(raw)
+        return parsed?.keybinds?.["terminal.toggle"]
+      })
+    })
+    .toBe("mod+shift+y")
+
+  const reloaded = await openSettings(page)
+  await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
+  const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
+  await expect(reloadedKeybind).toContainText("Y")
+  await closeDialog(page, reloaded)
+})
+
 test("changing command palette keybind works", async ({ page, gotoSession }) => {
 test("changing command palette keybind works", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 

+ 162 - 0
packages/app/e2e/settings/settings.spec.ts

@@ -9,6 +9,8 @@ import {
   settingsNotificationsPermissionsSelector,
   settingsNotificationsPermissionsSelector,
   settingsReleaseNotesSelector,
   settingsReleaseNotesSelector,
   settingsSoundsAgentSelector,
   settingsSoundsAgentSelector,
+  settingsSoundsErrorsSelector,
+  settingsSoundsPermissionsSelector,
   settingsThemeSelector,
   settingsThemeSelector,
   settingsUpdatesStartupSelector,
   settingsUpdatesStartupSelector,
 } from "../selectors"
 } from "../selectors"
@@ -139,6 +141,105 @@ test("changing font persists in localStorage and updates CSS variable", async ({
   expect(newFontFamily).not.toBe(initialFontFamily)
   expect(newFontFamily).not.toBe(initialFontFamily)
 })
 })
 
 
+test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+
+  const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
+  await expect(colorSchemeSelect).toBeVisible()
+  await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
+  await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
+
+  const fontSelect = dialog.locator(settingsFontSelector)
+  await expect(fontSelect).toBeVisible()
+
+  const initialFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+  })
+
+  const initialSettings = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  const currentFont =
+    (await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+  await fontSelect.locator('[data-slot="select-select-trigger"]').click()
+
+  const fontItems = page.locator('[data-slot="select-select-item"]')
+  expect(await fontItems.count()).toBeGreaterThan(1)
+
+  if (currentFont) {
+    await fontItems.filter({ hasNotText: currentFont }).first().click()
+  }
+  if (!currentFont) {
+    await fontItems.nth(1).click()
+  }
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        font: expect.any(String),
+      },
+    })
+
+  const updatedSettings = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  const updatedFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+  })
+  expect(updatedFontFamily).not.toBe(initialFontFamily)
+  expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
+
+  await closeDialog(page, dialog)
+  await page.reload()
+
+  await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        font: updatedSettings?.appearance?.font,
+      },
+    })
+
+  const rehydratedSettings = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate(() => {
+        return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+      })
+    })
+    .not.toBe(initialFontFamily)
+
+  const rehydratedFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+  })
+  expect(rehydratedFontFamily).not.toBe(initialFontFamily)
+  expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
+})
+
 test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
 test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 
@@ -234,6 +335,67 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
   expect(stored?.sounds?.agent).not.toBe("staplebops-01")
   expect(stored?.sounds?.agent).not.toBe("staplebops-01")
 })
 })
 
 
+test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
+  const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
+  await expect(permissionsSelect).toBeVisible()
+  await expect(errorsSelect).toBeVisible()
+
+  const initial = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  const permissionsCurrent =
+    (await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+  await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
+  const permissionItems = page.locator('[data-slot="select-select-item"]')
+  expect(await permissionItems.count()).toBeGreaterThan(1)
+  if (permissionsCurrent) {
+    await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
+  }
+  if (!permissionsCurrent) {
+    await permissionItems.nth(1).click()
+  }
+
+  const errorsCurrent =
+    (await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+  await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
+  const errorItems = page.locator('[data-slot="select-select-item"]')
+  expect(await errorItems.count()).toBeGreaterThan(1)
+  if (errorsCurrent) {
+    await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
+  }
+  if (!errorsCurrent) {
+    await errorItems.nth(1).click()
+  }
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      sounds: {
+        permissions: expect.any(String),
+        errors: expect.any(String),
+      },
+    })
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
+  expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
+})
+
 test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
 test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
 
 

+ 24 - 1
packages/app/e2e/sidebar/sidebar.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "../fixtures"
 import { test, expect } from "../fixtures"
-import { openSidebar, toggleSidebar } from "../actions"
+import { openSidebar, toggleSidebar, withSession } from "../actions"
 
 
 test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
 test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
   await gotoSession()
   await gotoSession()
@@ -12,3 +12,26 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
   await toggleSidebar(page)
   await toggleSidebar(page)
   await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
   await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
 })
 })
+
+test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, "sidebar persist session 1", async (session1) => {
+    await withSession(sdk, "sidebar persist session 2", async (session2) => {
+      await gotoSession(session1.id)
+
+      await openSidebar(page)
+      await toggleSidebar(page)
+      await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+      await gotoSession(session2.id)
+      await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+      await page.reload()
+      await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+      const opened = await page.evaluate(
+        () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
+      )
+      await expect(opened).toBe(false)
+    })
+  })
+})