Files
claudecodeui/server/shared/tests/image-attachments.test.ts
2026-07-06 13:32:06 +03:00

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'));
});