write-documents.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 { handleWriteTool } from "../tools/write-tools.js"; 6 import { handleReadTool } from "../tools/read-tools.js"; 7 import { setAllowedDirectories, getAllowedDirectories } from "../utils/lib.js"; 8 9 const TEST_WORKSPACE = path.join( 10 os.tmpdir(), 11 `vulcan-test-write-docs-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, 12 ); 13 const OUTPUT_DIR = path.join(TEST_WORKSPACE, "write-output"); 14 15 // Helper to set test roots 16 async function setupTestEnvironment() { 17 // Create output directory 18 await fs.mkdir(OUTPUT_DIR, { recursive: true }); 19 20 // Register test directories 21 const currentDirs = getAllowedDirectories(); 22 setAllowedDirectories([...currentDirs, TEST_WORKSPACE]); 23 } 24 25 async function cleanupTestEnvironment() { 26 try { 27 await fs.rm(TEST_WORKSPACE, { recursive: true, force: true }); 28 } catch (error) { 29 // Ignore cleanup errors 30 } 31 32 // Clean up any stray test files in root directory 33 const rootDir = path.join(__dirname, "..", ".."); 34 const strayFiles = ["test-output.docx", "test-output.pdf"]; 35 for (const file of strayFiles) { 36 try { 37 await fs.unlink(path.join(rootDir, file)); 38 } catch (error) { 39 // Ignore if file doesn't exist 40 } 41 } 42 } 43 44 describe("write_file with PDF", () => { 45 beforeAll(async () => { 46 await setupTestEnvironment(); 47 }); 48 49 afterAll(async () => { 50 await cleanupTestEnvironment(); 51 }); 52 53 test("creates PDF from plain text", async () => { 54 const testContent = 55 "This is a test PDF document.\nWith multiple lines.\nAnd some content."; 56 const pdfPath = path.join(OUTPUT_DIR, "test-output.pdf"); 57 58 // Write PDF 59 const writeResult = await handleWriteTool("write_file", { 60 path: pdfPath, 61 content: testContent, 62 }); 63 64 expect(writeResult.content).toBeDefined(); 65 expect(writeResult.content[0].type).toBe("text"); 66 expect((writeResult.content[0] as any).text).toContain( 67 "Successfully wrote", 68 ); 69 70 // Verify file was created 71 const stats = await fs.stat(pdfPath); 72 expect(stats.isFile()).toBe(true); 73 expect(stats.size).toBeGreaterThan(0); 74 }); 75 76 test("creates PDF with long content and pagination", async () => { 77 const longContent = Array(100) 78 .fill( 79 "This is line content that should span multiple pages when rendered in PDF format.", 80 ) 81 .join("\n"); 82 const pdfPath = path.join(OUTPUT_DIR, "long-content.pdf"); 83 84 const result = await handleWriteTool("write_file", { 85 path: pdfPath, 86 content: longContent, 87 }); 88 89 expect(result.content[0].type).toBe("text"); 90 91 const stats = await fs.stat(pdfPath); 92 expect(stats.size).toBeGreaterThan(1000); // Should be a substantial file 93 }); 94 95 test("overwrites existing PDF", async () => { 96 const pdfPath = path.join(OUTPUT_DIR, "overwrite-test.pdf"); 97 98 // Write initial PDF 99 await handleWriteTool("write_file", { 100 path: pdfPath, 101 content: "First version", 102 }); 103 104 const firstStats = await fs.stat(pdfPath); 105 106 // Overwrite with different content 107 await handleWriteTool("write_file", { 108 path: pdfPath, 109 content: "Second version with more content to make it larger", 110 }); 111 112 const secondStats = await fs.stat(pdfPath); 113 114 // File should exist and have different size 115 expect(secondStats.isFile()).toBe(true); 116 expect(secondStats.size).not.toBe(firstStats.size); 117 }); 118 119 test("creates PDF from HTML content with rich formatting", async () => { 120 const htmlContent = ` 121 <html> 122 <body> 123 <h1 style="color: #2c3e50;">PDF Test Document</h1> 124 <p>This PDF has <strong>bold</strong> and <em>italic</em> formatting.</p> 125 <ul> 126 <li>First bullet point</li> 127 <li>Second bullet point</li> 128 </ul> 129 </body> 130 </html> 131 `; 132 const pdfPath = path.join(OUTPUT_DIR, "html-formatted.pdf"); 133 134 const writeResult = await handleWriteTool("write_file", { 135 path: pdfPath, 136 content: htmlContent, 137 }); 138 139 expect(writeResult.content[0].type).toBe("text"); 140 expect((writeResult.content[0] as any).text).toContain( 141 "Successfully wrote", 142 ); 143 144 const stats = await fs.stat(pdfPath); 145 expect(stats.isFile()).toBe(true); 146 expect(stats.size).toBeGreaterThan(0); 147 148 // Read back to verify content 149 const readResult = await handleReadTool("read_file", { 150 path: pdfPath, 151 }); 152 const content = readResult.content[0] as { type: string; text: string }; 153 expect(content.text).toContain("PDF Test Document"); 154 expect(content.text).toContain("Format: PDF"); 155 }, 15000); 156 157 test("creates PDF from HTML with table", async () => { 158 const htmlContent = ` 159 <html> 160 <body> 161 <h2>Financial Data</h2> 162 <table style="width: 100%;"> 163 <tr style="background-color: #ecf0f1;"> 164 <th>Quarter</th> 165 <th>Revenue</th> 166 </tr> 167 <tr> 168 <td>Q1</td> 169 <td>$100,000</td> 170 </tr> 171 <tr> 172 <td>Q2</td> 173 <td>$150,000</td> 174 </tr> 175 </table> 176 </body> 177 </html> 178 `; 179 const pdfPath = path.join(OUTPUT_DIR, "html-table.pdf"); 180 181 await handleWriteTool("write_file", { 182 path: pdfPath, 183 content: htmlContent, 184 }); 185 186 const stats = await fs.stat(pdfPath); 187 expect(stats.isFile()).toBe(true); 188 expect(stats.size).toBeGreaterThan(1000); // Should be substantial with table 189 }, 15000); 190 }); 191 192 describe("write_file with DOCX", () => { 193 beforeAll(async () => { 194 await setupTestEnvironment(); 195 // Ensure OUTPUT_DIR exists 196 await fs.mkdir(OUTPUT_DIR, { recursive: true }); 197 }); 198 199 afterAll(async () => { 200 await cleanupTestEnvironment(); 201 }); 202 203 test("creates DOCX from plain text", async () => { 204 const testContent = 205 "This is a test DOCX document.\nWith multiple paragraphs.\nAnd some content."; 206 const docxPath = path.join(OUTPUT_DIR, "test-output.docx"); 207 208 // Write DOCX 209 const writeResult = await handleWriteTool("write_file", { 210 path: docxPath, 211 content: testContent, 212 }); 213 214 expect(writeResult.content).toBeDefined(); 215 expect(writeResult.content[0].type).toBe("text"); 216 expect((writeResult.content[0] as any).text).toContain( 217 "Successfully wrote", 218 ); 219 220 // Verify file was created 221 const stats = await fs.stat(docxPath); 222 expect(stats.isFile()).toBe(true); 223 expect(stats.size).toBeGreaterThan(0); 224 }); 225 226 test("creates DOCX from HTML content with rich formatting", async () => { 227 const htmlContent = ` 228 <html> 229 <body> 230 <h1 style="color: #2c3e50;">Test Document</h1> 231 <p>This is a <strong>bold</strong> and <em>italic</em> test.</p> 232 <ul> 233 <li>Item 1</li> 234 <li>Item 2</li> 235 </ul> 236 </body> 237 </html> 238 `; 239 const docxPath = path.join(OUTPUT_DIR, "html-formatted.docx"); 240 241 const writeResult = await handleWriteTool("write_file", { 242 path: docxPath, 243 content: htmlContent, 244 }); 245 246 expect(writeResult.content[0].type).toBe("text"); 247 expect((writeResult.content[0] as any).text).toContain( 248 "Successfully wrote", 249 ); 250 251 const stats = await fs.stat(docxPath); 252 expect(stats.isFile()).toBe(true); 253 expect(stats.size).toBeGreaterThan(0); 254 255 // Read back to verify content was converted 256 const readResult = await handleReadTool("read_file", { 257 path: docxPath, 258 }); 259 const content = readResult.content[0] as { type: string; text: string }; 260 expect(content.text).toContain("Test Document"); 261 expect(content.text).toContain("bold"); 262 expect(content.text).toContain("italic"); 263 }, 15000); 264 265 test("creates DOCX from HTML with table", async () => { 266 // Ensure output directory exists for this test 267 await fs.mkdir(OUTPUT_DIR, { recursive: true }); 268 269 const htmlContent = ` 270 <html> 271 <body> 272 <table style="width: 100%; border-collapse: collapse;"> 273 <tr style="background-color: #ecf0f1;"> 274 <th style="border: 1px solid #bdc3c7;">Header 1</th> 275 <th style="border: 1px solid #bdc3c7;">Header 2</th> 276 </tr> 277 <tr> 278 <td style="border: 1px solid #bdc3c7;">Data 1</td> 279 <td style="border: 1px solid #bdc3c7;">Data 2</td> 280 </tr> 281 </table> 282 </body> 283 </html> 284 `; 285 const docxPath = path.join(OUTPUT_DIR, "html-table.docx"); 286 287 await handleWriteTool("write_file", { 288 path: docxPath, 289 content: htmlContent, 290 }); 291 292 const stats = await fs.stat(docxPath); 293 expect(stats.isFile()).toBe(true); 294 expect(stats.size).toBeGreaterThan(0); 295 }, 15000); 296 297 test("preserves line breaks as paragraphs in DOCX", async () => { 298 // Ensure output directory exists for this test 299 await fs.mkdir(OUTPUT_DIR, { recursive: true }); 300 301 const content = "Paragraph 1\nParagraph 2\nParagraph 3"; 302 const docxPath = path.join(OUTPUT_DIR, "paragraphs.docx"); 303 304 await handleWriteTool("write_file", { 305 path: docxPath, 306 content, 307 }); 308 309 const stats = await fs.stat(docxPath); 310 expect(stats.isFile()).toBe(true); 311 }); 312 313 test("creates DOCX with long content", async () => { 314 // Ensure output directory exists for this test 315 await fs.mkdir(OUTPUT_DIR, { recursive: true }); 316 317 const longContent = Array(50) 318 .fill( 319 "This is a paragraph of text that will be written to the DOCX document.", 320 ) 321 .join("\n"); 322 const docxPath = path.join(OUTPUT_DIR, "long-content.docx"); 323 324 const result = await handleWriteTool("write_file", { 325 path: docxPath, 326 content: longContent, 327 }); 328 329 expect(result.content[0].type).toBe("text"); 330 331 const stats = await fs.stat(docxPath); 332 expect(stats.size).toBeGreaterThan(500); // Should be a substantial file 333 }); 334 }); 335 336 describe("write_multiple_files with documents", () => { 337 beforeAll(async () => { 338 await setupTestEnvironment(); 339 }); 340 341 afterAll(async () => { 342 await cleanupTestEnvironment(); 343 }); 344 345 test("write_file creates parent directories automatically within allowed root", async () => { 346 const nestedDir = path.join(OUTPUT_DIR, "auto-created", "nested", "dir"); 347 const targetPath = path.join(nestedDir, "auto-file.txt"); 348 349 // Ensure directory does NOT exist before the call 350 await expect(fs.stat(nestedDir)).rejects.toHaveProperty("code", "ENOENT"); 351 352 const result = await handleWriteTool("write_file", { 353 path: targetPath, 354 content: "auto-create test content", 355 }); 356 357 const content = result.content[0] as { type: string; text: string }; 358 expect(content.text).toContain("Successfully wrote"); 359 360 // Parent directory should now exist 361 const dirStats = await fs.stat(nestedDir); 362 expect(dirStats.isDirectory()).toBe(true); 363 364 // File should exist with content 365 const fileStats = await fs.stat(targetPath); 366 expect(fileStats.isFile()).toBe(true); 367 }); 368 369 test("write_multiple_files creates parent directories automatically within allowed root", async () => { 370 const baseDir = path.join(OUTPUT_DIR, "multi-auto"); 371 const file1Dir = path.join(baseDir, "one", "deep"); 372 const file2Dir = path.join(baseDir, "two", "deeper"); 373 const file1Path = path.join(file1Dir, "file1.txt"); 374 const file2Path = path.join(file2Dir, "file2.txt"); 375 376 // Ensure directories do NOT exist before the call 377 await expect(fs.stat(file1Dir)).rejects.toHaveProperty("code", "ENOENT"); 378 await expect(fs.stat(file2Dir)).rejects.toHaveProperty("code", "ENOENT"); 379 380 const result = await handleWriteTool("write_multiple_files", { 381 files: [ 382 { path: file1Path, content: "file 1 content" }, 383 { path: file2Path, content: "file 2 content" }, 384 ], 385 }); 386 387 const content = result.content[0] as { type: string; text: string }; 388 expect(content.text).toContain("Wrote 2 of 2 files"); 389 expect(content.text).toContain("file1.txt"); 390 expect(content.text).toContain("file2.txt"); 391 392 // Both parent directories should now exist 393 const dir1Stats = await fs.stat(file1Dir); 394 const dir2Stats = await fs.stat(file2Dir); 395 expect(dir1Stats.isDirectory()).toBe(true); 396 expect(dir2Stats.isDirectory()).toBe(true); 397 398 // Files should exist 399 const f1Stats = await fs.stat(file1Path); 400 const f2Stats = await fs.stat(file2Path); 401 expect(f1Stats.isFile()).toBe(true); 402 expect(f2Stats.isFile()).toBe(true); 403 }); 404 405 test("write_multiple_files still fails for paths outside allowed directories even with auto-create", async () => { 406 // This test assumes only TEST_WORKSPACE is registered as allowed in setupTestEnvironment. 407 // Construct a path clearly outside of TEST_WORKSPACE. 408 const outsideBase = path.join(TEST_WORKSPACE, "..", "outside-auto"); 409 const outsidePath = path.resolve(outsideBase, "outside.txt"); 410 411 const resultPromise = handleWriteTool("write_multiple_files", { 412 files: [{ path: outsidePath, content: "should not be written" }], 413 }); 414 415 await expect(resultPromise).rejects.toThrow("Invalid file paths"); 416 }); 417 418 test("creates multiple PDFs and DOCX files concurrently", async () => { 419 const result = await handleWriteTool("write_multiple_files", { 420 files: [ 421 { 422 path: path.join(OUTPUT_DIR, "batch-doc1.pdf"), 423 content: "Content for PDF document 1", 424 }, 425 { 426 path: path.join(OUTPUT_DIR, "batch-doc2.docx"), 427 content: "Content for DOCX document 2", 428 }, 429 { 430 path: path.join(OUTPUT_DIR, "batch-text.txt"), 431 content: "Regular text file", 432 }, 433 ], 434 }); 435 436 const content = result.content[0] as { type: string; text: string }; 437 expect(content.text).toContain("Wrote 3 of 3 files"); 438 expect(content.text).toContain("batch-doc1.pdf"); 439 expect(content.text).toContain("batch-doc2.docx"); 440 expect(content.text).toContain("batch-text.txt"); 441 442 // Verify all files were created 443 const pdf1Stats = await fs.stat(path.join(OUTPUT_DIR, "batch-doc1.pdf")); 444 const docx2Stats = await fs.stat(path.join(OUTPUT_DIR, "batch-doc2.docx")); 445 const txt3Stats = await fs.stat(path.join(OUTPUT_DIR, "batch-text.txt")); 446 447 expect(pdf1Stats.isFile()).toBe(true); 448 expect(docx2Stats.isFile()).toBe(true); 449 expect(txt3Stats.isFile()).toBe(true); 450 }); 451 452 test("creates multiple HTML documents concurrently", async () => { 453 const result = await handleWriteTool("write_multiple_files", { 454 files: [ 455 { 456 path: path.join(OUTPUT_DIR, "batch-html1.pdf"), 457 content: 458 "<html><body><h1>Batch HTML PDF 1</h1><p>Content here</p></body></html>", 459 }, 460 { 461 path: path.join(OUTPUT_DIR, "batch-html2.docx"), 462 content: 463 "<html><body><h1>Batch HTML DOCX 2</h1><p>Content here</p></body></html>", 464 }, 465 { 466 path: path.join(OUTPUT_DIR, "batch-html3.pdf"), 467 content: 468 "<html><body><h1>Batch HTML PDF 3</h1><p>Content here</p></body></html>", 469 }, 470 ], 471 }); 472 473 const content = result.content[0] as { type: string; text: string }; 474 expect(content.text).toContain("Wrote 3 of 3 files"); 475 expect(content.text).toContain("batch-html1.pdf"); 476 expect(content.text).toContain("batch-html2.docx"); 477 expect(content.text).toContain("batch-html3.pdf"); 478 479 // Verify all HTML documents were created 480 const pdf1Stats = await fs.stat(path.join(OUTPUT_DIR, "batch-html1.pdf")); 481 const docx2Stats = await fs.stat(path.join(OUTPUT_DIR, "batch-html2.docx")); 482 const pdf3Stats = await fs.stat(path.join(OUTPUT_DIR, "batch-html3.pdf")); 483 484 expect(pdf1Stats.isFile()).toBe(true); 485 expect(docx2Stats.isFile()).toBe(true); 486 expect(pdf3Stats.isFile()).toBe(true); 487 }, 20000); 488 489 test("handles mixed success and failures", async () => { 490 const result = await handleWriteTool("write_multiple_files", { 491 files: [ 492 { 493 path: path.join(OUTPUT_DIR, "success.pdf"), 494 content: "This should succeed", 495 }, 496 { 497 path: path.join(OUTPUT_DIR, "success.docx"), 498 content: "This should also succeed", 499 }, 500 ], 501 }); 502 503 const content = result.content[0] as { type: string; text: string }; 504 expect(content.text).toContain("success.pdf"); 505 expect(content.text).toContain("success.docx"); 506 }); 507 }); 508 509 describe("round-trip testing", () => { 510 beforeAll(async () => { 511 await setupTestEnvironment(); 512 }); 513 514 afterAll(async () => { 515 await cleanupTestEnvironment(); 516 }); 517 518 test("written PDF can be read back", async () => { 519 const originalContent = "Test content for round trip.\nLine 2\nLine 3"; 520 const pdfPath = path.join(OUTPUT_DIR, "roundtrip.pdf"); 521 522 // Write PDF 523 await handleWriteTool("write_file", { 524 path: pdfPath, 525 content: originalContent, 526 }); 527 528 // Read it back 529 const readResult = await handleReadTool("read_file", { 530 path: pdfPath, 531 }); 532 533 const content = readResult.content[0] as { type: string; text: string }; 534 535 // Should contain document metadata 536 expect(content.text).toContain("Document:"); 537 expect(content.text).toContain("Format: PDF"); 538 539 expect(content.text.trim().length).toBeGreaterThan(0); 540 }, 15000); 541 542 test("written DOCX can be read back", async () => { 543 const originalContent = 544 "DOCX test content.\nSecond paragraph.\nThird paragraph."; 545 const docxPath = path.join(OUTPUT_DIR, "roundtrip.docx"); 546 547 // Write DOCX 548 await handleWriteTool("write_file", { 549 path: docxPath, 550 content: originalContent, 551 }); 552 553 // Read it back 554 const readResult = await handleReadTool("read_file", { 555 path: docxPath, 556 }); 557 558 const content = readResult.content[0] as { type: string; text: string }; 559 560 // Should contain document metadata 561 expect(content.text).toContain("Document:"); 562 expect(content.text).toContain("Format: DOCX"); 563 564 // Should contain the original text 565 expect(content.text).toContain("DOCX test content"); 566 }, 15000); 567 });