@aigne/doc-smith 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,588 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtemp, rmdir } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+
7
+ import {
8
+ beforePublishHook,
9
+ checkD2Content,
10
+ ensureTmpDir,
11
+ getChart,
12
+ getD2Svg,
13
+ saveD2Assets,
14
+ } from "../utils/kroki-utils.mjs";
15
+
16
+ describe("kroki-utils", () => {
17
+ let tempDir;
18
+
19
+ beforeEach(async () => {
20
+ tempDir = await new Promise((resolve, reject) => {
21
+ mkdtemp(path.join(tmpdir(), "kroki-test-"), (err, dir) => {
22
+ if (err) reject(err);
23
+ else resolve(dir);
24
+ });
25
+ });
26
+ });
27
+
28
+ afterEach(async () => {
29
+ if (tempDir && existsSync(tempDir)) {
30
+ await new Promise((resolve) => {
31
+ rmdir(tempDir, { recursive: true }, () => resolve());
32
+ });
33
+ }
34
+ });
35
+
36
+ describe("getChart", () => {
37
+ test("should fetch chart with default parameters", async () => {
38
+ const content = "A -> B";
39
+ const result = await getChart({ content });
40
+
41
+ // Should either return SVG content or null (if network fails)
42
+ expect(result === null || typeof result === "string").toBe(true);
43
+
44
+ if (result !== null) {
45
+ // Basic SVG validation
46
+ expect(result).toContain("svg");
47
+ }
48
+ }, 15000);
49
+
50
+ test("should handle different chart types", async () => {
51
+ const content = "A -> B";
52
+ const result = await getChart({
53
+ chart: "d2",
54
+ format: "svg",
55
+ content,
56
+ });
57
+
58
+ expect(result === null || typeof result === "string").toBe(true);
59
+ }, 15000);
60
+
61
+ test("should handle different formats", async () => {
62
+ const content = "A -> B";
63
+ const result = await getChart({
64
+ chart: "d2",
65
+ format: "png",
66
+ content,
67
+ });
68
+
69
+ expect(result === null || typeof result === "string").toBe(true);
70
+ }, 15000);
71
+
72
+ test("should handle network errors gracefully when strict=false", async () => {
73
+ // Test with invalid base URL by mocking fetch
74
+ const originalFetch = global.fetch;
75
+ global.fetch = () => Promise.reject(new Error("Network error"));
76
+
77
+ try {
78
+ const result = await getChart({ content: "A -> B", strict: false });
79
+ expect(result).toBe(null);
80
+ } finally {
81
+ global.fetch = originalFetch;
82
+ }
83
+ });
84
+
85
+ test("should throw error when strict=true and request fails", async () => {
86
+ // Mock fetch to return error response
87
+ const originalFetch = global.fetch;
88
+ global.fetch = () =>
89
+ Promise.resolve({
90
+ ok: false,
91
+ status: 400,
92
+ statusText: "Bad Request",
93
+ text: () => Promise.resolve("Error response"),
94
+ });
95
+
96
+ try {
97
+ await expect(getChart({ content: "invalid content", strict: true })).rejects.toThrow(
98
+ "Failed to fetch chart: 400 Bad Request",
99
+ );
100
+ } finally {
101
+ global.fetch = originalFetch;
102
+ }
103
+ });
104
+
105
+ test("should handle empty content", async () => {
106
+ const result = await getChart({ content: "" });
107
+ expect(result === null || typeof result === "string").toBe(true);
108
+ }, 10000);
109
+
110
+ test("should handle malformed content", async () => {
111
+ const malformedContent = "A -> B -> C -> [invalid syntax";
112
+ const result = await getChart({ content: malformedContent, strict: false });
113
+
114
+ // Should not crash, may return error SVG or null
115
+ expect(result === null || typeof result === "string").toBe(true);
116
+ }, 10000);
117
+ });
118
+
119
+ describe("getD2Svg", () => {
120
+ test("should generate D2 SVG content", async () => {
121
+ const content = "A -> B: label";
122
+ const result = await getD2Svg({ content });
123
+
124
+ expect(result === null || typeof result === "string").toBe(true);
125
+
126
+ if (result !== null) {
127
+ expect(result).toContain("svg");
128
+ }
129
+ }, 15000);
130
+
131
+ test("should handle strict mode correctly", async () => {
132
+ // Mock fetch to simulate server error
133
+ const originalFetch = global.fetch;
134
+ global.fetch = () =>
135
+ Promise.resolve({
136
+ ok: false,
137
+ status: 500,
138
+ statusText: "Internal Server Error",
139
+ text: () => Promise.resolve("Error response"),
140
+ });
141
+
142
+ try {
143
+ // Should throw in strict mode
144
+ await expect(getD2Svg({ content: "A -> B", strict: true })).rejects.toThrow();
145
+
146
+ // Should return error response (not null) in non-strict mode
147
+ const result = await getD2Svg({ content: "A -> B", strict: false });
148
+ expect(result).toBe("Error response");
149
+ } finally {
150
+ global.fetch = originalFetch;
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("saveD2Assets", () => {
156
+ test("should process markdown with D2 code blocks", async () => {
157
+ const docsDir = path.join(tempDir, "docs");
158
+ await mkdir(docsDir, { recursive: true });
159
+
160
+ const markdown = `# Test Document
161
+
162
+ This is a test document with D2 diagram:
163
+
164
+ \`\`\`d2
165
+ A -> B: connection
166
+ B -> C: another connection
167
+ \`\`\`
168
+
169
+ Some more content here.
170
+ `;
171
+
172
+ const result = await saveD2Assets({ markdown, docsDir });
173
+
174
+ expect(typeof result).toBe("string");
175
+ expect(result).toContain("# Test Document");
176
+ expect(result).toContain("Some more content here");
177
+
178
+ // Should replace D2 code block with image reference
179
+ expect(result).not.toContain("```d2");
180
+ expect(result).toContain("![](../assets/d2/");
181
+ expect(result).toContain(".svg)");
182
+ });
183
+
184
+ test("should handle markdown without D2 blocks", async () => {
185
+ const docsDir = path.join(tempDir, "docs");
186
+ await mkdir(docsDir, { recursive: true });
187
+
188
+ const markdown = `# Test Document
189
+
190
+ This document has no D2 diagrams.
191
+
192
+ \`\`\`javascript
193
+ console.log('hello world');
194
+ \`\`\`
195
+ `;
196
+
197
+ const result = await saveD2Assets({ markdown, docsDir });
198
+
199
+ expect(result).toBe(markdown); // Should remain unchanged
200
+ });
201
+
202
+ test("should handle multiple D2 blocks", async () => {
203
+ const docsDir = path.join(tempDir, "docs");
204
+ await mkdir(docsDir, { recursive: true });
205
+
206
+ const markdown = `# Test
207
+
208
+ \`\`\`d2
209
+ A -> B
210
+ \`\`\`
211
+
212
+ Some text.
213
+
214
+ \`\`\`d2
215
+ C -> D
216
+ E -> F
217
+ \`\`\`
218
+ `;
219
+
220
+ const result = await saveD2Assets({ markdown, docsDir });
221
+
222
+ expect(typeof result).toBe("string");
223
+ expect(result).not.toContain("```d2");
224
+
225
+ // Should have two image references
226
+ const imageMatches = result.match(/!\[\]\(\.\.\/assets\/d2\/.*\.svg\)/g);
227
+ expect(imageMatches).toBeTruthy();
228
+ expect(imageMatches.length).toBe(2);
229
+ });
230
+
231
+ test("should handle empty markdown", async () => {
232
+ const docsDir = path.join(tempDir, "docs");
233
+ await mkdir(docsDir, { recursive: true });
234
+
235
+ const result = await saveD2Assets({ markdown: "", docsDir });
236
+ expect(result).toBe("");
237
+ });
238
+
239
+ test("should skip generation if SVG file already exists", async () => {
240
+ const docsDir = path.join(tempDir, "docs");
241
+ const assetsDir = path.join(docsDir, "../assets/d2");
242
+ await mkdir(assetsDir, { recursive: true });
243
+
244
+ const markdown = `\`\`\`d2\nA -> B\n\`\`\``;
245
+
246
+ // Pre-create a cached SVG file
247
+ const testSvgContent = "<svg>test</svg>";
248
+ await writeFile(path.join(assetsDir, "test.svg"), testSvgContent);
249
+
250
+ // This would normally generate the same filename for the same content
251
+ const result = await saveD2Assets({ markdown, docsDir });
252
+
253
+ expect(typeof result).toBe("string");
254
+ expect(result).toContain("![](../assets/d2/");
255
+ });
256
+
257
+ test("should handle D2 generation errors gracefully", async () => {
258
+ const docsDir = path.join(tempDir, "docs");
259
+ await mkdir(docsDir, { recursive: true });
260
+
261
+ const markdown = `\`\`\`d2\nA -> B\n\`\`\``;
262
+
263
+ // Mock getD2Svg to throw error
264
+ const originalFetch = global.fetch;
265
+ global.fetch = () => Promise.reject(new Error("Network error"));
266
+
267
+ try {
268
+ const result = await saveD2Assets({ markdown, docsDir });
269
+
270
+ // TODO: When retry still fails, it will use a non-existent image
271
+ expect(result).toContain("![](../assets/d2/");
272
+ } finally {
273
+ global.fetch = originalFetch;
274
+ }
275
+ });
276
+ });
277
+
278
+ describe("beforePublishHook", () => {
279
+ test("should process all markdown files in directory", async () => {
280
+ const docsDir = path.join(tempDir, "docs");
281
+ await mkdir(docsDir, { recursive: true });
282
+
283
+ // Create test markdown files
284
+ const file1Content = `# Doc 1\n\`\`\`d2\nA -> B\n\`\`\``;
285
+ const file2Content = `# Doc 2\n\`\`\`d2\nC -> D\n\`\`\``;
286
+ const file3Content = `# Doc 3\nNo diagrams here.`;
287
+
288
+ await writeFile(path.join(docsDir, "doc1.md"), file1Content);
289
+ await writeFile(path.join(docsDir, "doc2.md"), file2Content);
290
+ await writeFile(path.join(docsDir, "doc3.md"), file3Content);
291
+
292
+ await beforePublishHook({ docsDir });
293
+
294
+ // Check that files were processed
295
+ const processedFile1 = await readFile(path.join(docsDir, "doc1.md"), "utf8");
296
+ const processedFile2 = await readFile(path.join(docsDir, "doc2.md"), "utf8");
297
+ const processedFile3 = await readFile(path.join(docsDir, "doc3.md"), "utf8");
298
+
299
+ expect(processedFile1).not.toContain("```d2");
300
+ expect(processedFile2).not.toContain("```d2");
301
+ expect(processedFile3).toBe(file3Content); // Unchanged
302
+
303
+ expect(processedFile1).toContain("![](../assets/d2/");
304
+ expect(processedFile2).toContain("![](../assets/d2/");
305
+ });
306
+
307
+ test("should handle nested directories", async () => {
308
+ const docsDir = path.join(tempDir, "docs");
309
+ const subDir = path.join(docsDir, "subdir");
310
+ await mkdir(subDir, { recursive: true });
311
+
312
+ const fileContent = `# Nested Doc\n\`\`\`d2\nA -> B\n\`\`\``;
313
+ await writeFile(path.join(subDir, "nested.md"), fileContent);
314
+
315
+ await beforePublishHook({ docsDir });
316
+
317
+ const processedFile = await readFile(path.join(subDir, "nested.md"), "utf8");
318
+ expect(processedFile).not.toContain("```d2");
319
+ expect(processedFile).toContain("![](../assets/d2/");
320
+ });
321
+
322
+ test("should handle empty docs directory", async () => {
323
+ const docsDir = path.join(tempDir, "empty-docs");
324
+ await mkdir(docsDir, { recursive: true });
325
+
326
+ // Should not throw error
327
+ await expect(beforePublishHook({ docsDir })).resolves.toBeUndefined();
328
+ });
329
+
330
+ test("should handle non-existent directory", async () => {
331
+ const nonExistentDir = path.join(tempDir, "non-existent");
332
+
333
+ const result = await beforePublishHook({ docsDir: nonExistentDir });
334
+
335
+ expect(result).toBeUndefined();
336
+ });
337
+ });
338
+
339
+ describe("checkD2Content", () => {
340
+ test("should generate and cache D2 SVG", async () => {
341
+ const content = "A -> B: test connection";
342
+
343
+ // Should not throw in normal operation
344
+ await expect(checkD2Content({ content })).resolves.toBeUndefined();
345
+ });
346
+
347
+ test("should use cached file when available", async () => {
348
+ const content = "A -> B: cached test";
349
+
350
+ // First call should generate
351
+ await checkD2Content({ content });
352
+
353
+ // Second call should use cache (should be faster)
354
+ const startTime = Date.now();
355
+ await checkD2Content({ content });
356
+ const endTime = Date.now();
357
+
358
+ // Cache hit should be very fast (< 100ms)
359
+ expect(endTime - startTime).toBeLessThan(100);
360
+ });
361
+
362
+ test("should handle generation errors in strict mode", async () => {
363
+ // Mock fetch to simulate server error
364
+ const originalFetch = global.fetch;
365
+ global.fetch = () =>
366
+ Promise.resolve({
367
+ ok: false,
368
+ status: 500,
369
+ statusText: "Internal Server Error",
370
+ text: () => Promise.resolve("Error response"),
371
+ });
372
+
373
+ try {
374
+ await expect(checkD2Content({ content: "A -> B" })).rejects.toThrow();
375
+ } finally {
376
+ global.fetch = originalFetch;
377
+ }
378
+ });
379
+
380
+ test("should handle empty content", async () => {
381
+ await expect(checkD2Content({ content: "" })).resolves.toBeUndefined();
382
+ });
383
+
384
+ test("should handle malformed D2 content", async () => {
385
+ const malformedContent = "A -> B -> [invalid";
386
+
387
+ // May throw depending on D2 server validation
388
+ try {
389
+ await checkD2Content({ content: malformedContent });
390
+ } catch (error) {
391
+ expect(error).toBeDefined();
392
+ }
393
+ });
394
+ });
395
+
396
+ describe("ensureTmpDir", () => {
397
+ test("should create tmp directory structure", async () => {
398
+ // Change to temp directory for testing
399
+ const originalCwd = process.cwd();
400
+ process.chdir(tempDir);
401
+
402
+ try {
403
+ await ensureTmpDir();
404
+
405
+ const tmpDir = path.join(tempDir, ".aigne", "doc-smith", ".tmp");
406
+ const gitignorePath = path.join(tmpDir, ".gitignore");
407
+
408
+ expect(existsSync(tmpDir)).toBe(true);
409
+ expect(existsSync(gitignorePath)).toBe(true);
410
+
411
+ const gitignoreContent = await readFile(gitignorePath, "utf8");
412
+ expect(gitignoreContent).toBe("**/*");
413
+ } finally {
414
+ process.chdir(originalCwd);
415
+ }
416
+ });
417
+
418
+ test("should not recreate if already exists", async () => {
419
+ const originalCwd = process.cwd();
420
+ process.chdir(tempDir);
421
+
422
+ try {
423
+ // First call
424
+ await ensureTmpDir();
425
+
426
+ const tmpDir = path.join(tempDir, ".aigne", "doc-smith", ".tmp");
427
+ const gitignorePath = path.join(tmpDir, ".gitignore");
428
+
429
+ // Modify .gitignore to test if it gets overwritten
430
+ await writeFile(gitignorePath, "modified content");
431
+
432
+ // Second call
433
+ await ensureTmpDir();
434
+
435
+ const gitignoreContent = await readFile(gitignorePath, "utf8");
436
+ expect(gitignoreContent).toBe("modified content"); // Should not be overwritten
437
+ } finally {
438
+ process.chdir(originalCwd);
439
+ }
440
+ });
441
+
442
+ test("should handle directory creation errors", async () => {
443
+ // Try to create in a read-only location
444
+ const originalCwd = process.cwd();
445
+
446
+ try {
447
+ process.chdir("/root"); // Typically read-only
448
+ await expect(ensureTmpDir()).rejects.toThrow();
449
+ } catch (error) {
450
+ // Expected to fail in read-only directory
451
+ expect(error).toBeDefined();
452
+ } finally {
453
+ process.chdir(originalCwd);
454
+ }
455
+ });
456
+ });
457
+
458
+ describe("edge cases and error handling", () => {
459
+ test("should handle very large D2 content", async () => {
460
+ // Generate large D2 content
461
+ const largeContent = Array.from({ length: 1000 }, (_, i) => `Node${i} -> Node${i + 1}`).join(
462
+ "\n",
463
+ );
464
+
465
+ const result = await getD2Svg({ content: largeContent, strict: false });
466
+ expect(result === null || typeof result === "string").toBe(true);
467
+ }, 20000);
468
+
469
+ test("should handle special characters in D2 content", async () => {
470
+ const specialContent = `
471
+ "Node with spaces" -> "Node with 中文"
472
+ "Node with emoji 🎉" -> "Node with symbols @#$%"
473
+ `;
474
+
475
+ const result = await getD2Svg({ content: specialContent, strict: false });
476
+ expect(result === null || typeof result === "string").toBe(true);
477
+ }, 15000);
478
+
479
+ test("should handle concurrent D2 processing", async () => {
480
+ const contents = ["A -> B", "C -> D", "E -> F", "G -> H", "I -> J"];
481
+
482
+ // Process multiple D2 contents concurrently
483
+ const promises = contents.map((content) => getD2Svg({ content, strict: false }));
484
+ const results = await Promise.all(promises);
485
+
486
+ expect(results).toHaveLength(5);
487
+ results.forEach((result) => {
488
+ expect(result === null || typeof result === "string").toBe(true);
489
+ });
490
+ }, 20000);
491
+
492
+ test("should handle malformed regex in saveD2Assets", async () => {
493
+ const docsDir = path.join(tempDir, "docs");
494
+ await mkdir(docsDir, { recursive: true });
495
+
496
+ // Test with malformed D2 blocks (unclosed)
497
+ const malformedMarkdown = `# Test\n\`\`\`d2\nA -> B\n\`\`\`\n\`\`\`d2\nUnclosed block`;
498
+
499
+ const result = await saveD2Assets({ markdown: malformedMarkdown, docsDir });
500
+
501
+ // Should handle gracefully and process what it can
502
+ expect(typeof result).toBe("string");
503
+ });
504
+
505
+ test("should handle empty markdown input", async () => {
506
+ const docsDir = path.join(tempDir, "docs");
507
+ await mkdir(docsDir, { recursive: true });
508
+
509
+ const result = await saveD2Assets({ markdown: "", docsDir });
510
+ expect(result).toBe("");
511
+ });
512
+
513
+ test("should handle null and undefined markdown input", async () => {
514
+ const docsDir = path.join(tempDir, "docs");
515
+ await mkdir(docsDir, { recursive: true });
516
+
517
+ const result1 = await saveD2Assets({ markdown: null, docsDir });
518
+ expect(result1).toBe(null);
519
+
520
+ const result2 = await saveD2Assets({ markdown: undefined, docsDir });
521
+ expect(result2).toBe(undefined);
522
+ });
523
+
524
+ test("should handle getChart network errors in non-strict mode", async () => {
525
+ // Mock fetch to return error response
526
+ const originalFetch = global.fetch;
527
+ global.fetch = () =>
528
+ Promise.resolve({
529
+ ok: false,
530
+ status: 500,
531
+ statusText: "Internal Server Error",
532
+ text: () => Promise.resolve("Error response"),
533
+ });
534
+
535
+ try {
536
+ const result = await getChart({ content: "A -> B", strict: false });
537
+ // Should handle error gracefully in non-strict mode
538
+ expect(result === null || typeof result === "string").toBe(true);
539
+ } finally {
540
+ global.fetch = originalFetch;
541
+ }
542
+ });
543
+
544
+ test("should preserve line endings and whitespace in saveD2Assets", async () => {
545
+ const docsDir = path.join(tempDir, "docs");
546
+ await mkdir(docsDir, { recursive: true });
547
+
548
+ const markdown = `# Title\r\n\r\n\`\`\`d2\nA -> B\n\`\`\`\r\n\r\nEnd`;
549
+
550
+ const result = await saveD2Assets({ markdown, docsDir });
551
+
552
+ // Should preserve original line endings and spacing
553
+ expect(result).toContain("\r\n\r\n");
554
+ expect(result.split("\n").length).toBeGreaterThan(3);
555
+ });
556
+
557
+ test("should handle multiple concurrent calls to checkD2Content", async () => {
558
+ const content = "A -> B: test";
559
+
560
+ // Concurrent calls should not interfere with each other
561
+ const promises = Array.from({ length: 5 }, () => checkD2Content({ content }));
562
+
563
+ // All should succeed without throwing errors
564
+ await expect(Promise.all(promises)).resolves.toBeDefined();
565
+ });
566
+
567
+ test("should handle multiple concurrent calls to ensureTmpDir", async () => {
568
+ const originalCwd = process.cwd();
569
+ process.chdir(tempDir);
570
+
571
+ try {
572
+ // Multiple concurrent calls should not interfere
573
+ const promises = Array.from({ length: 5 }, () => ensureTmpDir());
574
+ await Promise.all(promises);
575
+
576
+ // Should only create directory once
577
+ const tmpDir = path.join(tempDir, ".aigne", "doc-smith", ".tmp");
578
+ expect(existsSync(tmpDir)).toBe(true);
579
+
580
+ // .gitignore should be created properly
581
+ const gitignoreContent = await readFile(path.join(tmpDir, ".gitignore"), "utf8");
582
+ expect(gitignoreContent).toBe("**/*");
583
+ } finally {
584
+ process.chdir(originalCwd);
585
+ }
586
+ });
587
+ });
588
+ });