startup-auto-selection.e2e.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  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. const fishHitbox = headline.locator('xpath=preceding-sibling::span[1]')
  40. const fish = fishHitbox.locator('svg')
  41. expect(await fish.evaluate(node => getComputedStyle(node).color))
  42. .toBe(await headline.evaluate(node => getComputedStyle(node).color))
  43. await fishHitbox.hover()
  44. expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
  45. await page.evaluate(() => {
  46. const refs = {
  47. root: document.querySelector('div[data-phase="hero"]'),
  48. workspaceChip: document.querySelector('[aria-label="Choose workspace"]'),
  49. scrollBody: document.querySelector('[data-conversation-scroll]'),
  50. composerSeat: document.querySelector('[data-composer-seat]'),
  51. composer: document.querySelector('[data-composer-input]'),
  52. }
  53. if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
  54. ;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
  55. })
  56. // A registered Workspace is the precondition for the reload case below;
  57. // this first connection is also the no-Workspace → Workspace path.
  58. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
  59. expect(await page.evaluate(() => {
  60. const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
  61. return {
  62. phase: document.querySelector('div[data-phase]')?.getAttribute('data-phase'),
  63. root: document.querySelector('div[data-phase="hero"]') === before.root,
  64. workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip,
  65. scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody,
  66. composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat,
  67. composer: document.querySelector('[data-composer-input]') === before.composer,
  68. composerEnabled: document.querySelector('[data-composer-input]')?.getAttribute('aria-disabled') !== 'true',
  69. }
  70. })).toEqual({
  71. phase: 'hero',
  72. root: true,
  73. workspaceChip: true,
  74. scrollBody: true,
  75. composerSeat: true,
  76. composer: true,
  77. composerEnabled: true,
  78. })
  79. expect(tripwire.pageErrors).toEqual([])
  80. }, 120_000)
  81. it('keeps the hero and composer visible while the opening follow snapshot is pending', async () => {
  82. onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
  83. await page.addInitScript(() => {
  84. const phases: string[] = []
  85. ;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
  86. setInterval(() => {
  87. const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
  88. if (phase === null || phase === undefined) return
  89. if (phases[phases.length - 1] !== phase) phases.push(phase)
  90. }, 8)
  91. })
  92. let releaseOpening = (): void => {}
  93. const openingHeld = new Promise<void>((resolve) => { releaseOpening = resolve })
  94. let openingRequested = (): void => {}
  95. const openingInFlight = new Promise<void>((resolve) => { openingRequested = resolve })
  96. let gated = false
  97. const readObservation = scaffold.ctx.sessionQuery.observeSession
  98. .bind(scaffold.ctx.sessionQuery)
  99. const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
  100. .mockImplementation(async (sessionId, options) => {
  101. const observation = await readObservation(sessionId, options)
  102. if (gated) return observation
  103. gated = true
  104. openingRequested()
  105. await openingHeld
  106. return observation
  107. })
  108. const warningsBefore = tripwire.warnings.length
  109. try {
  110. await page.reload({ waitUntil: 'commit' })
  111. await openingInFlight
  112. // The frame a user sees while the session is still opening: hero phase, the
  113. // hero title, and a composer that is actually painted (`settling` hides the
  114. // seat with `visibility:hidden`, which Playwright reports as not visible).
  115. await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
  116. expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
  117. expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
  118. expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true)
  119. releaseOpening()
  120. await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
  121. .waitFor({ timeout: 15_000 })
  122. acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
  123. // Settling is not merely absent from the frame sampled above: the root
  124. // never entered it at any point of the load.
  125. expect(await recordedPhases(page)).toEqual(['hero'])
  126. expect(tripwire.pageErrors).toEqual([])
  127. } finally {
  128. releaseOpening()
  129. observe.mockRestore()
  130. }
  131. }, 120_000)
  132. })