lifecycle-chrome.e2e.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. // Web e2e scenarios: lifecycle & chrome — the workspace-aware first-send
  2. // flow over the real wire, reload recovery, and the dark-mode token cascade.
  3. // One tiny recorded turn (text-only) drives the whole spec: the empty-state
  4. // hero materializes a real Workspace + Session on first send (the jsdom
  5. // workspace-flow suite pins the object-layer state machine over the fixture
  6. // client; THIS spec pins the same flow through HTTP RPC + WebSocket + the host
  7. // gateway), reload replays everything from the log (zero further model
  8. // calls), and the theme scenario proves the shipped dark palette actually
  9. // cascades: attribute -> alias token flip -> painted surface change. No
  10. // theme/layout golden: aria snapshots are color-blind (lane scope: the
  11. // browser-e2e-lane Agent Note); the hero's waiting state gets the one golden
  12. // here.
  13. import { readFile } from 'node:fs/promises'
  14. import { fileURLToPath } from 'node:url'
  15. import { join } from 'node:path'
  16. import type { Browser, Page, WebSocketRoute } from 'playwright'
  17. import { chromium } from 'playwright'
  18. import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
  19. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  20. import {
  21. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria,
  22. captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  23. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  24. } from './scaffold.ts'
  25. import {
  26. connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
  27. } from './support.ts'
  28. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
  29. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  30. const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
  31. const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
  32. const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
  33. const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md')
  34. const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
  35. const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
  36. const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md')
  37. // Post-reload golden: the same settled conversation rebuilt purely from
  38. // persistence + history — byte-equal rendering is exactly the recovery claim.
  39. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
  40. const RELOADED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded-expanded.expected.md')
  41. const MODE = webSnapshotMode()
  42. const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
  43. const REPLAY_PACE_MS = 100
  44. describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
  45. let scaffold: WebScaffold
  46. let browser: Browser
  47. let page: Page
  48. let tripwire: ReturnType<typeof watchConsole>
  49. const sessionEvents: SessionEvent[] = []
  50. beforeAll(async () => {
  51. scaffold = await launchWebScaffold(MODE === 'record'
  52. ? {}
  53. : { replayFixture: FIXTURE, replayOverride: REPLAY_OVERRIDE, paceMs: REPLAY_PACE_MS })
  54. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  55. browser = await chromium.launch()
  56. page = await newEnglishPage(browser)
  57. tripwire = watchConsole(page)
  58. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  59. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  60. // Fresh world: connect a Workspace so the composer scenarios start live.
  61. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  62. }, 120_000)
  63. afterAll(async () => {
  64. await browser?.close()
  65. await scaffold?.close()
  66. })
  67. it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
  68. onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
  69. const input = page.locator('[data-composer-input]').first()
  70. onTestFinished(async () => {
  71. await input.press('Escape')
  72. await writeComposerDraft(page, input, '')
  73. await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' })
  74. })
  75. const launcher = page.getByRole('button', { name: 'Add files or run commands' })
  76. await launcher.click()
  77. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  78. await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
  79. await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
  80. const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  81. await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
  82. expect(snapshot).toContain('text: Add')
  83. expect(snapshot).toContain('text: Commands')
  84. expect(snapshot).not.toContain('text: Skills')
  85. expect(snapshot).not.toContain('text: Subagents')
  86. const launchedBox = await menu.boundingBox()
  87. await page.locator('[data-composer-input]').first().press('Escape')
  88. await expect.poll(() => menu.count()).toBe(0)
  89. await writeComposerDraft(page, input, '/')
  90. await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
  91. await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
  92. const typedBox = await menu.boundingBox()
  93. expect(launchedBox).not.toBeNull()
  94. expect(typedBox).not.toBeNull()
  95. expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
  96. expect(Math.abs(
  97. launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
  98. )).toBeLessThan(1)
  99. await writeComposerDraft(page, input, '/cpt')
  100. await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
  101. 'CompactCompact older conversation history',
  102. ])
  103. const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  104. await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
  105. await writeComposerDraft(page, input, '')
  106. await expect.poll(() => menu.count()).toBe(0)
  107. })
  108. it.skipIf(MODE === 'record')('localizes slash-command descriptions from the browser language', async () => {
  109. const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  110. const zhTripwire = watchConsole(zhPage)
  111. onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-command-menu-zh'))
  112. try {
  113. await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  114. await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  115. const launcher = zhPage.getByRole('button', { name: '添加文件或调用指令' })
  116. await launcher.click()
  117. const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
  118. await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
  119. await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
  120. const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd)
  121. await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE)
  122. expect(zhTripwire.pageErrors).toEqual([])
  123. expect(zhTripwire.warnings).toEqual([])
  124. } finally {
  125. await zhPage.close()
  126. }
  127. })
  128. it.skipIf(MODE === 'record').each([
  129. { locale: 'en-US', token: '/goal', row: 'Goal Set or view the goal for a long-running task', hint: 'describe the objective for a long-running task' },
  130. { locale: 'en-US', token: '/plan', row: 'Plan Enter or leave plan mode', hint: 'describe your task to generate plan' },
  131. { locale: ZH_BROWSER_LOCALE, token: '/目标', row: '目标 goal 设置或查看长期任务目标', hint: '输入目标,智能体将持续执行' },
  132. { locale: ZH_BROWSER_LOCALE, token: '/计划', row: '计划 plan 进入或退出计划模式', hint: '描述你的任务以生成计划' },
  133. ])('keeps $token claimed across separator edits and hides hints during IME composition', async ({ locale, token, row, hint }) => {
  134. const inputPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale })
  135. const inputTripwire = watchConsole(inputPage)
  136. onTestFailed(() => saveFailureShot(inputPage, `web-e2e-command-input-${locale}-${token.slice(1)}`))
  137. try {
  138. await inputPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  139. await inputPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  140. const input = inputPage.locator('[data-composer-input]').first()
  141. await writeComposerDraft(inputPage, input, '/')
  142. await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click()
  143. await expect.poll(() => input.textContent()).toBe(`${token} `)
  144. await input.press('End')
  145. await inputPage.keyboard.insertText('这是任务')
  146. await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`)
  147. for (let i = 0; i < 5; i++) await input.press('Backspace')
  148. const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent()
  149. await expect.poll(() => input.textContent()).toBe(token)
  150. await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
  151. await expect.poll(tokenText).toBe(token)
  152. await input.press('Space')
  153. await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed')
  154. await expect.poll(() => input.textContent()).toBe(`${token} `)
  155. await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token)
  156. const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content)
  157. await expect.poll(shownHint).toBe(JSON.stringify(hint))
  158. const cdp = await inputPage.context().newCDPSession(inputPage)
  159. await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
  160. await expect.poll(shownHint).toBe('none')
  161. await cdp.send('Input.imeSetComposition', { text: 'zh', selectionStart: 2, selectionEnd: 2 })
  162. await expect.poll(shownHint).toBe('none')
  163. await cdp.send('Input.insertText', { text: '这' })
  164. await expect.poll(() => input.textContent()).toBe(`${token} 这`)
  165. await expect.poll(shownHint).toBe('none')
  166. await input.press('Backspace')
  167. await expect.poll(shownHint).toBe(JSON.stringify(hint))
  168. await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
  169. await expect.poll(shownHint).toBe('none')
  170. await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
  171. await expect.poll(shownHint).toBe(JSON.stringify(hint))
  172. await input.press('Backspace')
  173. await input.press('Backspace')
  174. await expect.poll(() => input.getAttribute('data-phase')).toBe('plain')
  175. await writeComposerDraft(inputPage, input, '')
  176. const placeholder = inputPage.locator('[data-composer-placeholder]').first()
  177. await expect.poll(() => placeholder.isVisible()).toBe(true)
  178. await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 })
  179. await expect.poll(() => placeholder.isVisible()).toBe(false)
  180. await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 })
  181. await expect.poll(() => placeholder.isVisible()).toBe(true)
  182. await cdp.detach()
  183. expect(inputTripwire.pageErrors).toEqual([])
  184. expect(inputTripwire.warnings).toEqual([])
  185. } finally {
  186. await inputPage.close()
  187. }
  188. })
  189. it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
  190. const activeScaffold = await launchWebScaffold()
  191. const activePage = await newEnglishPage(browser)
  192. const activeTripwire = watchConsole(activePage)
  193. try {
  194. await activePage.goto(activeScaffold.authenticatedUrl, { waitUntil: 'load' })
  195. await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  196. await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
  197. const input = activePage.locator('[data-composer-input]').first()
  198. await activePage.getByRole('button', { name: 'Add files or run commands' }).click()
  199. const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
  200. await menu.waitFor({ timeout: 10_000 })
  201. await menu.getByRole('option', { name: 'Plan Enter or leave plan mode' }).click()
  202. await expect.poll(() => input.textContent()).toBe('/plan ')
  203. await input.press('Enter')
  204. const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
  205. await planButton.waitFor({ timeout: 10_000 })
  206. // The golden encodes an empty composer, and the button arriving does not
  207. // mean the submitted text is gone yet: under load the capture can catch
  208. // a textbox still holding `/plan`.
  209. await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
  210. const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
  211. await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
  212. const planStyle = await planButton.evaluate((element) => {
  213. const probe = document.createElement('span')
  214. probe.style.color = 'var(--dsw-alias-state-warn-label)'
  215. probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
  216. document.body.append(probe)
  217. const actual = getComputedStyle(element)
  218. const reference = getComputedStyle(probe)
  219. const result = {
  220. color: actual.color,
  221. backgroundColor: actual.backgroundColor,
  222. borderRadius: actual.borderRadius,
  223. fontSize: actual.fontSize,
  224. referenceColor: reference.color,
  225. referenceBackgroundColor: reference.backgroundColor,
  226. }
  227. probe.remove()
  228. return result
  229. })
  230. expect(planStyle.color).toBe(planStyle.referenceColor)
  231. expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
  232. expect(planStyle.borderRadius).toBe('999px')
  233. expect(planStyle.fontSize).toBe('13px')
  234. await planButton.click()
  235. await expect.poll(() => planButton.count()).toBe(0)
  236. expect(activeTripwire.pageErrors).toEqual([])
  237. expect(activeTripwire.warnings).toEqual([])
  238. } catch (error) {
  239. await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
  240. throw error
  241. } finally {
  242. await activePage.close()
  243. await activeScaffold.close()
  244. }
  245. })
  246. it('sends the first prompt from the empty-state hero (all modes)', async () => {
  247. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
  248. if (MODE !== 'record') {
  249. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  250. }
  251. // The blank frame renders the hero, not the resident composer: the
  252. // headline plus the guidance placeholder are the empty state's anchors.
  253. await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
  254. const input = page.locator('[data-composer-input]').first()
  255. await input.waitFor({ timeout: 10_000 })
  256. if (MODE !== 'record') {
  257. await page.getByText('Into the Unknown', { exact: false }).hover()
  258. await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
  259. // Golden of the hero's stable waiting state (captured before any send;
  260. // the conversation-region goldens belong to the other scenarios).
  261. const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd)
  262. await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
  263. }
  264. const settled = scaffold.whenTurnSettled()
  265. await writeComposerDraft(page, input, PROMPT)
  266. const observeTurn = async () => {
  267. const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
  268. if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
  269. const observedReasoning = Promise.withResolvers<undefined>()
  270. const releaseStream = MODE === 'record' ? undefined : scaffold.ctx.on('llm/stream', async function* (_options, next) {
  271. let reasoning = false
  272. for await (const chunk of next()) {
  273. if (reasoning && chunk.type !== 'reasoning-delta') {
  274. await observedReasoning.promise
  275. }
  276. if (chunk.type === 'reasoning-delta') reasoning = true
  277. yield chunk
  278. }
  279. })
  280. try {
  281. await input.press('Enter')
  282. if (MODE !== 'record') {
  283. const thinking = page.locator('[data-variant="think"][data-state="running"]')
  284. await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
  285. const liveTail = thinking.locator('[data-follow-end]')
  286. await expect.poll(async () => {
  287. if (await liveTail.count() !== 1) return false
  288. return await liveTail.evaluate((element) => {
  289. const text = element.firstElementChild
  290. if (!(text instanceof HTMLElement)) return false
  291. const viewport = element.getBoundingClientRect()
  292. const content = text.getBoundingClientRect()
  293. return content.width > viewport.width && Math.abs(content.right - viewport.right) <= 1
  294. })
  295. }, { timeout: 10_000, interval: 10 }).toBe(true)
  296. }
  297. observedReasoning.resolve(undefined)
  298. return await settled
  299. } finally {
  300. observedReasoning.resolve(undefined)
  301. releaseStream?.()
  302. if (MODE !== 'record') await page.setViewportSize(originalViewport)
  303. }
  304. }
  305. const sessionId = await observeTurn()
  306. if (MODE === 'record') {
  307. await recordFixture(scaffold, sessionId, FIXTURE)
  308. }
  309. }, 200_000)
  310. it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
  311. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
  312. // Browser: the sidebar tree now carries the auto-created workspace group
  313. // with its one session, and the opened session is the selected row. The
  314. // compact layout dropped group session counts, so the group row itself is
  315. // the barrier.
  316. await expect.poll(
  317. () => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(),
  318. { timeout: 15_000 },
  319. ).toBeGreaterThanOrEqual(1)
  320. await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
  321. await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  322. // The usage pill's one label span concatenates the billed total and the cache-hit share.
  323. await expect.poll(() => page.getByRole('button', { name: /Cache hit 99\.5%/ }).count(), { timeout: 15_000 }).toBe(1)
  324. // Host: the session's durable header cwd is the folder the workspace
  325. // flow created and adopted (<workspaceCwd>/workspace) — the proof the
  326. // send went through workspace materialization rather than a bare
  327. // default-cwd session.
  328. const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
  329. expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
  330. const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
  331. expect(turnEnds).toHaveLength(1)
  332. expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
  333. }, 60_000)
  334. it.skipIf(MODE === 'record')('pins an open Think header to the conversation scrollport (real layout)', async () => {
  335. onTestFailed(() => saveFailureShot(page, 'web-e2e-think-sticky'))
  336. // The settled turn collapses its process row, which hides the Think row.
  337. // The fixture's recorded reasoning is one line, too short to overflow the
  338. // scrollport, so this case proves the CSS resolves onto the Think header in
  339. // a real browser (jsdom computes no sticky layout); the pinned-while-
  340. // scrolling and z-rank evidence belongs to the compaction path in
  341. // seeded-history.e2e.ts, whose summary length that suite controls.
  342. const thinkRow = page.locator('[data-variant="think"]').first()
  343. await thinkRow.waitFor({ state: 'attached', timeout: 15_000 })
  344. const process = page.locator('[data-turn-process]').first()
  345. const processWasOpen = await process.getAttribute('aria-expanded') === 'true'
  346. try {
  347. await expandOwningTurnProcess(page, thinkRow)
  348. const collapsedHeader = thinkRow.locator('[data-disclosure-row]').first()
  349. await collapsedHeader.waitFor({ timeout: 10_000 })
  350. // Collapsed, the rule's `data-open` gate is absent and the header stays in
  351. // flow. It is `relative` here — the row is the sweep-glare overlay anchor
  352. // — so the assertion is the absence of `sticky`, not a specific value.
  353. expect(await collapsedHeader.evaluate(element => getComputedStyle(element).position)).not.toBe('sticky')
  354. await collapsedHeader.click()
  355. const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]').first()
  356. await openHeader.waitFor({ timeout: 10_000 })
  357. const openStyle = await openHeader.evaluate((element) => {
  358. const style = getComputedStyle(element)
  359. return { position: style.position, top: style.top }
  360. })
  361. expect(openStyle.position).toBe('sticky')
  362. expect(openStyle.top).toBe('0px')
  363. } finally {
  364. // Restore the settled state the reload goldens below are captured in.
  365. const openThinkRow = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]')
  366. if (await openThinkRow.count() > 0) await openThinkRow.first().click()
  367. if (!processWasOpen && await process.getAttribute('aria-expanded') === 'true') await process.click()
  368. }
  369. await expect.poll(() => page.locator('[data-variant="think"] [data-open]').count(), { timeout: 5_000 }).toBe(0)
  370. expect(tripwire.pageErrors).toEqual([])
  371. }, 60_000)
  372. it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
  373. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
  374. const warningStart = tripwire.warnings.length
  375. await page.reload({ waitUntil: 'load' })
  376. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  377. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  378. // Selection persisted (dsh.sessions.current) and history replayed: the
  379. // recorded turn re-renders from a Session Controller page with zero model calls —
  380. // the replay cursor was fully consumed before the reload, so any stray
  381. // request would fail the scenario loudly at close().
  382. await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  383. await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
  384. // Golden of the recovered conversation region: rebuilt from the log, it
  385. // must render the same settled transcript the live turn produced.
  386. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  387. await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
  388. const expanded = await captureExpandedTurnProcessAria(
  389. page,
  390. '[class*="centerCol"]',
  391. scaffold.workspaceCwd,
  392. )
  393. await compareOrRefreshGolden(RELOADED_EXPANDED_EXPECTED, expanded, MODE)
  394. expect(tripwire.pageErrors).toEqual([])
  395. }, 90_000)
  396. it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
  397. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
  398. // This scenario pins the ThemeRuntime's DOM contract directly (the
  399. // body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
  400. // user gesture above it (Settings -> Appearance cubes) is owned by
  401. // settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
  402. // pinned independently of the settings surface's own lifecycle.
  403. const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> =>
  404. await page.evaluate(() => {
  405. const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body
  406. return {
  407. token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
  408. sidebarBg: getComputedStyle(sidebar).backgroundColor,
  409. bodyBg: getComputedStyle(document.body).backgroundColor,
  410. }
  411. })
  412. const light = await sample()
  413. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  414. const dark = await sample()
  415. // The alias token itself must flip — the cascade's root fact.
  416. expect(dark.token).not.toBe(light.token)
  417. // And a real painted surface must consume it (not just variables in a
  418. // void): at least one of the sampled backgrounds repaints.
  419. expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true)
  420. // Removing the attribute restores the light values exactly (the palettes
  421. // live in one stylesheet; activation is attribute-only by design).
  422. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  423. const restored = await sample()
  424. expect(restored).toEqual(light)
  425. expect(tripwire.pageErrors).toEqual([])
  426. }, 60_000)
  427. it.skipIf(MODE === 'record')('shows automatic and user-requested connection recovery beside Settings', async () => {
  428. const recoveryPage = await newEnglishPage(browser)
  429. const recoveryTripwire = watchConsole(recoveryPage)
  430. const sockets: WebSocketRoute[] = []
  431. let rejectConnections = false
  432. let holdConnections = false
  433. await recoveryPage.routeWebSocket('**/api/remote.mux', (route) => {
  434. sockets.push(route)
  435. if (rejectConnections || holdConnections) return
  436. route.connectToServer()
  437. })
  438. onTestFailed(() => saveFailureShot(recoveryPage, 'web-e2e-connection-recovery'))
  439. try {
  440. await recoveryPage.clock.install()
  441. await recoveryPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  442. await recoveryPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  443. await expect.poll(() => sockets.length).toBe(1)
  444. rejectConnections = true
  445. await recoveryPage.context().setOffline(true)
  446. await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(false)
  447. const offline = recoveryPage.getByRole('button', {
  448. name: 'Disconnected, reconnect now', exact: true,
  449. })
  450. await offline.waitFor({ timeout: 2_000 })
  451. await recoveryPage.clock.fastForward(60_000)
  452. expect(sockets).toHaveLength(1)
  453. await recoveryPage.context().setOffline(false)
  454. await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
  455. const connecting = recoveryPage.getByRole('button', {
  456. name: 'Reconnecting, reconnect now', exact: true,
  457. })
  458. await connecting.waitFor({ timeout: 10_000 })
  459. expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
  460. const connectingGeometry = await connectionIndicatorGeometry(connecting)
  461. expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
  462. // Hover keeps the state label; the pill never swaps copy or resizes.
  463. await connecting.hover()
  464. expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
  465. expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
  466. await recoveryPage.mouse.move(0, 0)
  467. for (let count = 2; count <= 9; count++) {
  468. await recoveryPage.clock.fastForward(10_000)
  469. await expect.poll(() => sockets.length).toBe(count)
  470. if (count === 2) {
  471. await recoveryPage.clock.fastForward(1_000)
  472. expect(sockets).toHaveLength(count)
  473. }
  474. await sockets.at(-1)!.close({ code: 4001, reason: 'connection recovery test' })
  475. // Drain the close event's promise continuations before advancing the next retry timer.
  476. await recoveryPage.evaluate(() => {})
  477. }
  478. const indicator = connecting
  479. expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
  480. expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
  481. const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
  482. await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
  483. const expectedColors = await recoveryPage.evaluate(() => {
  484. const probe = document.createElement('span')
  485. probe.style.color = 'var(--dsw-alias-state-warn-label)'
  486. probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
  487. document.body.append(probe)
  488. const reference = getComputedStyle(probe)
  489. const result = {
  490. background: reference.backgroundColor,
  491. color: reference.color,
  492. }
  493. probe.remove()
  494. return result
  495. })
  496. // CSS transitions use the browser's animation clock independently of the mocked retry timers.
  497. await expect.poll(() => indicator.evaluate((element) => {
  498. const actual = getComputedStyle(element)
  499. return { background: actual.backgroundColor, color: actual.color }
  500. })).toEqual(expectedColors)
  501. expect(await indicator.locator('svg').count()).toBe(1)
  502. expect(await indicator.getAttribute('title')).toBeNull()
  503. rejectConnections = false
  504. await recoveryPage.clock.fastForward(10_000)
  505. await expect.poll(() => sockets.length).toBe(10)
  506. const automaticRecovery = recoveryPage.getByRole('status')
  507. await automaticRecovery.waitFor({ timeout: 10_000 })
  508. expect(await automaticRecovery.innerText()).toBe('Connected')
  509. await recoveryPage.clock.fastForward(2_000)
  510. await automaticRecovery.waitFor({ state: 'detached' })
  511. holdConnections = true
  512. await sockets.at(-1)!.close({ code: 4001, reason: 'manual recovery test' })
  513. await connecting.waitFor()
  514. await recoveryPage.clock.fastForward(500)
  515. await expect.poll(() => sockets.length).toBe(11)
  516. await indicator.hover()
  517. expect(await indicator.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
  518. const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
  519. await recoveryPage.mouse.down()
  520. await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor))
  521. .not.toBe(hoverBackground)
  522. holdConnections = false
  523. await recoveryPage.mouse.up()
  524. await expect.poll(() => sockets.length).toBe(12)
  525. const recovered = recoveryPage.getByRole('status')
  526. await recovered.waitFor({ timeout: 10_000 })
  527. expect(await recovered.innerText()).toBe('Connected')
  528. // The pill sizes to its current label; chrome height and icon box stay fixed.
  529. const recoveredGeometry = await connectionIndicatorGeometry(recovered)
  530. expect(recoveredGeometry.outer[3]).toBe(connectingGeometry.outer[3])
  531. expect(recoveredGeometry.icon).toEqual(connectingGeometry.icon)
  532. expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
  533. await recoveryPage.clock.fastForward(2_000)
  534. await recovered.waitFor({ state: 'detached', timeout: 5_000 })
  535. expect(recoveryTripwire.pageErrors).toEqual([])
  536. expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #/i.test(warning)))
  537. .toHaveLength(11)
  538. } finally {
  539. await recoveryPage.close()
  540. }
  541. }, 60_000)
  542. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  543. expect(tripwire.warnings).toEqual([])
  544. await assertFixtureInventory(SNAPSHOT_DIR, [
  545. 'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md',
  546. 'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md',
  547. 'hero.expected.md', 'plan-active.expected.md',
  548. 'reloaded.expected.md', 'reloaded-expanded.expected.md',
  549. ])
  550. })
  551. })
  552. async function connectionIndicatorGeometry(locator: ReturnType<Page['getByRole']>): Promise<{
  553. readonly outer: readonly number[]
  554. readonly icon: readonly number[]
  555. readonly label: readonly number[]
  556. }> {
  557. return await locator.evaluate((element) => {
  558. const outer = element.getBoundingClientRect()
  559. const icon = element.children.item(0)?.getBoundingClientRect()
  560. const label = element.children.item(1)?.getBoundingClientRect()
  561. if (icon === undefined || label === undefined) throw new Error('connection indicator children missing')
  562. const rounded = (values: readonly number[]): readonly number[] => values.map(value => Math.round(value * 100) / 100)
  563. return {
  564. outer: rounded([outer.x, outer.y, outer.width, outer.height]),
  565. icon: rounded([icon.x - outer.x, icon.y - outer.y, icon.width, icon.height]),
  566. label: rounded([label.x - outer.x, label.y - outer.y, label.width, label.height]),
  567. }
  568. })
  569. }
  570. async function connectionIndicatorTextAlignment(
  571. locator: ReturnType<Page['getByRole']>,
  572. ): Promise<string> {
  573. return await locator.evaluate((element) => {
  574. const label = element.children.item(1)
  575. if (label === null) throw new Error('connection indicator label missing')
  576. return getComputedStyle(label).textAlign
  577. })
  578. }