refactor: simplify sidebar logics

This commit is contained in:
imccyu
2026-07-29 14:52:41 +08:00
parent f280a97c56
commit 66d650e4fb
18 changed files with 113 additions and 125 deletions

View File

@@ -1,30 +1,33 @@
// 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.
// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership.
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,
fixtureUserPrompts, launchWebScaffold, seedSession, 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 SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.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<number> {
return await page.locator('[class*="frame"]').first().evaluate((element) => {
return await appFrame(page).evaluate((element) => {
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
return Number.parseFloat(tracks.at(-1) ?? 'NaN')
})
}
/** AppFrame is the only product element with an inline grid track template. */
function appFrame(page: Page) {
return page.locator('[style*="grid-template-columns"]').first()
}
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -32,13 +35,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
const fixture = await readFile(FIXTURE, 'utf8')
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 })
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
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 appFrame(page).waitFor({ timeout: 30_000 })
await connectFreshWorkspace(page)
}, 120_000)
@@ -47,7 +52,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await scaffold?.close()
})
it('removes the details track for New Session and keeps it closed when returning', async () => {
it('retains geometry through hero and closes it for a different Session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
const settled = scaffold.whenTurnSettled()
const input = page.locator('textarea').first()
@@ -56,28 +61,33 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
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)
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).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(360)
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
const ungrouped = page.getByText('Ungrouped', { exact: true })
const ungroupedRow = ungrouped.locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await ungrouped.click()
await page.waitForTimeout(50)
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1)
await seeded.click()
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])

View File

@@ -101,23 +101,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
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
// (persisted under dsh.layout.panels) before reloading.
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
// Layout persisted: the sidebar comes back collapsed.
await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
// Selection persisted (dsh.sessions.current) and history replayed: the
// recorded turn re-renders from session.history with zero model calls —
// the replay cursor was fully consumed before the reload, so any stray
// request would fail the scenario loudly at close().
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Expand back and confirm the tree still lists the materialized session.
await page.getByRole('button', { name: 'Open sidebar' }).click()
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
// Golden of the recovered conversation region: rebuilt from the log, it
// must render the same settled transcript the live turn produced.

View File

@@ -469,7 +469,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '09-details-closed')
}, 150_000)
it('6 sidebar drag widens the column and persists across reload', async () => {
it('6 sidebar drag widens the column and resets across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
const before = await firstTrack(page)
const handle = page.locator('[class*="handle"]').first()
@@ -484,7 +484,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await screen(page, '10-sidebar-dragged')
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await firstTrack(page)).toBe(after)
expect(await firstTrack(page)).toBe(before)
})
it('7 dark mode: the body attribute cascades the token sheets', async () => {