Przeglądaj źródła

test(app): general settings, shortcuts, providers and status popover (#11517)

Filip 3 tygodni temu
rodzic
commit
33252a65b4

+ 176 - 0
packages/app/e2e/AGENTS.md

@@ -0,0 +1,176 @@
+# E2E Testing Guide
+
+## Build/Lint/Test Commands
+
+```bash
+# Run all e2e tests
+bun test:e2e
+
+# Run specific test file
+bun test:e2e -- app/home.spec.ts
+
+# Run single test by title
+bun test:e2e -- -g "home renders and shows core entrypoints"
+
+# Run tests with UI mode (for debugging)
+bun test:e2e:ui
+
+# Run tests locally with full server setup
+bun test:e2e:local
+
+# View test report
+bun test:e2e:report
+
+# Typecheck
+bun typecheck
+```
+
+## Test Structure
+
+All tests live in `packages/app/e2e/`:
+
+```
+e2e/
+├── fixtures.ts       # Test fixtures (test, expect, gotoSession, sdk)
+├── actions.ts        # Reusable action helpers
+├── selectors.ts      # DOM selectors
+├── utils.ts          # Utilities (serverUrl, modKey, path helpers)
+└── [feature]/
+    └── *.spec.ts     # Test files
+```
+
+## Test Patterns
+
+### Basic Test Structure
+
+```typescript
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
+
+test("test description", async ({ page, sdk, gotoSession }) => {
+  await gotoSession() // or gotoSession(sessionID)
+
+  // Your test code
+  await expect(page.locator(promptSelector)).toBeVisible()
+})
+```
+
+### Using Fixtures
+
+- `page` - Playwright page
+- `sdk` - OpenCode SDK client for API calls
+- `gotoSession(sessionID?)` - Navigate to session
+
+### Helper Functions
+
+**Actions** (`actions.ts`):
+
+- `openPalette(page)` - Open command palette
+- `openSettings(page)` - Open settings dialog
+- `closeDialog(page, dialog)` - Close any dialog
+- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
+- `withSession(sdk, title, callback)` - Create temp session
+- `clickListItem(container, filter)` - Click list item by key/text
+
+**Selectors** (`selectors.ts`):
+
+- `promptSelector` - Prompt input
+- `terminalSelector` - Terminal panel
+- `sessionItemSelector(id)` - Session in sidebar
+- `listItemSelector` - Generic list items
+
+**Utils** (`utils.ts`):
+
+- `modKey` - Meta (Mac) or Control (Linux/Win)
+- `serverUrl` - Backend server URL
+- `sessionPath(dir, id?)` - Build session URL
+
+## Code Style Guidelines
+
+### Imports
+
+Always import from `../fixtures`, not `@playwright/test`:
+
+```typescript
+// ✅ Good
+import { test, expect } from "../fixtures"
+
+// ❌ Bad
+import { test, expect } from "@playwright/test"
+```
+
+### Naming Conventions
+
+- Test files: `feature-name.spec.ts`
+- Test names: lowercase, descriptive: `"sidebar can be toggled"`
+- Variables: camelCase
+- Constants: SCREAMING_SNAKE_CASE
+
+### Error Handling
+
+Tests should clean up after themselves:
+
+```typescript
+test("test with cleanup", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, "test session", async (session) => {
+    await gotoSession(session.id)
+    // Test code...
+  }) // Auto-deletes session
+})
+```
+
+### Timeouts
+
+Default: 60s per test, 10s per assertion. Override when needed:
+
+```typescript
+test.setTimeout(120_000) // For long LLM operations
+test("slow test", async () => {
+  await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
+})
+```
+
+### Selectors
+
+Use `data-component`, `data-action`, or semantic roles:
+
+```typescript
+// ✅ Good
+await page.locator('[data-component="prompt-input"]').click()
+await page.getByRole("button", { name: "Open settings" }).click()
+
+// ❌ Bad
+await page.locator(".css-class-name").click()
+await page.locator("#id-name").click()
+```
+
+### Keyboard Shortcuts
+
+Use `modKey` for cross-platform compatibility:
+
+```typescript
+import { modKey } from "../utils"
+
+await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
+await page.keyboard.press(`${modKey}+Comma`) // Open settings
+```
+
+## Writing New Tests
+
+1. Choose appropriate folder or create new one
+2. Import from `../fixtures`
+3. Use helper functions from `../actions` and `../selectors`
+4. Clean up any created resources
+5. Use specific selectors (avoid CSS classes)
+6. Test one feature per test file
+
+## Local Development
+
+For UI debugging, use:
+
+```bash
+bun test:e2e:ui
+```
+
+This opens Playwright's interactive UI for step-through debugging.

+ 22 - 0
packages/app/e2e/actions.ts

@@ -269,3 +269,25 @@ export async function withSession<T>(
     await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
   }
 }
+
+export async function openStatusPopover(page: Page) {
+  await defocus(page)
+
+  const rightSection = page.locator(titlebarRightSelector)
+  const trigger = rightSection.getByRole("button", { name: /status/i }).first()
+
+  const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
+
+  const opened = await popoverBody
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (!opened) {
+    await expect(trigger).toBeVisible()
+    await trigger.click()
+    await expect(popoverBody).toBeVisible()
+  }
+
+  return { rightSection, popoverBody }
+}

+ 3 - 0
packages/app/e2e/fixtures.ts

@@ -3,6 +3,9 @@ import { seedProjects } from "./actions"
 import { promptSelector } from "./selectors"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
+
+export const settingsKey = "settings.v3"
+
 type TestFixtures = {
   sdk: ReturnType<typeof createSdk>
   gotoSession: (sessionID?: string) => Promise<void>

+ 13 - 0
packages/app/e2e/selectors.ts

@@ -3,6 +3,17 @@ export const terminalSelector = '[data-component="terminal"]'
 
 export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
 export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
+export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
+export const settingsThemeSelector = '[data-action="settings-theme"]'
+export const settingsFontSelector = '[data-action="settings-font"]'
+export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
+export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
+export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
+export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
+export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
+export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
+export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
+export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
 
 export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
 
@@ -33,3 +44,5 @@ export const listItemSelector = '[data-slot="list-item"]'
 export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
 
 export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
+
+export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`

+ 317 - 0
packages/app/e2e/settings/settings-keybinds.spec.ts

@@ -0,0 +1,317 @@
+import { test, expect } from "../fixtures"
+import { openSettings, closeDialog, withSession } from "../actions"
+import { keybindButtonSelector } from "../selectors"
+import { modKey } from "../utils"
+
+test("changing sidebar toggle keybind works", 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+KeyH`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("H")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
+
+  await closeDialog(page, dialog)
+
+  const main = page.locator("main")
+  const initialClasses = (await main.getAttribute("class")) ?? ""
+  const initiallyClosed = initialClasses.includes("xl:border-l")
+
+  await page.keyboard.press(`${modKey}+Shift+H`)
+  await page.waitForTimeout(100)
+
+  const afterToggleClasses = (await main.getAttribute("class")) ?? ""
+  const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
+  expect(afterToggleClosed).toBe(!initiallyClosed)
+
+  await page.keyboard.press(`${modKey}+Shift+H`)
+  await page.waitForTimeout(100)
+
+  const finalClasses = (await main.getAttribute("class")) ?? ""
+  const finalClosed = finalClasses.includes("xl:border-l")
+  expect(finalClosed).toBe(initiallyClosed)
+})
+
+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" } }))
+  })
+
+  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 customKeybind = await keybindButton.textContent()
+  expect(customKeybind).toContain("X")
+
+  const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
+  await expect(resetButton).toBeVisible()
+  await expect(resetButton).toBeEnabled()
+  await resetButton.click()
+  await page.waitForTimeout(100)
+
+  const restoredKeybind = await keybindButton.textContent()
+  expect(restoredKeybind).toContain("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("clearing a keybind works", 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("Delete")
+  await page.waitForTimeout(100)
+
+  const clearedKeybind = await keybindButton.textContent()
+  expect(clearedKeybind).toMatch(/unassigned|press/i)
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
+
+  await closeDialog(page, dialog)
+
+  await page.keyboard.press(`${modKey}+B`)
+  await page.waitForTimeout(100)
+
+  const stillOnSession = page.url().includes("/session")
+  expect(stillOnSession).toBe(true)
+})
+
+test("changing settings open keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain(",")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Slash`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("/")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
+
+  await closeDialog(page, dialog)
+
+  const settingsDialog = page.getByRole("dialog")
+  await expect(settingsDialog).toHaveCount(0)
+
+  await page.keyboard.press(`${modKey}+Slash`)
+  await page.waitForTimeout(100)
+
+  await expect(settingsDialog).toBeVisible()
+
+  await closeDialog(page, settingsDialog)
+})
+
+test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, "test session for keybind", async (session) => {
+    await gotoSession(session.id)
+
+    const initialUrl = page.url()
+    expect(initialUrl).toContain(`/session/${session.id}`)
+
+    const dialog = await openSettings(page)
+    await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+    const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
+    await expect(keybindButton).toBeVisible()
+
+    await keybindButton.click()
+    await expect(keybindButton).toHaveText(/press/i)
+
+    await page.keyboard.press(`${modKey}+Shift+KeyN`)
+    await page.waitForTimeout(100)
+
+    const newKeybind = await keybindButton.textContent()
+    expect(newKeybind).toContain("N")
+
+    const stored = await page.evaluate(() => {
+      const raw = localStorage.getItem("settings.v3")
+      return raw ? JSON.parse(raw) : null
+    })
+    expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
+
+    await closeDialog(page, dialog)
+
+    await page.keyboard.press(`${modKey}+Shift+N`)
+    await page.waitForTimeout(200)
+
+    const newUrl = page.url()
+    expect(newUrl).toMatch(/\/session\/?$/)
+    expect(newUrl).not.toContain(session.id)
+  })
+})
+
+test("changing file open keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("P")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyF`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("F")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
+
+  await closeDialog(page, dialog)
+
+  const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
+  await expect(filePickerDialog).toHaveCount(0)
+
+  await page.keyboard.press(`${modKey}+Shift+F`)
+  await page.waitForTimeout(100)
+
+  await expect(filePickerDialog).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(filePickerDialog).toHaveCount(0)
+})
+
+test("changing terminal toggle keybind works", 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}+KeyY`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("Y")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
+
+  await closeDialog(page, dialog)
+
+  await page.keyboard.press(`${modKey}+Y`)
+  await page.waitForTimeout(100)
+
+  const pageStable = await page.evaluate(() => document.readyState === "complete")
+  expect(pageStable).toBe(true)
+})
+
+test("changing command palette keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("P")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyK`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("K")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
+
+  await closeDialog(page, dialog)
+
+  const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
+  await expect(palette).toHaveCount(0)
+
+  await page.keyboard.press(`${modKey}+Shift+K`)
+  await page.waitForTimeout(100)
+
+  await expect(palette).toBeVisible()
+  await expect(palette.getByRole("textbox").first()).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(palette).toHaveCount(0)
+})

+ 0 - 28
packages/app/e2e/settings/settings-language.spec.ts

@@ -1,28 +0,0 @@
-import { test, expect } from "../fixtures"
-import { settingsLanguageSelectSelector } from "../selectors"
-import { openSettings } from "../actions"
-
-test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
-  await page.addInitScript(() => {
-    localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
-  })
-
-  await gotoSession()
-
-  const dialog = await openSettings(page)
-
-  const heading = dialog.getByRole("heading", { level: 2 })
-  await expect(heading).toHaveText("General")
-
-  const select = dialog.locator(settingsLanguageSelectSelector)
-  await expect(select).toBeVisible()
-  await select.locator('[data-slot="select-select-trigger"]').click()
-
-  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
-
-  await expect(heading).toHaveText("Allgemein")
-
-  await select.locator('[data-slot="select-select-trigger"]').click()
-  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
-  await expect(heading).toHaveText("General")
-})

+ 122 - 0
packages/app/e2e/settings/settings-models.spec.ts

@@ -0,0 +1,122 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings } from "../actions"
+
+test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+
+  const command = page.locator('[data-slash-id="model.choose"]')
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const picker = page.getByRole("dialog")
+  await expect(picker).toBeVisible()
+
+  const target = picker.locator('[data-slot="list-item"]').first()
+  await expect(target).toBeVisible()
+
+  const key = await target.getAttribute("data-key")
+  if (!key) throw new Error("Failed to resolve model key from list item")
+
+  const name = (await target.locator("span").first().innerText()).trim()
+  if (!name) throw new Error("Failed to resolve model name from list item")
+
+  await page.keyboard.press("Escape")
+  await expect(picker).toHaveCount(0)
+
+  const settings = await openSettings(page)
+
+  await settings.getByRole("tab", { name: "Models" }).click()
+  const search = settings.getByPlaceholder("Search models")
+  await expect(search).toBeVisible()
+  await search.fill(name)
+
+  const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
+  const input = toggle.locator('[data-slot="switch-input"]')
+  await expect(toggle).toBeVisible()
+  await expect(input).toHaveAttribute("aria-checked", "true")
+  await toggle.locator('[data-slot="switch-control"]').click()
+  await expect(input).toHaveAttribute("aria-checked", "false")
+
+  await closeDialog(page, settings)
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const pickerAgain = page.getByRole("dialog")
+  await expect(pickerAgain).toBeVisible()
+  await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
+
+  await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
+
+  await page.keyboard.press("Escape")
+  await expect(pickerAgain).toHaveCount(0)
+})
+
+test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+
+  const command = page.locator('[data-slash-id="model.choose"]')
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const picker = page.getByRole("dialog")
+  await expect(picker).toBeVisible()
+
+  const target = picker.locator('[data-slot="list-item"]').first()
+  await expect(target).toBeVisible()
+
+  const key = await target.getAttribute("data-key")
+  if (!key) throw new Error("Failed to resolve model key from list item")
+
+  const name = (await target.locator("span").first().innerText()).trim()
+  if (!name) throw new Error("Failed to resolve model name from list item")
+
+  await page.keyboard.press("Escape")
+  await expect(picker).toHaveCount(0)
+
+  const settings = await openSettings(page)
+
+  await settings.getByRole("tab", { name: "Models" }).click()
+  const search = settings.getByPlaceholder("Search models")
+  await expect(search).toBeVisible()
+  await search.fill(name)
+
+  const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
+  const input = toggle.locator('[data-slot="switch-input"]')
+  await expect(toggle).toBeVisible()
+  await expect(input).toHaveAttribute("aria-checked", "true")
+
+  await toggle.locator('[data-slot="switch-control"]').click()
+  await expect(input).toHaveAttribute("aria-checked", "false")
+
+  await toggle.locator('[data-slot="switch-control"]').click()
+  await expect(input).toHaveAttribute("aria-checked", "true")
+
+  await closeDialog(page, settings)
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const pickerAgain = page.getByRole("dialog")
+  await expect(pickerAgain).toBeVisible()
+
+  await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(pickerAgain).toHaveCount(0)
+})

+ 121 - 15
packages/app/e2e/settings/settings-providers.spec.ts

@@ -1,30 +1,136 @@
 import { test, expect } from "../fixtures"
-import { promptSelector } from "../selectors"
-import { closeDialog, openSettings, clickListItem } from "../actions"
+import { closeDialog, openSettings } from "../actions"
 
-test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
+test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
   await gotoSession()
 
-  const dialog = await openSettings(page)
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
 
-  await dialog.getByRole("tab", { name: "Providers" }).click()
-  await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
-  await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await expect(customProviderSection).toBeVisible()
 
-  await dialog.getByRole("button", { name: "Show more providers" }).click()
+  const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
+  await connectButton.click()
 
-  const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+  await expect(providerDialog).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("test-provider")
+  await providerDialog.getByLabel("Display name").fill("Test Provider")
+  await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
+  await providerDialog.getByLabel("API key").fill("fake-key")
+
+  await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
+  await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
+
+  await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
+  await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
+  await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
+  await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
+  await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
+  await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
+
+  await page.keyboard.press("Escape")
+  await expect(providerDialog).toHaveCount(0)
+
+  await closeDialog(page, settings)
+})
+
+test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
 
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
   await expect(providerDialog).toBeVisible()
-  await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
-  await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
+  await providerDialog.getByLabel("Base URL").fill("not-a-url")
+
+  await providerDialog.getByRole("button", { name: /submit|save/i }).click()
+
+  await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
+  await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
 
   await page.keyboard.press("Escape")
   await expect(providerDialog).toHaveCount(0)
-  await expect(page.locator(promptSelector)).toBeVisible()
 
-  const stillOpen = await dialog.isVisible().catch(() => false)
-  if (!stillOpen) return
+  await closeDialog(page, settings)
+})
+
+test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
+
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+  await expect(providerDialog).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
+  await providerDialog.getByLabel("Display name").fill("Multi Model Test")
+  await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
+
+  await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
+  await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
+
+  const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
+  await providerDialog.getByRole("button", { name: "Add model" }).click()
+  const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
+  expect(idInputsAfter).toBe(idInputsBefore + 1)
+
+  await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
+  await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
+
+  await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
+  await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
+
+  await page.keyboard.press("Escape")
+  await expect(providerDialog).toHaveCount(0)
+
+  await closeDialog(page, settings)
+})
+
+test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
+
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+  await expect(providerDialog).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("header-test")
+  await providerDialog.getByLabel("Display name").fill("Header Test")
+  await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
+
+  await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
+  await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
+
+  const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
+  await providerDialog.getByRole("button", { name: "Add header" }).click()
+  const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
+  expect(headerInputsAfter).toBe(headerInputsBefore + 1)
+
+  await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
+  await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
+
+  await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
+  await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
+
+  await page.keyboard.press("Escape")
+  await expect(providerDialog).toHaveCount(0)
 
-  await closeDialog(page, dialog)
+  await closeDialog(page, settings)
 })

+ 274 - 1
packages/app/e2e/settings/settings.spec.ts

@@ -1,5 +1,6 @@
-import { test, expect } from "../fixtures"
+import { test, expect, settingsKey } from "../fixtures"
 import { closeDialog, openSettings } from "../actions"
+import { settingsColorSchemeSelector, settingsFontSelector, settingsLanguageSelectSelector, settingsNotificationsAgentSelector, settingsNotificationsErrorsSelector, settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, settingsSoundsAgentSelector, settingsThemeSelector, settingsUpdatesStartupSelector } from "../selectors"
 
 test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
   await gotoSession()
@@ -12,3 +13,275 @@ test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSe
 
   await closeDialog(page, dialog)
 })
+
+
+test("changing language updates settings labels", async ({ page, gotoSession }) => {
+  await page.addInitScript(() => {
+    localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
+  })
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+
+  const heading = dialog.getByRole("heading", { level: 2 })
+  await expect(heading).toHaveText("General")
+
+  const select = dialog.locator(settingsLanguageSelectSelector)
+  await expect(select).toBeVisible()
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
+
+  await expect(heading).toHaveText("Allgemein")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
+  await expect(heading).toHaveText("General")
+})
+
+test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsColorSchemeSelector)
+  await expect(select).toBeVisible()
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
+
+  const colorScheme = await page.evaluate(() => {
+    return document.documentElement.getAttribute("data-color-scheme")
+  })
+  expect(colorScheme).toBe("dark")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
+
+  const lightColorScheme = await page.evaluate(() => {
+    return document.documentElement.getAttribute("data-color-scheme")
+  })
+  expect(lightColorScheme).toBe("light")
+})
+
+test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsThemeSelector)
+  await expect(select).toBeVisible()
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  const count = await items.count()
+  expect(count).toBeGreaterThan(1)
+
+  const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
+  expect(firstTheme).toBeTruthy()
+
+  await items.nth(1).click()
+
+  await page.keyboard.press("Escape")
+
+  const storedThemeId = await page.evaluate(() => {
+    return localStorage.getItem("opencode-theme-id")
+  })
+
+  expect(storedThemeId).not.toBeNull()
+  expect(storedThemeId).not.toBe("oc-1")
+
+  const dataTheme = await page.evaluate(() => {
+    return document.documentElement.getAttribute("data-theme")
+  })
+  expect(dataTheme).toBe(storedThemeId)
+})
+
+test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsFontSelector)
+  await expect(select).toBeVisible()
+
+  const initialFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
+  })
+  expect(initialFontFamily).toContain("IBM Plex Mono")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  await items.nth(2).click()
+
+  await page.waitForTimeout(100)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
+
+  const newFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
+  })
+  expect(newFontFamily).not.toBe(initialFontFamily)
+})
+
+test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.notifications?.agent).toBe(false)
+})
+
+test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.notifications?.permissions).toBe(false)
+})
+
+test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(false)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(true)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.notifications?.errors).toBe(true)
+})
+
+test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsSoundsAgentSelector)
+  await expect(select).toBeVisible()
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  await items.nth(2).click()
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.sounds?.agent).not.toBe("staplebops-01")
+})
+
+test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+
+  const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
+  if (isDisabled) {
+    test.skip()
+    return
+  }
+
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.updates?.startup).toBe(false)
+})
+
+test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsReleaseNotesSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.general?.releaseNotes).toBe(false)
+})

+ 94 - 0
packages/app/e2e/status/status-popover.spec.ts

@@ -0,0 +1,94 @@
+import { test, expect } from "../fixtures"
+import { openStatusPopover, defocus } from "../actions"
+
+test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
+  await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
+  await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
+  await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(popoverBody).toHaveCount(0)
+})
+
+test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
+  await expect(serversTab).toHaveAttribute("aria-selected", "true")
+
+  const serverList = popoverBody.locator('[role="tabpanel"]').first()
+  await expect(serverList.locator("button").first()).toBeVisible()
+})
+
+test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
+  await mcpTab.click()
+
+  const ariaSelected = await mcpTab.getAttribute("aria-selected")
+  expect(ariaSelected).toBe("true")
+
+  const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+  await expect(mcpContent).toBeVisible()
+})
+
+test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
+  await lspTab.click()
+
+  const ariaSelected = await lspTab.getAttribute("aria-selected")
+  expect(ariaSelected).toBe("true")
+
+  const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+  await expect(lspContent).toBeVisible()
+})
+
+test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
+  await pluginsTab.click()
+
+  const ariaSelected = await pluginsTab.getAttribute("aria-selected")
+  expect(ariaSelected).toBe("true")
+
+  const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+  await expect(pluginsContent).toBeVisible()
+})
+
+test("status popover closes on escape", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+  await expect(popoverBody).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(popoverBody).toHaveCount(0)
+})
+
+test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+  await expect(popoverBody).toBeVisible()
+
+  await defocus(page)
+
+  await expect(popoverBody).toHaveCount(0)
+})

+ 37 - 21
packages/app/src/components/settings-general.tsx

@@ -165,6 +165,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.row.appearance.description")}
             >
               <Select
+                data-action="settings-color-scheme"
                 options={colorSchemeOptions()}
                 current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
                 value={(o) => o.value}
@@ -191,6 +192,7 @@ export const SettingsGeneral: Component = () => {
               }
             >
               <Select
+                data-action="settings-theme"
                 options={themeOptions()}
                 current={themeOptions().find((o) => o.id === theme.themeId())}
                 value={(o) => o.id}
@@ -215,6 +217,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.row.font.description")}
             >
               <Select
+                data-action="settings-font"
                 options={fontOptionsList}
                 current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
                 value={(o) => o.value}
@@ -244,30 +247,36 @@ export const SettingsGeneral: Component = () => {
               title={language.t("settings.general.notifications.agent.title")}
               description={language.t("settings.general.notifications.agent.description")}
             >
-              <Switch
-                checked={settings.notifications.agent()}
-                onChange={(checked) => settings.notifications.setAgent(checked)}
-              />
+              <div data-action="settings-notifications-agent">
+                <Switch
+                  checked={settings.notifications.agent()}
+                  onChange={(checked) => settings.notifications.setAgent(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow
               title={language.t("settings.general.notifications.permissions.title")}
               description={language.t("settings.general.notifications.permissions.description")}
             >
-              <Switch
-                checked={settings.notifications.permissions()}
-                onChange={(checked) => settings.notifications.setPermissions(checked)}
-              />
+              <div data-action="settings-notifications-permissions">
+                <Switch
+                  checked={settings.notifications.permissions()}
+                  onChange={(checked) => settings.notifications.setPermissions(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow
               title={language.t("settings.general.notifications.errors.title")}
               description={language.t("settings.general.notifications.errors.description")}
             >
-              <Switch
-                checked={settings.notifications.errors()}
-                onChange={(checked) => settings.notifications.setErrors(checked)}
-              />
+              <div data-action="settings-notifications-errors">
+                <Switch
+                  checked={settings.notifications.errors()}
+                  onChange={(checked) => settings.notifications.setErrors(checked)}
+                />
+              </div>
             </SettingsRow>
           </div>
         </div>
@@ -282,6 +291,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.sounds.agent.description")}
             >
               <Select
+                data-action="settings-sounds-agent"
                 options={soundOptions}
                 current={soundOptions.find((o) => o.id === settings.sounds.agent())}
                 value={(o) => o.id}
@@ -306,6 +316,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.sounds.permissions.description")}
             >
               <Select
+                data-action="settings-sounds-permissions"
                 options={soundOptions}
                 current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
                 value={(o) => o.id}
@@ -330,6 +341,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.sounds.errors.description")}
             >
               <Select
+                data-action="settings-sounds-errors"
                 options={soundOptions}
                 current={soundOptions.find((o) => o.id === settings.sounds.errors())}
                 value={(o) => o.id}
@@ -360,21 +372,25 @@ export const SettingsGeneral: Component = () => {
               title={language.t("settings.updates.row.startup.title")}
               description={language.t("settings.updates.row.startup.description")}
             >
-              <Switch
-                checked={settings.updates.startup()}
-                disabled={!platform.checkUpdate}
-                onChange={(checked) => settings.updates.setStartup(checked)}
-              />
+              <div data-action="settings-updates-startup">
+                <Switch
+                  checked={settings.updates.startup()}
+                  disabled={!platform.checkUpdate}
+                  onChange={(checked) => settings.updates.setStartup(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow
               title={language.t("settings.general.row.releaseNotes.title")}
               description={language.t("settings.general.row.releaseNotes.description")}
             >
-              <Switch
-                checked={settings.general.releaseNotes()}
-                onChange={(checked) => settings.general.setReleaseNotes(checked)}
-              />
+              <div data-action="settings-release-notes">
+                <Switch
+                  checked={settings.general.releaseNotes()}
+                  onChange={(checked) => settings.general.setReleaseNotes(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow

+ 1 - 0
packages/app/src/components/settings-keybinds.tsx

@@ -396,6 +396,7 @@ export const SettingsKeybinds: Component = () => {
                         <span class="text-14-regular text-text-strong">{title(id)}</span>
                         <button
                           type="button"
+                          data-keybind-id={id}
                           classList={{
                             "h-8 px-3 rounded-md text-12-regular": true,
                             "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":

+ 5 - 2
packages/app/src/components/settings-providers.tsx

@@ -123,7 +123,7 @@ export const SettingsProviders: Component = () => {
       </div>
 
       <div class="flex flex-col gap-8 max-w-[720px]">
-        <div class="flex flex-col gap-1">
+        <div class="flex flex-col gap-1" data-component="connected-providers-section">
           <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
           <div class="bg-surface-raised-base px-4 rounded-lg">
             <Show
@@ -225,7 +225,10 @@ export const SettingsProviders: Component = () => {
               )}
             </For>
 
-            <div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
+            <div
+              class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none"
+              data-component="custom-provider-section"
+            >
               <div class="flex flex-col min-w-0">
                 <div class="flex items-center gap-x-3">
                   <ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />