mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
test(web): 提交回显的组装路径 e2e 与不可见标记
PendingSubmissionBubble 携带 data-submission-echo 标记(渲染不变,仅供检测), 新增 keyless 组装 e2e:发送按键当下回显即在流中、composer 已清空可编辑, durable 节点到达后原位替换且只剩一条气泡。
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user