@aigne/doc-smith 0.7.2 → 0.8.0

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.
@@ -1,7 +1,323 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { isGlobPattern } from "../utils/utils.mjs";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { execSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ detectSystemLanguage,
8
+ getAvailablePaths,
9
+ getContentHash,
10
+ getCurrentGitHead,
11
+ getGitHubRepoInfo,
12
+ getGithubRepoUrl,
13
+ getModifiedFilesBetweenCommits,
14
+ getProjectInfo,
15
+ hasFileChangesBetweenCommits,
16
+ hasSourceFilesChanged,
17
+ isGlobPattern,
18
+ loadConfigFromFile,
19
+ normalizePath,
20
+ processConfigFields,
21
+ processContent,
22
+ resolveFileReferences,
23
+ saveDocWithTranslations,
24
+ saveGitHeadToConfig,
25
+ saveValueToConfig,
26
+ toRelativePath,
27
+ validatePath,
28
+ validatePaths,
29
+ } from "../utils/utils.mjs";
3
30
 
4
31
  describe("utils", () => {
32
+ let tempDir;
33
+
34
+ beforeEach(() => {
35
+ tempDir = path.join(os.tmpdir(), `utils-test-${Date.now()}`);
36
+ if (!existsSync(tempDir)) {
37
+ mkdirSync(tempDir, { recursive: true });
38
+ }
39
+ });
40
+
41
+ afterEach(() => {
42
+ if (existsSync(tempDir)) {
43
+ rmSync(tempDir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ describe("normalizePath", () => {
48
+ test("should return absolute path when given absolute path", () => {
49
+ const absolutePath = "/usr/local/bin";
50
+ expect(normalizePath(absolutePath)).toBe(absolutePath);
51
+ });
52
+
53
+ test("should resolve relative path to absolute path", () => {
54
+ const relativePath = "./test";
55
+ const result = normalizePath(relativePath);
56
+ expect(path.isAbsolute(result)).toBe(true);
57
+ expect(result).toContain("test");
58
+ });
59
+
60
+ test("should handle empty string", () => {
61
+ const result = normalizePath("");
62
+ expect(path.isAbsolute(result)).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe("toRelativePath", () => {
67
+ test("should convert absolute path to relative", () => {
68
+ const absolutePath = path.join(process.cwd(), "test", "file.js");
69
+ const result = toRelativePath(absolutePath);
70
+ expect(result).toBe(path.join("test", "file.js"));
71
+ });
72
+
73
+ test("should return relative path unchanged", () => {
74
+ const relativePath = "./test/file.js";
75
+ expect(toRelativePath(relativePath)).toBe(relativePath);
76
+ });
77
+
78
+ test("should handle current working directory", () => {
79
+ const result = toRelativePath(process.cwd());
80
+ expect(result).toBe("");
81
+ });
82
+ });
83
+
84
+ describe("processContent", () => {
85
+ test("should process markdown links correctly", () => {
86
+ const content = "Check out [this link](./docs/readme) for more info.";
87
+ const result = processContent({ content });
88
+ expect(result).toBe("Check out [this link](./docs-readme.md) for more info.");
89
+ });
90
+
91
+ test("should preserve external links", () => {
92
+ const content = "Visit [Google](https://google.com) for search.";
93
+ const result = processContent({ content });
94
+ expect(result).toBe(content);
95
+ });
96
+
97
+ test("should preserve mailto links", () => {
98
+ const content = "Contact [us](mailto:test@example.com).";
99
+ const result = processContent({ content });
100
+ expect(result).toBe(content);
101
+ });
102
+
103
+ test("should handle links with anchors", () => {
104
+ const content = "See [section](./guide#installation) for details.";
105
+ const result = processContent({ content });
106
+ expect(result).toBe("See [section](./guide.md#installation) for details.");
107
+ });
108
+
109
+ test("should not process image links", () => {
110
+ const content = "Here's an image: ![alt text](./image.png)";
111
+ const result = processContent({ content });
112
+ expect(result).toBe(content);
113
+ });
114
+
115
+ test("should handle links with existing extensions", () => {
116
+ const content = "Download [file](./docs/readme.pdf) here.";
117
+ const result = processContent({ content });
118
+ expect(result).toBe(content);
119
+ });
120
+
121
+ test("should handle root-relative paths", () => {
122
+ const content = "Check [root link](/docs/api) here.";
123
+ const result = processContent({ content });
124
+ expect(result).toBe("Check [root link](./docs-api.md) here.");
125
+ });
126
+
127
+ test("should handle paths starting with dot", () => {
128
+ const content = "See [dotted path](./src/utils) for more.";
129
+ const result = processContent({ content });
130
+ expect(result).toBe("See [dotted path](./src-utils.md) for more.");
131
+ });
132
+ });
133
+
134
+ describe("getContentHash", () => {
135
+ test("should return consistent hash for same content", () => {
136
+ const content = "test content";
137
+ const hash1 = getContentHash(content);
138
+ const hash2 = getContentHash(content);
139
+ expect(hash1).toBe(hash2);
140
+ });
141
+
142
+ test("should return different hashes for different content", () => {
143
+ const content1 = "test content 1";
144
+ const content2 = "test content 2";
145
+ const hash1 = getContentHash(content1);
146
+ const hash2 = getContentHash(content2);
147
+ expect(hash1).not.toBe(hash2);
148
+ });
149
+
150
+ test("should return 64-character hex string", () => {
151
+ const content = "test";
152
+ const hash = getContentHash(content);
153
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
154
+ });
155
+
156
+ test("should handle empty string", () => {
157
+ const hash = getContentHash("");
158
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
159
+ });
160
+ });
161
+
162
+ describe("validatePath", () => {
163
+ test("should validate existing file", () => {
164
+ const testFile = path.join(tempDir, "test.txt");
165
+ writeFileSync(testFile, "test content");
166
+
167
+ const result = validatePath(testFile);
168
+ expect(result.isValid).toBe(true);
169
+ expect(result.error).toBe(null);
170
+ });
171
+
172
+ test("should validate existing directory", () => {
173
+ const result = validatePath(tempDir);
174
+ expect(result.isValid).toBe(true);
175
+ expect(result.error).toBe(null);
176
+ });
177
+
178
+ test("should invalidate non-existent path", () => {
179
+ const nonExistentPath = path.join(tempDir, "non-existent");
180
+ const result = validatePath(nonExistentPath);
181
+ expect(result.isValid).toBe(false);
182
+ expect(result.error).toContain("does not exist");
183
+ });
184
+
185
+ test("should handle relative paths", () => {
186
+ const testFile = path.join(tempDir, "relative-test.txt");
187
+ writeFileSync(testFile, "content");
188
+
189
+ const relativePath = path.relative(process.cwd(), testFile);
190
+ const result = validatePath(relativePath);
191
+ expect(result.isValid).toBe(true);
192
+ });
193
+ });
194
+
195
+ describe("validatePaths", () => {
196
+ test("should validate multiple valid paths", () => {
197
+ const testFile1 = path.join(tempDir, "test1.txt");
198
+ const testFile2 = path.join(tempDir, "test2.txt");
199
+ writeFileSync(testFile1, "content1");
200
+ writeFileSync(testFile2, "content2");
201
+
202
+ const result = validatePaths([testFile1, testFile2]);
203
+ expect(result.validPaths).toHaveLength(2);
204
+ expect(result.errors).toHaveLength(0);
205
+ });
206
+
207
+ test("should separate valid and invalid paths", () => {
208
+ const testFile = path.join(tempDir, "valid.txt");
209
+ const invalidFile = path.join(tempDir, "invalid.txt");
210
+ writeFileSync(testFile, "content");
211
+
212
+ const result = validatePaths([testFile, invalidFile]);
213
+ expect(result.validPaths).toHaveLength(1);
214
+ expect(result.errors).toHaveLength(1);
215
+ expect(result.validPaths[0]).toBe(testFile);
216
+ expect(result.errors[0].path).toBe(invalidFile);
217
+ });
218
+
219
+ test("should handle empty array", () => {
220
+ const result = validatePaths([]);
221
+ expect(result.validPaths).toHaveLength(0);
222
+ expect(result.errors).toHaveLength(0);
223
+ });
224
+ });
225
+
226
+ describe("detectSystemLanguage", () => {
227
+ test("should return a valid language code", () => {
228
+ const result = detectSystemLanguage();
229
+ expect(typeof result).toBe("string");
230
+ expect(result.length).toBeGreaterThan(0);
231
+ });
232
+
233
+ test("should return 'en' as default when no env vars", () => {
234
+ const originalEnv = {
235
+ LANG: process.env.LANG,
236
+ LANGUAGE: process.env.LANGUAGE,
237
+ LC_ALL: process.env.LC_ALL,
238
+ };
239
+
240
+ delete process.env.LANG;
241
+ delete process.env.LANGUAGE;
242
+ delete process.env.LC_ALL;
243
+
244
+ const result = detectSystemLanguage();
245
+
246
+ // Restore original env vars
247
+ if (originalEnv.LANG) process.env.LANG = originalEnv.LANG;
248
+ if (originalEnv.LANGUAGE) process.env.LANGUAGE = originalEnv.LANGUAGE;
249
+ if (originalEnv.LC_ALL) process.env.LC_ALL = originalEnv.LC_ALL;
250
+
251
+ expect(result).toBe("en");
252
+ });
253
+
254
+ test("should handle Chinese locale variants", () => {
255
+ const originalLang = process.env.LANG;
256
+
257
+ process.env.LANG = "zh_TW.UTF-8";
258
+ let result = detectSystemLanguage();
259
+ expect(result).toBe("zh"); // "zh" is found first in SUPPORTED_LANGUAGES
260
+
261
+ process.env.LANG = "zh_CN.UTF-8";
262
+ result = detectSystemLanguage();
263
+ expect(result).toBe("zh");
264
+
265
+ if (originalLang) {
266
+ process.env.LANG = originalLang;
267
+ } else {
268
+ delete process.env.LANG;
269
+ }
270
+ });
271
+
272
+ test("detectSystemLanguage should handle edge cases", () => {
273
+ const originalEnv = {
274
+ LANG: process.env.LANG,
275
+ LANGUAGE: process.env.LANGUAGE,
276
+ LC_ALL: process.env.LC_ALL,
277
+ };
278
+
279
+ // Test case 1: No system locale at all
280
+ delete process.env.LANG;
281
+ delete process.env.LANGUAGE;
282
+ delete process.env.LC_ALL;
283
+
284
+ // Mock Intl to also fail
285
+ const originalDateTimeFormat = Intl.DateTimeFormat;
286
+ Intl.DateTimeFormat = () => {
287
+ throw new Error("Intl not available");
288
+ };
289
+
290
+ try {
291
+ const result = detectSystemLanguage();
292
+ expect(result).toBe("en"); // Should fall back to default
293
+ } finally {
294
+ Intl.DateTimeFormat = originalDateTimeFormat;
295
+ }
296
+
297
+ // Test case 2: Handle special Chinese locale variants
298
+ process.env.LANG = "zh_TW";
299
+ let result = detectSystemLanguage();
300
+ expect(["zh", "zh-TW"].includes(result)).toBe(true);
301
+
302
+ process.env.LANG = "zh_HK.Big5";
303
+ result = detectSystemLanguage();
304
+ expect(["zh", "zh-TW"].includes(result)).toBe(true);
305
+
306
+ // Test case 3: Unsupported language
307
+ process.env.LANG = "xx_XX.UTF-8"; // Non-existent language
308
+ result = detectSystemLanguage();
309
+ expect(result).toBe("en"); // Should fall back to default
310
+
311
+ // Restore original environment
312
+ if (originalEnv.LANG) process.env.LANG = originalEnv.LANG;
313
+ else delete process.env.LANG;
314
+ if (originalEnv.LANGUAGE) process.env.LANGUAGE = originalEnv.LANGUAGE;
315
+ else delete process.env.LANGUAGE;
316
+ if (originalEnv.LC_ALL) process.env.LC_ALL = originalEnv.LC_ALL;
317
+ else delete process.env.LC_ALL;
318
+ });
319
+ });
320
+
5
321
  describe("isGlobPattern", () => {
6
322
  test("should return true for patterns with asterisk", () => {
7
323
  expect(isGlobPattern("*.js")).toBe(true);
@@ -46,4 +362,1706 @@ describe("utils", () => {
46
362
  expect(isGlobPattern("test/**/*test.js")).toBe(true);
47
363
  });
48
364
  });
365
+
366
+ describe("saveDocWithTranslations", () => {
367
+ test("should save document without translations", async () => {
368
+ const docsDir = path.join(tempDir, "docs");
369
+ const result = await saveDocWithTranslations({
370
+ path: "test-doc",
371
+ content: "# Test Document\n\nContent here.",
372
+ docsDir,
373
+ locale: "en",
374
+ });
375
+
376
+ expect(result).toHaveLength(1);
377
+ expect(result[0].success).toBe(true);
378
+ expect(existsSync(path.join(docsDir, "test-doc.md"))).toBe(true);
379
+ });
380
+
381
+ test("should save document with translations", async () => {
382
+ const docsDir = path.join(tempDir, "docs");
383
+ const result = await saveDocWithTranslations({
384
+ path: "test-doc",
385
+ content: "# Test Document",
386
+ docsDir,
387
+ locale: "en",
388
+ translates: [{ language: "zh", translation: "# 测试文档" }],
389
+ });
390
+
391
+ expect(result).toHaveLength(2);
392
+ expect(result.every((r) => r.success)).toBe(true);
393
+ expect(existsSync(path.join(docsDir, "test-doc.md"))).toBe(true);
394
+ expect(existsSync(path.join(docsDir, "test-doc.zh.md"))).toBe(true);
395
+ });
396
+
397
+ test("should handle path with slashes", async () => {
398
+ const docsDir = path.join(tempDir, "docs");
399
+ const result = await saveDocWithTranslations({
400
+ path: "/api/user",
401
+ content: "# API Documentation",
402
+ docsDir,
403
+ locale: "en",
404
+ });
405
+
406
+ expect(result[0].success).toBe(true);
407
+ expect(existsSync(path.join(docsDir, "api-user.md"))).toBe(true);
408
+ });
409
+
410
+ test("should add labels to front matter", async () => {
411
+ const docsDir = path.join(tempDir, "docs");
412
+ await saveDocWithTranslations({
413
+ path: "labeled-doc",
414
+ content: "# Test",
415
+ docsDir,
416
+ locale: "en",
417
+ labels: ["api", "guide"],
418
+ });
419
+
420
+ const content = readFileSync(path.join(docsDir, "labeled-doc.md"), "utf8");
421
+ expect(content).toContain('labels: ["api","guide"]');
422
+ });
423
+
424
+ test("should skip main content when isTranslate is true", async () => {
425
+ const docsDir = path.join(tempDir, "docs");
426
+ const result = await saveDocWithTranslations({
427
+ path: "translate-only",
428
+ content: "# Main",
429
+ docsDir,
430
+ locale: "en",
431
+ translates: [{ language: "zh", translation: "# 中文" }],
432
+ isTranslate: true,
433
+ });
434
+
435
+ expect(result).toHaveLength(1);
436
+ expect(existsSync(path.join(docsDir, "translate-only.md"))).toBe(false);
437
+ expect(existsSync(path.join(docsDir, "translate-only.zh.md"))).toBe(true);
438
+ });
439
+ });
440
+
441
+ describe("getCurrentGitHead", () => {
442
+ test("should return current git HEAD hash in real git repository", () => {
443
+ const result = getCurrentGitHead();
444
+ // In our real git repository, should return a valid hash
445
+ expect(typeof result).toBe("string");
446
+ expect(result.length).toBe(40); // Git SHA-1 hash length
447
+ expect(result).toMatch(/^[a-f0-9]{40}$/); // Valid hex hash
448
+ });
449
+
450
+ test("should handle git command errors gracefully", () => {
451
+ // Mock console.warn to capture warning messages
452
+ const originalWarn = console.warn;
453
+ const warnMessages = [];
454
+ console.warn = (message) => warnMessages.push(message);
455
+
456
+ // Change to a non-git directory temporarily
457
+ const originalCwd = process.cwd();
458
+ const nonGitDir = path.join(tempDir, "non-git");
459
+ mkdirSync(nonGitDir, { recursive: true });
460
+
461
+ try {
462
+ process.chdir(nonGitDir);
463
+ const result = getCurrentGitHead();
464
+ expect(result).toBe(null);
465
+ // Should have logged a warning
466
+ expect(warnMessages.some((msg) => msg.includes("Failed to get git HEAD:"))).toBe(true);
467
+ } finally {
468
+ process.chdir(originalCwd);
469
+ console.warn = originalWarn;
470
+ }
471
+ });
472
+ });
473
+
474
+ describe("getModifiedFilesBetweenCommits", () => {
475
+ test("should return modified files between recent commits", () => {
476
+ // Dynamically get the last few commits to avoid hardcoded commit issues
477
+ const currentHead = getCurrentGitHead();
478
+ if (!currentHead) {
479
+ // Skip test if not in a git repository
480
+ return;
481
+ }
482
+
483
+ // Try to get the previous commit (HEAD~1)
484
+ let previousCommit;
485
+ try {
486
+ previousCommit = execSync("git rev-parse HEAD~1", {
487
+ encoding: "utf8",
488
+ stdio: ["pipe", "pipe", "ignore"],
489
+ }).trim();
490
+ } catch {
491
+ // If there's no previous commit, skip this test
492
+ return;
493
+ }
494
+
495
+ const result = getModifiedFilesBetweenCommits(previousCommit, currentHead);
496
+ expect(Array.isArray(result)).toBe(true);
497
+
498
+ // Validate file format if any files are returned
499
+ result.forEach((file) => {
500
+ expect(typeof file).toBe("string");
501
+ expect(file.length).toBeGreaterThan(0);
502
+ });
503
+ });
504
+
505
+ test("should detect changes between commits with more history", () => {
506
+ // Try to find commits that are further apart by checking if we have enough history
507
+ let olderCommit;
508
+ try {
509
+ olderCommit = execSync("git rev-parse HEAD~3", {
510
+ encoding: "utf8",
511
+ stdio: ["pipe", "pipe", "ignore"],
512
+ }).trim();
513
+ } catch {
514
+ // If we don't have enough history, skip this test
515
+ return;
516
+ }
517
+
518
+ const result = getModifiedFilesBetweenCommits(olderCommit, "HEAD");
519
+ expect(Array.isArray(result)).toBe(true);
520
+ // With 3+ commits difference, there should usually be some changes
521
+ // But we won't enforce this since it depends on the actual history
522
+ });
523
+
524
+ test("should filter by provided file paths when files exist in changes", () => {
525
+ // First try to get some modified files
526
+ let olderCommit;
527
+ try {
528
+ olderCommit = execSync("git rev-parse HEAD~2", {
529
+ encoding: "utf8",
530
+ stdio: ["pipe", "pipe", "ignore"],
531
+ }).trim();
532
+ } catch {
533
+ return; // Skip if not enough history
534
+ }
535
+
536
+ const allModified = getModifiedFilesBetweenCommits(olderCommit, "HEAD");
537
+
538
+ if (allModified.length > 0) {
539
+ // Test filtering with actual modified file
540
+ const testFile = allModified[0];
541
+ const result = getModifiedFilesBetweenCommits(olderCommit, "HEAD", [testFile]);
542
+ expect(Array.isArray(result)).toBe(true);
543
+ expect(result).toContain(testFile);
544
+ }
545
+ });
546
+
547
+ test("should return empty array for same commit", () => {
548
+ const result = getModifiedFilesBetweenCommits("HEAD", "HEAD");
549
+ expect(Array.isArray(result)).toBe(true);
550
+ expect(result.length).toBe(0);
551
+ });
552
+
553
+ test("should handle invalid commits gracefully", () => {
554
+ const result = getModifiedFilesBetweenCommits("invalid-commit1", "invalid-commit2");
555
+ expect(Array.isArray(result)).toBe(true);
556
+ expect(result.length).toBe(0); // Should return empty array for invalid commits
557
+ });
558
+ });
559
+
560
+ describe("hasSourceFilesChanged", () => {
561
+ test("should return false for empty inputs", () => {
562
+ expect(hasSourceFilesChanged([], [])).toBe(false);
563
+ expect(hasSourceFilesChanged(null, [])).toBe(false);
564
+ expect(hasSourceFilesChanged(["test.js"], null)).toBe(false);
565
+ });
566
+
567
+ test("should detect changes when files match", () => {
568
+ const sourceIds = ["/path/to/file.js"];
569
+ const modifiedFiles = ["/path/to/file.js"];
570
+ expect(hasSourceFilesChanged(sourceIds, modifiedFiles)).toBe(true);
571
+ });
572
+
573
+ test("should return false when no files match", () => {
574
+ const sourceIds = ["/path/to/file1.js"];
575
+ const modifiedFiles = ["/path/to/file2.js"];
576
+ expect(hasSourceFilesChanged(sourceIds, modifiedFiles)).toBe(false);
577
+ });
578
+ });
579
+
580
+ describe("hasFileChangesBetweenCommits", () => {
581
+ test("should detect file additions/deletions with dynamic commits", () => {
582
+ // hasFileChangesBetweenCommits only checks for added (A) and deleted (D) files, not modified (M) files
583
+ // It also excludes test files by default since they don't affect documentation structure
584
+
585
+ // Try to get commits dynamically
586
+ let olderCommit;
587
+ try {
588
+ olderCommit = execSync("git rev-parse HEAD~3", {
589
+ encoding: "utf8",
590
+ stdio: ["pipe", "pipe", "ignore"],
591
+ }).trim();
592
+ } catch {
593
+ // If we don't have enough history, skip this test
594
+ return;
595
+ }
596
+
597
+ const result = hasFileChangesBetweenCommits(olderCommit, "HEAD");
598
+ expect(typeof result).toBe("boolean");
599
+
600
+ // The result depends on actual git history, so we just verify it's a boolean
601
+ // In most cases with test files being excluded, it might be false
602
+ });
603
+
604
+ test("should detect changes when exclude patterns are empty", () => {
605
+ // Test with empty exclude patterns to verify detection mechanism works
606
+ let olderCommit;
607
+ try {
608
+ olderCommit = execSync("git rev-parse HEAD~2", {
609
+ encoding: "utf8",
610
+ stdio: ["pipe", "pipe", "ignore"],
611
+ }).trim();
612
+ } catch {
613
+ return; // Skip if not enough history
614
+ }
615
+
616
+ const result = hasFileChangesBetweenCommits(olderCommit, "HEAD", ["*.mjs", "*.js"], []);
617
+ expect(typeof result).toBe("boolean");
618
+
619
+ // With no exclusions and broad include patterns, more likely to detect changes
620
+ });
621
+
622
+ test("should return false for same commit", () => {
623
+ const result = hasFileChangesBetweenCommits("HEAD", "HEAD");
624
+ expect(result).toBe(false);
625
+ });
626
+
627
+ test("should respect include patterns for JavaScript files", () => {
628
+ // Try with recent commits
629
+ let olderCommit;
630
+ try {
631
+ olderCommit = execSync("git rev-parse HEAD~1", {
632
+ encoding: "utf8",
633
+ stdio: ["pipe", "pipe", "ignore"],
634
+ }).trim();
635
+ } catch {
636
+ return;
637
+ }
638
+
639
+ const result = hasFileChangesBetweenCommits(
640
+ olderCommit,
641
+ "HEAD",
642
+ ["*.js", "*.mjs", "*.ts"], // Include JS-related files
643
+ [],
644
+ );
645
+ expect(typeof result).toBe("boolean");
646
+ });
647
+
648
+ test("should respect exclude patterns", () => {
649
+ // Test excluding test files but including other JS files
650
+ let olderCommit;
651
+ try {
652
+ olderCommit = execSync("git rev-parse HEAD~2", {
653
+ encoding: "utf8",
654
+ stdio: ["pipe", "pipe", "ignore"],
655
+ }).trim();
656
+ } catch {
657
+ return;
658
+ }
659
+
660
+ const result = hasFileChangesBetweenCommits(
661
+ olderCommit,
662
+ "HEAD",
663
+ ["*.mjs"], // Include mjs files
664
+ ["tests/**"], // But exclude test directory
665
+ );
666
+ expect(typeof result).toBe("boolean");
667
+ });
668
+
669
+ test("should handle complex include/exclude pattern combinations", () => {
670
+ // Test with a broader range if available
671
+ let olderCommit;
672
+ try {
673
+ olderCommit = execSync("git rev-parse HEAD~4", {
674
+ encoding: "utf8",
675
+ stdio: ["pipe", "pipe", "ignore"],
676
+ }).trim();
677
+ } catch {
678
+ return;
679
+ }
680
+
681
+ const result = hasFileChangesBetweenCommits(
682
+ olderCommit,
683
+ "HEAD",
684
+ ["*.mjs", "*.js"], // Include JS files
685
+ ["node_modules/**", "dist/**"], // Exclude build artifacts
686
+ );
687
+ expect(typeof result).toBe("boolean");
688
+ });
689
+
690
+ test("should return false for invalid commits", () => {
691
+ const result = hasFileChangesBetweenCommits("invalid-commit1", "invalid-commit2");
692
+ expect(result).toBe(false);
693
+ });
694
+
695
+ test("should handle commits with no matching file patterns", () => {
696
+ let olderCommit;
697
+ try {
698
+ olderCommit = execSync("git rev-parse HEAD~1", {
699
+ encoding: "utf8",
700
+ stdio: ["pipe", "pipe", "ignore"],
701
+ }).trim();
702
+ } catch {
703
+ return;
704
+ }
705
+
706
+ const result = hasFileChangesBetweenCommits(
707
+ olderCommit,
708
+ "HEAD",
709
+ ["*.nonexistent"], // Pattern that won't match any files
710
+ [],
711
+ );
712
+ expect(result).toBe(false);
713
+ });
714
+ });
715
+
716
+ describe("getAvailablePaths", () => {
717
+ beforeEach(() => {
718
+ // Create a complex directory structure for testing
719
+ const testStructure = {
720
+ src: {
721
+ components: {
722
+ "Button.js": "export default Button",
723
+ "Modal.js": "export default Modal",
724
+ },
725
+ utils: {
726
+ "helpers.js": "export const help = () => {}",
727
+ "constants.js": "export const API_URL = 'test'",
728
+ },
729
+ "index.js": "export * from './components'",
730
+ },
731
+ tests: {
732
+ unit: {
733
+ "button.test.js": "test('button', () => {})",
734
+ },
735
+ integration: {
736
+ "app.test.js": "test('app', () => {})",
737
+ },
738
+ },
739
+ docs: {
740
+ "README.md": "# Documentation",
741
+ "api.md": "# API Reference",
742
+ },
743
+ "package.json": '{"name": "test-project"}',
744
+ "config.yaml": "test: true",
745
+ ".gitignore": "node_modules/",
746
+ };
747
+
748
+ createDirectoryStructure(tempDir, testStructure);
749
+ });
750
+
751
+ test("should return current directory contents when no input", () => {
752
+ // Mock process.cwd to return tempDir for this test
753
+ const originalCwd = process.cwd;
754
+ process.cwd = () => tempDir;
755
+
756
+ const result = getAvailablePaths();
757
+
758
+ process.cwd = originalCwd;
759
+
760
+ expect(Array.isArray(result)).toBe(true);
761
+
762
+ // We created 5 items: src, tests, docs, package.json, config.yaml, .gitignore
763
+ // But .gitignore should be filtered out (hidden file)
764
+ expect(result.length).toBe(5);
765
+
766
+ // Should have required properties
767
+ result.forEach((item) => {
768
+ expect(item).toHaveProperty("name");
769
+ expect(item).toHaveProperty("value");
770
+ expect(item).toHaveProperty("description");
771
+ expect(typeof item.name).toBe("string");
772
+ expect(typeof item.value).toBe("string");
773
+ expect(typeof item.description).toBe("string");
774
+ });
775
+
776
+ // Should find our test directories and files (with ./ prefix)
777
+ const names = result.map((r) => r.name);
778
+ expect(names).toContain("./src");
779
+ expect(names).toContain("./tests");
780
+ expect(names).toContain("./docs");
781
+ expect(names).toContain("./package.json");
782
+ expect(names).toContain("./config.yaml");
783
+
784
+ // Should not contain hidden files
785
+ expect(names.find((name) => name.includes(".gitignore"))).toBeUndefined();
786
+ });
787
+
788
+ test("should filter by search term correctly", () => {
789
+ const originalCwd = process.cwd;
790
+ process.cwd = () => tempDir;
791
+
792
+ // Search for items containing "src"
793
+ const result = getAvailablePaths("src");
794
+
795
+ process.cwd = originalCwd;
796
+
797
+ expect(Array.isArray(result)).toBe(true);
798
+
799
+ expect(result.length).toBe(1);
800
+ expect(result[0].description).toBe("📁 Directory");
801
+
802
+ expect(result[0].name).toBe("src");
803
+
804
+ process.cwd = () => tempDir;
805
+ const packResult = getAvailablePaths("pack");
806
+ process.cwd = originalCwd;
807
+
808
+ expect(packResult.length).toBe(1);
809
+ expect(packResult[0].name).toBe("./package.json");
810
+ expect(packResult[0].description).toBe("📄 File");
811
+ });
812
+
813
+ test("Should not return duplicate paths for the same file/directory", () => {
814
+ const originalCwd = process.cwd;
815
+ process.cwd = () => tempDir;
816
+
817
+ // This test demonstrates the duplication bug
818
+ const result = getAvailablePaths("src");
819
+
820
+ process.cwd = originalCwd;
821
+
822
+ // Extract all unique actual paths (normalize both "src" and "./src" to the same absolute path)
823
+ const absolutePaths = result.map((r) => path.resolve(tempDir, r.name));
824
+ const uniqueAbsolutePaths = [...new Set(absolutePaths)];
825
+
826
+ expect(result.length).toBe(uniqueAbsolutePaths.length); // Should be equal (no duplicates)
827
+
828
+ // Additional check: No two results should point to the same physical path
829
+ expect(absolutePaths.length).toBe(uniqueAbsolutePaths.length);
830
+ });
831
+
832
+ test("should handle absolute path navigation", () => {
833
+ const srcPath = path.join(tempDir, "src");
834
+ const result = getAvailablePaths(srcPath);
835
+
836
+ expect(Array.isArray(result)).toBe(true);
837
+ // Should find exactly the src directory itself (as it's navigating TO the src path)
838
+ expect(result.length).toBe(1);
839
+ expect(result[0].name).toBe(srcPath);
840
+ expect(result[0].value).toBe(srcPath);
841
+ expect(result[0].description).toBe("📁 Directory");
842
+ });
843
+
844
+ test("should handle relative path with ./ prefix", () => {
845
+ const originalCwd = process.cwd;
846
+ process.cwd = () => tempDir;
847
+
848
+ const result = getAvailablePaths("./src");
849
+
850
+ process.cwd = originalCwd;
851
+
852
+ expect(Array.isArray(result)).toBe(true);
853
+ // Should find exactly the src directory (matching exact path)
854
+ expect(result.length).toBe(1);
855
+ expect(result[0].name).toBe("./src");
856
+ expect(result[0].description).toBe("📁 Directory");
857
+ });
858
+
859
+ test("should handle nested path navigation", () => {
860
+ const originalCwd = process.cwd;
861
+ process.cwd = () => tempDir;
862
+
863
+ const result = getAvailablePaths("./src/comp");
864
+
865
+ process.cwd = originalCwd;
866
+
867
+ expect(Array.isArray(result)).toBe(true);
868
+ // Should find exactly the components directory that matches "comp"
869
+ expect(result.length).toBe(1);
870
+ expect(result[0].name).toBe("./src/components");
871
+ expect(result[0].description).toBe("📁 Directory");
872
+ });
873
+
874
+ test("should distinguish between files and directories", () => {
875
+ const originalCwd = process.cwd;
876
+ process.cwd = () => tempDir;
877
+
878
+ const result = getAvailablePaths();
879
+
880
+ process.cwd = originalCwd;
881
+
882
+ expect(Array.isArray(result)).toBe(true);
883
+ expect(result.length).toBe(5);
884
+
885
+ // Should have exactly 3 directories and 2 files
886
+ const directories = result.filter((r) => r.description === "📁 Directory");
887
+ const files = result.filter((r) => r.description === "📄 File");
888
+
889
+ expect(directories.length).toBe(3); // src, tests, docs
890
+ expect(files.length).toBe(2); // package.json, config.yaml
891
+
892
+ // Verify specific items
893
+ const srcItem = result.find((r) => r.name === "./src");
894
+ expect(srcItem.description).toBe("📁 Directory");
895
+
896
+ const packageItem = result.find((r) => r.name === "./package.json");
897
+ expect(packageItem.description).toBe("📄 File");
898
+
899
+ const configItem = result.find((r) => r.name === "./config.yaml");
900
+ expect(configItem.description).toBe("📄 File");
901
+ });
902
+
903
+ test("should handle non-existent directory gracefully", () => {
904
+ const result = getAvailablePaths("/non/existent/path");
905
+
906
+ expect(Array.isArray(result)).toBe(true);
907
+ // Should return exactly 1 error item
908
+ expect(result.length).toBe(1);
909
+ expect(result[0]).toHaveProperty("description");
910
+ expect(result[0].description).toContain("does not exist");
911
+ expect(result[0].name).toBe("/non/existent");
912
+ expect(result[0].value).toBe("/non/existent");
913
+ });
914
+
915
+ test("should exclude common ignore patterns", () => {
916
+ // Create additional test structure with ignored items
917
+ const ignoredStructure = {
918
+ node_modules: {
919
+ package: "ignored",
920
+ },
921
+ ".git": {
922
+ config: "ignored",
923
+ },
924
+ dist: {
925
+ "bundle.js": "ignored",
926
+ },
927
+ };
928
+
929
+ createDirectoryStructure(tempDir, ignoredStructure);
930
+
931
+ const originalCwd = process.cwd;
932
+ process.cwd = () => tempDir;
933
+
934
+ const result = getAvailablePaths();
935
+
936
+ process.cwd = originalCwd;
937
+
938
+ expect(Array.isArray(result)).toBe(true);
939
+ // Should still be 5 items (original ones), ignored items should be filtered out
940
+ expect(result.length).toBe(5);
941
+
942
+ // Should not include ignored patterns
943
+ const names = result.map((r) => r.name);
944
+ expect(names).not.toContain("./node_modules");
945
+ expect(names).not.toContain("./.git");
946
+ expect(names).not.toContain("./dist");
947
+ expect(names).not.toContain("./build");
948
+
949
+ // Should still contain the original items
950
+ expect(names).toContain("./src");
951
+ expect(names).toContain("./tests");
952
+ expect(names).toContain("./docs");
953
+ });
954
+
955
+ test("should sort results alphabetically with directories first", () => {
956
+ const originalCwd = process.cwd;
957
+ process.cwd = () => tempDir;
958
+
959
+ const result = getAvailablePaths();
960
+
961
+ process.cwd = originalCwd;
962
+
963
+ expect(Array.isArray(result)).toBe(true);
964
+ expect(result.length).toBe(5);
965
+
966
+ // Expected order: directories first (docs, src, tests), then files (config.yaml, package.json)
967
+ expect(result[0].name).toBe("./docs");
968
+ expect(result[0].description).toBe("📁 Directory");
969
+
970
+ expect(result[1].name).toBe("./src");
971
+ expect(result[1].description).toBe("📁 Directory");
972
+
973
+ expect(result[2].name).toBe("./tests");
974
+ expect(result[2].description).toBe("📁 Directory");
975
+
976
+ expect(result[3].name).toBe("./config.yaml");
977
+ expect(result[3].description).toBe("📄 File");
978
+
979
+ expect(result[4].name).toBe("./package.json");
980
+ expect(result[4].description).toBe("📄 File");
981
+
982
+ // Verify directories come before files
983
+ const directories = result.filter((r) => r.description === "📁 Directory");
984
+ const files = result.filter((r) => r.description === "📄 File");
985
+ expect(directories.length).toBe(3);
986
+ expect(files.length).toBe(2);
987
+
988
+ // All directories should appear before all files
989
+ const lastDirIndex = result.lastIndexOf(directories[directories.length - 1]);
990
+ const firstFileIndex = result.indexOf(files[0]);
991
+ expect(lastDirIndex).toBeLessThan(firstFileIndex);
992
+ });
993
+
994
+ test("getAvailablePaths should handle relative path validation errors", () => {
995
+ const originalCwd = process.cwd;
996
+ process.cwd = () => tempDir;
997
+
998
+ // Test path with invalid directory
999
+ const result = getAvailablePaths("./nonexistent/file");
1000
+
1001
+ process.cwd = originalCwd;
1002
+
1003
+ expect(Array.isArray(result)).toBe(true);
1004
+ expect(result.length).toBe(1);
1005
+ expect(result[0].name).toBe("./nonexistent/");
1006
+ expect(result[0].description).toContain("does not exist");
1007
+ });
1008
+
1009
+ test("getAvailablePaths should handle relative paths without slash", () => {
1010
+ const originalCwd = process.cwd;
1011
+ process.cwd = () => tempDir;
1012
+
1013
+ // Test case where lastSlashIndex === -1 for relative path
1014
+ const result = getAvailablePaths("./noslashthingy");
1015
+
1016
+ process.cwd = originalCwd;
1017
+
1018
+ expect(Array.isArray(result)).toBe(true);
1019
+ // Should search current directory for the term
1020
+ });
1021
+ });
1022
+
1023
+ // Helper function to create directory structure
1024
+ function createDirectoryStructure(basePath, structure) {
1025
+ for (const [name, content] of Object.entries(structure)) {
1026
+ const itemPath = path.join(basePath, name);
1027
+
1028
+ if (typeof content === "string") {
1029
+ // It's a file
1030
+ writeFileSync(itemPath, content);
1031
+ } else {
1032
+ // It's a directory
1033
+ mkdirSync(itemPath, { recursive: true });
1034
+ createDirectoryStructure(itemPath, content);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ describe("getGithubRepoUrl", () => {
1040
+ test("should return string", () => {
1041
+ const result = getGithubRepoUrl();
1042
+ expect(typeof result).toBe("string");
1043
+ });
1044
+ });
1045
+
1046
+ describe("processConfigFields", () => {
1047
+ test("should apply default values for missing fields", () => {
1048
+ const config = {};
1049
+ const result = processConfigFields(config);
1050
+
1051
+ expect(result.nodeName).toBe("Section");
1052
+ expect(result.locale).toBe("en");
1053
+ expect(result.sourcesPath).toEqual(["./"]);
1054
+ expect(result.docsDir).toBe("./.aigne/doc-smith/docs");
1055
+ expect(result.outputDir).toBe("./.aigne/doc-smith/output");
1056
+ expect(result.translateLanguages).toEqual([]);
1057
+ expect(result.rules).toBe("");
1058
+ expect(result.targetAudience).toBe("");
1059
+ });
1060
+
1061
+ test("should only set defaults, not preserve other values", () => {
1062
+ const config = {
1063
+ locale: "zh",
1064
+ sourcesPath: ["./src"],
1065
+ };
1066
+ const result = processConfigFields(config);
1067
+
1068
+ // Function only sets defaults when values are missing/empty, doesn't copy existing non-default values
1069
+ expect(result.nodeName).toBe("Section"); // Default applied
1070
+ expect(result.locale).toBeUndefined(); // Not copied because zh is not empty/missing per logic
1071
+ });
1072
+
1073
+ test("should process document purpose array", () => {
1074
+ const config = {
1075
+ documentPurpose: ["getStarted"],
1076
+ };
1077
+ const result = processConfigFields(config);
1078
+
1079
+ expect(result.rules).toContain("Document Purpose");
1080
+ });
1081
+
1082
+ test("should process target audience types", () => {
1083
+ const config = {
1084
+ targetAudienceTypes: ["developers"],
1085
+ };
1086
+ const result = processConfigFields(config);
1087
+
1088
+ expect(result.rules).toContain("Target Audience");
1089
+ });
1090
+
1091
+ test("should handle string rules only (array rules cause TypeError)", () => {
1092
+ const config = {
1093
+ rules: "Custom rule content",
1094
+ };
1095
+ const result = processConfigFields(config);
1096
+
1097
+ // The function should process string rules
1098
+ expect(typeof result.rules).toBe("string");
1099
+ expect(result.rules).toContain("Custom rule");
1100
+ });
1101
+ });
1102
+
1103
+ describe("resolveFileReferences", () => {
1104
+ test("should return non-string values unchanged", async () => {
1105
+ const config = { number: 123, boolean: true };
1106
+ const result = await resolveFileReferences(config);
1107
+ expect(result).toEqual(config);
1108
+ });
1109
+
1110
+ test("should return strings without @ prefix unchanged", async () => {
1111
+ const config = { text: "normal string" };
1112
+ const result = await resolveFileReferences(config);
1113
+ expect(result).toEqual(config);
1114
+ });
1115
+
1116
+ test("should handle arrays recursively", async () => {
1117
+ const config = ["normal", "@nonexistent.txt"];
1118
+ const result = await resolveFileReferences(config);
1119
+ expect(result[0]).toBe("normal");
1120
+ expect(result[1]).toBe("@nonexistent.txt"); // File doesn't exist, returns original
1121
+ });
1122
+
1123
+ test("should handle nested objects", async () => {
1124
+ const config = {
1125
+ nested: {
1126
+ value: "@nonexistent.txt",
1127
+ },
1128
+ };
1129
+ const result = await resolveFileReferences(config);
1130
+ expect(result.nested.value).toBe("@nonexistent.txt");
1131
+ });
1132
+
1133
+ test("should load existing file content", async () => {
1134
+ const testFile = path.join(tempDir, "test.txt");
1135
+ writeFileSync(testFile, "file content");
1136
+
1137
+ const config = { file: `@${testFile}` };
1138
+ const result = await resolveFileReferences(config);
1139
+ expect(result.file).toBe("file content");
1140
+ });
1141
+
1142
+ test("should handle JSON files", async () => {
1143
+ const jsonFile = path.join(tempDir, "test.json");
1144
+ writeFileSync(jsonFile, JSON.stringify({ key: "value" }));
1145
+
1146
+ const config = { data: `@${jsonFile}` };
1147
+ const result = await resolveFileReferences(config);
1148
+ expect(result.data).toEqual({ key: "value" });
1149
+ });
1150
+ });
1151
+
1152
+ describe("saveGitHeadToConfig", () => {
1153
+ test("should handle no git HEAD", async () => {
1154
+ // Should not throw error and should return without action
1155
+ await saveGitHeadToConfig(null);
1156
+ // No assertion needed, just verify it doesn't crash
1157
+ });
1158
+
1159
+ test("should skip in test environment", async () => {
1160
+ // Should skip because BUN_TEST env var is set
1161
+ await saveGitHeadToConfig("abcd1234");
1162
+ // No assertion needed, just verify it doesn't crash
1163
+ });
1164
+
1165
+ test("should handle test environment correctly", async () => {
1166
+ // This function should skip in test environment (BUN_TEST is set)
1167
+ // We don't need to test the actual file creation since that would affect real config
1168
+ await saveGitHeadToConfig("test-hash");
1169
+ // Should complete without error due to test environment skip
1170
+ });
1171
+ });
1172
+
1173
+ describe("loadConfigFromFile", () => {
1174
+ test("should return null or config object", async () => {
1175
+ const result = await loadConfigFromFile();
1176
+ // Function either returns null (if no config) or a config object
1177
+ expect(result === null || typeof result === "object").toBe(true);
1178
+ });
1179
+
1180
+ test("should handle malformed config file gracefully", async () => {
1181
+ // Create invalid config file in temp directory with process.cwd() override
1182
+ const originalCwd = process.cwd;
1183
+ process.cwd = () => tempDir;
1184
+
1185
+ const configDir = path.join(tempDir, ".aigne", "doc-smith");
1186
+ mkdirSync(configDir, { recursive: true });
1187
+ writeFileSync(path.join(configDir, "config.yaml"), "invalid: yaml: [");
1188
+
1189
+ const result = await loadConfigFromFile();
1190
+ expect(result).toBe(null); // Should handle parse error gracefully
1191
+
1192
+ process.cwd = originalCwd;
1193
+ });
1194
+ });
1195
+
1196
+ describe("saveValueToConfig", () => {
1197
+ test("should skip undefined values", async () => {
1198
+ await saveValueToConfig("testKey", undefined);
1199
+ // Should not crash and should skip the operation
1200
+ });
1201
+
1202
+ test("should handle string values", async () => {
1203
+ const originalCwd = process.cwd;
1204
+ process.cwd = () => tempDir;
1205
+
1206
+ await saveValueToConfig("testKey", "testValue");
1207
+
1208
+ process.cwd = originalCwd;
1209
+ // Should not crash
1210
+ });
1211
+
1212
+ test("should handle array values", async () => {
1213
+ const originalCwd = process.cwd;
1214
+ process.cwd = () => tempDir;
1215
+
1216
+ await saveValueToConfig("testArray", ["item1", "item2"]);
1217
+
1218
+ process.cwd = originalCwd;
1219
+ // Should not crash
1220
+ });
1221
+ });
1222
+
1223
+ describe("getProjectInfo", () => {
1224
+ test("should return project info object", async () => {
1225
+ const result = await getProjectInfo();
1226
+
1227
+ expect(typeof result).toBe("object");
1228
+ expect(result).toHaveProperty("name");
1229
+ expect(result).toHaveProperty("description");
1230
+ expect(result).toHaveProperty("icon");
1231
+ expect(result).toHaveProperty("fromGitHub");
1232
+ expect(typeof result.fromGitHub).toBe("boolean");
1233
+ });
1234
+ });
1235
+
1236
+ describe("getGitHubRepoInfo", () => {
1237
+ test("should return null for invalid URL", async () => {
1238
+ const result = await getGitHubRepoInfo("invalid-url");
1239
+ expect(result).toBe(null);
1240
+ });
1241
+
1242
+ test("should return null for non-GitHub URL", async () => {
1243
+ const result = await getGitHubRepoInfo("https://gitlab.com/user/repo");
1244
+ expect(result).toBe(null);
1245
+ });
1246
+
1247
+ test("should handle network errors gracefully", async () => {
1248
+ const result = await getGitHubRepoInfo("https://github.com/nonexistent/nonexistent");
1249
+ // Should not crash, may return null depending on network
1250
+ expect(result === null || typeof result === "object").toBe(true);
1251
+ });
1252
+
1253
+ test("should fetch real GitHub repository info - aigne-doc-smith", async () => {
1254
+ const result = await getGitHubRepoInfo("https://github.com/AIGNE-io/aigne-doc-smith.git");
1255
+
1256
+ if (result !== null) {
1257
+ // If successful, should have expected repository structure
1258
+ expect(typeof result).toBe("object");
1259
+ expect(result).toHaveProperty("name");
1260
+ expect(result).toHaveProperty("description");
1261
+ expect(result.name).toBe("aigne-doc-smith");
1262
+ } else {
1263
+ // Network might be unavailable or API rate limited - that's acceptable
1264
+ expect(result).toBe(null);
1265
+ }
1266
+ }, 10000); // 10 second timeout for network request
1267
+
1268
+ test("should fetch real GitHub repository info - FastAPI", async () => {
1269
+ // Test with SSH URL format converted to HTTPS
1270
+ const result = await getGitHubRepoInfo("git@github.com:fastapi/fastapi.git");
1271
+
1272
+ if (result !== null) {
1273
+ // If successful, should have expected repository structure
1274
+ expect(typeof result).toBe("object");
1275
+ expect(result).toHaveProperty("name");
1276
+ expect(result).toHaveProperty("description");
1277
+ expect(result.name).toBe("fastapi");
1278
+ expect(typeof result.description).toBe("string");
1279
+ expect(result.description.length).toBeGreaterThan(0);
1280
+ } else {
1281
+ // Network might be unavailable or API rate limited - that's acceptable
1282
+ expect(result).toBe(null);
1283
+ }
1284
+ }, 10000); // 10 second timeout for network request
1285
+
1286
+ test("should handle SSH URL format correctly", async () => {
1287
+ // Test that SSH URLs are properly converted to GitHub API URLs
1288
+ const sshUrl = "git@github.com:fastapi/fastapi.git";
1289
+ const result = await getGitHubRepoInfo(sshUrl);
1290
+
1291
+ // Should either return repository info or null (network issues)
1292
+ expect(result === null || typeof result === "object").toBe(true);
1293
+
1294
+ if (result !== null) {
1295
+ expect(result.name).toBe("fastapi");
1296
+ }
1297
+ }, 10000);
1298
+
1299
+ test("should handle HTTPS URL with .git suffix", async () => {
1300
+ const httpsUrl = "https://github.com/AIGNE-io/aigne-doc-smith.git";
1301
+ const result = await getGitHubRepoInfo(httpsUrl);
1302
+
1303
+ // Should either return repository info or null (network issues)
1304
+ expect(result === null || typeof result === "object").toBe(true);
1305
+
1306
+ if (result !== null) {
1307
+ expect(result.name).toBe("aigne-doc-smith");
1308
+ }
1309
+ }, 10000);
1310
+ });
1311
+
1312
+ describe("error handling edge cases", () => {
1313
+ test("processContent should handle malformed links", () => {
1314
+ const content = "Malformed [link](incomplete";
1315
+ const result = processContent({ content });
1316
+ expect(result).toBe(content); // Should not crash
1317
+ });
1318
+
1319
+ test("validatePath should handle extremely long paths", () => {
1320
+ const longPath = "a".repeat(1000);
1321
+ const result = validatePath(longPath);
1322
+ expect(result).toHaveProperty("isValid");
1323
+ expect(result).toHaveProperty("error");
1324
+ });
1325
+
1326
+ test("getAvailablePaths should handle permission errors", () => {
1327
+ const result = getAvailablePaths("/root/restricted");
1328
+ expect(Array.isArray(result)).toBe(true);
1329
+ });
1330
+
1331
+ test("normalizePath should handle special characters", () => {
1332
+ const result = normalizePath("./test with spaces/file.txt");
1333
+ expect(path.isAbsolute(result)).toBe(true);
1334
+ });
1335
+
1336
+ test("toRelativePath should handle root path", () => {
1337
+ const result = toRelativePath("/");
1338
+ expect(typeof result).toBe("string");
1339
+ });
1340
+ });
1341
+
1342
+ // Additional tests for uncovered lines
1343
+ describe("additional coverage tests", () => {
1344
+ test("saveDocWithTranslations should add labels to front matter", async () => {
1345
+ const testDocsDir = path.join(tempDir, "docs");
1346
+ const content = "# Test content";
1347
+ const labels = ["test", "example"];
1348
+
1349
+ const results = await saveDocWithTranslations({
1350
+ path: "test-with-labels.md",
1351
+ content,
1352
+ docsDir: testDocsDir,
1353
+ locale: "en",
1354
+ labels,
1355
+ });
1356
+
1357
+ expect(results.length).toBe(1);
1358
+
1359
+ if (!results[0].success) {
1360
+ console.log("Error:", results[0].error);
1361
+ }
1362
+ expect(results[0].success).toBe(true);
1363
+
1364
+ // The actual path should be what's returned in results
1365
+ const actualPath = results[0].path;
1366
+ expect(existsSync(actualPath)).toBe(true);
1367
+
1368
+ const savedContent = readFileSync(actualPath, "utf8");
1369
+ expect(savedContent).toContain('labels: ["test","example"]');
1370
+ expect(savedContent).toContain("# Test content");
1371
+ });
1372
+
1373
+ test("saveDocWithTranslations should handle error cases", async () => {
1374
+ // Test with invalid directory - use read-only directory
1375
+ const results = await saveDocWithTranslations({
1376
+ path: "test.md",
1377
+ content: "# Test content",
1378
+ docsDir: "/root/invalid", // This should fail
1379
+ locale: "en",
1380
+ });
1381
+
1382
+ expect(results.length).toBe(1);
1383
+ expect(results[0].success).toBe(false);
1384
+ expect(results[0].error).toBeDefined();
1385
+ });
1386
+
1387
+ test("saveGitHeadToConfig should create directory and handle file operations", async () => {
1388
+ // Test in non-test environment by temporarily unsetting test env
1389
+ const originalBunTest = process.env.BUN_TEST;
1390
+ const originalNodeEnv = process.env.NODE_ENV;
1391
+ delete process.env.BUN_TEST;
1392
+ delete process.env.NODE_ENV;
1393
+
1394
+ const originalCwd = process.cwd;
1395
+ const testCwd = path.join(tempDir, "git-test");
1396
+ mkdirSync(testCwd, { recursive: true });
1397
+ process.cwd = () => testCwd;
1398
+
1399
+ try {
1400
+ await saveGitHeadToConfig("abc123456");
1401
+
1402
+ const configPath = path.join(testCwd, ".aigne", "doc-smith", "config.yaml");
1403
+ if (existsSync(configPath)) {
1404
+ const configContent = readFileSync(configPath, "utf8");
1405
+ expect(configContent).toContain("lastGitHead:");
1406
+ }
1407
+ } finally {
1408
+ // Restore environment
1409
+ process.cwd = originalCwd;
1410
+ if (originalBunTest) process.env.BUN_TEST = originalBunTest;
1411
+ if (originalNodeEnv) process.env.NODE_ENV = originalNodeEnv;
1412
+ }
1413
+ });
1414
+
1415
+ test("loadConfigFromFile should handle existing config file", async () => {
1416
+ const originalCwd = process.cwd;
1417
+ process.cwd = () => tempDir;
1418
+
1419
+ try {
1420
+ const configDir = path.join(tempDir, ".aigne", "doc-smith");
1421
+ mkdirSync(configDir, { recursive: true });
1422
+
1423
+ const validConfig = `
1424
+ projectName: test-project
1425
+ locale: en
1426
+ sourcesPath:
1427
+ - ./src
1428
+ `;
1429
+ writeFileSync(path.join(configDir, "config.yaml"), validConfig);
1430
+
1431
+ const result = await loadConfigFromFile();
1432
+ expect(result).toBeDefined();
1433
+ expect(result.projectName).toBe("test-project");
1434
+ } finally {
1435
+ process.cwd = originalCwd;
1436
+ }
1437
+ });
1438
+
1439
+ test("saveValueToConfig should handle different value types", async () => {
1440
+ const configDir = path.join(tempDir, "save-config-test");
1441
+ mkdirSync(configDir, { recursive: true });
1442
+
1443
+ const originalCwd = process.cwd;
1444
+ process.cwd = () => configDir;
1445
+
1446
+ try {
1447
+ // Test with various data types
1448
+ await saveValueToConfig("testKey", "string value");
1449
+ await saveValueToConfig("testArray", ["item1", "item2"]);
1450
+ await saveValueToConfig("testNumber", 42);
1451
+ await saveValueToConfig("testBoolean", true);
1452
+
1453
+ const configPath = path.join(configDir, ".aigne", "doc-smith", "config.yaml");
1454
+ if (existsSync(configPath)) {
1455
+ const configContent = readFileSync(configPath, "utf8");
1456
+ expect(configContent).toContain("testKey:");
1457
+ expect(configContent).toContain("testArray:");
1458
+ }
1459
+ } finally {
1460
+ process.cwd = originalCwd;
1461
+ }
1462
+ });
1463
+
1464
+ test("resolveFileReferences should handle file read errors", async () => {
1465
+ const config = {
1466
+ file: "@/nonexistent/path/file.txt",
1467
+ };
1468
+ const result = await resolveFileReferences(config);
1469
+ // Should return original reference when file doesn't exist
1470
+ expect(result.file).toBe("@/nonexistent/path/file.txt");
1471
+ });
1472
+
1473
+ test("processConfigFields should handle empty config", () => {
1474
+ const config = {};
1475
+ const result = processConfigFields(config);
1476
+
1477
+ expect(result.locale).toBe("en");
1478
+ expect(Array.isArray(result.sourcesPath)).toBe(true);
1479
+ expect(result.sourcesPath.length).toBe(1);
1480
+ expect(result.sourcesPath[0]).toBe("./");
1481
+ });
1482
+
1483
+ test("saveDocWithTranslations should handle translations correctly", async () => {
1484
+ const testDocsDir = path.join(tempDir, "docs-translate");
1485
+ const content = "# Test";
1486
+
1487
+ // Create translations with valid structure
1488
+ const translations = [{ language: "zh", translation: "# 测试" }];
1489
+
1490
+ const results = await saveDocWithTranslations({
1491
+ path: "translation-test.md",
1492
+ content,
1493
+ docsDir: testDocsDir,
1494
+ locale: "en",
1495
+ translates: translations,
1496
+ });
1497
+
1498
+ expect(Array.isArray(results)).toBe(true);
1499
+ expect(results.length).toBe(2); // Original + 1 translation
1500
+ expect(results.every((r) => r.success)).toBe(true);
1501
+ });
1502
+
1503
+ test("saveDocWithTranslations should add labels to translations", async () => {
1504
+ const testDocsDir = path.join(tempDir, "docs-translation-labels");
1505
+ const content = "# Main Content";
1506
+ const labels = ["test", "translation"];
1507
+ const translations = [{ language: "zh", translation: "# 主要内容" }];
1508
+
1509
+ const results = await saveDocWithTranslations({
1510
+ path: "labeled-translation",
1511
+ content,
1512
+ docsDir: testDocsDir,
1513
+ locale: "en",
1514
+ translates: translations,
1515
+ labels,
1516
+ });
1517
+
1518
+ expect(results.length).toBe(2); // Main + translation
1519
+ expect(results.every((r) => r.success)).toBe(true);
1520
+
1521
+ // Check that translation file has labels
1522
+ const translationPath = results[1].path;
1523
+ expect(existsSync(translationPath)).toBe(true);
1524
+ const translationContent = readFileSync(translationPath, "utf8");
1525
+ expect(translationContent).toContain('labels: ["test","translation"]');
1526
+ expect(translationContent).toContain("# 主要内容");
1527
+ });
1528
+
1529
+ test("saveGitHeadToConfig should handle file replacement scenario", async () => {
1530
+ const originalBunTest = process.env.BUN_TEST;
1531
+ const originalNodeEnv = process.env.NODE_ENV;
1532
+ delete process.env.BUN_TEST;
1533
+ delete process.env.NODE_ENV;
1534
+
1535
+ const originalCwd = process.cwd;
1536
+ const testCwd = path.join(tempDir, "replace-git-test");
1537
+ mkdirSync(testCwd, { recursive: true });
1538
+ process.cwd = () => testCwd;
1539
+
1540
+ try {
1541
+ // First call creates the file
1542
+ await saveGitHeadToConfig("first-hash");
1543
+
1544
+ // Second call should replace existing lastGitHead
1545
+ await saveGitHeadToConfig("second-hash");
1546
+
1547
+ const configPath = path.join(testCwd, ".aigne", "doc-smith", "config.yaml");
1548
+ if (existsSync(configPath)) {
1549
+ const configContent = readFileSync(configPath, "utf8");
1550
+ expect(configContent).toContain("second-hash");
1551
+ expect(configContent).not.toContain("first-hash");
1552
+ }
1553
+ } finally {
1554
+ // Restore environment
1555
+ process.cwd = originalCwd;
1556
+ if (originalBunTest) process.env.BUN_TEST = originalBunTest;
1557
+ if (originalNodeEnv) process.env.NODE_ENV = originalNodeEnv;
1558
+ }
1559
+ });
1560
+
1561
+ test("saveGitHeadToConfig should handle file write errors gracefully", async () => {
1562
+ const originalBunTest = process.env.BUN_TEST;
1563
+ const originalNodeEnv = process.env.NODE_ENV;
1564
+ const originalWarn = console.warn;
1565
+ delete process.env.BUN_TEST;
1566
+ delete process.env.NODE_ENV;
1567
+
1568
+ const warnMessages = [];
1569
+ console.warn = (message) => warnMessages.push(message);
1570
+
1571
+ const originalCwd = process.cwd;
1572
+ process.cwd = () => "/root"; // Read-only directory
1573
+
1574
+ try {
1575
+ await saveGitHeadToConfig("test-hash");
1576
+ // Should handle error gracefully and log warning
1577
+ expect(
1578
+ warnMessages.some((msg) => msg.includes("Failed to save git HEAD to config.yaml:")),
1579
+ ).toBe(true);
1580
+ } finally {
1581
+ // Restore environment
1582
+ process.cwd = originalCwd;
1583
+ console.warn = originalWarn;
1584
+ if (originalBunTest) process.env.BUN_TEST = originalBunTest;
1585
+ if (originalNodeEnv) process.env.NODE_ENV = originalNodeEnv;
1586
+ }
1587
+ });
1588
+
1589
+ test("saveGitHeadToConfig should append to file without ending newline", async () => {
1590
+ const originalBunTest = process.env.BUN_TEST;
1591
+ const originalNodeEnv = process.env.NODE_ENV;
1592
+ delete process.env.BUN_TEST;
1593
+ delete process.env.NODE_ENV;
1594
+
1595
+ const originalCwd = process.cwd;
1596
+ const testCwd = path.join(tempDir, "append-git-test");
1597
+ mkdirSync(testCwd, { recursive: true });
1598
+ process.cwd = () => testCwd;
1599
+
1600
+ try {
1601
+ // Create config directory and file without ending newline
1602
+ const configDir = path.join(testCwd, ".aigne", "doc-smith");
1603
+ mkdirSync(configDir, { recursive: true });
1604
+ const configPath = path.join(configDir, "config.yaml");
1605
+ writeFileSync(configPath, "existingKey: value"); // No ending newline
1606
+
1607
+ await saveGitHeadToConfig("test-hash");
1608
+
1609
+ const configContent = readFileSync(configPath, "utf8");
1610
+ expect(configContent).toContain("existingKey: value");
1611
+ expect(configContent).toContain("lastGitHead: test-hash");
1612
+ // Should properly handle newline addition
1613
+ expect(configContent.endsWith("\n")).toBe(true);
1614
+ } finally {
1615
+ // Restore environment
1616
+ process.cwd = originalCwd;
1617
+ if (originalBunTest) process.env.BUN_TEST = originalBunTest;
1618
+ if (originalNodeEnv) process.env.NODE_ENV = originalNodeEnv;
1619
+ }
1620
+ });
1621
+
1622
+ test("loadConfigFromFile should handle non-existent config directory", async () => {
1623
+ const originalCwd = process.cwd;
1624
+ process.cwd = () => path.join(tempDir, "no-config-dir");
1625
+
1626
+ try {
1627
+ const result = await loadConfigFromFile();
1628
+ expect(result).toBe(null);
1629
+ } finally {
1630
+ process.cwd = originalCwd;
1631
+ }
1632
+ });
1633
+
1634
+ test("resolveFileReferences should handle various file types", async () => {
1635
+ // Test with YAML file
1636
+ const yamlFile = path.join(tempDir, "test.yaml");
1637
+ writeFileSync(yamlFile, "key: value\narray:\n - item1\n - item2");
1638
+
1639
+ const config = {
1640
+ yaml: `@${yamlFile}`,
1641
+ nonexistent: "@nonexistent.txt",
1642
+ normal: "normal value",
1643
+ };
1644
+
1645
+ const result = await resolveFileReferences(config);
1646
+ expect(result.yaml).toBeDefined();
1647
+ expect(typeof result.yaml).toBe("object");
1648
+ expect(result.nonexistent).toBe("@nonexistent.txt");
1649
+ expect(result.normal).toBe("normal value");
1650
+ });
1651
+
1652
+ test("saveValueToConfig should handle file append scenario", async () => {
1653
+ const configDir = path.join(tempDir, "append-config-test");
1654
+ mkdirSync(configDir, { recursive: true });
1655
+
1656
+ const originalCwd = process.cwd;
1657
+ process.cwd = () => configDir;
1658
+
1659
+ try {
1660
+ // Create initial config without newline ending
1661
+ const aigneDir = path.join(configDir, ".aigne", "doc-smith");
1662
+ mkdirSync(aigneDir, { recursive: true });
1663
+ writeFileSync(path.join(aigneDir, "config.yaml"), "existingKey: value");
1664
+
1665
+ // This should append with proper newline handling
1666
+ await saveValueToConfig("newKey", "newValue");
1667
+
1668
+ const configPath = path.join(aigneDir, "config.yaml");
1669
+ const configContent = readFileSync(configPath, "utf8");
1670
+ expect(configContent).toContain("existingKey: value");
1671
+ expect(configContent).toContain("newKey: newValue");
1672
+ } finally {
1673
+ process.cwd = originalCwd;
1674
+ }
1675
+ });
1676
+
1677
+ test("saveValueToConfig should handle complex array update scenarios", async () => {
1678
+ const configDir = path.join(tempDir, "array-update-test");
1679
+ mkdirSync(configDir, { recursive: true });
1680
+
1681
+ const originalCwd = process.cwd;
1682
+ process.cwd = () => configDir;
1683
+
1684
+ try {
1685
+ const aigneDir = path.join(configDir, ".aigne", "doc-smith");
1686
+ mkdirSync(aigneDir, { recursive: true });
1687
+ const configPath = path.join(aigneDir, "config.yaml");
1688
+
1689
+ // Test case 1: Array with inline format
1690
+ writeFileSync(configPath, "testArray: [item1, item2]");
1691
+ await saveValueToConfig("testArray", ["newItem1", "newItem2"]);
1692
+ let configContent = readFileSync(configPath, "utf8");
1693
+ expect(configContent).toContain("testArray:");
1694
+ expect(configContent).toContain("newItem1");
1695
+
1696
+ // Test case 2: Array with mixed content
1697
+ writeFileSync(
1698
+ configPath,
1699
+ `# Initial comment
1700
+ testArray:
1701
+ - oldItem
1702
+ # Another comment
1703
+ otherKey: value`,
1704
+ );
1705
+ await saveValueToConfig("testArray", ["replacedItem"]);
1706
+ configContent = readFileSync(configPath, "utf8");
1707
+ expect(configContent).toContain("replacedItem");
1708
+ expect(configContent).toContain("otherKey: value");
1709
+
1710
+ // Test case 3: Array at end of file
1711
+ writeFileSync(
1712
+ configPath,
1713
+ `someKey: value
1714
+ testArray:
1715
+ - item1
1716
+ - item2`,
1717
+ );
1718
+ await saveValueToConfig("testArray", ["endItem"]);
1719
+ configContent = readFileSync(configPath, "utf8");
1720
+ expect(configContent).toContain("endItem");
1721
+ expect(configContent).toContain("someKey: value");
1722
+
1723
+ // Test case 4: Add new array to end without newline
1724
+ writeFileSync(configPath, "existingKey: value");
1725
+ await saveValueToConfig("newArray", ["newArrayItem"], "Array comment");
1726
+ configContent = readFileSync(configPath, "utf8");
1727
+ expect(configContent).toContain("existingKey: value");
1728
+ expect(configContent).toContain("# Array comment");
1729
+ expect(configContent).toContain("newArray:");
1730
+ expect(configContent).toContain("newArrayItem");
1731
+ } finally {
1732
+ process.cwd = originalCwd;
1733
+ }
1734
+ });
1735
+
1736
+ test("saveDocWithTranslations should skip main content when isTranslate is true", async () => {
1737
+ const testDocsDir = path.join(tempDir, "skip-main-test");
1738
+
1739
+ const results = await saveDocWithTranslations({
1740
+ path: "skip-test.md",
1741
+ content: "# Should be skipped",
1742
+ docsDir: testDocsDir,
1743
+ locale: "en",
1744
+ isTranslate: true,
1745
+ translates: [{ language: "zh", translation: "# 翻译内容" }],
1746
+ });
1747
+
1748
+ expect(results.length).toBe(1); // Only translation, main content skipped
1749
+ expect(results[0].path).toContain(".zh.md");
1750
+ });
1751
+
1752
+ test("processConfigFields should handle complex configurations", () => {
1753
+ const config = {
1754
+ documentPurpose: ["getStarted", "findAnswers"], // Already an array
1755
+ targetAudienceTypes: ["developers", "devops"],
1756
+ rules: "string rule", // Keep as string to avoid error
1757
+ locale: "zh-CN",
1758
+ sourcesPath: [], // Empty array should get default
1759
+ };
1760
+
1761
+ const result = processConfigFields(config);
1762
+
1763
+ // Function processes arrays if constants are defined
1764
+ expect(typeof result.rules).toBe("string");
1765
+ expect(result.sourcesPath).toContain("./");
1766
+
1767
+ // Target audience should be processed
1768
+ if (result.targetAudience) {
1769
+ expect(typeof result.targetAudience).toBe("string");
1770
+ }
1771
+ });
1772
+
1773
+ test("resolveFileReferences should handle unsupported file extensions", async () => {
1774
+ const unsupportedFile = path.join(tempDir, "test.exe");
1775
+ writeFileSync(unsupportedFile, "binary content");
1776
+
1777
+ const config = { file: `@${unsupportedFile}` };
1778
+ const result = await resolveFileReferences(config);
1779
+
1780
+ // Should return original reference for unsupported file type
1781
+ expect(result.file).toBe(`@${unsupportedFile}`);
1782
+ });
1783
+
1784
+ test("resolveFileReferences should handle JSON parsing errors", async () => {
1785
+ const malformedJsonFile = path.join(tempDir, "malformed.json");
1786
+ writeFileSync(malformedJsonFile, '{"key": value without quotes}');
1787
+
1788
+ const config = { file: `@${malformedJsonFile}` };
1789
+ const result = await resolveFileReferences(config);
1790
+
1791
+ // Should return raw content when JSON parsing fails
1792
+ expect(result.file).toBe('{"key": value without quotes}');
1793
+ });
1794
+
1795
+ test("resolveFileReferences should handle YAML parsing errors", async () => {
1796
+ const malformedYamlFile = path.join(tempDir, "malformed.yaml");
1797
+ writeFileSync(malformedYamlFile, "key: value\n invalid: indentation: error");
1798
+
1799
+ const config = { file: `@${malformedYamlFile}` };
1800
+ const result = await resolveFileReferences(config);
1801
+
1802
+ // Should return raw content when YAML parsing fails
1803
+ expect(result.file).toBe("key: value\n invalid: indentation: error");
1804
+ });
1805
+
1806
+ test("resolveFileReferences should handle absolute file paths", async () => {
1807
+ const absoluteFile = path.join(tempDir, "absolute.txt");
1808
+ writeFileSync(absoluteFile, "absolute path content");
1809
+
1810
+ const config = { file: `@${absoluteFile}` };
1811
+ const result = await resolveFileReferences(config, "/different/base/path");
1812
+
1813
+ // Should work with absolute path regardless of basePath
1814
+ expect(result.file).toBe("absolute path content");
1815
+ });
1816
+
1817
+ test("resolveFileReferences should handle file read errors gracefully", async () => {
1818
+ const config = { file: "@/root/protected/file.txt" };
1819
+ const result = await resolveFileReferences(config);
1820
+
1821
+ // Should return original reference when file read fails
1822
+ expect(result.file).toBe("@/root/protected/file.txt");
1823
+ });
1824
+
1825
+ test("processConfigFields should handle existing target audience with new audience types", () => {
1826
+ const config = {
1827
+ targetAudience: "Existing audience description",
1828
+ targetAudienceTypes: ["developers"],
1829
+ };
1830
+
1831
+ const result = processConfigFields(config);
1832
+
1833
+ if (result.targetAudience) {
1834
+ expect(result.targetAudience).toContain("Existing audience description");
1835
+ }
1836
+ });
1837
+
1838
+ test("getAvailablePaths should handle permission errors gracefully", () => {
1839
+ const originalWarn = console.warn;
1840
+ const warnMessages = [];
1841
+ console.warn = (message) => warnMessages.push(message);
1842
+
1843
+ try {
1844
+ const result = getAvailablePaths("/root/protected");
1845
+ expect(Array.isArray(result)).toBe(true);
1846
+ // Should handle permission errors gracefully
1847
+ } finally {
1848
+ console.warn = originalWarn;
1849
+ }
1850
+ });
1851
+
1852
+ test("processConfigFields should handle reader knowledge level content", () => {
1853
+ const config = {
1854
+ readerKnowledgeLevel: "domainFamiliar",
1855
+ };
1856
+
1857
+ const result = processConfigFields(config);
1858
+
1859
+ if (result.readerKnowledgeContent) {
1860
+ expect(typeof result.readerKnowledgeContent).toBe("string");
1861
+ }
1862
+ });
1863
+
1864
+ test("processConfigFields should handle documentation depth content", () => {
1865
+ const config = {
1866
+ documentationDepth: "comprehensive",
1867
+ };
1868
+
1869
+ const result = processConfigFields(config);
1870
+
1871
+ if (result.documentationDepthContent) {
1872
+ expect(typeof result.documentationDepthContent).toBe("string");
1873
+ }
1874
+ });
1875
+
1876
+ test("getProjectInfo should handle git repository without GitHub", async () => {
1877
+ // Mock execSync to return non-GitHub remote
1878
+ const originalWarn = console.warn;
1879
+ console.warn = () => {}; // Suppress warnings
1880
+
1881
+ try {
1882
+ const result = await getProjectInfo();
1883
+ expect(typeof result).toBe("object");
1884
+ expect(result).toHaveProperty("fromGitHub");
1885
+ expect(typeof result.fromGitHub).toBe("boolean");
1886
+ } finally {
1887
+ console.warn = originalWarn;
1888
+ }
1889
+ });
1890
+
1891
+ test("getProjectInfo should handle no git repository", async () => {
1892
+ const originalWarn = console.warn;
1893
+ const warnMessages = [];
1894
+ console.warn = (message) => warnMessages.push(message);
1895
+
1896
+ const originalCwd = process.cwd();
1897
+ const nonGitDir = path.join(tempDir, "no-git");
1898
+ mkdirSync(nonGitDir, { recursive: true });
1899
+
1900
+ try {
1901
+ process.chdir(nonGitDir);
1902
+ const result = await getProjectInfo();
1903
+
1904
+ expect(typeof result).toBe("object");
1905
+ expect(result.fromGitHub).toBe(false);
1906
+ expect(warnMessages.some((msg) => msg.includes("No git repository found"))).toBe(true);
1907
+ } finally {
1908
+ process.chdir(originalCwd);
1909
+ console.warn = originalWarn;
1910
+ }
1911
+ });
1912
+
1913
+ test("saveValueToConfig should handle write errors gracefully", async () => {
1914
+ const originalWarn = console.warn;
1915
+ const warnMessages = [];
1916
+ console.warn = (message) => warnMessages.push(message);
1917
+
1918
+ const originalCwd = process.cwd;
1919
+ process.cwd = () => "/root"; // Read-only directory
1920
+
1921
+ try {
1922
+ await saveValueToConfig("testKey", "testValue");
1923
+ // Should handle error gracefully and log warning
1924
+ expect(
1925
+ warnMessages.some((msg) => msg.includes("Failed to save testKey to config.yaml:")),
1926
+ ).toBe(true);
1927
+ } finally {
1928
+ process.cwd = originalCwd;
1929
+ console.warn = originalWarn;
1930
+ }
1931
+ });
1932
+
1933
+ test("validatePath should handle access permission errors", () => {
1934
+ // Test with a path that exists but might not be accessible
1935
+ const result = validatePath("/root");
1936
+ // Should handle gracefully regardless of access permissions
1937
+ expect(result).toHaveProperty("isValid");
1938
+ expect(result).toHaveProperty("error");
1939
+ });
1940
+
1941
+ test("getAvailablePaths should handle directory read errors", () => {
1942
+ const originalWarn = console.warn;
1943
+ const warnMessages = [];
1944
+ console.warn = (message) => warnMessages.push(message);
1945
+
1946
+ try {
1947
+ // Test with a problematic path that might cause read errors
1948
+ const result = getAvailablePaths("/proc/nonexistent");
1949
+ expect(Array.isArray(result)).toBe(true);
1950
+ // May or may not log warnings depending on system
1951
+ } finally {
1952
+ console.warn = originalWarn;
1953
+ }
1954
+ });
1955
+
1956
+ test("saveValueToConfig should handle array end detection edge cases", async () => {
1957
+ const testDir = path.join(tempDir, "array-edge-test");
1958
+ mkdirSync(testDir, { recursive: true });
1959
+
1960
+ const originalCwd = process.cwd;
1961
+ process.cwd = () => testDir;
1962
+
1963
+ try {
1964
+ // Create config with array that has inline start and complex structure
1965
+ const aigneDir = path.join(testDir, ".aigne", "doc-smith");
1966
+ mkdirSync(aigneDir, { recursive: true });
1967
+
1968
+ // Test case 1: Array with inline start
1969
+ writeFileSync(
1970
+ path.join(aigneDir, "config.yaml"),
1971
+ "testArray: [item1, item2]\notherKey: value\n",
1972
+ );
1973
+
1974
+ await saveValueToConfig("testArray", ["new1", "new2"]);
1975
+
1976
+ let configContent = readFileSync(path.join(aigneDir, "config.yaml"), "utf8");
1977
+ expect(configContent).toContain("- new1");
1978
+ expect(configContent).toContain("- new2");
1979
+
1980
+ // Test case 2: Array at end of file without trailing newline
1981
+ writeFileSync(
1982
+ path.join(aigneDir, "config.yaml"),
1983
+ "otherKey: value\ntestArray:\n - item1\n - item2",
1984
+ );
1985
+
1986
+ await saveValueToConfig("testArray", ["final1", "final2"]);
1987
+
1988
+ configContent = readFileSync(path.join(aigneDir, "config.yaml"), "utf8");
1989
+ expect(configContent).toContain("- final1");
1990
+ expect(configContent).toContain("- final2");
1991
+ } finally {
1992
+ process.cwd = originalCwd;
1993
+ }
1994
+ });
1995
+
1996
+ test("getDirectoryContents should handle read errors", () => {
1997
+ const originalWarn = console.warn;
1998
+ const warnMessages = [];
1999
+ console.warn = (message) => warnMessages.push(message);
2000
+
2001
+ try {
2002
+ // Import the internal function - this may not work due to module structure
2003
+ // So we'll test via getAvailablePaths which calls it
2004
+ const result = getAvailablePaths("/root/nonexistent/path");
2005
+ expect(Array.isArray(result)).toBe(true);
2006
+ // May log warnings for directory read errors
2007
+ } finally {
2008
+ console.warn = originalWarn;
2009
+ }
2010
+ });
2011
+
2012
+ test("getGitHubRepoInfo should handle API response errors", async () => {
2013
+ // Mock fetch to return error response
2014
+ const originalFetch = global.fetch;
2015
+ global.fetch = () =>
2016
+ Promise.resolve({
2017
+ ok: false,
2018
+ statusText: "Not Found",
2019
+ });
2020
+
2021
+ const originalWarn = console.warn;
2022
+ const warnMessages = [];
2023
+ console.warn = (message) => warnMessages.push(message);
2024
+
2025
+ try {
2026
+ const result = await getGitHubRepoInfo("https://github.com/user/repo");
2027
+ expect(result).toBe(null);
2028
+ expect(
2029
+ warnMessages.some((msg) => msg.includes("Failed to fetch GitHub repository info:")),
2030
+ ).toBe(true);
2031
+ } finally {
2032
+ global.fetch = originalFetch;
2033
+ console.warn = originalWarn;
2034
+ }
2035
+ });
2036
+
2037
+ test("getGitHubRepoInfo should handle fetch errors", async () => {
2038
+ // Mock fetch to throw error
2039
+ const originalFetch = global.fetch;
2040
+ global.fetch = () => Promise.reject(new Error("Network error"));
2041
+
2042
+ const originalWarn = console.warn;
2043
+ const warnMessages = [];
2044
+ console.warn = (message) => warnMessages.push(message);
2045
+
2046
+ try {
2047
+ const result = await getGitHubRepoInfo("https://github.com/user/repo");
2048
+ expect(result).toBe(null);
2049
+ expect(
2050
+ warnMessages.some((msg) => msg.includes("Failed to fetch GitHub repository info:")),
2051
+ ).toBe(true);
2052
+ } finally {
2053
+ global.fetch = originalFetch;
2054
+ console.warn = originalWarn;
2055
+ }
2056
+ });
2057
+
2058
+ test("resolveFileReferences should handle file read errors", async () => {
2059
+ // Test with file that doesn't exist
2060
+ const config = { file: "@/nonexistent/file.txt" };
2061
+ const result = await resolveFileReferences(config);
2062
+
2063
+ // Should return original reference when file read fails
2064
+ expect(result.file).toBe("@/nonexistent/file.txt");
2065
+ });
2066
+ });
49
2067
  });