mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
189 lines
7.2 KiB
TypeScript
189 lines
7.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
appendImagesInputTag,
|
|
buildClaudeUserContent,
|
|
buildCodexInputItems,
|
|
normalizeImageDescriptors,
|
|
parseImagesInputTag,
|
|
resolveImageMediaType,
|
|
toImageAttachments,
|
|
} from '@/shared/image-attachments.js';
|
|
|
|
// 1x1 transparent PNG
|
|
const PNG_BYTES = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
|
|
test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => {
|
|
const descriptors = normalizeImageDescriptors([
|
|
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
|
|
'scripts/pic.jpg',
|
|
{ name: 'no-path.png' },
|
|
42,
|
|
null,
|
|
'',
|
|
]);
|
|
|
|
assert.deepEqual(descriptors, [
|
|
{ path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' },
|
|
{ path: 'scripts/pic.jpg' },
|
|
]);
|
|
assert.deepEqual(normalizeImageDescriptors(undefined), []);
|
|
assert.deepEqual(normalizeImageDescriptors('not-an-array'), []);
|
|
});
|
|
|
|
test('appendImagesInputTag and parseImagesInputTag round-trip', () => {
|
|
const prompt = 'Describe these screenshots.\n\nFocus on the header.';
|
|
const tagged = appendImagesInputTag(prompt, [
|
|
{ path: '.cloudcli/assets/1-a.png' },
|
|
{ path: '.cloudcli\\assets\\2-b.jpg' },
|
|
]);
|
|
|
|
assert.ok(tagged.startsWith(prompt));
|
|
assert.ok(tagged.includes('<images_input>'));
|
|
assert.ok(tagged.includes('</images_input>'));
|
|
assert.ok(tagged.includes('The user attached 2 image(s)'));
|
|
|
|
const parsed = parseImagesInputTag(tagged);
|
|
assert.equal(parsed.text, prompt);
|
|
// Backslashes are normalized so references stay portable.
|
|
assert.deepEqual(parsed.imagePaths, ['.cloudcli/assets/1-a.png', '.cloudcli/assets/2-b.jpg']);
|
|
});
|
|
|
|
test('original filenames round-trip through the tag', () => {
|
|
const tagged = appendImagesInputTag('compare these', [
|
|
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot (final).png' },
|
|
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
|
|
]);
|
|
|
|
const parsed = parseImagesInputTag(tagged);
|
|
assert.equal(parsed.text, 'compare these');
|
|
// Parentheses are dropped from names so the "(original name: ...)" suffix
|
|
// stays parseable; the path-only entry carries no name.
|
|
assert.deepEqual(parsed.attachments, [
|
|
{ path: 'C:/Users/x/.cloudcli/assets/1-a.png', name: 'screenshot final.png' },
|
|
{ path: 'C:/Users/x/.cloudcli/assets/2-b.jpg' },
|
|
]);
|
|
});
|
|
|
|
test('only the LAST images_input block is treated as the attachment carrier', () => {
|
|
const userTypedTag = 'What does <images_input> mean in this codebase?';
|
|
const tagged = appendImagesInputTag(
|
|
`${userTypedTag}\n\n<images_input>\nfake user block\n</images_input>\n\nAlso check this.`,
|
|
[{ path: 'C:/Users/x/.cloudcli/assets/real.png' }],
|
|
);
|
|
|
|
const parsed = parseImagesInputTag(tagged);
|
|
assert.ok(parsed.text.includes('fake user block'));
|
|
assert.ok(parsed.text.includes('Also check this.'));
|
|
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/real.png']);
|
|
});
|
|
|
|
test('appendImagesInputTag without images returns the prompt untouched', () => {
|
|
assert.equal(appendImagesInputTag('hello', []), 'hello');
|
|
assert.equal(appendImagesInputTag('hello', undefined), 'hello');
|
|
});
|
|
|
|
test('parseImagesInputTag handles prompts flattened to one line for cmd.exe shims', () => {
|
|
// Windows spawn runtimes collapse newlines before passing the argument to
|
|
// .cmd-shimmed CLIs; the persisted prompt is then a single line.
|
|
const flattened = appendImagesInputTag('now?', [{ path: 'C:/Users/x/.cloudcli/assets/a.jpg' }])
|
|
.replace(/\s*\r?\n\s*/g, ' ')
|
|
.trim();
|
|
|
|
assert.ok(!flattened.includes('\n'));
|
|
const parsed = parseImagesInputTag(flattened);
|
|
assert.equal(parsed.text, 'now?');
|
|
assert.deepEqual(parsed.imagePaths, ['C:/Users/x/.cloudcli/assets/a.jpg']);
|
|
});
|
|
|
|
test('parseImagesInputTag leaves text without a tag untouched', () => {
|
|
const text = 'Just a normal prompt with [brackets] and JSON ["like"] content.';
|
|
const parsed = parseImagesInputTag(text);
|
|
assert.equal(parsed.text, text);
|
|
assert.deepEqual(parsed.imagePaths, []);
|
|
});
|
|
|
|
test('parseImagesInputTag strips a malformed tag body without attaching images', () => {
|
|
const text = 'prompt\n\n<images_input>\nnot json here\n</images_input>';
|
|
const parsed = parseImagesInputTag(text);
|
|
assert.equal(parsed.text, 'prompt');
|
|
assert.deepEqual(parsed.imagePaths, []);
|
|
});
|
|
|
|
test('toImageAttachments maps paths to posix attachment records', () => {
|
|
assert.deepEqual(toImageAttachments(['a\\b\\c.png', 'd/e.jpg']), [
|
|
{ path: 'a/b/c.png' },
|
|
{ path: 'd/e.jpg' },
|
|
]);
|
|
});
|
|
|
|
test('resolveImageMediaType prefers the mime type and falls back to the extension', () => {
|
|
assert.equal(resolveImageMediaType({ path: 'x.bin', mimeType: 'image/webp' }), 'image/webp');
|
|
assert.equal(resolveImageMediaType({ path: 'x.JPG' }), 'image/jpeg');
|
|
assert.equal(resolveImageMediaType({ path: 'x.png' }), 'image/png');
|
|
assert.equal(resolveImageMediaType({ path: 'x.unknown' }), null);
|
|
});
|
|
|
|
test('buildClaudeUserContent reads image bytes into base64 blocks', async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
|
|
try {
|
|
await writeFile(path.join(tempDir, 'shot.png'), PNG_BYTES);
|
|
|
|
const content = await buildClaudeUserContent(
|
|
'What is in this image?',
|
|
[{ path: 'shot.png', mimeType: 'image/png' }],
|
|
tempDir,
|
|
);
|
|
|
|
assert.equal(content.length, 2);
|
|
assert.deepEqual(content[0], { type: 'text', text: 'What is in this image?' });
|
|
assert.equal(content[1].type, 'image');
|
|
const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>;
|
|
assert.equal(imageBlock.source.type, 'base64');
|
|
assert.equal(imageBlock.source.media_type, 'image/png');
|
|
assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64'));
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('buildClaudeUserContent skips unsupported types and unreadable files', async () => {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-'));
|
|
try {
|
|
await writeFile(path.join(tempDir, 'vector.svg'), '<svg></svg>');
|
|
|
|
const content = await buildClaudeUserContent(
|
|
'prompt',
|
|
[
|
|
{ path: 'vector.svg', mimeType: 'image/svg+xml' },
|
|
{ path: 'missing.png', mimeType: 'image/png' },
|
|
],
|
|
tempDir,
|
|
);
|
|
|
|
// Only the text block survives; the prompt still goes through.
|
|
assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]);
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('buildCodexInputItems emits text plus absolute local_image paths', () => {
|
|
const cwd = path.join(os.tmpdir(), 'codex-project');
|
|
const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd);
|
|
|
|
assert.equal(items.length, 2);
|
|
assert.deepEqual(items[0], { type: 'text', text: 'Describe this image:' });
|
|
assert.equal(items[1].type, 'local_image');
|
|
const imageItem = items[1] as Extract<(typeof items)[number], { type: 'local_image' }>;
|
|
assert.ok(path.isAbsolute(imageItem.path));
|
|
assert.equal(imageItem.path, path.resolve(cwd, '.cloudcli/assets/pic.jpg'));
|
|
});
|