/ src / tests / write-documents.test.ts
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  });