183 lines
12 KiB
TypeScript
183 lines
12 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import { ComposerControls } from '../src/renderer/src/workbuddy/composer/ComposerControls'
|
|
import { encodePcmWav, downsamplePcm, VOICE_MAX_DURATION_MS } from '../src/renderer/src/workbuddy/composer/VoiceInputControl'
|
|
import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentNames, parseResourcePath, readReferenceUris } from '../src/renderer/src/workbuddy/composer/ComposerControls'
|
|
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
|
|
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
|
|
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
|
|
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
|
|
import type { ChatMessage } from '../src/shared/types'
|
|
|
|
const protectedMessage = {
|
|
id: 'assistant-sensitive',
|
|
messageType: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'before secret after', _meta: { sensitiveProtection: { version: 2, items: [{ start: 7, end: 13 }] } } },
|
|
{ type: 'tool-call', tool: { id: 'secret-tool', name: 'readFile', status: 'ok', args: { path: '/private' }, metaData: { sensitiveProtection: { version: 1, items: [{ ciphertext: 'ciphertext-1' }] } }, result: { private: true } } }
|
|
]
|
|
}
|
|
const originalSerialized = JSON.stringify(protectedMessage)
|
|
const projected = projectSafeMessage(protectedMessage)
|
|
assert.equal(JSON.stringify(protectedMessage), originalSerialized, 'projection must preserve the caller object')
|
|
const projectedText = (projected.content?.[0] as { text: string }).text
|
|
assert.equal(projectedText.startsWith('before ') && projectedText.endsWith(' after'), true)
|
|
assert.equal(projectedText.includes('secret'), false, 'copy projection must redact protected text')
|
|
const unchangedUser = { messageType: 'user', content: [{ type: 'text', text: 'user secret', _meta: { sensitiveProtection: { version: 2, items: [{ start: 5, end: 11 }] } } }] }
|
|
assert.equal(projectSafeMessage(unchangedUser), unchangedUser, 'assistant output hooks leave user messages unchanged')
|
|
assert.equal((projected.content?.[1] as { tool: { args: { sensitiveContent: string } } }).tool.args.sensitiveContent, 'ciphertext-1')
|
|
|
|
const quickQa = projectSafeMessage({ messageType: 'assistant', extra: { quickQa: true }, content: [
|
|
{ type: 'text', text: 'answer' },
|
|
{ type: 'tool-call', tool: { id: 'hidden', name: 'search', status: 'ok' } }
|
|
] })
|
|
assert.equal(quickQa.content?.length, 1, 'Quick QA copy projection omits tool-call blocks')
|
|
|
|
const legacy: ChatMessage = {
|
|
id: 'legacy-assistant',
|
|
role: 'assistant',
|
|
content: 'legacy text',
|
|
tools: [{ id: 'legacy-tool', name: 'readFile', args: { path: '/tmp/a' }, argsRaw: '{}', output: 'full output', status: 'ok' }],
|
|
createdAt: 1
|
|
}
|
|
const userMessage: ChatMessage = { id: 'user-copy', role: 'user', content: 'plain user text', createdAt: 1 }
|
|
const userClipboard = buildUserMessageClipboard({ ...userMessage, content: '<plain>&\u200B' })
|
|
assert.equal(userClipboard.plainText, '<plain>&', 'user text/plain omits the source zero-width marker')
|
|
assert.equal(userClipboard.html, '<plain>&\u200B')
|
|
assert.equal(userClipboard.customMimeType, USER_CONTENT_BLOCKS_MIME)
|
|
assert.deepEqual(JSON.parse(userClipboard.customData), { version: '1.0', blocks: [{ type: 'text', text: '<plain>&\u200B' }] })
|
|
const richClipboard = buildUserMessageClipboard({
|
|
...userMessage,
|
|
content: 'see <img>&\u200B',
|
|
attachments: [
|
|
{
|
|
id: 'image-1', name: 'preview.png', mimeType: 'image/png', size: 3,
|
|
previewUrl: 'data:image/png;base64,AQID', path: 'C:\\work\\preview.png'
|
|
},
|
|
{
|
|
id: 'file-1', name: 'notes.pdf', mimeType: 'application/pdf', size: 4,
|
|
path: 'C:\\work\\notes.pdf'
|
|
}
|
|
],
|
|
resources: ['C:\\work\\src.ts', 'C:\\work\\src.ts']
|
|
})
|
|
const richPayload = JSON.parse(richClipboard.customData) as {
|
|
version: string
|
|
blocks: Array<{ type: string; text?: string; data?: string; mimeType?: string; name?: string; uri?: string; _meta?: Record<string, unknown> }>
|
|
}
|
|
assert.equal(richPayload.version, '1.0')
|
|
assert.deepEqual(richPayload.blocks.map((block) => block.type), ['text', 'image', 'resource_link', 'resource_link', 'resource_link'],
|
|
'custom MIME should preserve the text, image, file, and both resource blocks')
|
|
assert.equal(richClipboard.customMimeType, 'application/x-cb-chat-contentblocks')
|
|
assert.equal(richClipboard.plainText, 'Context: C:\\work\\src.ts\n\nsee <img>&preview.pngnotes.pdf',
|
|
'plain text should summarize context links once and append attachment phrases')
|
|
assert.ok(richClipboard.html.includes('see <img>&\u200B'), 'HTML should escape text while retaining source zero-width markers')
|
|
assert.equal((richClipboard.html.match(/data-contentblock=/g) ?? []).length, 2,
|
|
'phrase attachment blocks should be carried in the HTML data-contentblock spans')
|
|
assert.ok(!richClipboard.html.includes('C:\\work\\src.ts'), 'context links are represented in plain text and custom MIME, not HTML')
|
|
assert.equal(richPayload.blocks[1].data, 'AQID')
|
|
assert.equal(richPayload.blocks[1].mimeType, 'image/png')
|
|
assert.equal(richPayload.blocks[1]._meta?.filename, 'preview.png')
|
|
assert.equal(richPayload.blocks[2].uri, 'C:\\work\\notes.pdf')
|
|
assert.equal(richPayload.blocks[2]._meta?.filePath, 'C:\\work\\notes.pdf')
|
|
assert.equal(richPayload.blocks[3]._meta?.displayAsContext, true)
|
|
const payload = buildCopyMessagePayload('request-1', [legacy])
|
|
assert.equal(payload.requestId, 'request-1')
|
|
assert.equal(payload.messageCount, 1)
|
|
assert.equal(payload.toolCallCount, 1)
|
|
assert.equal(payload.summary[0].contentBlockCount, 2, 'legacy content and tools both appear in fallback copy')
|
|
assert.equal((payload.messages[0].content?.[1] as { tool: { result: string } }).tool.result, 'full output')
|
|
assert.equal(JSON.parse(safeStringify(payload)).summary[0].toolCalls?.[0].id, 'legacy-tool')
|
|
const circular: { self?: unknown } = {}
|
|
circular.self = circular
|
|
assert.equal(JSON.parse(safeStringify(circular)).self, '[Circular]')
|
|
|
|
const requestPayload = buildCopyMessagePayload('request-2', [
|
|
{ id: 'user', role: 'user', content: 'question', createdAt: 1 } as ChatMessage,
|
|
{ id: 'a1', messageType: 'assistant', content: [{ type: 'text', text: 'one' }] },
|
|
{ id: 'a2', messageType: 'assistant', content: [{ type: 'text', text: 'two' }] }
|
|
])
|
|
assert.equal(requestPayload.messageCount, 2, 'request copy envelope includes assistant messages only')
|
|
const requestOrder = getSortedRequestMessages([
|
|
{ id: 'late', role: 'assistant', requestId: 'ordered', content: 'later', createdAt: 20 },
|
|
{ id: 'other', role: 'assistant', requestId: 'different', content: 'other', createdAt: 5 },
|
|
{ id: 'early', role: 'assistant', requestId: 'ordered', content: 'earlier', createdAt: 10 }
|
|
], 'ordered')
|
|
assert.deepEqual(requestOrder.map((item) => item.id), ['early', 'late'])
|
|
|
|
const samples = Float32Array.from([-1, -0.5, 0, 0.5, 1, 0])
|
|
assert.equal(downsamplePcm(samples, 32_000).length, 3)
|
|
const wav = encodePcmWav(samples)
|
|
assert.equal(new TextDecoder().decode(wav.slice(0, 4)), 'RIFF')
|
|
assert.equal(new DataView(wav.buffer).getUint32(24, true), 16_000)
|
|
assert.equal(new DataView(wav.buffer).getUint32(40, true), samples.length * 2)
|
|
assert.equal(VOICE_MAX_DURATION_MS, 295_000)
|
|
assert.deepEqual(oversizedAttachmentNames([
|
|
{ name: 'limit.bin', size: MAX_ATTACHMENT_BYTES },
|
|
{ name: 'over.bin', size: MAX_ATTACHMENT_BYTES + 1 }
|
|
]), ['over.bin'])
|
|
assert.deepEqual(buildComposerSendPayload(' prompt ', [], ['C:/work/file.ts'], 'full-access'), {
|
|
text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: [], mode: 'full-access'
|
|
})
|
|
const quotedPayload = buildComposerSendPayload('', [], [], 'full-access', [
|
|
{ id: 'm1-text-0:table:table-0', kind: 'table', sourceId: 'm1-text-0:table:table-0', messageId: 'm1', markdown: '| a |\n| - |\n| 1 |', preview: '| a |' }
|
|
])
|
|
assert.deepEqual(quotedPayload.quotes.map((quote) => quote.sourceId), ['m1-text-0:table:table-0'])
|
|
assert.equal(quotedPayload.text, '', '引用单独存在时正文可以为空')
|
|
assert.equal(parseResourcePath('file:///C:/work/file.ts'), 'C:\\work\\file.ts')
|
|
assert.equal(parseResourcePath('C:\\work\\file.ts'), 'C:\\work\\file.ts')
|
|
assert.deepEqual(readReferenceUris({ getData: (type) => type === 'application/vnd.code.uri-list' ? 'file:///C:/a.ts\r\nfile:///C:/b.ts' : '' }), ['file:///C:/a.ts', 'file:///C:/b.ts'])
|
|
assert.deepEqual(readReferenceUris({ getData: (type) => type === 'resourceurls' ? '["file:///C:/a.ts"]' : '' }), ['file:///C:/a.ts'])
|
|
|
|
const assistant: ChatMessage = { id: 'toolbar-assistant', role: 'assistant', content: 'done', createdAt: 1, requestId: 'request-3' }
|
|
const assistantMarkup = renderToStaticMarkup(<MessageActionToolbar message={assistant} canRegenerate isSessionActive isLastAssistant onRegenerate={() => undefined} />)
|
|
assert.match(assistantMarkup, /复制/)
|
|
assert.match(assistantMarkup, /重新生成/)
|
|
assert.doesNotMatch(assistantMarkup, /编辑/)
|
|
// 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。
|
|
const userMarkup = renderToStaticMarkup(<UserMessageToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: Date.now() }} canEdit onEdit={() => undefined} onRevert={() => undefined} />)
|
|
assert.match(userMarkup, /class="cr-toolbar"/)
|
|
assert.match(userMarkup, /cr-toolbar__time/)
|
|
assert.match(userMarkup, /data-action="copy"/)
|
|
assert.match(userMarkup, /data-action="revert"/)
|
|
assert.match(userMarkup, /data-action="edit"/)
|
|
assert.match(userMarkup, /cr-icon-button__icon/)
|
|
assert.doesNotMatch(userMarkup, /重新发送|重新生成|赞同|不赞同/)
|
|
assert.doesNotMatch(userMarkup, /wb-message-action__label/, '工具栏只保留图标,不再渲染文字标签')
|
|
|
|
const editorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
|
|
assert.match(editorMarkup, /class="cr-user-message-editor"/)
|
|
assert.match(editorMarkup, /cr-input-container-wrapper/)
|
|
assert.match(editorMarkup, /cr-input-editor-host/)
|
|
assert.match(editorMarkup, /data-slate-editor/)
|
|
assert.match(editorMarkup, /cr-user-message-editor__hint-icon/)
|
|
assert.match(editorMarkup, /编辑后将从此处重新开始对话,产物也将回退至此信息前版本/)
|
|
assert.match(editorMarkup, /cr-user-message-editor__cancel/)
|
|
assert.match(editorMarkup, /cr-user-message-editor__send/)
|
|
assert.match(editorMarkup, />取消</)
|
|
assert.match(editorMarkup, />发送</)
|
|
assert.doesNotMatch(editorMarkup, /role="alert"/, '有内容时不显示错误')
|
|
|
|
const emptyEditorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: '', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
|
|
assert.match(emptyEditorMarkup, /消息内容不能为空/)
|
|
assert.match(emptyEditorMarkup, /role="alert"/)
|
|
assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/)
|
|
|
|
const composerMarkup = renderToStaticMarkup(
|
|
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
|
|
)
|
|
assert.match(composerMarkup, /语音转写服务不可用/, 'without an ASR callback, voice input reports unavailable')
|
|
assert.match(composerMarkup, /默认权限/)
|
|
assert.match(composerMarkup, /添加/)
|
|
assert.match(composerMarkup, /Model slot/)
|
|
assert.match(composerMarkup, /Workspace slot/)
|
|
assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语音转写服务不可用'), true)
|
|
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('默认权限'))
|
|
assert.ok(composerMarkup.indexOf('默认权限') < composerMarkup.indexOf('cr-input-toolbar__right'), 'permission control belongs in the left toolbar')
|
|
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
|
|
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
|
|
assert.match(activeRunMarkup, /aria-label="停止"/)
|
|
assert.doesNotMatch(activeRunMarkup, /<textarea[^>]*disabled/)
|
|
|
|
console.log('Composer/message action regression cases passed')
|