From f1606e31d247535498f8c89b6369fb32589371b7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 12:12:08 +0800 Subject: [PATCH] =?UTF-8?q?test(web):=20=E6=8F=90=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E7=9A=84=E7=BB=84=E8=A3=85=E8=B7=AF=E5=BE=84=20e2e=20?= =?UTF-8?q?=E4=B8=8E=E4=B8=8D=E5=8F=AF=E8=A7=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingSubmissionBubble 携带 data-submission-echo 标记(渲染不变,仅供检测), 新增 keyless 组装 e2e:发送按键当下回显即在流中、composer 已清空可编辑, durable 节点到达后原位替换且只剩一条气泡。 --- apps/web/tests/submission-echo.e2e.ts | 65 +++++++++++++++++++ .../tests/control-queue.host.spec.ts | 4 +- ...session-pending-submissions.client.spec.ts | 3 +- .../ui-chat/src/client/chat/MessageItem.tsx | 12 +++- .../ui-chat/tests/chat-view.client.spec.tsx | 5 +- .../tests/historical-images.client.spec.ts | 2 +- .../tests/input-machine.client.spec.ts | 32 +++++++++ .../service-orchestration.client.spec.ts | 2 +- 8 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/submission-echo.e2e.ts diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts new file mode 100644 index 0000000000..42f04e730d --- /dev/null +++ b/apps/web/tests/submission-echo.e2e.ts @@ -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('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('[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 }) +}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index e3fe868c89..b4645dc2f3 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -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) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 0ab7073c3f..76b58abf79 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -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 } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 6a3e26ff69..58af540f70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -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 ( -
+
{renderMessageImages({ images, align: 'end' })} {showBubble &&
@@ -315,6 +322,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: content={content} previewImages={previewImages} renderMessageImages={renderMessageImages} + echo t={t} actions={text => ( { }, ) const view = render() - 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: [] }) }) diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 7b44cd2016..a8f777d12c 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -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() diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index c9938c6c10..0b601c4ff2 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -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' }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 8950cf5945..39ced8ab36 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -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',