Files
deepseek-harness/apps/web/tests/question-ptc.e2e.ts
T
Kaige-Gaoandimccyu 0778dec09f feat(ui-user-questions): show answered batches as shared message bubbles (#6362)
* feat(ui-user-questions): show answered batches as shared message bubbles

* refactor(ui-primitives): share clipboard confirmation lifecycle

* test(web): include answered question bubbles in steering goldens

* fix(ui-user-questions): keep answered turns collapsible

* test(ui-user-questions): drop the removed compactAnswer expectation

master removed ChatTurnProcessPresentation.compactAnswer in aaeb344
("fix(chat): keep the reply gap on a folded final answer"); reply spacing
now comes from the adjacent-row rules. The rebase left this assertion
behind, so the parametrized replay/live/prepend cases failed on every CI
coverage lane even though typecheck stayed green: toMatchObject accepts
the stale property.

* fix(ui-user-questions): address answer bubble review feedback

* test(web): observe file card styles after both cards mount

---------

Co-authored-by: imccyu <[email protected]>
2026-10-21 23:44:02 +08:00

117 lines
6.0 KiB
TypeScript

/** PTC answers stay visible through real Web composition, process folding, and reload. */
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const question = 'Which PTC answer should remain visible?'
const args = JSON.stringify({
description: 'Ask for a retained answer',
code: `return await tools.ask_user_question(${JSON.stringify({ questions: [{
id: 'choice', question, options: [{ label: 'Blue' }, { label: 'Green' }],
}] })});`,
})
const script: ReplayEntry[] = [
{ kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: ToolCallId('ptc-question'), name: 'run_code', argumentsDelta: args },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('ptc-question'), name: 'run_code', arguments: args } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] },
{ kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'PTC answer recorded.' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'PTC answer recorded.' } },
{ type: 'finish', reason: { kind: 'stop' } },
] },
]
describe.skipIf(webSnapshotMode() === 'record')('web e2e: PTC answered question', () => {
let directory: string | undefined
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const events: SessionEvent[] = []
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'dsh-question-ptc-'))
const replayOverride = join(directory, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(script))
scaffold = await launchWebScaffold({
agentPresets: { default: 'ptc' },
replayFixture: join(directory, 'override-only.jsonl'), replayOverride,
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (directory !== undefined) await rm(directory, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
if (failures.length > 0) throw new AggregateError(failures, 'PTC question cleanup failed')
})
it('records a real subcall answer once and retains its bubble when process rows fold', async () => {
const settled = scaffold!.whenTurnSettled(60_000)
const input = page.locator('[data-composer-input]').first()
await input.fill('Ask me which color to retain.')
await input.press('Enter')
const composer = page.locator('[data-question-key]')
await composer.getByRole('radio', { name: 'Blue', exact: true }).click()
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
await settled
const dispatch = events.find(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'ask_user_question')
expect(dispatch?.type).toBe('tool/ptc-dispatch')
if (dispatch?.type !== 'tool/ptc-dispatch') throw new Error('missing PTC question settlement')
expect(dispatch.data.isError).toBe(false)
expect(events.filter(event => event.type === 'tool/ptc-dispatch-start' && event.data.subCallId === dispatch.data.subCallId)).toHaveLength(1)
const bubble = page.locator('[data-question-reply]')
await bubble.waitFor({ state: 'visible' })
expect(await bubble.count()).toBe(1)
expect(await bubble.getAttribute('data-question-reply')).toBe(String(dispatch.data.subCallId))
expect(await bubble.getByText(question, { exact: true }).isVisible()).toBe(true)
expect(await bubble.getByText('Blue', { exact: true }).isVisible()).toBe(true)
expect(await bubble.getByText('Green', { exact: true }).count()).toBe(0)
const process = page.locator('[data-turn-process]').first()
await expect.poll(() => process.isEnabled()).toBe(true)
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false')
await process.click()
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true')
await process.click()
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false')
expect(await bubble.isVisible()).toBe(true)
await page.reload({ waitUntil: 'load' })
await bubble.waitFor({ state: 'visible' })
expect(await bubble.count()).toBe(1)
expect(await bubble.getByText('Blue', { exact: true }).isVisible()).toBe(true)
for (const width of [390, 1440]) {
await page.setViewportSize({ width, height: 900 })
const bounds = await bubble.evaluate((row) => {
const wrapper = row.firstElementChild!
return { width: wrapper.getBoundingClientRect().width, limit: row.getBoundingClientRect().width * 0.82,
overflow: document.documentElement.scrollWidth - window.innerWidth }
})
expect(bounds.width).toBeLessThanOrEqual(bounds.limit + 1)
expect(bounds.overflow).toBeLessThanOrEqual(1)
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
})