Validate replay sidecars and cross-copy failure facts, make browser console tripwires and macOS temp paths deterministic, and wait for asynchronous TUI resume details. Keep the owning docs, translations, and generated catalog aligned.
171 lines
9.6 KiB
TypeScript
171 lines
9.6 KiB
TypeScript
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
|
|
// section switching, both close paths), the Appearance preference row (the
|
|
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
|
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
|
// and the Language row (settings-scoped localization + persisted dsh.locale).
|
|
// Zero model calls: everything is pure client + persistence state on a blank
|
|
// frame, so there is no fixture and a stray stream would fail loud on the
|
|
// open llm seam.
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Browser, Page } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
import { join } from 'node:path'
|
|
import {
|
|
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
|
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
|
} from './scaffold.ts'
|
|
import { saveFailureShot } from './support.ts'
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
|
|
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
|
const MODE = webSnapshotMode()
|
|
|
|
describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold({})
|
|
browser = await chromium.launch()
|
|
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
await scaffold?.close()
|
|
})
|
|
|
|
it('opens the settings dialog, switches sections, and closes by every path', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
|
|
const trigger = page.getByRole('button', { name: '设置', exact: true })
|
|
expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog')
|
|
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
|
await trigger.click()
|
|
const dialog = page.getByRole('dialog', { name: '设置' })
|
|
await dialog.waitFor({ timeout: 10_000 })
|
|
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
|
// General is the active section by default; its skeleton rows plus the
|
|
// functional Language and Appearance rows render.
|
|
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
|
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
|
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
|
// Golden of the freshly opened dialog (default zh, General active).
|
|
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
|
|
// Section switch: aria-current moves; Models is deliberately empty.
|
|
await dialog.getByRole('button', { name: '模型' }).click()
|
|
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
|
|
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
|
|
// Close path 1: Escape.
|
|
await page.keyboard.press('Escape')
|
|
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
|
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
|
// Close path 2: the header close button (focus lands there on open).
|
|
await trigger.click()
|
|
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
|
|
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
|
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
|
|
await page.evaluate(() => ({
|
|
attr: document.body.hasAttribute('data-ds-dark-theme'),
|
|
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
|
|
stored: localStorage.getItem('dsh.theme'),
|
|
}))
|
|
// Pin the OS scheme to light so the default `system` preference resolves
|
|
// light and the dark flip below is unambiguously the gesture's doing.
|
|
await page.emulateMedia({ colorScheme: 'light' })
|
|
const light = await readState()
|
|
expect(light.attr).toBe(false)
|
|
|
|
await page.getByRole('button', { name: '设置', exact: true }).click()
|
|
const dialog = page.getByRole('dialog', { name: '设置' })
|
|
await dialog.waitFor({ timeout: 10_000 })
|
|
const darkCube = dialog.getByRole('button', { name: '深色' })
|
|
expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
|
|
await darkCube.click()
|
|
// The full cascade: pressed state, persisted preference, body attribute,
|
|
// alias token flip — all from one real user gesture.
|
|
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
|
const dark = await readState()
|
|
expect(dark.attr).toBe(true)
|
|
expect(dark.stored).toBe('dark')
|
|
expect(dark.token).not.toBe(light.token)
|
|
await page.keyboard.press('Escape')
|
|
|
|
// Reload: the preference survives boot (restore + presenter initial apply).
|
|
const warningStart = tripwire.warnings.length
|
|
await page.reload({ waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
|
await page.emulateMedia({ colorScheme: 'light' })
|
|
const reloaded = await readState()
|
|
expect(reloaded.attr).toBe(true)
|
|
expect(reloaded.stored).toBe('dark')
|
|
|
|
// `system` follows the emulated OS scheme (dark stays dark, light clears).
|
|
await page.getByRole('button', { name: '设置', exact: true }).click()
|
|
const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
|
|
await systemCube.click()
|
|
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
|
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
|
|
await page.emulateMedia({ colorScheme: 'dark' })
|
|
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
|
|
// Restore for the specs that follow: light preference beats the emulated
|
|
// dark OS scheme, leaving the shared page in the light default.
|
|
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
|
|
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
|
|
await page.keyboard.press('Escape')
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 90_000)
|
|
|
|
it('switches the settings surface language and persists dsh.locale', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
|
await page.getByRole('button', { name: '设置', exact: true }).click()
|
|
const zhDialog = page.getByRole('dialog', { name: '设置' })
|
|
await zhDialog.waitFor({ timeout: 10_000 })
|
|
// The Language selector pill shows the active locale's own name.
|
|
const selector = zhDialog.getByRole('button', { name: '中文' })
|
|
expect(await selector.getAttribute('aria-haspopup')).toBe('menu')
|
|
await selector.click()
|
|
await page.getByRole('menuitem', { name: 'English' }).click()
|
|
// The settings-owned copy re-registers localized: dialog title, nav,
|
|
// Appearance labels. (Only the settings namespaces are localized today —
|
|
// the rest of the app's copy is intentionally out of this row's scope.)
|
|
const enDialog = page.getByRole('dialog', { name: 'Settings' })
|
|
await enDialog.waitFor({ timeout: 10_000 })
|
|
expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
|
|
await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
|
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
|
|
// Reload keeps English; then restore zh so shared page state (and the
|
|
// other specs' 设置-anchored selectors + goldens) see the default again.
|
|
const warningStart = tripwire.warnings.length
|
|
await page.reload({ waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
|
const enTrigger = page.getByRole('button', { name: 'Settings' })
|
|
await enTrigger.waitFor({ timeout: 10_000 })
|
|
await enTrigger.click()
|
|
await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
|
|
await page.getByRole('menuitem', { name: '中文' }).click()
|
|
await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
|
|
expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
|
|
await page.keyboard.press('Escape')
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 90_000)
|
|
|
|
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
|
expect(tripwire.warnings).toEqual([])
|
|
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
|
|
})
|
|
})
|