diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts new file mode 100644 index 0000000000..6ffb6cbf83 --- /dev/null +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -0,0 +1,119 @@ +// 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 { + 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 + + 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, '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((resolve) => { releaseHistory = resolve }) + let historyRequested = (): void => {} + const historyInFlight = new Promise((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) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a4cd2d9121..7e4e278f3a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -47,7 +47,8 @@ "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", - "tests/access-confirmation.e2e.ts" + "tests/access-confirmation.e2e.ts", + "tests/startup-auto-selection.e2e.ts" ], "references": [ { diff --git a/tsconfig.host.json b/tsconfig.host.json index 0b2a16c7f8..f474693e7c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -35,6 +35,7 @@ "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", + "apps/web/tests/startup-auto-selection.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",