lifecycle-chrome.e2e.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  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 + SSE + 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 } from 'playwright'
  17. import { chromium } from 'playwright'
  18. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  19. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  20. import {
  21. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  22. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  23. } from './scaffold.ts'
  24. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  25. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
  26. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  27. const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
  28. const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
  29. const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
  30. const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
  31. // Post-reload golden: the same settled conversation rebuilt purely from
  32. // persistence + history — byte-equal rendering is exactly the recovery claim.
  33. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
  34. const MODE = webSnapshotMode()
  35. const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
  36. const REPLAY_PACE_MS = 100
  37. describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
  38. let scaffold: WebScaffold
  39. let browser: Browser
  40. let page: Page
  41. let tripwire: ReturnType<typeof watchConsole>
  42. const sessionEvents: SessionEvent[] = []
  43. beforeAll(async () => {
  44. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
  45. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  46. browser = await chromium.launch()
  47. page = await newEnglishPage(browser)
  48. tripwire = watchConsole(page)
  49. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  50. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  51. // Fresh world: connect a Workspace so the composer scenarios start live.
  52. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  53. }, 120_000)
  54. afterAll(async () => {
  55. await browser?.close()
  56. await scaffold?.close()
  57. })
  58. it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
  59. onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
  60. const launcher = page.getByRole('button', { name: 'Commands' })
  61. await launcher.click()
  62. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  63. await menu.waitFor({ timeout: 10_000 })
  64. const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  65. await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
  66. expect(snapshot).toContain('text: Commands')
  67. expect(snapshot).not.toContain('text: Skills')
  68. expect(snapshot).not.toContain('text: Subagents')
  69. const launchedBox = await menu.boundingBox()
  70. await page.locator('textarea').first().press('Escape')
  71. await expect.poll(() => menu.count()).toBe(0)
  72. const input = page.locator('textarea').first()
  73. await input.fill('/')
  74. await menu.waitFor({ timeout: 10_000 })
  75. const typedBox = await menu.boundingBox()
  76. expect(launchedBox).not.toBeNull()
  77. expect(typedBox).not.toBeNull()
  78. expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
  79. expect(Math.abs(
  80. launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
  81. )).toBeLessThan(1)
  82. await input.fill('/cpt')
  83. await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
  84. 'compactCompact older conversation history',
  85. ])
  86. const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  87. await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
  88. await input.fill('')
  89. await expect.poll(() => menu.count()).toBe(0)
  90. })
  91. it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
  92. const activeScaffold = await launchWebScaffold()
  93. const activePage = await newEnglishPage(browser)
  94. const activeTripwire = watchConsole(activePage)
  95. try {
  96. await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
  97. await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  98. await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
  99. const input = activePage.locator('textarea').first()
  100. await activePage.getByRole('button', { name: 'Commands' }).click()
  101. const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
  102. await menu.waitFor({ timeout: 10_000 })
  103. await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
  104. await expect.poll(() => input.inputValue()).toBe('/plan ')
  105. await input.press('Enter')
  106. const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
  107. await planButton.waitFor({ timeout: 10_000 })
  108. // The golden encodes an empty composer, and the button arriving does not
  109. // mean the submitted text is gone yet: under load the capture can catch
  110. // a textbox still holding `/plan`.
  111. await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
  112. const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
  113. await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
  114. const planStyle = await planButton.evaluate((element) => {
  115. const probe = document.createElement('span')
  116. probe.style.color = 'var(--dsw-alias-state-warn-label)'
  117. probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
  118. document.body.append(probe)
  119. const actual = getComputedStyle(element)
  120. const reference = getComputedStyle(probe)
  121. const result = {
  122. color: actual.color,
  123. backgroundColor: actual.backgroundColor,
  124. borderRadius: actual.borderRadius,
  125. fontSize: actual.fontSize,
  126. referenceColor: reference.color,
  127. referenceBackgroundColor: reference.backgroundColor,
  128. }
  129. probe.remove()
  130. return result
  131. })
  132. expect(planStyle.color).toBe(planStyle.referenceColor)
  133. expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
  134. expect(planStyle.borderRadius).toBe('999px')
  135. expect(planStyle.fontSize).toBe('13px')
  136. await planButton.click()
  137. await expect.poll(() => planButton.count()).toBe(0)
  138. expect(activeTripwire.pageErrors).toEqual([])
  139. expect(activeTripwire.warnings).toEqual([])
  140. } catch (error) {
  141. await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
  142. throw error
  143. } finally {
  144. await activePage.close()
  145. await activeScaffold.close()
  146. }
  147. })
  148. it('sends the first prompt from the empty-state hero (all modes)', async () => {
  149. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
  150. if (MODE !== 'record') {
  151. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  152. }
  153. // The blank frame renders the hero, not the resident composer: the
  154. // headline plus the guidance placeholder are the empty state's anchors.
  155. await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
  156. const input = page.locator('textarea').first()
  157. await input.waitFor({ timeout: 10_000 })
  158. if (MODE !== 'record') {
  159. // Golden of the hero's stable waiting state (captured before any send;
  160. // the conversation-region goldens belong to the other scenarios).
  161. const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd)
  162. await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
  163. }
  164. const settled = scaffold.whenTurnSettled()
  165. await input.fill(PROMPT)
  166. const observeTurn = async () => {
  167. const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
  168. if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
  169. try {
  170. await input.press('Enter')
  171. if (MODE !== 'record') {
  172. const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
  173. await expect.poll(async () => await liveTail.evaluate(element => (
  174. element.scrollWidth > element.clientWidth
  175. && element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
  176. )), { timeout: 10_000, interval: 10 }).toBe(true)
  177. }
  178. return await settled
  179. } finally {
  180. if (MODE !== 'record') await page.setViewportSize(originalViewport)
  181. }
  182. }
  183. const sessionId = await observeTurn()
  184. if (MODE === 'record') {
  185. await recordFixture(scaffold, sessionId, FIXTURE)
  186. }
  187. }, 200_000)
  188. it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
  189. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
  190. // Browser: the sidebar tree now carries the auto-created workspace group
  191. // with its one session, and the opened session is the selected row.
  192. await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  193. await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
  194. await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  195. // Host: the session's durable header cwd is the folder the workspace
  196. // flow created and adopted (<workspaceCwd>/workspace) — the proof the
  197. // send went through workspace materialization rather than a bare
  198. // default-cwd session.
  199. const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
  200. expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
  201. const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
  202. expect(turnEnds).toHaveLength(1)
  203. expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
  204. }, 60_000)
  205. it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
  206. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
  207. const warningStart = tripwire.warnings.length
  208. await page.reload({ waitUntil: 'load' })
  209. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  210. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  211. // Selection persisted (dsh.sessions.current) and history replayed: the
  212. // recorded turn re-renders from session.history with zero model calls —
  213. // the replay cursor was fully consumed before the reload, so any stray
  214. // request would fail the scenario loudly at close().
  215. await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  216. await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
  217. // Golden of the recovered conversation region: rebuilt from the log, it
  218. // must render the same settled transcript the live turn produced.
  219. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  220. await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
  221. expect(tripwire.pageErrors).toEqual([])
  222. }, 90_000)
  223. it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
  224. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
  225. // This scenario pins the ThemeService's DOM contract directly (the
  226. // body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
  227. // user gesture above it (Settings -> Appearance cubes) is owned by
  228. // settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
  229. // pinned independently of the settings surface's own lifecycle.
  230. const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> =>
  231. await page.evaluate(() => {
  232. const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body
  233. return {
  234. token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
  235. sidebarBg: getComputedStyle(sidebar).backgroundColor,
  236. bodyBg: getComputedStyle(document.body).backgroundColor,
  237. }
  238. })
  239. const light = await sample()
  240. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  241. const dark = await sample()
  242. // The alias token itself must flip — the cascade's root fact.
  243. expect(dark.token).not.toBe(light.token)
  244. // And a real painted surface must consume it (not just variables in a
  245. // void): at least one of the sampled backgrounds repaints.
  246. expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true)
  247. // Removing the attribute restores the light values exactly (the palettes
  248. // live in one stylesheet; activation is attribute-only by design).
  249. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  250. const restored = await sample()
  251. expect(restored).toEqual(light)
  252. expect(tripwire.pageErrors).toEqual([])
  253. }, 60_000)
  254. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  255. expect(tripwire.warnings).toEqual([])
  256. await assertFixtureInventory(SNAPSHOT_DIR, [
  257. 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
  258. ])
  259. })
  260. })