The user bubble's IconActions row carried an edit button with no click handler, no client mutation, and no host operation behind it, advertising an affordance the product cannot honor. The row now renders clock / copy / branch only for both user and assistant chrome, and MessageIconActions loses the `edit` prop with the button. The package README records the missing capability under Known Limitations, the web aria goldens pin the row without the control, and the Agent Note states what must exist before it comes back.
145 lines
7.1 KiB
TypeScript
145 lines
7.1 KiB
TypeScript
// 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<typeof watchConsole>
|
|
|
|
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 and each turn's last content assistant both
|
|
// have 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(0)
|
|
}, 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, current deepseek-v4-flash',
|
|
}).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'])
|
|
})
|
|
})
|