startup-auto-selection.e2e.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /** Web acceptance that startup Session opening preserves the resident Hero tree. */
  2. import type { Browser, Page } from 'playwright'
  3. import { chromium } from 'playwright'
  4. import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
  5. import {
  6. acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold,
  7. } from './scaffold.ts'
  8. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  9. /**
  10. * The conversation root's own phase attribute. `div` disambiguates it from the
  11. * composer textarea, which carries an unrelated `data-phase` of its own.
  12. */
  13. const ROOT_PHASE = 'div[data-phase]'
  14. /** Every distinct conversation-root phase observed during one page load. */
  15. function recordedPhases(page: Page): Promise<string[]> {
  16. return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
  17. }
  18. describe('web e2e: startup auto-selection', () => {
  19. let scaffold: WebScaffold
  20. let browser: Browser
  21. let page: Page
  22. let tripwire: ReturnType<typeof watchConsole>
  23. beforeAll(async () => {
  24. scaffold = await launchWebScaffold({})
  25. browser = await chromium.launch()
  26. page = await newEnglishPage(browser)
  27. tripwire = watchConsole(page)
  28. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  29. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  30. }, 180_000)
  31. afterAll(async () => {
  32. await browser?.close()
  33. await scaffold?.close()
  34. })
  35. it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
  36. onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
  37. await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
  38. const headline = page.getByText('Into the Unknown', { exact: true })
  39. // The headline text sits in its own span inside the title group; the fish
  40. // hitbox precedes the group, not the text span.
  41. const fishHitbox = headline.locator('xpath=../preceding-sibling::span[1]')
  42. const fish = fishHitbox.locator('svg')
  43. expect(await fish.evaluate(node => getComputedStyle(node).color))
  44. .toBe(await headline.evaluate(node => getComputedStyle(node).color))
  45. await fishHitbox.hover()
  46. expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
  47. await page.evaluate(() => {
  48. const refs = {
  49. root: document.querySelector('div[data-phase="hero"]'),
  50. workspaceChip: document.querySelector('[aria-label="Choose workspace"]'),
  51. scrollBody: document.querySelector('[data-conversation-scroll]'),
  52. composerSeat: document.querySelector('[data-composer-seat]'),
  53. composer: document.querySelector('[data-composer-input]'),
  54. }
  55. if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
  56. ;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
  57. })
  58. // A registered Workspace is the precondition for the reload case below;
  59. // this first connection is also the no-Workspace → Workspace path.
  60. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
  61. expect(await page.evaluate(() => {
  62. const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
  63. return {
  64. phase: document.querySelector('div[data-phase]')?.getAttribute('data-phase'),
  65. root: document.querySelector('div[data-phase="hero"]') === before.root,
  66. workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip,
  67. scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody,
  68. composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat,
  69. composer: document.querySelector('[data-composer-input]') === before.composer,
  70. composerEnabled: document.querySelector('[data-composer-input]')?.getAttribute('aria-disabled') !== 'true',
  71. }
  72. })).toEqual({
  73. phase: 'hero',
  74. root: true,
  75. workspaceChip: true,
  76. scrollBody: true,
  77. composerSeat: true,
  78. composer: true,
  79. composerEnabled: true,
  80. })
  81. expect(tripwire.pageErrors).toEqual([])
  82. }, 120_000)
  83. it('keeps the hero and composer visible while the opening follow snapshot is pending', async () => {
  84. onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
  85. await page.addInitScript(() => {
  86. const phases: string[] = []
  87. ;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
  88. setInterval(() => {
  89. const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
  90. if (phase === null || phase === undefined) return
  91. if (phases[phases.length - 1] !== phase) phases.push(phase)
  92. }, 8)
  93. })
  94. let releaseOpening = (): void => {}
  95. const openingHeld = new Promise<void>((resolve) => { releaseOpening = resolve })
  96. let openingRequested = (): void => {}
  97. const openingInFlight = new Promise<void>((resolve) => { openingRequested = resolve })
  98. let gated = false
  99. const readObservation = scaffold.ctx.sessionQuery.observeSession
  100. .bind(scaffold.ctx.sessionQuery)
  101. const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
  102. .mockImplementation(async (sessionId, options) => {
  103. const observation = await readObservation(sessionId, options)
  104. if (gated) return observation
  105. gated = true
  106. openingRequested()
  107. await openingHeld
  108. return observation
  109. })
  110. const warningsBefore = tripwire.warnings.length
  111. try {
  112. await page.reload({ waitUntil: 'commit' })
  113. await openingInFlight
  114. // The frame a user sees while the session is still opening: hero phase, the
  115. // hero title, and a composer that is actually painted (`settling` hides the
  116. // seat with `visibility:hidden`, which Playwright reports as not visible).
  117. await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
  118. expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
  119. expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
  120. expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true)
  121. releaseOpening()
  122. await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
  123. .waitFor({ timeout: 15_000 })
  124. acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
  125. // Settling is not merely absent from the frame sampled above: the root
  126. // never entered it at any point of the load.
  127. expect(await recordedPhases(page)).toEqual(['hero'])
  128. expect(tripwire.pageErrors).toEqual([])
  129. } finally {
  130. releaseOpening()
  131. observe.mockRestore()
  132. }
  133. }, 120_000)
  134. })