test(web): 提交回显的组装路径 e2e 与不可见标记

PendingSubmissionBubble 携带 data-submission-echo 标记(渲染不变,仅供检测),
新增 keyless 组装 e2e:发送按键当下回显即在流中、composer 已清空可编辑,
durable 节点到达后原位替换且只剩一条气泡。
This commit is contained in:
creatixchu
2026-08-26 12:12:08 +08:00
parent 1da466a0a6
commit f1606e31d2
8 changed files with 115 additions and 10 deletions
+65
View File
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
// Local submission echo over the BUILT client graph (keyless FixtureApiClient
// transport): a text-plus-image send paints its echo bubble synchronously on
// the submit keystroke — before serialization, transport, or the fixture's
// durable admission — with the composer already cleared and editable, and the
// durable user/message replaces the echo without a duplicate. The fixture host
// echoes the prompt requestId as the durable source's rpcId, so the retirement
// path here is the production correlation, not a test hook.
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => {
mountAssembledApp()
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
if (start === null) throw new Error('fixture Workspace new-session action missing')
fireEvent.click(start)
const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' })
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
getData: () => '',
},
})
await waitFor(() => {
if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) {
throw new Error('attachment rail missing')
}
}, { timeout: 5_000 })
fireEvent.change(textarea, { target: { value: '回显这条消息' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
// Synchronously after the keystroke: the echo bubble is in the flow with
// the draft text and the object-URL preview, while the prompt has not even
// been serialized yet (it starts after a paint yield). The composer is
// already cleared, editable, and free of the rail.
const echo = document.querySelector<HTMLElement>('[data-submission-echo]')
if (echo === null) throw new Error('submission echo missing on the send keystroke')
expect(echo.textContent).toContain('回显这条消息')
expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob')
expect((textarea as HTMLTextAreaElement).value).toBe('')
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
// The fixture's durable user/message (source.rpcId echoes the prompt
// requestId) replaces the echo: one bubble, no marker left, and the image
// now renders from the durable gallery.
await waitFor(() => {
if (document.querySelector('[data-submission-echo]') !== null) {
throw new Error('submission echo still present after the durable node arrived')
}
}, { timeout: 10_000 })
expect(screen.getAllByText('回显这条消息')).toHaveLength(1)
await waitFor(() => {
if (document.querySelector('[data-align="end"] img') === null) {
throw new Error('durable user gallery missing')
}
}, { timeout: 10_000 })
})
@@ -83,9 +83,9 @@ describe('Session control queue projection', () => {
const opened = await iterator.next()
if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
const items = opened.value.value.queues['queue-session' as SessionId] ?? []
expect(items).toMatchObject([
expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([
{ id: identified.id, placement: 'queued', rpcId: 'req-42' },
{ id: expect.anything(), placement: 'steering' },
{ id: items[1]?.id, placement: 'steering', rpcId: undefined },
])
expect('rpcId' in (items[1] ?? {})).toBe(false)
@@ -2,7 +2,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import { Session } from '../src/client/sessions/session.ts'
@@ -43,7 +42,7 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA
...refs.map(attachment => ({ type: 'image' as const, attachment })),
{ type: 'text' as const, text: '发送' },
],
source: { kind: 'user', rpcId } as MessageSource,
source: { kind: 'user', rpcId },
}),
} as unknown as SessionEvent
}
@@ -216,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t,
content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
}: {
content: readonly unknown[]
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -224,6 +224,8 @@ function UserStyleBubble({
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */
echo?: boolean
/** Exact session mention labels associated by the adjacent recall node. */
referenceLabels?: readonly string[]
/** Local submission-echo previews replacing the content-derived image group. */
@@ -235,7 +237,12 @@ function UserStyleBubble({
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || rest.length > 0
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
<div
className={css.userRow}
data-pending-steering={pending || undefined}
data-submission-echo={echo || undefined}
data-time-hover-root
>
<div className={css.userStack}>
{renderMessageImages({ images, align: 'end' })}
{showBubble && <div className={css.bubble}>
@@ -315,6 +322,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
content={content}
previewImages={previewImages}
renderMessageImages={renderMessageImages}
echo
t={t}
actions={text => (
<MessageIconActions
@@ -671,7 +671,7 @@ describe('ChatView', () => {
},
)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('即发即显')).toBeTruthy()
expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull()
// The durable node arrives while the echo is STILL in the session
// snapshot: the render-time rpcId dedupe keeps exactly one bubble.
@@ -682,12 +682,13 @@ describe('ChatView', () => {
{
kind: 'user', seq: 2, time: 2_000,
content: [{ type: 'text', text: '即发即显' }] as never,
source: { kind: 'user', rpcId: 'req-1' } as never,
source: { kind: 'user', rpcId: 'req-1' },
},
],
})
})
expect(view.getAllByText('即发即显')).toHaveLength(1)
expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
// The delayed snapshot retirement changes nothing visible.
act(() => { h.setSession({ pendingSubmissions: [] }) })
@@ -28,7 +28,7 @@ describe('HistoricalImageCache', () => {
it('adopts a seeded URL, reuses it for later resolves, and revokes it with the Session scope', async () => {
const revoked: string[] = []
const originalRevoke = URL.revokeObjectURL
const originalRevoke = URL.revokeObjectURL.bind(URL)
URL.revokeObjectURL = (url: string) => { revoked.push(url) }
try {
const runtime = await SlotTestRuntime.create()
@@ -550,6 +550,38 @@ describe('input-machine: undo / redo', () => {
expect(n.state.draft).toBe('typed during flight')
})
it('runs concurrent detached sends and settles them independently in any order', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '第一条' })
const first = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
m.dispatch({ type: 'draft-changed', draft: '第二条' })
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
expect(m.state.phase).toBe('plain')
expect(m.state.draft).toBe('')
expect(second.attempt.seq).toBeGreaterThan(first.attempt.seq)
// Later attempt fails first: its draft restores into the empty composer.
m.dispatch({ type: 'sink-settled', attempt: second.attempt, ok: false, message: 'boom' })
expect(m.state.draft).toBe('第二条')
// The earlier failure then finds a non-empty composer and must not clobber it.
m.dispatch({ type: 'sink-settled', attempt: first.attempt, ok: false, message: 'boom' })
expect(m.state.draft).toBe('第二条')
// Release aborts nothing further: both settlements already consumed their records.
expect(m.dispatch({ type: 'release' })).toEqual([])
})
it('release aborts every in-flight detached send', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'A' })
const first = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
m.dispatch({ type: 'draft-changed', draft: 'B' })
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')
m.dispatch({ type: 'release' })
expect(first.attempt.signal.aborted).toBe(true)
expect(second.attempt.signal.aborted).toBe(true)
// Settlements after release are dropped stale events.
expect(m.dispatch({ type: 'sink-settled', attempt: first.attempt, ok: false, message: 'late' })).toEqual([])
})
it('a failed detached flight restores the sent draft and occurrences into an untouched composer', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'restore me' })
@@ -170,7 +170,7 @@ describe('sendSession submission echo', () => {
await expect(sending).resolves.toEqual({ kind: 'success' })
expect(b.prompt).toHaveBeenCalledWith(
[
{ type: 'image', mediaType: 'image/png', data: expect.any(String), name: 'a.png' },
{ type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'a.png' },
{ type: 'text', text: '带图' },
],
'queue',