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() }) })