// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history // fixture (zero model calls) and pins the settled conversation aria after the // user/assistant footers are focus-revealed — the surface package jsdom tests // cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' 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 { SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) // Borrowed read-only: this scenario needs any settled user+assistant pair, not // a new recording (workspace-management / sidebar-scrollbar pattern). const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' describe('web e2e: message IconActions and clocks on settled history', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { scaffold = await launchWebScaffold({}) const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) await seedSession(scaffold, raw, SEED_ID) 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 }) }, 120_000) afterAll(async () => { await browser?.close() await scaffold?.close() }) it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) await groupRow.click() const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). User has three actions; each turn's last content // assistant has copy + branch. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) await copyButtons.first().focus() await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) .toBeGreaterThanOrEqual(2) await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) await page.getByRole('button', { name: 'Select model', exact: true }) .waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) // Exercise the assistant action specifically; package coverage pins the // user action separately at its own event seq. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), { timeout: 15_000 }, ).toBeDefined() await expect.poll( () => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }, ).toBe(3) await expect.poll( () => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }, ).toBe(1) // The row action owns a distinct ui-workspace injection from the message // action above, so exercise both through the loaded app before capture. const sourceRow = page.locator('[role="treeitem"][aria-selected="true"]') const rowBox = await sourceRow.boundingBox() if (rowBox === null) throw new Error('fork source row has no layout box') const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]') await sourceRow.hover({ position: { x: rowBox.width - 16, y: rowBox.height / 2 } }) await expect.poll(() => actionButton.isVisible(), { timeout: 2_000 }).toBe(true) const buttonBox = await actionButton.boundingBox() if (buttonBox === null) throw new Error('fork source row action has no layout box') await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2) await page.getByRole('menuitem', { name: 'Fork session' }).click() await expect.poll( () => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length, { timeout: 15_000 }, ).toBe(2) await expect.poll( () => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }, ).toBe(4) await expect.poll( () => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }, ).toBe(1) const tree = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE) }) it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md']) }) })