settings-chrome.e2e.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. // Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
  2. // section switching, both close paths), the Appearance preference row (the
  3. // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
  4. // -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
  5. // and the Language row (settings-scoped localization + persisted dsh.locale).
  6. // Zero model calls: everything is pure client + persistence state on a blank
  7. // frame, so there is no fixture and a stray stream would fail loud on the
  8. // open llm seam.
  9. import { fileURLToPath } from 'node:url'
  10. import type { Browser, Page } from 'playwright'
  11. import { chromium } from 'playwright'
  12. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  13. import { join } from 'node:path'
  14. import {
  15. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  16. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  17. } from './scaffold.ts'
  18. import { saveFailureShot } from './support.ts'
  19. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
  20. const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
  21. const MODE = webSnapshotMode()
  22. describe('web e2e: settings modal, appearance gesture, language switch', () => {
  23. let scaffold: WebScaffold
  24. let browser: Browser
  25. let page: Page
  26. let tripwire: ReturnType<typeof watchConsole>
  27. beforeAll(async () => {
  28. scaffold = await launchWebScaffold({})
  29. browser = await chromium.launch()
  30. page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
  31. tripwire = watchConsole(page)
  32. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  33. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  34. }, 120_000)
  35. afterAll(async () => {
  36. await browser?.close()
  37. await scaffold?.close()
  38. })
  39. it('opens the settings dialog, switches sections, and closes by every path', async () => {
  40. onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
  41. const trigger = page.getByRole('button', { name: '设置', exact: true })
  42. expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog')
  43. expect(await trigger.getAttribute('aria-expanded')).toBe('false')
  44. await trigger.click()
  45. const dialog = page.getByRole('dialog', { name: '设置' })
  46. await dialog.waitFor({ timeout: 10_000 })
  47. expect(await trigger.getAttribute('aria-expanded')).toBe('true')
  48. // General is the active section by default; its skeleton rows plus the
  49. // functional Language and Appearance rows render.
  50. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
  51. await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
  52. await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
  53. // Golden of the freshly opened dialog (default zh, General active).
  54. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  55. await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
  56. // Section switch: aria-current moves; Models is deliberately empty.
  57. await dialog.getByRole('button', { name: '模型' }).click()
  58. await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
  59. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
  60. // Close path 1: Escape.
  61. await page.keyboard.press('Escape')
  62. await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
  63. expect(await trigger.getAttribute('aria-expanded')).toBe('false')
  64. // Close path 2: the header close button (focus lands there on open).
  65. await trigger.click()
  66. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '关闭' }).click()
  67. await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
  68. expect(tripwire.pageErrors).toEqual([])
  69. }, 60_000)
  70. it('flips the theme through the Appearance cubes and persists across reload', async () => {
  71. onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
  72. const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
  73. await page.evaluate(() => ({
  74. attr: document.body.hasAttribute('data-ds-dark-theme'),
  75. token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
  76. stored: localStorage.getItem('dsh.theme'),
  77. }))
  78. // Pin the OS scheme to light so the default `system` preference resolves
  79. // light and the dark flip below is unambiguously the gesture's doing.
  80. await page.emulateMedia({ colorScheme: 'light' })
  81. const light = await readState()
  82. expect(light.attr).toBe(false)
  83. await page.getByRole('button', { name: '设置', exact: true }).click()
  84. const dialog = page.getByRole('dialog', { name: '设置' })
  85. await dialog.waitFor({ timeout: 10_000 })
  86. const darkCube = dialog.getByRole('button', { name: '深色' })
  87. expect(await darkCube.getAttribute('aria-pressed')).toBe('false')
  88. await darkCube.click()
  89. // The full cascade: pressed state, persisted preference, body attribute,
  90. // alias token flip — all from one real user gesture.
  91. await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
  92. const dark = await readState()
  93. expect(dark.attr).toBe(true)
  94. expect(dark.stored).toBe('dark')
  95. expect(dark.token).not.toBe(light.token)
  96. await page.keyboard.press('Escape')
  97. // Reload: the preference survives boot (restore + presenter initial apply).
  98. const warningStart = tripwire.warnings.length
  99. await page.reload({ waitUntil: 'load' })
  100. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  101. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  102. await page.emulateMedia({ colorScheme: 'light' })
  103. const reloaded = await readState()
  104. expect(reloaded.attr).toBe(true)
  105. expect(reloaded.stored).toBe('dark')
  106. // `system` follows the emulated OS scheme (dark stays dark, light clears).
  107. await page.getByRole('button', { name: '设置', exact: true }).click()
  108. const systemCube = page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '跟随系统' })
  109. await systemCube.click()
  110. await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
  111. await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
  112. await page.emulateMedia({ colorScheme: 'dark' })
  113. await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
  114. // Restore for the specs that follow: light preference beats the emulated
  115. // dark OS scheme, leaving the shared page in the light default.
  116. await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
  117. await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
  118. await page.keyboard.press('Escape')
  119. expect(tripwire.pageErrors).toEqual([])
  120. }, 90_000)
  121. it('switches the settings surface language and persists dsh.locale', async () => {
  122. onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
  123. await page.getByRole('button', { name: '设置', exact: true }).click()
  124. const zhDialog = page.getByRole('dialog', { name: '设置' })
  125. await zhDialog.waitFor({ timeout: 10_000 })
  126. // The Language selector pill shows the active locale's own name.
  127. const selector = zhDialog.getByRole('button', { name: '中文' })
  128. expect(await selector.getAttribute('aria-haspopup')).toBe('menu')
  129. await selector.click()
  130. await page.getByRole('menuitem', { name: 'English' }).click()
  131. // The settings-owned copy re-registers localized: dialog title, nav,
  132. // Appearance labels. (Only the settings namespaces are localized today —
  133. // the rest of the app's copy is intentionally out of this row's scope.)
  134. const enDialog = page.getByRole('dialog', { name: 'Settings' })
  135. await enDialog.waitFor({ timeout: 10_000 })
  136. expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
  137. await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
  138. expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en')
  139. // Reload keeps English; then restore zh so shared page state (and the
  140. // other specs' 设置-anchored selectors + goldens) see the default again.
  141. const warningStart = tripwire.warnings.length
  142. await page.reload({ waitUntil: 'load' })
  143. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  144. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  145. const enTrigger = page.getByRole('button', { name: 'Settings' })
  146. await enTrigger.waitFor({ timeout: 10_000 })
  147. await enTrigger.click()
  148. await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
  149. await page.getByRole('menuitem', { name: '中文' }).click()
  150. await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 })
  151. expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh')
  152. await page.keyboard.press('Escape')
  153. expect(tripwire.pageErrors).toEqual([])
  154. }, 90_000)
  155. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  156. expect(tripwire.warnings).toEqual([])
  157. await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
  158. })
  159. })