startup-auto-selection.e2e.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. // Web e2e scenario: startup auto-selection keeps the hero on screen.
  2. //
  3. // A page load with a workspace already registered runs
  4. // `WorkspacesService.startInitialSelection`: it connects the most recent
  5. // workspace and opens its blank session. `openState` flips to `loading` the
  6. // moment `open()` lands; driving `data-phase=settling` on the conversation
  7. // root from that flip would hide the composer seat and the header
  8. // (`visibility:hidden`) for the whole `session.history` round-trip — the
  9. // center column blanks and repaints like a full-page refresh on every launch.
  10. //
  11. // The unit spec pins the phase condition over hand-built stores. What only the
  12. // assembled application can show is that the path a user actually takes
  13. // reaches it: the real selection service, the real client session opening over
  14. // the real /api transport, and a real browser deciding what is painted.
  15. // The initial Workspace pick also records the resident Hero/composer nodes and
  16. // proves that opening the first blank Session fills the strict outlets without
  17. // replacing those nodes.
  18. //
  19. // The round-trip against a loopback host is far too fast to observe, so this
  20. // scenario HOLDS the `session.history` response open in the browser's network
  21. // handler and asserts the visible frame while it is in flight. That wait is
  22. // what makes the assertions non-vacuous: without the phase exemption, the held
  23. // window is exactly when `settling` would be painted and the composer hidden.
  24. //
  25. // Zero model calls: registering a workspace and opening its blank session are
  26. // host RPCs with no model involvement. A stray stream would fail loud with
  27. // NO_ADAPTER.
  28. import type { Browser, Page } from 'playwright'
  29. import { chromium } from 'playwright'
  30. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  31. import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
  32. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  33. /** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */
  34. const HISTORY_ROUTE = '**/api/session.history'
  35. /**
  36. * The conversation root's own phase attribute. `div` disambiguates it from the
  37. * composer textarea, which carries an unrelated `data-phase` of its own.
  38. */
  39. const ROOT_PHASE = 'div[data-phase]'
  40. /** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
  41. function recordedPhases(page: Page): Promise<string[]> {
  42. return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
  43. }
  44. describe('web e2e: startup auto-selection', () => {
  45. let scaffold: WebScaffold
  46. let browser: Browser
  47. let page: Page
  48. let tripwire: ReturnType<typeof watchConsole>
  49. beforeAll(async () => {
  50. scaffold = await launchWebScaffold({})
  51. browser = await chromium.launch()
  52. page = await newEnglishPage(browser)
  53. tripwire = watchConsole(page)
  54. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  55. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  56. }, 180_000)
  57. afterAll(async () => {
  58. await browser?.close()
  59. await scaffold?.close()
  60. })
  61. it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
  62. onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
  63. await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
  64. await page.evaluate(() => {
  65. const refs = {
  66. root: document.querySelector('div[data-phase="hero"]'),
  67. workspaceChip: document.querySelector('[aria-label="Choose workspace"]'),
  68. scrollBody: document.querySelector('[data-conversation-scroll]'),
  69. composerSeat: document.querySelector('[data-composer-seat]'),
  70. textarea: document.querySelector('textarea'),
  71. }
  72. if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
  73. ;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
  74. })
  75. // A registered Workspace is the precondition for the reload case below;
  76. // this first connection is also the no-Workspace → Workspace path.
  77. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
  78. expect(await page.evaluate(() => {
  79. const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
  80. return {
  81. phase: document.querySelector('div[data-phase]')?.getAttribute('data-phase'),
  82. root: document.querySelector('div[data-phase="hero"]') === before.root,
  83. workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip,
  84. scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody,
  85. composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat,
  86. textarea: document.querySelector('textarea') === before.textarea,
  87. textareaEnabled: !(document.querySelector('textarea') as HTMLTextAreaElement).disabled,
  88. }
  89. })).toEqual({
  90. phase: 'hero',
  91. root: true,
  92. workspaceChip: true,
  93. scrollBody: true,
  94. composerSeat: true,
  95. textarea: true,
  96. textareaEnabled: true,
  97. })
  98. expect(tripwire.pageErrors).toEqual([])
  99. }, 120_000)
  100. it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
  101. onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
  102. // Runs before any page script on the reload below, so the first phase the
  103. // root ever renders is recorded, not just the ones after a listener attaches.
  104. await page.addInitScript(() => {
  105. const phases: string[] = []
  106. ;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
  107. setInterval(() => {
  108. const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
  109. if (phase === null || phase === undefined) return
  110. if (phases[phases.length - 1] !== phase) phases.push(phase)
  111. }, 8)
  112. })
  113. let releaseHistory = (): void => {}
  114. const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
  115. let historyRequested = (): void => {}
  116. const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
  117. let gated = false
  118. await page.route(HISTORY_ROUTE, async (route) => {
  119. // Only the auto-selection's own round-trip is held; later pages must not
  120. // deadlock behind a gate this test has already released.
  121. if (gated) { await route.continue(); return }
  122. gated = true
  123. historyRequested()
  124. await historyHeld
  125. await route.continue()
  126. })
  127. const warningsBefore = tripwire.warnings.length
  128. await page.reload({ waitUntil: 'commit' })
  129. await historyInFlight
  130. // The frame a user sees while the session is still opening: hero phase, the
  131. // hero title, and a composer that is actually painted (`settling` hides the
  132. // seat with `visibility:hidden`, which Playwright reports as not visible).
  133. await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
  134. expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
  135. expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
  136. expect(await page.locator('textarea').first().isVisible()).toBe(true)
  137. releaseHistory()
  138. await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
  139. .waitFor({ timeout: 15_000 })
  140. acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
  141. // Settling is not merely absent from the frame sampled above: the root
  142. // never entered it at any point of the load.
  143. expect(await recordedPhases(page)).toEqual(['hero'])
  144. expect(tripwire.pageErrors).toEqual([])
  145. }, 120_000)
  146. })