| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- // Web e2e scenario: startup auto-selection keeps the hero on screen.
- //
- // A page load with a workspace already registered runs
- // `WorkspacesService.startInitialSelection`: it connects the most recent
- // workspace and opens its blank session. `openState` flips to `loading` the
- // moment `open()` lands, which used to drive `data-phase=settling` on the
- // conversation root — `visibility:hidden` over the composer seat and the
- // header for the whole `session.history` round-trip, so the center column went
- // blank and repainted, reading as a full-page refresh on every launch.
- //
- // The unit spec pins the phase condition over hand-built stores. What only the
- // assembled application can show is that the path a user actually takes
- // reaches it: the real selection service, the real client session opening over
- // the real /api transport, and a real browser deciding what is painted.
- //
- // The round-trip against a loopback host is far too fast to observe, so this
- // scenario HOLDS the `session.history` response open at the browser's network
- // boundary and asserts the visible frame while it is in flight. That gate is
- // what makes the assertions non-vacuous: with the exemption reverted the held
- // window is exactly when `settling` is painted and the composer is hidden.
- //
- // Zero model calls: registering a workspace and opening its blank session are
- // host RPCs with no model involvement. A stray stream would fail loud with
- // NO_ADAPTER.
- import type { Browser, Page } from 'playwright'
- import { chromium } from 'playwright'
- import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
- import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
- import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
- /** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */
- const HISTORY_ROUTE = '**/api/session.history'
- /**
- * The conversation root's own phase attribute. `div` disambiguates it from the
- * composer textarea, which carries an unrelated `data-phase` of its own.
- */
- const ROOT_PHASE = 'div[data-phase]'
- /** Every distinct `data-phase` the conversation root shows, in order, across one page load. */
- function recordedPhases(page: Page): Promise<string[]> {
- return page.evaluate(() => (window as unknown as { __conversationPhases: string[] }).__conversationPhases)
- }
- describe('web e2e: startup auto-selection', () => {
- let scaffold: WebScaffold
- let browser: Browser
- let page: Page
- let tripwire: ReturnType<typeof watchConsole>
- beforeAll(async () => {
- scaffold = await launchWebScaffold({})
- browser = await chromium.launch()
- page = await newEnglishPage(browser)
- tripwire = watchConsole(page)
- await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
- await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
- // A registered workspace is the precondition for auto-selection: the first
- // load has nothing to select, so the reload below is the path under test.
- await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
- }, 180_000)
- afterAll(async () => {
- await browser?.close()
- await scaffold?.close()
- })
- it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
- // Runs before any page script on the reload below, so the first phase the
- // root ever renders is recorded, not just the ones after a listener attaches.
- await page.addInitScript(() => {
- const phases: string[] = []
- ;(window as unknown as { __conversationPhases: string[] }).__conversationPhases = phases
- setInterval(() => {
- const phase = document.querySelector('div[data-phase]')?.getAttribute('data-phase')
- if (phase === null || phase === undefined) return
- if (phases[phases.length - 1] !== phase) phases.push(phase)
- }, 8)
- })
- let releaseHistory = (): void => {}
- const historyHeld = new Promise<void>((resolve) => { releaseHistory = resolve })
- let historyRequested = (): void => {}
- const historyInFlight = new Promise<void>((resolve) => { historyRequested = resolve })
- let gated = false
- await page.route(HISTORY_ROUTE, async (route) => {
- // Only the auto-selection's own round-trip is held; later pages must not
- // deadlock behind a gate this test has already released.
- if (gated) { await route.continue(); return }
- gated = true
- historyRequested()
- await historyHeld
- await route.continue()
- })
- const warningsBefore = tripwire.warnings.length
- await page.reload({ waitUntil: 'commit' })
- await historyInFlight
- // The frame a user sees while the session is still opening: hero phase, the
- // hero title, and a composer that is actually painted (`settling` hides the
- // seat with `visibility:hidden`, which Playwright reports as not visible).
- await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
- expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
- expect(await page.getByText("Let's start building").isVisible()).toBe(true)
- expect(await page.locator('textarea').first().isVisible()).toBe(true)
- releaseHistory()
- await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
- .waitFor({ timeout: 15_000 })
- acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
- // Settling is not merely absent from the frame sampled above: the root
- // never entered it at any point of the load.
- expect(await recordedPhases(page)).toEqual(['hero'])
- expect(tripwire.pageErrors).toEqual([])
- }, 120_000)
- })
|