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