From f280a97c5643de17edfccbda1756dfc38acecc95 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 29 Jul 2026 11:32:03 +0800 Subject: [PATCH] test(web): isolate details session lifecycle e2e --- .../tests/details-session-lifecycle.e2e.ts | 85 +++++++++++++++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 47 ---------- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 4 files changed, 87 insertions(+), 47 deletions(-) create mode 100644 apps/web/tests/details-session-lifecycle.e2e.ts diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts new file mode 100644 index 0000000000..c066077edf --- /dev/null +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -0,0 +1,85 @@ +// Keyless browser regression for the details column's Session ownership. +// The real shipped composition owns the state transition: an active Session +// rehydrates an open panel, New Session replaces the details owner, and the +// root layout must release the third grid track before the next paint. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' +const MODE = webSnapshotMode() + +/** Last AppFrame grid track in CSS pixels. */ +async function detailsTrack(page: Page): Promise { + return await page.locator('[class*="frame"]').first().evaluate((element) => { + const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') + return Number.parseFloat(tracks.at(-1) ?? 'NaN') + }) +} + +describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('removes the details track for New Session and keeps it closed when returning', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) + const settled = scaffold.whenTurnSettled() + const input = page.locator('textarea').first() + await input.fill(PROMPT) + await input.press('Enter') + await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + + // Rehydrate the production layout action's persisted result. The active + // Session survives reload, so its details panel remains valid and open. + await page.evaluate(() => { + localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) + }) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await detailsTrack(page)).toBe(360) + expect(await page.getByText('详情', { exact: true }).count()).toBe(1) + + await page.getByRole('button', { name: 'New session', exact: true }).last().click() + await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) + expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + + const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() + await original.click() + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2c2fdd4c83..4b54242495 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -32,14 +32,6 @@ const MODE = webSnapshotMode() const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' -/** Last AppFrame grid track in CSS pixels. */ -async function detailsTrack(page: Page): Promise { - return await page.locator('[class*="frame"]').first().evaluate((element) => { - const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') - return Number.parseFloat(tracks.at(-1) ?? 'NaN') - }) -} - describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => { let scaffold: WebScaffold let browser: Browser @@ -107,45 +99,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') }, 60_000) - it.skipIf(MODE === 'record')('closes details for New Session and keeps it closed when returning', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) - // Keep the scenario focused-runnable: the full file already sent this - // turn, while `-t` starts from the connected blank Session in beforeAll. - if (await page.getByText('LIGHTHOUSE', { exact: true }).count() === 0) { - const settled = scaffold.whenTurnSettled() - const input = page.locator('textarea').first() - await input.fill(PROMPT) - await input.press('Enter') - await settled - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - } - // Rehydrate the production layout action's persisted result. The active - // Session survives reload, so its details panel remains valid and open. - await page.evaluate(() => { - localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) - }) - const warningStart = tripwire.warnings.length - await page.reload({ waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - acknowledgeReloadConnectionLoss(tripwire, warningStart) - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - expect(await detailsTrack(page)).toBe(360) - expect(await page.getByText('详情', { exact: true }).count()).toBe(1) - - await page.getByRole('button', { name: 'New session', exact: true }).last().click() - await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) - expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() - await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) - - const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() - await original.click() - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) - it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload')) // Fold a layout preference into the same reload: collapse the sidebar diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1b0f807d5f..dbbe67f98b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -28,6 +28,7 @@ "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", "tests/lifecycle-chrome.e2e.ts", + "tests/details-session-lifecycle.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..599931c2d6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -15,6 +15,7 @@ "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", + "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts",