lifecycle-chrome.e2e.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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. Per the
  10. // lane's scope ruling there is no theme/layout golden (aria is color-blind);
  11. // the hero's waiting state gets the one golden here.
  12. import { readFile } from 'node:fs/promises'
  13. import { fileURLToPath } from 'node:url'
  14. import { join } from 'node:path'
  15. import type { Browser, Page } from 'playwright'
  16. import { chromium } from 'playwright'
  17. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  18. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  19. import {
  20. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  21. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  22. } from './scaffold.ts'
  23. import { connectFreshWorkspace, saveFailureShot } from './support.ts'
  24. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
  25. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  26. const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
  27. // Post-reload golden: the same settled conversation rebuilt purely from
  28. // persistence + history — byte-equal rendering is exactly the recovery claim.
  29. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
  30. const MODE = webSnapshotMode()
  31. const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
  32. describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
  33. let scaffold: WebScaffold
  34. let browser: Browser
  35. let page: Page
  36. let tripwire: ReturnType<typeof watchConsole>
  37. const sessionEvents: SessionEvent[] = []
  38. beforeAll(async () => {
  39. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
  40. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  41. browser = await chromium.launch()
  42. page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
  43. tripwire = watchConsole(page)
  44. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  45. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  46. // Fresh world: connect a Workspace so the composer scenarios start live.
  47. await connectFreshWorkspace(page)
  48. }, 120_000)
  49. afterAll(async () => {
  50. await browser?.close()
  51. await scaffold?.close()
  52. })
  53. it('sends the first prompt from the empty-state hero (all modes)', async () => {
  54. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
  55. if (MODE !== 'record') {
  56. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  57. }
  58. // The blank frame renders the hero, not the resident composer: the
  59. // headline plus the guidance placeholder are the empty state's anchors.
  60. await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
  61. const input = page.locator('textarea').first()
  62. await input.waitFor({ timeout: 10_000 })
  63. if (MODE !== 'record') {
  64. // Golden of the hero's stable waiting state (captured before any send;
  65. // the conversation-region goldens belong to the other scenarios).
  66. const snapshot = await captureStableAria(page, '[class*="frame"]', scaffold.workspaceCwd)
  67. await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
  68. }
  69. const settled = scaffold.whenTurnSettled()
  70. await input.fill(PROMPT)
  71. await input.press('Enter')
  72. const sessionId = await settled
  73. if (MODE === 'record') {
  74. await recordFixture(scaffold, sessionId, FIXTURE)
  75. }
  76. }, 200_000)
  77. it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
  78. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
  79. // Browser: the sidebar tree now carries the auto-created workspace group
  80. // with its one session, and the opened session is the selected row.
  81. await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  82. await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
  83. await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  84. // Host: the session's durable header cwd is the workspace flow's
  85. // create-by-name target (<workspaceRoot>/workspace, the composer's
  86. // default draft name) — the proof the send went through workspace
  87. // materialization rather than a bare default-cwd session.
  88. const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
  89. expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
  90. const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
  91. expect(turnEnds).toHaveLength(1)
  92. expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
  93. }, 60_000)
  94. it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
  95. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
  96. // Fold a layout preference into the same reload: collapse the sidebar
  97. // (persisted under dsh.layout.panels) before reloading.
  98. await page.getByRole('button', { name: 'Collapse sidebar' }).click()
  99. await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
  100. const warningStart = tripwire.warnings.length
  101. await page.reload({ waitUntil: 'load' })
  102. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  103. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  104. // Layout persisted: the sidebar comes back collapsed.
  105. await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
  106. // Selection persisted (dsh.sessions.current) and history replayed: the
  107. // recorded turn re-renders from session.history with zero model calls —
  108. // the replay cursor was fully consumed before the reload, so any stray
  109. // request would fail the scenario loudly at close().
  110. await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  111. // Expand back and confirm the tree still lists the materialized session.
  112. await page.getByRole('button', { name: 'Open sidebar' }).click()
  113. await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
  114. // Golden of the recovered conversation region: rebuilt from the log, it
  115. // must render the same settled transcript the live turn produced.
  116. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  117. await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
  118. expect(tripwire.pageErrors).toEqual([])
  119. }, 90_000)
  120. it.skipIf(MODE === 'record')('cascades the dark theme from the body attribute to painted surfaces', async () => {
  121. onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-dark'))
  122. // This scenario pins the ThemeService's DOM contract seam directly (the
  123. // body[data-ds-dark-theme] attribute -> stylesheet cascade); the REAL
  124. // user gesture above it (Settings -> Appearance cubes) is owned by
  125. // settings-chrome.e2e.ts. Driving the attribute here keeps the cascade
  126. // pinned independently of the settings surface's own lifecycle.
  127. const sample = async (): Promise<{ token: string; sidebarBg: string; bodyBg: string }> =>
  128. await page.evaluate(() => {
  129. const sidebar = document.querySelector('[class*="sidebar"], [class*="rail"]') ?? document.body
  130. return {
  131. token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
  132. sidebarBg: getComputedStyle(sidebar).backgroundColor,
  133. bodyBg: getComputedStyle(document.body).backgroundColor,
  134. }
  135. })
  136. const light = await sample()
  137. await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
  138. const dark = await sample()
  139. // The alias token itself must flip — the cascade's root fact.
  140. expect(dark.token).not.toBe(light.token)
  141. // And a real painted surface must consume it (not just variables in a
  142. // void): at least one of the sampled backgrounds repaints.
  143. expect(dark.sidebarBg !== light.sidebarBg || dark.bodyBg !== light.bodyBg).toBe(true)
  144. // Removing the attribute restores the light values exactly (the palettes
  145. // live in one stylesheet; activation is attribute-only by design).
  146. await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
  147. const restored = await sample()
  148. expect(restored).toEqual(light)
  149. expect(tripwire.pageErrors).toEqual([])
  150. }, 60_000)
  151. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  152. expect(tripwire.warnings).toEqual([])
  153. await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
  154. })
  155. })