Files
ycode/scripts/composer-regression.tsx
T

153 lines
10 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 { 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, '&lt;plain&gt;&amp;\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 &lt;img&gt;&amp;\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'], mode: 'full-access'
})
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, /编辑/)
const userMarkup = renderToStaticMarkup(<MessageActionToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onEdit={() => undefined} onRevert={() => undefined} onResend={() => undefined} />)
assert.match(userMarkup, /编辑/)
assert.match(userMarkup, /重新发送/)
assert.doesNotMatch(userMarkup, /重新生成/)
assert.doesNotMatch(userMarkup, /赞同|不赞同/, 'feedback actions are assistant-only')
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')