/ src / tests / attach-image.test.ts
attach-image.test.ts
  1  import { describe, test, expect, beforeAll, afterAll } from "@jest/globals";
  2  import { promises as fs } from "fs";
  3  import path from "path";
  4  import os from "os";
  5  import { handleReadTool } from "../tools/read-tools.js";
  6  import { setAllowedDirectories, getAllowedDirectories } from "../utils/lib.js";
  7  
  8  const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures");
  9  const TEST_WORKSPACE = path.join(os.tmpdir(), `vulcan-test-attach-img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
 10  const FIXTURES_DIR = path.join(TEST_WORKSPACE, "fixtures");
 11  
 12  // Test image files - now in src/tests/fixtures
 13  const TEST_IMAGE_1 = path.join(
 14    TEST_FIXTURES_DIR,
 15    "pexels-stephen-alicia-1315397-3063411.jpg"
 16  );
 17  const TEST_IMAGE_2 = path.join(TEST_FIXTURES_DIR, "pexels-stywo-1054218.jpg");
 18  
 19  async function setupTestEnvironment() {
 20    await fs.mkdir(FIXTURES_DIR, { recursive: true });
 21  
 22    // Register test directories
 23    const currentDirs = getAllowedDirectories();
 24    setAllowedDirectories([...currentDirs, TEST_FIXTURES_DIR, TEST_WORKSPACE]);
 25  }
 26  
 27  async function cleanupTestEnvironment() {
 28    try {
 29      await fs.rm(TEST_WORKSPACE, { recursive: true, force: true });
 30    } catch (error) {
 31      // Ignore cleanup errors
 32    }
 33  }
 34  
 35  describe("attach_image tool", () => {
 36    beforeAll(async () => {
 37      await setupTestEnvironment();
 38    });
 39  
 40    afterAll(async () => {
 41      await cleanupTestEnvironment();
 42    });
 43  
 44    describe("successful image attachment", () => {
 45      test("attaches JPEG image in correct MCP format", async () => {
 46        const result = await handleReadTool("attach_image", {
 47          path: TEST_IMAGE_1,
 48        });
 49  
 50        // Verify response structure
 51        expect(result.content).toBeDefined();
 52        expect(Array.isArray(result.content)).toBe(true);
 53        expect(result.content.length).toBe(1);
 54  
 55        const content = result.content[0] as any;
 56  
 57        // Verify MCP format (type, data, mimeType)
 58        expect(content.type).toBe("image");
 59        expect(content.data).toBeTruthy();
 60        expect(typeof content.data).toBe("string");
 61        expect(content.mimeType).toBe("image/jpeg");
 62  
 63        // Old source wrapper should NOT exist
 64        expect(content.source).toBeUndefined();
 65  
 66        // Verify base64 is valid
 67        expect(() => Buffer.from(content.data, "base64")).not.toThrow();
 68  
 69        // Verify base64 data is substantial (not empty/corrupted)
 70        const decoded = Buffer.from(content.data, "base64");
 71        expect(decoded.length).toBeGreaterThan(1000); // Real images should be >1KB
 72      });
 73  
 74      test("attaches second JPEG image successfully", async () => {
 75        const result = await handleReadTool("attach_image", {
 76          path: TEST_IMAGE_2,
 77        });
 78  
 79        expect(result.content).toBeDefined();
 80        expect((result.content[0] as any).type).toBe("image");
 81        expect((result.content[0] as any).mimeType).toBe("image/jpeg");
 82        expect((result.content[0] as any).data).toBeTruthy();
 83  
 84        // Verify different images have different base64 data
 85        const result1 = await handleReadTool("attach_image", {
 86          path: TEST_IMAGE_1,
 87        });
 88        expect((result.content[0] as any).data).not.toBe(
 89          (result1.content[0] as any).data
 90        );
 91      });
 92  
 93      test("handles .jpg extension (alternative JPEG)", async () => {
 94        // Create a copy with .jpg extension
 95        const jpgPath = path.join(FIXTURES_DIR, "test-image.jpg");
 96        await fs.copyFile(TEST_IMAGE_1, jpgPath);
 97  
 98        const result = await handleReadTool("attach_image", {
 99          path: jpgPath,
100        });
101  
102        expect((result.content[0] as any).mimeType).toBe("image/jpeg");
103        expect((result.content[0] as any).data).toBeTruthy();
104      });
105  
106      test("returns no 'data' or 'mimeType' at root level (old format)", async () => {
107        const result = await handleReadTool("attach_image", {
108          path: TEST_IMAGE_1,
109        });
110  
111        const content = result.content[0] as any;
112  
113        // Correct MCP format has data and mimeType at root level
114        expect(content.data).toBeDefined();
115        expect(content.mimeType).toBeDefined();
116        expect(content.type).toBe("image");
117  
118        // Old source wrapper should NOT exist
119        expect(content.source).toBeUndefined();
120      });
121    });
122  
123    describe("supported image formats", () => {
124      test("supports PNG format", async () => {
125        // Create a minimal 1x1 PNG
126        const pngPath = path.join(FIXTURES_DIR, "test.png");
127        const pngData = Buffer.from(
128          "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
129          "base64"
130        );
131        await fs.writeFile(pngPath, pngData);
132  
133        const result = await handleReadTool("attach_image", {
134          path: pngPath,
135        });
136  
137        expect((result.content[0] as any).mimeType).toBe("image/png");
138      });
139  
140      test("supports GIF format", async () => {
141        // Create a minimal 1x1 GIF
142        const gifPath = path.join(FIXTURES_DIR, "test.gif");
143        const gifData = Buffer.from(
144          "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
145          "base64"
146        );
147        await fs.writeFile(gifPath, gifData);
148  
149        const result = await handleReadTool("attach_image", {
150          path: gifPath,
151        });
152  
153        expect((result.content[0] as any).mimeType).toBe("image/gif");
154      });
155  
156      test("supports WebP format", async () => {
157        const webpPath = path.join(FIXTURES_DIR, "test.webp");
158        // Minimal WebP file (1x1 pixel)
159        const webpData = Buffer.from(
160          "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=",
161          "base64"
162        );
163        await fs.writeFile(webpPath, webpData);
164  
165        const result = await handleReadTool("attach_image", {
166          path: webpPath,
167        });
168  
169        expect((result.content[0] as any).mimeType).toBe("image/webp");
170      });
171  
172      test("supports BMP format", async () => {
173        const bmpPath = path.join(FIXTURES_DIR, "test.bmp");
174        // Minimal BMP file header + 1x1 pixel
175        const bmpData = Buffer.from(
176          "Qk1+AAAAAAAAAHoAAAAMAAAAAAEAAQABACAAACQA",
177          "base64"
178        );
179        await fs.writeFile(bmpPath, bmpData);
180  
181        const result = await handleReadTool("attach_image", {
182          path: bmpPath,
183        });
184  
185        expect((result.content[0] as any).mimeType).toBe("image/bmp");
186      });
187  
188      test("supports SVG format", async () => {
189        const svgPath = path.join(FIXTURES_DIR, "test.svg");
190        const svgData =
191          '<svg width="1" height="1"><rect width="1" height="1" fill="red"/></svg>';
192        await fs.writeFile(svgPath, svgData);
193  
194        const result = await handleReadTool("attach_image", {
195          path: svgPath,
196        });
197  
198        expect((result.content[0] as any).mimeType).toBe("image/svg+xml");
199      });
200    });
201  
202    describe("error handling", () => {
203      test("rejects unsupported audio formats (MP3)", async () => {
204        const mp3Path = path.join(FIXTURES_DIR, "test.mp3");
205        await fs.writeFile(mp3Path, Buffer.from("fake audio data"));
206  
207        await expect(
208          handleReadTool("attach_image", { path: mp3Path })
209        ).rejects.toThrow("Unsupported image format: .mp3");
210  
211        await expect(
212          handleReadTool("attach_image", { path: mp3Path })
213        ).rejects.toThrow("Supported formats: PNG, JPEG, GIF, WebP, BMP, SVG");
214      });
215  
216      test("rejects unsupported audio formats (WAV)", async () => {
217        const wavPath = path.join(FIXTURES_DIR, "test.wav");
218        await fs.writeFile(wavPath, Buffer.from("fake audio data"));
219  
220        await expect(
221          handleReadTool("attach_image", { path: wavPath })
222        ).rejects.toThrow("Unsupported image format: .wav");
223      });
224  
225      test("rejects unsupported audio formats (OGG)", async () => {
226        const oggPath = path.join(FIXTURES_DIR, "test.ogg");
227        await fs.writeFile(oggPath, Buffer.from("fake audio data"));
228  
229        await expect(
230          handleReadTool("attach_image", { path: oggPath })
231        ).rejects.toThrow("Unsupported image format: .ogg");
232      });
233  
234      test("rejects unsupported audio formats (FLAC)", async () => {
235        const flacPath = path.join(FIXTURES_DIR, "test.flac");
236        await fs.writeFile(flacPath, Buffer.from("fake audio data"));
237  
238        await expect(
239          handleReadTool("attach_image", { path: flacPath })
240        ).rejects.toThrow("Unsupported image format: .flac");
241      });
242  
243      test("rejects unsupported file formats (PDF)", async () => {
244        const pdfPath = path.join(TEST_FIXTURES_DIR, "sample.pdf");
245  
246        await expect(
247          handleReadTool("attach_image", { path: pdfPath })
248        ).rejects.toThrow("Unsupported image format: .pdf");
249      });
250  
251      test("rejects unsupported file formats (TXT)", async () => {
252        const txtPath = path.join(FIXTURES_DIR, "test.txt");
253        await fs.writeFile(txtPath, "plain text");
254  
255        await expect(
256          handleReadTool("attach_image", { path: txtPath })
257        ).rejects.toThrow("Unsupported image format: .txt");
258      });
259  
260      test("rejects paths outside allowed directories", async () => {
261        const outsidePath = path.join("/", "tmp", "image.png");
262  
263        await expect(
264          handleReadTool("attach_image", { path: outsidePath })
265        ).rejects.toThrow();
266      });
267  
268      test("handles missing files gracefully", async () => {
269        const nonExistentPath = path.join(FIXTURES_DIR, "nonexistent.png");
270  
271        await expect(
272          handleReadTool("attach_image", { path: nonExistentPath })
273        ).rejects.toThrow();
274      });
275  
276      test("validates arguments schema", async () => {
277        await expect(handleReadTool("attach_image", {})).rejects.toThrow(
278          "Invalid arguments for attach_image"
279        );
280  
281        await expect(
282          handleReadTool("attach_image", { path: 123 })
283        ).rejects.toThrow("Invalid arguments for attach_image");
284  
285        await expect(
286          handleReadTool("attach_image", { wrong_field: "test.png" })
287        ).rejects.toThrow("Invalid arguments for attach_image");
288      });
289  
290      test("handles corrupted image files", async () => {
291        const corruptedPath = path.join(FIXTURES_DIR, "corrupted.png");
292        await fs.writeFile(corruptedPath, "not a real PNG file");
293  
294        // Should still return the data (MCP client will handle validation)
295        const result = await handleReadTool("attach_image", {
296          path: corruptedPath,
297        });
298  
299        expect((result.content[0] as any).mimeType).toBe("image/png");
300        expect((result.content[0] as any).data).toBeTruthy();
301      });
302    });
303  
304    describe("case sensitivity", () => {
305      test("handles uppercase extensions (PNG)", async () => {
306        const upperPath = path.join(FIXTURES_DIR, "test.PNG");
307        await fs.copyFile(TEST_IMAGE_1, upperPath);
308  
309        const result = await handleReadTool("attach_image", {
310          path: upperPath,
311        });
312  
313        expect((result.content[0] as any).mimeType).toBe("image/png");
314      });
315  
316      test("handles uppercase extensions (JPG)", async () => {
317        const upperPath = path.join(FIXTURES_DIR, "test.JPG");
318        await fs.copyFile(TEST_IMAGE_1, upperPath);
319  
320        const result = await handleReadTool("attach_image", {
321          path: upperPath,
322        });
323  
324        expect((result.content[0] as any).mimeType).toBe("image/jpeg");
325      });
326  
327      test("handles mixed case extensions (JpEg)", async () => {
328        const mixedPath = path.join(FIXTURES_DIR, "test.JpEg");
329        await fs.copyFile(TEST_IMAGE_1, mixedPath);
330  
331        const result = await handleReadTool("attach_image", {
332          path: mixedPath,
333        });
334  
335        expect((result.content[0] as any).mimeType).toBe("image/jpeg");
336      });
337    });
338  
339    describe("base64 encoding validation", () => {
340      test("base64 data can be decoded back to binary", async () => {
341        const result = await handleReadTool("attach_image", {
342          path: TEST_IMAGE_1,
343        });
344  
345        const base64Data = (result.content[0] as any).data;
346        const decoded = Buffer.from(base64Data, "base64");
347  
348        // Verify decoded buffer is valid
349        expect(decoded.length).toBeGreaterThan(0);
350  
351        // JPEG files start with FF D8 FF
352        expect(decoded[0]).toBe(0xff);
353        expect(decoded[1]).toBe(0xd8);
354        expect(decoded[2]).toBe(0xff);
355      });
356  
357      test("base64 encoding preserves file content", async () => {
358        const originalFile = await fs.readFile(TEST_IMAGE_1);
359        const result = await handleReadTool("attach_image", {
360          path: TEST_IMAGE_1,
361        });
362  
363        const base64Data = (result.content[0] as any).data;
364        const decoded = Buffer.from(base64Data, "base64");
365  
366        // Decoded data should match original file exactly
367        expect(decoded.equals(originalFile)).toBe(true);
368      });
369  
370      test("base64 contains no whitespace or newlines", async () => {
371        const result = await handleReadTool("attach_image", {
372          path: TEST_IMAGE_1,
373        });
374  
375        const base64Data = (result.content[0] as any).data;
376  
377        // Base64 should be continuous without whitespace
378        expect(base64Data).not.toMatch(/\s/);
379        expect(base64Data).not.toMatch(/\n/);
380        expect(base64Data).not.toMatch(/\r/);
381      });
382    });
383  
384    describe("MCP specification compliance", () => {
385      test("returns exact MCP format structure", async () => {
386        const result = await handleReadTool("attach_image", {
387          path: TEST_IMAGE_1,
388        });
389  
390        // Verify top-level structure
391        expect(result).toHaveProperty("content");
392        expect(Array.isArray(result.content)).toBe(true);
393  
394        // Verify content item structure - new correct format
395        const content = result.content[0] as any;
396        expect(content).toHaveProperty("type");
397        expect(content).toHaveProperty("data");
398        expect(content).toHaveProperty("mimeType");
399        expect(content.type).toBe("image");
400        expect(content.mimeType).toBe("image/jpeg");
401        expect(typeof content.data).toBe("string");
402  
403        // Old source wrapper should NOT exist
404        expect(content).not.toHaveProperty("source");
405      });
406  
407      test("content array contains exactly one item", async () => {
408        const result = await handleReadTool("attach_image", {
409          path: TEST_IMAGE_1,
410        });
411  
412        expect(result.content.length).toBe(1);
413      });
414  
415      test("media_type uses correct MIME format", async () => {
416        const formats = [
417          { ext: "png", mime: "image/png" },
418          { ext: "jpg", mime: "image/jpeg" },
419          { ext: "gif", mime: "image/gif" },
420          { ext: "webp", mime: "image/webp" },
421          { ext: "bmp", mime: "image/bmp" },
422        ];
423  
424        for (const format of formats) {
425          const testPath = path.join(FIXTURES_DIR, `test.${format.ext}`);
426          await fs.copyFile(TEST_IMAGE_1, testPath);
427  
428          const result = await handleReadTool("attach_image", {
429            path: testPath,
430          });
431  
432          expect((result.content[0] as any).mimeType).toBe(format.mime);
433          expect((result.content[0] as any).mimeType).toMatch(/^image\//);
434        }
435      });
436    });
437  
438    describe("Batch Image Attachment", () => {
439      test("attaches multiple images in a single call", async () => {
440        const result = await handleReadTool("attach_image", {
441          path: [TEST_IMAGE_1, TEST_IMAGE_2],
442        });
443  
444        // Should return array with both images
445        expect(result.content).toBeDefined();
446        expect(Array.isArray(result.content)).toBe(true);
447        expect(result.content.length).toBe(2);
448  
449        // First image
450        const image1 = result.content[0] as any;
451        expect(image1.type).toBe("image");
452        expect(image1.data).toBeTruthy();
453        expect(image1.mimeType).toBe("image/jpeg");
454        expect(typeof image1.data).toBe("string");
455  
456        // Second image
457        const image2 = result.content[1] as any;
458        expect(image2.type).toBe("image");
459        expect(image2.data).toBeTruthy();
460        expect(image2.mimeType).toBe("image/jpeg");
461        expect(typeof image2.data).toBe("string");
462  
463        // Images should have different base64 data
464        expect(image1.data).not.toBe(image2.data);
465      });
466  
467      test("handles mixed image formats in batch", async () => {
468        // Create a PNG copy for testing
469        const pngPath = path.join(FIXTURES_DIR, "test-batch.png");
470        await fs.copyFile(TEST_IMAGE_1, pngPath);
471  
472        const result = await handleReadTool("attach_image", {
473          path: [TEST_IMAGE_2, pngPath],
474        });
475  
476        expect(result.content.length).toBe(2);
477        expect((result.content[0] as any).mimeType).toBe("image/jpeg");
478        expect((result.content[1] as any).mimeType).toBe("image/png");
479      });
480  
481      test("single path string still works (backward compatibility)", async () => {
482        const result = await handleReadTool("attach_image", {
483          path: TEST_IMAGE_1, // Single string, not array
484        });
485  
486        expect(result.content).toBeDefined();
487        expect(Array.isArray(result.content)).toBe(true);
488        expect(result.content.length).toBe(1);
489        expect((result.content[0] as any).type).toBe("image");
490        expect((result.content[0] as any).mimeType).toBe("image/jpeg");
491      });
492  
493      test("rejects batch with unsupported format", async () => {
494        const mp3Path = path.join(FIXTURES_DIR, "test.mp3");
495        await fs.writeFile(mp3Path, "fake mp3 content");
496  
497        await expect(
498          handleReadTool("attach_image", {
499            path: [TEST_IMAGE_1, mp3Path],
500          })
501        ).rejects.toThrow(/Unsupported image format/);
502      });
503  
504      test("batch with nonexistent file fails gracefully", async () => {
505        await expect(
506          handleReadTool("attach_image", {
507            path: [TEST_IMAGE_1, path.join(FIXTURES_DIR, "nonexistent.jpg")],
508          })
509        ).rejects.toThrow();
510      });
511    });
512  });