336 lines
9.2 KiB
TypeScript
336 lines
9.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
findRedoUserMessage,
|
|
findUndoableUserMessage,
|
|
getRestorableUserText,
|
|
getStoredAttachmentsForRestore,
|
|
} from '@/pages/Chat/chat-command-message';
|
|
import { normalizeOpencodeSessionMessages } from '@/lib/opencode-message';
|
|
import type { RawMessage } from '@/types/chat';
|
|
|
|
const messages: RawMessage[] = [
|
|
{
|
|
id: 'msg_user_1',
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'Restore this text' },
|
|
{ type: 'text', text: 'Synthetic path context', synthetic: true },
|
|
{
|
|
id: 'part_image',
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'data:image/png;base64,QUFB',
|
|
},
|
|
],
|
|
_attachedFiles: [
|
|
{
|
|
fileName: 'screen.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 3,
|
|
preview: null,
|
|
source: 'message-ref',
|
|
},
|
|
{
|
|
fileName: 'notes.md',
|
|
mimeType: 'text/markdown',
|
|
fileSize: 0,
|
|
preview: null,
|
|
filePath: 'D:/repo/notes.md',
|
|
source: 'message-ref',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'msg_assistant_1',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Done' }],
|
|
},
|
|
{
|
|
id: 'msg_user_2',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Second request' }],
|
|
},
|
|
];
|
|
|
|
describe('chat command message helpers', () => {
|
|
it('restores only non-synthetic text', () => {
|
|
expect(getRestorableUserText(messages[0])).toBe('Restore this text');
|
|
});
|
|
|
|
it('finds undo before the revert cursor and fails closed when the cursor is absent', () => {
|
|
expect(findUndoableUserMessage(messages, undefined)?.id).toBe('msg_user_2');
|
|
expect(findUndoableUserMessage(messages, 'msg_user_2')?.id).toBe('msg_user_1');
|
|
expect(findUndoableUserMessage(messages, 'missing')).toBeNull();
|
|
expect(findUndoableUserMessage(
|
|
[messages[1], { id: 'msg_system', role: 'system', content: 'notice' }],
|
|
undefined,
|
|
)).toBeNull();
|
|
});
|
|
|
|
it('restores a normalized safe command invocation rather than hidden template text', () => {
|
|
const commandMessage: RawMessage = {
|
|
id: 'msg_command',
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: '/Review raw arguments ' },
|
|
{ type: 'text', text: 'Bounded selected path context', synthetic: true },
|
|
],
|
|
};
|
|
expect(findUndoableUserMessage([commandMessage], undefined)?.id).toBe('msg_command');
|
|
expect(getRestorableUserText(commandMessage)).toBe('/Review raw arguments');
|
|
});
|
|
|
|
it('finds the next user for redo and returns null when unrevert is next', () => {
|
|
expect(findRedoUserMessage(messages, 'msg_user_1')?.id).toBe('msg_user_2');
|
|
expect(findRedoUserMessage(messages, 'msg_user_2')).toBeNull();
|
|
expect(findRedoUserMessage(messages, 'missing')).toBeNull();
|
|
});
|
|
|
|
it('pairs stored images with a validated data url and leaves path files path-only', () => {
|
|
expect(getStoredAttachmentsForRestore(messages[0])).toEqual([
|
|
{
|
|
file: messages[0]._attachedFiles?.[0],
|
|
imageDataUrl: 'data:image/png;base64,QUFB',
|
|
},
|
|
{
|
|
file: messages[0]._attachedFiles?.[1],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('never treats a remote image url as restorable bytes', () => {
|
|
const remote: RawMessage = {
|
|
id: 'msg_remote',
|
|
role: 'user',
|
|
content: [{
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'https://runtime.invalid/private.png',
|
|
}],
|
|
_attachedFiles: [{
|
|
fileName: 'private.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 0,
|
|
preview: null,
|
|
}],
|
|
};
|
|
expect(getStoredAttachmentsForRestore(remote)).toEqual([
|
|
{ file: remote._attachedFiles?.[0] },
|
|
]);
|
|
});
|
|
|
|
it('preserves remote and embedded image positions when MIME types repeat', () => {
|
|
const interleaved: RawMessage = {
|
|
id: 'msg_interleaved',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
id: 'part_remote',
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'https://runtime.invalid/remote.png',
|
|
},
|
|
{
|
|
id: 'part_embedded',
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'data:image/png;base64,QUFB',
|
|
},
|
|
],
|
|
_attachedFiles: [
|
|
{
|
|
fileName: 'remote.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 0,
|
|
preview: null,
|
|
runtimeUrl: 'https://runtime.invalid/remote.png',
|
|
source: 'runtime-media',
|
|
},
|
|
{
|
|
fileName: 'embedded.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 3,
|
|
preview: null,
|
|
source: 'message-ref',
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(getStoredAttachmentsForRestore(interleaved)).toEqual([
|
|
{ file: interleaved._attachedFiles?.[0] },
|
|
{
|
|
file: interleaved._attachedFiles?.[1],
|
|
imageDataUrl: 'data:image/png;base64,QUFB',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('consumes the preview-backed image position before pairing the next embedded image', () => {
|
|
const previewThenEmbedded: RawMessage = {
|
|
id: 'msg_preview_then_embedded',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'https://runtime.invalid/preview-backed.png',
|
|
},
|
|
{
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'data:image/png;base64,QkJC',
|
|
},
|
|
],
|
|
_attachedFiles: [
|
|
{
|
|
fileName: 'preview-backed.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 3,
|
|
preview: 'data:image/png;base64,QUFB',
|
|
},
|
|
{
|
|
fileName: 'embedded.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 3,
|
|
preview: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(getStoredAttachmentsForRestore(previewThenEmbedded)).toEqual([
|
|
{
|
|
file: previewThenEmbedded._attachedFiles?.[0],
|
|
imageDataUrl: 'data:image/png;base64,QUFB',
|
|
},
|
|
{
|
|
file: previewThenEmbedded._attachedFiles?.[1],
|
|
imageDataUrl: 'data:image/png;base64,QkJC',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('rejects empty base64 payloads and MIME-conflicting data urls', () => {
|
|
const unsafe: RawMessage = {
|
|
id: 'msg_unsafe_images',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'data:image/png;base64, ',
|
|
},
|
|
{
|
|
type: 'image',
|
|
mimeType: 'image/png',
|
|
url: 'data:image/jpeg;base64,QUFB',
|
|
},
|
|
],
|
|
_attachedFiles: [
|
|
{
|
|
fileName: 'empty.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 0,
|
|
preview: null,
|
|
},
|
|
{
|
|
fileName: 'mismatch.png',
|
|
mimeType: 'image/png',
|
|
fileSize: 3,
|
|
preview: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(getStoredAttachmentsForRestore(unsafe)).toEqual([
|
|
{ file: unsafe._attachedFiles?.[0] },
|
|
{ file: unsafe._attachedFiles?.[1] },
|
|
]);
|
|
});
|
|
|
|
it('preserves duplicate same-name image parts and restores their bytes in order', () => {
|
|
const [normalized] = normalizeOpencodeSessionMessages([{
|
|
info: {
|
|
id: 'msg_duplicate_images',
|
|
role: 'user',
|
|
time: { created: 1_700_000_004_000 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: 'prt_duplicate_invocation',
|
|
type: 'command-invocation',
|
|
command: 'Review',
|
|
arguments: '',
|
|
contextPartIDs: ['prt_duplicate_image_a', 'prt_duplicate_image_b'],
|
|
},
|
|
{
|
|
id: 'prt_duplicate_image_a',
|
|
type: 'file',
|
|
mime: 'image/png',
|
|
filename: 'screen.png',
|
|
url: 'data:image/png;base64,QUFB',
|
|
},
|
|
{
|
|
id: 'prt_duplicate_image_b',
|
|
type: 'file',
|
|
mime: 'image/png',
|
|
filename: 'screen.png',
|
|
url: 'data:image/png;base64,QkJC',
|
|
},
|
|
],
|
|
}]);
|
|
|
|
expect(normalized?.content).toEqual([
|
|
{
|
|
id: 'prt_duplicate_invocation',
|
|
type: 'text',
|
|
text: '/Review',
|
|
},
|
|
expect.objectContaining({
|
|
id: 'prt_duplicate_image_a',
|
|
type: 'image',
|
|
url: 'data:image/png;base64,QUFB',
|
|
}),
|
|
expect.objectContaining({
|
|
id: 'prt_duplicate_image_b',
|
|
type: 'image',
|
|
url: 'data:image/png;base64,QkJC',
|
|
}),
|
|
]);
|
|
expect(normalized?._attachedFiles).toHaveLength(2);
|
|
expect(getStoredAttachmentsForRestore(normalized!)).toEqual([
|
|
{
|
|
file: normalized?._attachedFiles?.[0],
|
|
imageDataUrl: 'data:image/png;base64,QUFB',
|
|
},
|
|
{
|
|
file: normalized?._attachedFiles?.[1],
|
|
imageDataUrl: 'data:image/png;base64,QkJC',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('treats image MIME prefixes case-insensitively when restoring bytes', () => {
|
|
const uppercaseMime: RawMessage = {
|
|
id: 'msg_uppercase_mime',
|
|
role: 'user',
|
|
content: [{
|
|
id: 'part_uppercase_mime',
|
|
type: 'image',
|
|
mimeType: 'IMAGE/PNG',
|
|
url: 'data:image/png;base64,QUFB',
|
|
}],
|
|
_attachedFiles: [{
|
|
fileName: 'upper.png',
|
|
mimeType: 'IMAGE/PNG',
|
|
fileSize: 3,
|
|
preview: null,
|
|
}],
|
|
};
|
|
|
|
expect(getStoredAttachmentsForRestore(uppercaseMime)).toEqual([{
|
|
file: uppercaseMime._attachedFiles?.[0],
|
|
imageDataUrl: 'data:image/png;base64,QUFB',
|
|
}]);
|
|
});
|
|
});
|