summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-11 09:11:41 -0600
committeropencode <[email protected]>2026-02-11 15:12:28 +0000
commitfc88dde63f1c4a7f547bbe0634c68c5ce9fc787c (patch)
tree4e6aaad7afb646348c3b676a6269455dac63a7d8
parent4619e9d183d036f2f7234046927ec2f72d426168 (diff)
downloadopencode-fc88dde63f1c4a7f547bbe0634c68c5ce9fc787c.tar.gz
opencode-fc88dde63f1c4a7f547bbe0634c68c5ce9fc787c.zip
test(app): more e2e tests (#13162)
-rw-r--r--packages/app/e2e/app/titlebar-history.spec.ts84
-rw-r--r--packages/app/e2e/files/file-tree.spec.ts46
-rw-r--r--packages/app/e2e/projects/projects-close.spec.ts32
-rw-r--r--packages/app/e2e/projects/workspaces.spec.ts44
-rw-r--r--packages/app/e2e/session/session-undo-redo.spec.ts113
-rw-r--r--packages/app/e2e/settings/settings-keybinds.spec.ts74
-rw-r--r--packages/app/e2e/settings/settings.spec.ts162
-rw-r--r--packages/app/e2e/sidebar/sidebar.spec.ts25
8 files changed, 540 insertions, 40 deletions
diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts
index ec65dca0b..9d6091176 100644
--- a/packages/app/e2e/app/titlebar-history.spec.ts
+++ b/packages/app/e2e/app/titlebar-history.spec.ts
@@ -1,6 +1,7 @@
import { test, expect } from "../fixtures"
-import { openSidebar, withSession } from "../actions"
+import { defocus, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
+import { modKey } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
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()
+ })
+ })
+})
diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts
index 844da1b32..321d96af5 100644
--- a/packages/app/e2e/files/file-tree.spec.ts
+++ b/packages/app/e2e/files/file-tree.spec.ts
@@ -1,37 +1,49 @@
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()
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()
+ await expect(toggle).toHaveAttribute("aria-expanded", "true")
+ await expect(panel).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" })
await expect(tab).toBeVisible()
await tab.click()
+ await expect(tab).toHaveAttribute("aria-selected", "true")
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")
})
diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts
index 95768d21e..4b39ed82c 100644
--- a/packages/app/e2e/projects/projects-close.spec.ts
+++ b/packages/app/e2e/projects/projects-close.spec.ts
@@ -1,6 +1,6 @@
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"
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 })
const other = await createTestProject()
- const otherName = other.split("/").pop() ?? other
const otherSlug = dirSlug(other)
try {
await withProject(
- async () => {
+ async ({ slug }) => {
await openSidebar(page)
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`))
- 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)
},
{ extra: [other] },
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
index 41a28e3e3..071c398b2 100644
--- a/packages/app/e2e/projects/workspaces.spec.ts
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -1,5 +1,6 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
+import os from "node:os"
import path from "node:path"
import type { Page } from "@playwright/test"
@@ -10,11 +11,18 @@ import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
+ openProjectMenu,
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
} from "../actions"
-import { inlineInputSelector, workspaceItemSelector } from "../selectors"
+import {
+ inlineInputSelector,
+ projectSwitchSelector,
+ projectWorkspacesToggleSelector,
+ workspaceItemSelector,
+} from "../selectors"
+import { dirSlug } from "../utils"
function slugFromUrl(url: string) {
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 }) => {
await page.setViewportSize({ width: 1400, height: 800 })
diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts
index c7af038c2..2a250dd86 100644
--- a/packages/app/e2e/session/session-undo-redo.spec.ts
+++ b/packages/app/e2e/session/session-undo-redo.spec.ts
@@ -23,10 +23,15 @@ async function seedConversation(input: {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.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
- 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
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()
+ })
+ })
+})
diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts
index a8e7f3352..5e98bd158 100644
--- a/packages/app/e2e/settings/settings-keybinds.spec.ts
+++ b/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)
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()
const initialKeybind = await keybindButton.textContent()
@@ -51,6 +51,40 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
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 }) => {
await page.addInitScript(() => {
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()
})
+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 }) => {
await gotoSession()
diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts
index 2865419f0..42534968b 100644
--- a/packages/app/e2e/settings/settings.spec.ts
+++ b/packages/app/e2e/settings/settings.spec.ts
@@ -9,6 +9,8 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
+ settingsSoundsErrorsSelector,
+ settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
@@ -139,6 +141,105 @@ test("changing font persists in localStorage and updates CSS variable", async ({
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 }) => {
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")
})
+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 }) => {
await gotoSession()
diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts
index 6239a04bd..5c78c2220 100644
--- a/packages/app/e2e/sidebar/sidebar.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar.spec.ts
@@ -1,5 +1,5 @@
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 }) => {
await gotoSession()
@@ -12,3 +12,26 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await toggleSidebar(page)
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)
+ })
+ })
+})