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