Files
2026-07-13 13:12:33 +08:00

546 lines
25 KiB
TypeScript

import { test, expect } from '@playwright/test'
const CONTROL_URL = '/control/'
// Backend-config sections carry a readiness/status dot; Connection is the first
// entry (live socket state). Appearance, Keyboard, and Advanced are client-only.
// (Runtime exists too, but it is desktop-only so the web rail hides it.)
const SECTIONS = ['Connection', 'Model Service', 'Model Routing', 'Capabilities', 'Channels', 'Behavior', 'Privacy']
const CLIENT_SECTIONS = ['Appearance', 'Keyboard', 'Advanced']
const settingsRow = (page: import('@playwright/test').Page) =>
page.locator('.sidebar-foot button')
const dialog = (page: import('@playwright/test').Page) =>
page.getByRole('dialog', { name: 'Settings' })
const railTab = (page: import('@playwright/test').Page, name: string) =>
dialog(page).getByRole('tab', { name: new RegExp(`^${name}:`) })
async function openFromSidebar(page: import('@playwright/test').Page) {
await page.goto(CONTROL_URL)
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await settingsRow(page).click()
await expect(dialog(page)).toBeVisible()
}
test.describe('Settings modal', () => {
test('opens from the sidebar with the curated section rail and readiness banner', async ({ page }) => {
await openFromSidebar(page)
// Dialog a11y: focus moves into the modal on open, before any interaction.
await expect(page.getByRole('button', { name: 'Close' })).toBeFocused()
// Rail has the backend-config sections (each with a readiness/status dot)
// plus the client-only sections.
const tabs = dialog(page).getByRole('tab')
await expect(tabs).toHaveCount(SECTIONS.length + CLIENT_SECTIONS.length)
for (const name of SECTIONS) {
await expect(railTab(page, name)).toBeVisible()
}
for (const name of CLIENT_SECTIONS) {
await expect(dialog(page).getByRole('tab', { name, exact: true })).toBeVisible()
}
// Readiness banner: quiet ready line or actionable count.
const banner = dialog(page).locator('.settings-banner')
await expect(banner).toBeVisible()
await expect(banner.locator('.settings-banner__row')).toContainText(/Ready to run|Action needed \(\d+\)/)
// CLI handoff disclosure expands with command groups and the config summary.
await banner.getByRole('button', { name: 'CLI handoff' }).click()
await expect(banner.locator('.setup-cli__group', { hasText: 'CLI handoff' })).toBeVisible()
await expect(banner.locator('.setup-cli__group', { hasText: 'CLI recipes' })).toBeVisible()
await expect(banner.locator('.setup-summary')).toContainText('Provider')
})
test('no YAML editor, raw key search, or guided-setup wording anywhere', async ({ page }) => {
await openFromSidebar(page)
await expect(dialog(page).getByRole('button', { name: 'YAML', exact: true })).toHaveCount(0)
await expect(dialog(page).getByRole('button', { name: 'Form', exact: true })).toHaveCount(0)
await expect(dialog(page).locator('#cfg-search')).toHaveCount(0)
await expect(dialog(page).locator('textarea#cfg-yaml-area')).toHaveCount(0)
await expect(dialog(page).getByText('Guided setup')).toHaveCount(0)
// Footer keeps the config.toml escape hatch with a copy affordance.
const foot = dialog(page).locator('.settings-foot')
await expect(foot).toContainText('More options live in')
// Honest restart copy: most edits apply live, only some need a restart.
await expect(foot).toContainText('Most changes apply live; some need a gateway restart')
// The old blanket "always restart" leak is gone.
await expect(foot).not.toContainText('Restart the gateway after manual edits')
await expect(foot.locator('.settings-foot__path')).toContainText(/config.*\.toml/)
await foot.getByRole('button', { name: 'Copy config path' }).click()
await expect(page.locator('.toast', { hasText: /Copied/ }).first()).toBeVisible()
})
test('rail switches sections, marks the active tab, and syncs the URL with replace', async ({ page }) => {
await openFromSidebar(page)
await expect(page).toHaveURL(/\/settings$/)
await railTab(page, 'Capabilities').click()
await expect(railTab(page, 'Capabilities')).toHaveAttribute('aria-selected', 'true')
// Switching a section reflects in the URL so it stays deep-linkable.
await expect(page).toHaveURL(/\/settings\/capabilities$/)
// The Capabilities panel renders its tools as stacked sections (Web search,
// Memory, Image, Audio) rather than a redundant "Capabilities" card heading.
await expect(dialog(page).getByRole('heading', { name: 'Web search' })).toBeVisible()
await railTab(page, 'Model Routing').click()
await expect(railTab(page, 'Model Routing')).toHaveAttribute('aria-selected', 'true')
await expect(page).toHaveURL(/\/settings\/modelStrategy$/)
await expect(dialog(page).getByRole('heading', { name: 'Model routing', exact: true })).toBeVisible()
// Section navigation uses replace, so a single Back exits Settings rather
// than walking section history.
await page.goBack()
await expect(dialog(page)).toBeHidden()
await expect(page).not.toHaveURL(/\/settings/)
})
test('keeps the SettingsDialog mounted when routing from default settings to a section', async ({ page }) => {
await openFromSidebar(page)
await expect(page).toHaveURL(/\/settings$/)
await expect(dialog(page).locator('.settings-banner')).toBeVisible()
await expect(dialog(page).locator('.settings-loading')).toHaveCount(0)
await page.evaluate(() => {
const modal = document.querySelector('.settings-modal')
if (!modal) throw new Error('settings modal missing')
const state = {
modal,
removed: false,
loadingSeen: false,
observer: null as MutationObserver | null,
}
state.observer = new MutationObserver(() => {
state.removed = state.removed || !document.body.contains(state.modal)
state.loadingSeen = state.loadingSeen || !!document.querySelector('.settings-loading')
})
state.observer.observe(document.body, { childList: true, subtree: true })
;(window as typeof window & { __settingsModalProbe?: typeof state }).__settingsModalProbe = state
})
await railTab(page, 'Capabilities').click()
await expect(page).toHaveURL(/\/settings\/capabilities$/)
await expect(railTab(page, 'Capabilities')).toHaveAttribute('aria-selected', 'true')
await expect(dialog(page).getByRole('heading', { name: 'Web search' })).toBeVisible()
// Wait past the route fade and Settings pop enter windows so delayed
// remounts, re-entry animations, or loading fallbacks are caught.
await page.waitForTimeout(350)
const probe = await page.evaluate(() => {
const state = (window as typeof window & {
__settingsModalProbe?: {
modal: Element
removed: boolean
loadingSeen: boolean
observer: MutationObserver | null
}
}).__settingsModalProbe
if (!state) throw new Error('settings modal probe missing')
state.observer?.disconnect()
return {
sameNode: state.modal === document.querySelector('.settings-modal'),
removed: state.removed,
loadingSeen: state.loadingSeen,
}
})
expect(probe.sameNode).toBe(true)
expect(probe.removed).toBe(false)
expect(probe.loadingSeen).toBe(false)
})
test('opens Settings without applying the app route fade to the overlay', async ({ page }) => {
await page.goto(CONTROL_URL)
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await page.evaluate(() => {
const state = {
overlaySeen: false,
routeFadeSeen: false,
observer: null as MutationObserver | null,
}
const inspect = () => {
const overlay = document.querySelector('.settings-overlay')
if (!overlay) return
state.overlaySeen = true
state.routeFadeSeen = state.routeFadeSeen || String(overlay.className).includes('route-fade-')
}
state.observer = new MutationObserver(inspect)
state.observer.observe(document.body, {
attributeFilter: ['class'],
attributes: true,
childList: true,
subtree: true,
})
inspect()
;(window as typeof window & { __settingsOpenProbe?: typeof state }).__settingsOpenProbe = state
})
await settingsRow(page).click()
await expect(dialog(page)).toBeVisible()
await expect(dialog(page).locator('.settings-banner')).toBeVisible()
await page.waitForTimeout(200)
const probe = await page.evaluate(() => {
const state = (window as typeof window & {
__settingsOpenProbe?: {
overlaySeen: boolean
routeFadeSeen: boolean
observer: MutationObserver | null
}
}).__settingsOpenProbe
if (!state) throw new Error('settings open probe missing')
state.observer?.disconnect()
return {
overlaySeen: state.overlaySeen,
routeFadeSeen: state.routeFadeSeen,
}
})
expect(probe.overlaySeen).toBe(true)
expect(probe.routeFadeSeen).toBe(false)
})
test('Model Routing section saves the routing panel style', async ({ page }) => {
test.setTimeout(90000)
await openFromSidebar(page)
const visualMode = () => dialog(page).locator('select[name="setup_model_strategy_router_visual_mode"]')
const dirtybar = dialog(page).locator('.settings-dirtybar')
await railTab(page, 'Model Routing').click()
// The picker rides with the router details, so make the router strategy
// active first if the gateway under test starts on another strategy (the
// strategy choice is then saved along with the style — acceptable for the
// disposable gateways this suite runs against).
const routerCard = dialog(page).locator('[data-strategy-id="router"]')
await expect(routerCard).toBeVisible()
if ((await routerCard.getAttribute('aria-checked')) !== 'true') await routerCard.click()
await expect(visualMode()).toBeVisible()
const initial = await visualMode().inputValue()
const next = initial === 'legacy_grid' ? 'real_candidates' : 'legacy_grid'
await visualMode().selectOption(next)
await expect(dirtybar).toBeVisible()
await expect(dirtybar).toContainText('Unsaved changes in Model Routing')
await dirtybar.getByRole('button', { name: 'Save' }).click()
await expect(page.locator('.toast', { hasText: /Router saved/ }).first()).toBeVisible()
await expect(dirtybar).toBeHidden({ timeout: 10000 })
await page.reload()
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
await railTab(page, 'Model Routing').click()
await expect(visualMode()).toHaveValue(next)
await visualMode().selectOption(initial)
await dirtybar.getByRole('button', { name: 'Save' }).click()
await expect(dirtybar).toBeHidden({ timeout: 10000 })
})
test('Escape closes the overlay, leaves /settings, and returns focus to the invoker', async ({ page }) => {
await openFromSidebar(page)
await expect(page).toHaveURL(/\/settings$/)
await page.keyboard.press('Escape')
await expect(dialog(page)).toBeHidden()
// The overlay is route-mounted: closing navigates back off /settings.
await expect(page).not.toHaveURL(/\/settings/)
await expect(settingsRow(page)).toBeFocused()
// Escape inside the overlay must not collapse the docked sidebar.
await expect(page.locator('.sidebar.docked')).toBeVisible()
})
test('closing Settings restores the previous route view without blanking the outlet', async ({ page }) => {
await page.goto(CONTROL_URL + 'chat/new')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(page.getByLabel('Message to send')).toBeVisible()
await settingsRow(page).click()
await expect(dialog(page)).toBeVisible()
await page.getByRole('button', { name: 'Close' }).click()
await expect(dialog(page)).toBeHidden()
await expect(page).toHaveURL(/\/chat\/new$/)
await expect(page.getByLabel('Message to send')).toBeVisible()
})
test('cold deep link to /settings/connection closes home and moves focus to the sidebar', async ({ page }) => {
// Land directly on a section with no in-app invoker.
await page.goto(CONTROL_URL + 'settings/connection')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
await expect(railTab(page, 'Connection')).toHaveAttribute('aria-selected', 'true')
// Connection renders even before/without a loaded config gate.
await expect(dialog(page).getByRole('heading', { name: 'Connection' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog(page)).toBeHidden()
await expect(page).not.toHaveURL(/\/settings/)
// No detached focus: it lands on the sidebar Settings button.
await expect(settingsRow(page)).toBeFocused()
})
test('/config deep link redirects into the settings overlay', async ({ page }) => {
await page.goto(CONTROL_URL + 'config')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
// /config now redirects to /settings (default section).
await expect(page).toHaveURL(/\/settings$/)
await expect(railTab(page, 'Model Service')).toHaveAttribute('aria-selected', 'true')
})
test('/setup deep link redirects into the overlay on the first not-ready section', async ({ page }) => {
await page.goto(CONTROL_URL + 'setup')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
// /setup redirects to /settings/auto (first not-ready section).
await expect(page).toHaveURL(/\/settings\/auto$/)
// The selected tab matches the readiness state: with everything ready it
// is Model Service, otherwise the first section whose rail dot needs action.
await expect(dialog(page).getByRole('tab', { selected: true })).toHaveCount(1)
const banner = dialog(page).locator('.settings-banner')
const ready = await banner.locator('.settings-banner__row').textContent()
if (ready && ready.includes('Ready to run')) {
await expect(railTab(page, 'Model Service')).toHaveAttribute('aria-selected', 'true')
} else {
const selected = dialog(page).getByRole('tab', { selected: true })
await expect(selected).toHaveAttribute('aria-label', /Needs action|Provider first|Missing/)
}
})
test('dirty edits raise the bar, guard close, and Discard restores values', async ({ page }) => {
await openFromSidebar(page)
await railTab(page, 'Capabilities').click()
const maxResults = dialog(page).locator('input[name="setup_search_max_results"]')
await expect(maxResults).toBeVisible()
const original = await maxResults.inputValue()
await maxResults.fill(String(Number(original || '5') + 3))
const dirtybar = dialog(page).locator('.settings-dirtybar')
await expect(dirtybar).toBeVisible()
await expect(dirtybar).toContainText('Unsaved changes in Capabilities')
// Closing with unsaved edits raises the themed confirm; declining keeps the modal.
await page.keyboard.press('Escape')
const confirm = page.getByRole('dialog', { name: 'Discard unsaved changes?' })
await expect(confirm).toBeVisible()
await confirm.getByRole('button', { name: 'Cancel' }).click()
await expect(confirm).toBeHidden()
await expect(dialog(page)).toBeVisible()
await dirtybar.getByRole('button', { name: 'Discard' }).click()
await expect(dirtybar).toBeHidden()
await expect(maxResults).toHaveValue(original)
})
test('history Back with dirty edits raises the discard confirm instead of silently closing', async ({ page }) => {
await openFromSidebar(page)
await railTab(page, 'Capabilities').click()
await expect(page).toHaveURL(/\/settings\/capabilities$/)
const maxResults = dialog(page).locator('input[name="setup_search_max_results"]')
await expect(maxResults).toBeVisible()
const original = await maxResults.inputValue()
await maxResults.fill(String(Number(original || '5') + 3))
await expect(dialog(page).locator('.settings-dirtybar')).toBeVisible()
// History traversal (browser Back, a trackpad back-swipe) unmounts the
// route-mounted overlay without passing through requestClose, so the
// router-level guard must raise the same confirm. Declining cancels the
// navigation and restores the /settings URL.
await page.goBack()
const confirm = page.getByRole('dialog', { name: 'Discard unsaved changes?' })
await expect(confirm).toBeVisible()
await confirm.getByRole('button', { name: 'Cancel' }).click()
await expect(confirm).toBeHidden()
await expect(dialog(page)).toBeVisible()
await expect(page).toHaveURL(/\/settings\/capabilities$/)
await expect(maxResults).not.toHaveValue(original)
// Accepting the discard lets the same Back proceed and close the overlay.
await page.goBack()
await expect(confirm).toBeVisible()
await confirm.getByRole('button', { name: 'Discard' }).click()
await expect(dialog(page)).toBeHidden()
await expect(page).not.toHaveURL(/\/settings/)
await expect(settingsRow(page)).toBeFocused()
})
test('viewport opts out of horizontal overscroll so a trackpad swipe cannot pop history', async ({ page }) => {
await page.goto(CONTROL_URL)
await page.waitForSelector('.conn-pill', { timeout: 10000 })
// Chromium turns a two-finger horizontal overscroll into history navigation;
// with Settings and chat sessions living on the history stack that gesture
// would dismiss them wholesale. Both propagation roots must opt out.
const behaviors = await page.evaluate(() => [
getComputedStyle(document.documentElement).overscrollBehaviorX,
getComputedStyle(document.body).overscrollBehaviorX,
])
expect(behaviors).toEqual(['none', 'none'])
})
test('live save round-trip persists a harmless Capabilities toggle', async ({ page }) => {
test.setTimeout(90000)
await openFromSidebar(page)
// memory.auto_capture_enabled is hot-applied via the config.patch path.
const capture = () => dialog(page).locator('input[name="setup_memory_auto_capture"]')
const saveMemory = () => dialog(page).getByRole('button', { name: 'Save memory embedding' })
await railTab(page, 'Capabilities').click()
await expect(capture()).toBeVisible()
const initial = await capture().isChecked()
await capture().setChecked(!initial)
await saveMemory().click()
await expect(page.locator('.toast', { hasText: /Memory/ }).first()).toBeVisible()
await expect(dialog(page).locator('.settings-dirtybar')).toBeHidden({ timeout: 10000 })
// Reload: the persisted value must survive a fresh modal.
await page.reload()
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
await railTab(page, 'Capabilities').click()
await expect(capture()).toBeVisible()
expect(await capture().isChecked()).toBe(!initial)
// Restore the original value.
await capture().setChecked(initial)
await saveMemory().click()
await expect(dialog(page).locator('.settings-dirtybar')).toBeHidden({ timeout: 10000 })
await page.reload()
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
await railTab(page, 'Capabilities').click()
await expect(capture()).toBeVisible()
expect(await capture().isChecked()).toBe(initial)
})
test('mobile: full-screen dialog with horizontal section chips', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(CONTROL_URL + 'config')
await page.waitForSelector('.conn-pill', { timeout: 10000 })
await expect(dialog(page)).toBeVisible()
const modalBox = await dialog(page).boundingBox()
expect(modalBox?.width).toBe(390)
const rail = dialog(page).getByRole('tablist', { name: 'Settings sections' })
await expect(rail).toHaveAttribute('aria-orientation', 'horizontal')
await railTab(page, 'Capabilities').click()
await expect(dialog(page).getByRole('heading', { name: 'Web search' })).toBeVisible()
// No horizontal scroll on the page at 390px.
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
test('boolean settings render as the canonical control-switch and keep checkbox semantics', async ({ page }) => {
await openFromSidebar(page)
await railTab(page, 'Capabilities').click()
const capture = dialog(page).locator('input[name="setup_memory_auto_capture"]')
await expect(capture).toBeVisible()
// It carries the shared switch primitive, not a raw browser checkbox, and
// is exposed to assistive tech as a switch.
await expect(capture).toHaveClass(/control-switch/)
await expect(capture).toHaveAttribute('role', 'switch')
// The primitive CSS actually loaded and applied end-to-end (built bundle →
// gateway → page): the checkbox is restyled (appearance:none) and sized as
// a 36px switch track.
const appearance = await capture.evaluate((el) => getComputedStyle(el).appearance)
expect(appearance).toBe('none')
const box = await capture.boundingBox()
expect(box?.width).toBeGreaterThanOrEqual(34)
expect(box?.width).toBeLessThanOrEqual(40)
// Native checkbox semantics are preserved: it stays a real, keyboard-
// operable input (Space toggles, :checked round-trips).
const before = await capture.isChecked()
await capture.focus()
await page.keyboard.press('Space')
expect(await capture.isChecked()).toBe(!before)
await page.keyboard.press('Space')
expect(await capture.isChecked()).toBe(before)
})
test('every boolean control in Capabilities uses the switch primitive (no raw checkboxes)', async ({ page }) => {
await openFromSidebar(page)
await railTab(page, 'Capabilities').click()
await expect(dialog(page).getByRole('heading', { name: 'Web search' })).toBeVisible()
// Open the advanced search disclosure so its toggles render.
const advanced = dialog(page).getByText('Advanced search options')
if (await advanced.count()) await advanced.click()
const checkboxes = dialog(page).locator('input[type="checkbox"]')
const total = await checkboxes.count()
expect(total).toBeGreaterThan(0)
const switches = dialog(page).locator('input[type="checkbox"].control-switch')
expect(await switches.count()).toBe(total)
})
test('Appearance section applies theme instantly without a dirty bar', async ({ page }) => {
await openFromSidebar(page)
const appearanceTab = dialog(page).getByRole('tab', { name: 'Appearance' })
await expect(appearanceTab).toBeVisible()
await appearanceTab.click()
await expect(appearanceTab).toHaveAttribute('aria-selected', 'true')
await expect(dialog(page).getByRole('heading', { name: 'Appearance' })).toBeVisible()
// Theme radios flip the live document theme with no save step, and persist
// to the same store the sidebar shortcut reads.
const dark = dialog(page).getByRole('radio', { name: 'Dark' })
const light = dialog(page).getByRole('radio', { name: 'Light' })
await dark.click()
await expect(dark).toBeChecked()
await expect.poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme'))).toBe('dark')
await light.click()
await expect.poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme'))).toBe('light')
expect(await page.evaluate(() => localStorage.getItem('opensquilla-theme'))).toBe('light')
// The router visual-effects toggle is NOT duplicated here — it stays in the
// chat composer where its live state belongs.
await expect(dialog(page).locator('input[name="appearance_visual_effects"]')).toHaveCount(0)
// Client-only: theme changes never raise the settings dirty bar.
await expect(dialog(page).locator('.settings-dirtybar')).toBeHidden()
})
test('Advanced section surfaces homeless flags, applies instantly without a dirty bar', async ({ page }) => {
await openFromSidebar(page)
const advTab = dialog(page).getByRole('tab', { name: 'Advanced' })
await expect(advTab).toBeVisible()
await advTab.click()
await expect(advTab).toHaveAttribute('aria-selected', 'true')
await expect(dialog(page).getByRole('heading', { name: 'Advanced' })).toBeVisible()
// A boolean Labs flag is the shared switch and writes its raw '1'/'0' key.
const poll = dialog(page).locator('input[name="labs_approval_poll"]')
await expect(poll).toHaveAttribute('role', 'switch')
const before = await poll.isChecked()
await poll.click()
expect(await page.evaluate(() => localStorage.getItem('opensquilla.chat.approvalPoll'))).toBe(before ? '0' : '1')
// The answer-reveal window persists as a validated "min,max" string.
const min = dialog(page).locator('input[name="labs_reveal_min"]')
const max = dialog(page).locator('input[name="labs_reveal_max"]')
await min.fill('1000')
await max.fill('3000')
await max.blur()
expect(await page.evaluate(() => localStorage.getItem('opensquilla.chat.answerReveal'))).toBe('1000,3000')
// Client-only: none of this raises the settings dirty bar.
await expect(dialog(page).locator('.settings-dirtybar')).toBeHidden()
})
})