/**
* view_image 工具实体夹具套件(v0.7.5 覆盖补齐)
*
* 以真实临时目录为夹具,锁定:
* - path 必填 / 工作空间越界拒绝 / 扩展名白名单(png/jpg/jpeg/gif/webp/bmp/svg)
* - 5MB 大小闸门 / 文件不存在 / MIME 映射 / dataUrl 载荷形态
* - 大小写扩展名归一 / 子目录路径
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { ViewImageTool } from '../view-image';
import type { ToolExecutionContext } from '../../../types/metona-tool';
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
interface ViewResult {
success: boolean;
error?: string;
path?: string;
size?: number;
mimeType?: string;
dataUrl?: string;
supportedFormats?: string[];
}
describe('view_image', () => {
let ws: string;
let tool: ViewImageTool;
const ctx = (): ToolExecutionContext => ({
sessionId: 't',
workspacePath: ws,
iteration: 1,
requestId: 'r',
});
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-img-'));
tool = new ViewImageTool();
// 最小合法 PNG 载荷(8 字节签名 + 少量内容)
writeFileSync(
join(ws, 'pic.png'),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02]),
);
writeFileSync(join(ws, 'photo.JPG'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
writeFileSync(join(ws, 'anim.gif'), Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]));
writeFileSync(join(ws, 'vector.svg'), '');
writeFileSync(join(ws, 'bits.webp'), 'RIFF\x00\x00\x00\x00WEBPVP8 ');
writeFileSync(join(ws, 'bitmap.bmp'), 'BM\x00\x00');
writeFileSync(join(ws, 'doc.txt'), 'plain text');
mkdirSync(join(ws, 'sub'), { recursive: true });
writeFileSync(
join(ws, 'sub', 'nested.png'),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
// 超限文件(> 5MB)
writeFileSync(join(ws, 'huge.png'), Buffer.alloc(MAX_IMAGE_BYTES + 5, 0x61));
});
afterAll(() => {
try {
rmSync(ws, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('path 缺失 → 失败并提示 path is required', async () => {
const r = (await tool.execute({}, ctx())) as ViewResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('path is required');
});
it('path 为 null → 同样失败', async () => {
const r = (await tool.execute({ path: null }, ctx())) as ViewResult;
expect(r.success).toBe(false);
});
it('工作空间外绝对路径 → Path outside workspace', async () => {
const outside = process.platform === 'win32' ? 'C:\\Windows\\notepad.exe' : '/etc/passwd';
const r = (await tool.execute({ path: outside }, ctx())) as ViewResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('Path outside workspace');
});
it('路径遍历(../ 跳出)→ 拒绝', async () => {
const r = (await tool.execute({ path: '../outside.png' }, ctx())) as ViewResult;
expect(r.success).toBe(false);
});
it('不支持扩展名(.txt)→ Unsupported image format 且列出支持列表', async () => {
const r = (await tool.execute({ path: 'doc.txt' }, ctx())) as ViewResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('Unsupported image format');
expect(Array.isArray(r.supportedFormats)).toBe(true);
expect(r.supportedFormats).toContain('.png');
expect(r.supportedFormats).toContain('.svg');
});
it('无扩展名文件 → 不支持格式', async () => {
const r = (await tool.execute({ path: 'noext' }, ctx())) as ViewResult;
expect(r.success).toBe(false);
});
it('支持格式中不存在的文件 → File not found', async () => {
const r = (await tool.execute({ path: 'missing.png' }, ctx())) as ViewResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('File not found');
});
it('超过 5MB → Image too large (max 5MB) 且回传 size', async () => {
const r = (await tool.execute({ path: 'huge.png' }, ctx())) as ViewResult;
expect(r.success).toBe(false);
expect(String(r.error)).toContain('Image too large');
expect(Number(r.size)).toBe(MAX_IMAGE_BYTES + 5);
});
it('PNG 成功:mimeType=image/png、dataUrl 前缀正确', async () => {
const r = (await tool.execute({ path: 'pic.png' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.mimeType).toBe('image/png');
expect(r.size).toBe(10);
expect(String(r.dataUrl)).toMatch(/^data:image\/png;base64,/);
});
it('dataUrl 载荷解码后与源文件字节一致', async () => {
const r = (await tool.execute({ path: 'pic.png' }, ctx())) as ViewResult;
const b64 = String(r.dataUrl).slice('data:image/png;base64,'.length);
const decoded = Buffer.from(b64, 'base64');
expect([...decoded]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02]);
});
it('大写扩展名 .JPG 归一为 image/jpeg', async () => {
const r = (await tool.execute({ path: 'photo.JPG' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.mimeType).toBe('image/jpeg');
});
it('.jpeg 与 .jpg 共用 image/jpeg(同名映射)', async () => {
writeFileSync(join(ws, 'dual.jpeg'), Buffer.from([0xff, 0xd8, 0xff]));
const r = (await tool.execute({ path: 'dual.jpeg' }, ctx())) as ViewResult;
expect(r.mimeType).toBe('image/jpeg');
});
it('GIF → image/gif', async () => {
const r = (await tool.execute({ path: 'anim.gif' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.mimeType).toBe('image/gif');
});
it('WEBP → image/webp', async () => {
const r = (await tool.execute({ path: 'bits.webp' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.mimeType).toBe('image/webp');
});
it('BMP → image/bmp', async () => {
const r = (await tool.execute({ path: 'bitmap.bmp' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.mimeType).toBe('image/bmp');
});
it('SVG → image/svg+xml', async () => {
const r = (await tool.execute({ path: 'vector.svg' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.mimeType).toBe('image/svg+xml');
expect(String(r.dataUrl)).toMatch(/^data:image\/svg\+xml;base64,/);
});
it('子目录路径可读取(仍在校验边界内)', async () => {
const r = (await tool.execute({ path: 'sub/nested.png' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.size).toBe(8);
});
it('空文件(0 字节)可正常读取', async () => {
writeFileSync(join(ws, 'empty.png'), Buffer.alloc(0));
const r = (await tool.execute({ path: 'empty.png' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
expect(r.size).toBe(0);
});
it('相对路径带 ./ 前缀可读', async () => {
const r = (await tool.execute({ path: './pic.png' }, ctx())) as ViewResult;
expect(r.success).toBe(true);
});
it('目录路径(扩展名为 .png 的目录)→ 读取失败(stat 非文件)', async () => {
mkdirSync(join(ws, 'dir.png'), { recursive: true });
const r = (await tool.execute({ path: 'dir.png' }, ctx())) as ViewResult;
expect(r.success).toBe(false);
});
it('返回值不含 dataUrl 之外的泄露字段(path/size/mimeType 齐全)', async () => {
const r = (await tool.execute({ path: 'pic.png' }, ctx())) as Record;
expect(r.success).toBe(true);
expect(r.path).toBe('pic.png');
expect(typeof r.dataUrl).toBe('string');
});
});