@agent-wiki/mcp-server 0.3.3 → 0.4.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.
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Tests for agent-wiki core engine.
3
+ *
4
+ * Covers: init, config, raw layer (add/list/read/verify), wiki CRUD,
5
+ * search, lint, classify, synthesize, schemas, log, index, timeline.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { Wiki, safePath } from "./wiki.js";
12
+ // ── Test helpers ─────────────────────────────────────────────────
13
+ const TEST_ROOT = join(import.meta.dirname ?? ".", "__test_workspace__");
14
+ function freshWiki(workspace) {
15
+ return Wiki.init(TEST_ROOT, workspace);
16
+ }
17
+ function cleanUp() {
18
+ if (existsSync(TEST_ROOT)) {
19
+ rmSync(TEST_ROOT, { recursive: true, force: true });
20
+ }
21
+ }
22
+ // ═══════════════════════════════════════════════════════════════════
23
+ // INIT & CONFIG
24
+ // ═══════════════════════════════════════════════════════════════════
25
+ describe("Wiki.init", () => {
26
+ beforeEach(cleanUp);
27
+ afterEach(cleanUp);
28
+ it("creates wiki/, raw/, schemas/ directories", () => {
29
+ freshWiki();
30
+ expect(existsSync(join(TEST_ROOT, "wiki"))).toBe(true);
31
+ expect(existsSync(join(TEST_ROOT, "raw"))).toBe(true);
32
+ expect(existsSync(join(TEST_ROOT, "schemas"))).toBe(true);
33
+ });
34
+ it("creates default system pages (index, log, timeline)", () => {
35
+ freshWiki();
36
+ expect(existsSync(join(TEST_ROOT, "wiki", "index.md"))).toBe(true);
37
+ expect(existsSync(join(TEST_ROOT, "wiki", "log.md"))).toBe(true);
38
+ expect(existsSync(join(TEST_ROOT, "wiki", "timeline.md"))).toBe(true);
39
+ });
40
+ it("creates .agent-wiki.yaml config", () => {
41
+ freshWiki();
42
+ expect(existsSync(join(TEST_ROOT, ".agent-wiki.yaml"))).toBe(true);
43
+ });
44
+ it("creates default schema templates", () => {
45
+ freshWiki();
46
+ const schemasDir = join(TEST_ROOT, "schemas");
47
+ for (const name of ["person", "concept", "event", "artifact", "comparison", "summary", "how-to", "note", "synthesis"]) {
48
+ expect(existsSync(join(schemasDir, `${name}.md`))).toBe(true);
49
+ }
50
+ });
51
+ it("creates .gitignore", () => {
52
+ freshWiki();
53
+ expect(existsSync(join(TEST_ROOT, ".gitignore"))).toBe(true);
54
+ });
55
+ });
56
+ describe("Wiki.init with separate workspace", () => {
57
+ const wsDir = join(TEST_ROOT, "data");
58
+ beforeEach(cleanUp);
59
+ afterEach(cleanUp);
60
+ it("puts data in workspace dir, config in root", () => {
61
+ Wiki.init(TEST_ROOT, wsDir);
62
+ expect(existsSync(join(TEST_ROOT, ".agent-wiki.yaml"))).toBe(true);
63
+ expect(existsSync(join(wsDir, "wiki"))).toBe(true);
64
+ expect(existsSync(join(wsDir, "raw"))).toBe(true);
65
+ expect(existsSync(join(wsDir, "schemas"))).toBe(true);
66
+ });
67
+ it("creates .gitignore in workspace too", () => {
68
+ Wiki.init(TEST_ROOT, wsDir);
69
+ expect(existsSync(join(wsDir, ".gitignore"))).toBe(true);
70
+ });
71
+ });
72
+ describe("Wiki.loadConfig", () => {
73
+ beforeEach(cleanUp);
74
+ afterEach(cleanUp);
75
+ it("loads config from .agent-wiki.yaml", () => {
76
+ const wiki = freshWiki();
77
+ const cfg = wiki.config;
78
+ expect(cfg.configRoot).toBe(TEST_ROOT);
79
+ expect(cfg.wikiDir).toContain("wiki");
80
+ expect(cfg.rawDir).toContain("raw");
81
+ expect(cfg.schemasDir).toContain("schemas");
82
+ });
83
+ it("has default lint settings", () => {
84
+ const wiki = freshWiki();
85
+ expect(wiki.config.lint.checkOrphans).toBe(true);
86
+ expect(wiki.config.lint.checkStaleDays).toBe(30);
87
+ expect(wiki.config.lint.checkMissingSources).toBe(true);
88
+ expect(wiki.config.lint.checkContradictions).toBe(true);
89
+ expect(wiki.config.lint.checkIntegrity).toBe(true);
90
+ });
91
+ it("workspace override takes priority over config", () => {
92
+ const wsDir = join(TEST_ROOT, "override_ws");
93
+ Wiki.init(TEST_ROOT);
94
+ const wiki = new Wiki(TEST_ROOT, wsDir);
95
+ expect(wiki.config.workspace).toBe(wsDir);
96
+ });
97
+ });
98
+ // ═══════════════════════════════════════════════════════════════════
99
+ // RAW LAYER
100
+ // ═══════════════════════════════════════════════════════════════════
101
+ describe("rawAdd", () => {
102
+ beforeEach(cleanUp);
103
+ afterEach(cleanUp);
104
+ it("adds a text file with content", () => {
105
+ const wiki = freshWiki();
106
+ const doc = wiki.rawAdd("test.md", { content: "Hello world" });
107
+ expect(doc.path).toBe("test.md");
108
+ expect(doc.size).toBeGreaterThan(0);
109
+ expect(doc.sha256).toHaveLength(64);
110
+ expect(doc.mimeType).toBe("text/markdown");
111
+ });
112
+ it("creates .meta.yaml sidecar", () => {
113
+ const wiki = freshWiki();
114
+ wiki.rawAdd("data.json", { content: '{"key": "value"}' });
115
+ expect(existsSync(join(wiki.config.rawDir, "data.json.meta.yaml"))).toBe(true);
116
+ });
117
+ it("stores correct SHA-256 hash", () => {
118
+ const wiki = freshWiki();
119
+ const content = "Test content for hashing";
120
+ const doc = wiki.rawAdd("hash-test.txt", { content });
121
+ const expected = createHash("sha256").update(content).digest("hex");
122
+ expect(doc.sha256).toBe(expected);
123
+ });
124
+ it("rejects duplicate filenames (immutability)", () => {
125
+ const wiki = freshWiki();
126
+ wiki.rawAdd("once.txt", { content: "first" });
127
+ expect(() => wiki.rawAdd("once.txt", { content: "second" }))
128
+ .toThrow(/immutable/i);
129
+ });
130
+ it("requires content or sourcePath", () => {
131
+ const wiki = freshWiki();
132
+ expect(() => wiki.rawAdd("empty.txt", {}))
133
+ .toThrow(/content|sourcePath/i);
134
+ });
135
+ it("accepts sourcePath (file copy)", () => {
136
+ const wiki = freshWiki();
137
+ const srcFile = join(TEST_ROOT, "src_file.txt");
138
+ writeFileSync(srcFile, "copied content");
139
+ const doc = wiki.rawAdd("copied.txt", { sourcePath: srcFile });
140
+ expect(doc.size).toBeGreaterThan(0);
141
+ const stored = readFileSync(join(wiki.config.rawDir, "copied.txt"), "utf-8");
142
+ expect(stored).toBe("copied content");
143
+ });
144
+ it("stores optional metadata (sourceUrl, description, tags)", () => {
145
+ const wiki = freshWiki();
146
+ const doc = wiki.rawAdd("meta.txt", {
147
+ content: "x",
148
+ sourceUrl: "https://example.com",
149
+ description: "A test file",
150
+ tags: ["test", "demo"],
151
+ });
152
+ expect(doc.sourceUrl).toBe("https://example.com");
153
+ expect(doc.description).toBe("A test file");
154
+ expect(doc.tags).toEqual(["test", "demo"]);
155
+ });
156
+ });
157
+ describe("rawList", () => {
158
+ beforeEach(cleanUp);
159
+ afterEach(cleanUp);
160
+ it("returns empty array when no raw files", () => {
161
+ const wiki = freshWiki();
162
+ expect(wiki.rawList()).toEqual([]);
163
+ });
164
+ it("lists added raw documents", () => {
165
+ const wiki = freshWiki();
166
+ wiki.rawAdd("a.txt", { content: "aaa" });
167
+ wiki.rawAdd("b.md", { content: "bbb" });
168
+ const docs = wiki.rawList();
169
+ expect(docs).toHaveLength(2);
170
+ expect(docs.map(d => d.path).sort()).toEqual(["a.txt", "b.md"]);
171
+ });
172
+ });
173
+ describe("rawRead", () => {
174
+ beforeEach(cleanUp);
175
+ afterEach(cleanUp);
176
+ it("reads text file content", async () => {
177
+ const wiki = freshWiki();
178
+ wiki.rawAdd("readme.md", { content: "# Hello" });
179
+ const result = await wiki.rawRead("readme.md");
180
+ expect(result).not.toBeNull();
181
+ expect(result.binary).toBe(false);
182
+ expect(result.content).toBe("# Hello");
183
+ expect(result.meta).not.toBeNull();
184
+ });
185
+ it("returns null for non-existent file", async () => {
186
+ const wiki = freshWiki();
187
+ const result = await wiki.rawRead("nonexistent.md");
188
+ expect(result).toBeNull();
189
+ });
190
+ it("handles JSON as text", async () => {
191
+ const wiki = freshWiki();
192
+ wiki.rawAdd("data.json", { content: '{"x":1}' });
193
+ const result = await wiki.rawRead("data.json");
194
+ expect(result.binary).toBe(false);
195
+ expect(result.content).toBe('{"x":1}');
196
+ });
197
+ it("returns binary=true for image files", async () => {
198
+ const wiki = freshWiki();
199
+ // Write a fake PNG (just bytes, not a real image)
200
+ const rawPath = join(wiki.config.rawDir, "photo.png");
201
+ writeFileSync(rawPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
202
+ writeFileSync(rawPath + ".meta.yaml", `path: photo.png\ndownloadedAt: "2024-01-01"\nsha256: abcd\nsize: 4\nmimeType: image/png\n`);
203
+ const result = await wiki.rawRead("photo.png");
204
+ expect(result.binary).toBe(true);
205
+ expect(result.content).toBeNull();
206
+ });
207
+ });
208
+ describe("rawVerify", () => {
209
+ beforeEach(cleanUp);
210
+ afterEach(cleanUp);
211
+ it("returns empty for empty raw dir", () => {
212
+ const wiki = freshWiki();
213
+ expect(wiki.rawVerify()).toEqual([]);
214
+ });
215
+ it("reports ok for valid files", () => {
216
+ const wiki = freshWiki();
217
+ wiki.rawAdd("valid.txt", { content: "intact" });
218
+ const results = wiki.rawVerify();
219
+ expect(results).toHaveLength(1);
220
+ expect(results[0].status).toBe("ok");
221
+ });
222
+ it("detects corrupted files", () => {
223
+ const wiki = freshWiki();
224
+ wiki.rawAdd("tamper.txt", { content: "original" });
225
+ // Tamper with the file
226
+ writeFileSync(join(wiki.config.rawDir, "tamper.txt"), "modified!");
227
+ const results = wiki.rawVerify();
228
+ expect(results[0].status).toBe("corrupted");
229
+ });
230
+ it("detects missing metadata", () => {
231
+ const wiki = freshWiki();
232
+ // Write a raw file without using rawAdd (no meta sidecar)
233
+ writeFileSync(join(wiki.config.rawDir, "orphan.txt"), "no meta");
234
+ const results = wiki.rawVerify();
235
+ const orphan = results.find(r => r.path === "orphan.txt");
236
+ expect(orphan).toBeDefined();
237
+ expect(orphan.status).toBe("missing-meta");
238
+ });
239
+ });
240
+ // ═══════════════════════════════════════════════════════════════════
241
+ // WIKI LAYER — CRUD
242
+ // ═══════════════════════════════════════════════════════════════════
243
+ describe("wiki.write & wiki.read", () => {
244
+ beforeEach(cleanUp);
245
+ afterEach(cleanUp);
246
+ it("writes and reads a page with frontmatter", () => {
247
+ const wiki = freshWiki();
248
+ const content = `---
249
+ title: Test Page
250
+ type: concept
251
+ tags: [test, demo]
252
+ ---
253
+
254
+ # Test Page
255
+
256
+ This is a test.
257
+ `;
258
+ wiki.write("concept-test.md", content);
259
+ const page = wiki.read("concept-test.md");
260
+ expect(page).not.toBeNull();
261
+ expect(page.title).toBe("Test Page");
262
+ expect(page.type).toBe("concept");
263
+ expect(page.tags).toEqual(["test", "demo"]);
264
+ expect(page.content).toContain("This is a test.");
265
+ });
266
+ it("auto-injects created/updated timestamps", () => {
267
+ const wiki = freshWiki();
268
+ wiki.write("timestamped.md", "---\ntitle: Time\n---\nBody");
269
+ const page = wiki.read("timestamped.md");
270
+ expect(page.created).toBeDefined();
271
+ expect(page.updated).toBeDefined();
272
+ });
273
+ it("preserves created time on update", () => {
274
+ const wiki = freshWiki();
275
+ wiki.write("update-me.md", "---\ntitle: V1\n---\nFirst version");
276
+ const v1 = wiki.read("update-me.md");
277
+ const created1 = v1.created;
278
+ // Wait a tick so timestamps differ
279
+ wiki.write("update-me.md", "---\ntitle: V2\n---\nSecond version");
280
+ const v2 = wiki.read("update-me.md");
281
+ expect(v2.created).toBe(created1);
282
+ expect(v2.title).toBe("V2");
283
+ });
284
+ it("returns null for non-existent page", () => {
285
+ const wiki = freshWiki();
286
+ expect(wiki.read("nonexistent.md")).toBeNull();
287
+ });
288
+ it("auto-appends .md when reading", () => {
289
+ const wiki = freshWiki();
290
+ wiki.write("auto-ext.md", "---\ntitle: Auto\n---\nBody");
291
+ const page = wiki.read("auto-ext");
292
+ expect(page).not.toBeNull();
293
+ expect(page.title).toBe("Auto");
294
+ });
295
+ it("extracts [[links]] from body", () => {
296
+ const wiki = freshWiki();
297
+ wiki.write("linker.md", "---\ntitle: Linker\n---\nSee [[concept-a]] and [[concept-b]].");
298
+ const page = wiki.read("linker.md");
299
+ expect(page.links).toEqual(["concept-a", "concept-b"]);
300
+ });
301
+ });
302
+ describe("wiki.delete", () => {
303
+ beforeEach(cleanUp);
304
+ afterEach(cleanUp);
305
+ it("deletes an existing page", () => {
306
+ const wiki = freshWiki();
307
+ wiki.write("to-delete.md", "---\ntitle: Delete Me\n---\nGone");
308
+ const existed = wiki.delete("to-delete.md");
309
+ expect(existed).toBe(true);
310
+ expect(wiki.read("to-delete.md")).toBeNull();
311
+ });
312
+ it("returns false for non-existent page", () => {
313
+ const wiki = freshWiki();
314
+ expect(wiki.delete("nope.md")).toBe(false);
315
+ });
316
+ it("refuses to delete system pages", () => {
317
+ const wiki = freshWiki();
318
+ expect(() => wiki.delete("index.md")).toThrow();
319
+ expect(() => wiki.delete("log.md")).toThrow();
320
+ expect(() => wiki.delete("timeline.md")).toThrow();
321
+ });
322
+ });
323
+ describe("wiki.list", () => {
324
+ beforeEach(cleanUp);
325
+ afterEach(cleanUp);
326
+ it("lists all non-system pages", () => {
327
+ const wiki = freshWiki();
328
+ wiki.write("concept-a.md", "---\ntitle: A\ntype: concept\ntags: [x]\n---\nA");
329
+ wiki.write("person-b.md", "---\ntitle: B\ntype: person\ntags: [y]\n---\nB");
330
+ const pages = wiki.list();
331
+ // Should include our pages + system pages
332
+ expect(pages.length).toBeGreaterThanOrEqual(2);
333
+ expect(pages).toContain("concept-a.md");
334
+ expect(pages).toContain("person-b.md");
335
+ });
336
+ it("filters by type", () => {
337
+ const wiki = freshWiki();
338
+ wiki.write("concept-x.md", "---\ntitle: X\ntype: concept\n---\nX");
339
+ wiki.write("person-y.md", "---\ntitle: Y\ntype: person\n---\nY");
340
+ const concepts = wiki.list("concept");
341
+ expect(concepts).toContain("concept-x.md");
342
+ expect(concepts).not.toContain("person-y.md");
343
+ });
344
+ it("filters by tag", () => {
345
+ const wiki = freshWiki();
346
+ wiki.write("tagged-a.md", "---\ntitle: A\ntags: [ml, python]\n---\nA");
347
+ wiki.write("tagged-b.md", "---\ntitle: B\ntags: [rust]\n---\nB");
348
+ const mlPages = wiki.list(undefined, "ml");
349
+ expect(mlPages).toContain("tagged-a.md");
350
+ expect(mlPages).not.toContain("tagged-b.md");
351
+ });
352
+ });
353
+ // ═══════════════════════════════════════════════════════════════════
354
+ // SEARCH
355
+ // ═══════════════════════════════════════════════════════════════════
356
+ describe("wiki.search", () => {
357
+ beforeEach(cleanUp);
358
+ afterEach(cleanUp);
359
+ it("finds pages by keyword", () => {
360
+ const wiki = freshWiki();
361
+ wiki.write("concept-yolo.md", "---\ntitle: YOLO Overview\ntype: concept\ntags: [detection]\n---\nYOLO is a real-time object detection model.");
362
+ wiki.write("concept-bert.md", "---\ntitle: BERT Overview\ntype: concept\ntags: [nlp]\n---\nBERT is a language model.");
363
+ const results = wiki.search("YOLO");
364
+ expect(results.length).toBeGreaterThan(0);
365
+ expect(results[0].path).toBe("concept-yolo.md");
366
+ });
367
+ it("returns empty for no matches", () => {
368
+ const wiki = freshWiki();
369
+ wiki.write("page.md", "---\ntitle: Page\n---\nSome content");
370
+ expect(wiki.search("nonexistent_xyz_12345")).toEqual([]);
371
+ });
372
+ it("boosts title matches", () => {
373
+ const wiki = freshWiki();
374
+ wiki.write("a.md", "---\ntitle: Python Guide\n---\nLearn Python programming.");
375
+ wiki.write("b.md", "---\ntitle: JavaScript Guide\n---\nPython is mentioned here too.");
376
+ const results = wiki.search("python");
377
+ // Title match should rank higher
378
+ expect(results[0].path).toBe("a.md");
379
+ });
380
+ it("respects limit", () => {
381
+ const wiki = freshWiki();
382
+ for (let i = 0; i < 5; i++) {
383
+ wiki.write(`page-${i}.md`, `---\ntitle: Page ${i}\n---\nCommon keyword here.`);
384
+ }
385
+ const results = wiki.search("keyword", 2);
386
+ expect(results.length).toBeLessThanOrEqual(2);
387
+ });
388
+ it("returns snippets", () => {
389
+ const wiki = freshWiki();
390
+ wiki.write("snippet.md", "---\ntitle: Snippet Test\n---\nThe quick brown fox jumps over the lazy dog.");
391
+ const results = wiki.search("fox");
392
+ expect(results[0].snippet).toBeTruthy();
393
+ expect(results[0].snippet.toLowerCase()).toContain("fox");
394
+ });
395
+ });
396
+ // ═══════════════════════════════════════════════════════════════════
397
+ // LINT
398
+ // ═══════════════════════════════════════════════════════════════════
399
+ describe("wiki.lint", () => {
400
+ beforeEach(cleanUp);
401
+ afterEach(cleanUp);
402
+ it("runs without errors on fresh wiki", () => {
403
+ const wiki = freshWiki();
404
+ const report = wiki.lint();
405
+ expect(report.pagesChecked).toBeGreaterThan(0);
406
+ expect(report.contradictions).toEqual([]);
407
+ });
408
+ it("detects broken links", () => {
409
+ const wiki = freshWiki();
410
+ wiki.write("broken.md", "---\ntitle: Broken Links\ntype: note\n---\nSee [[nonexistent-page]].");
411
+ const report = wiki.lint();
412
+ const brokenLink = report.issues.find(i => i.category === "broken-link" && i.page === "broken.md");
413
+ expect(brokenLink).toBeDefined();
414
+ expect(brokenLink.message).toContain("nonexistent-page");
415
+ });
416
+ it("detects orphan pages", () => {
417
+ const wiki = freshWiki();
418
+ wiki.write("orphan.md", "---\ntitle: Lonely Page\ntype: note\n---\nNo one links here.");
419
+ const report = wiki.lint();
420
+ const orphan = report.issues.find(i => i.category === "orphan" && i.page === "orphan.md");
421
+ expect(orphan).toBeDefined();
422
+ });
423
+ it("detects missing sources", () => {
424
+ const wiki = freshWiki();
425
+ wiki.write("no-sources.md", "---\ntitle: No Sources\ntype: concept\ntags: [x]\n---\nClaims without evidence.");
426
+ const report = wiki.lint();
427
+ const missing = report.issues.find(i => i.category === "missing-source" && i.page === "no-sources.md");
428
+ expect(missing).toBeDefined();
429
+ });
430
+ it("detects corrupted raw files", () => {
431
+ const wiki = freshWiki();
432
+ wiki.rawAdd("integrity.txt", { content: "original" });
433
+ // Corrupt the file
434
+ writeFileSync(join(wiki.config.rawDir, "integrity.txt"), "tampered");
435
+ const report = wiki.lint();
436
+ const corruption = report.issues.find(i => i.category === "integrity" && i.message.includes("corrupted"));
437
+ expect(corruption).toBeDefined();
438
+ });
439
+ it("detects missing frontmatter", () => {
440
+ const wiki = freshWiki();
441
+ // Write a page with no frontmatter
442
+ writeFileSync(join(wiki.config.wikiDir, "bare.md"), "Just text, no frontmatter.");
443
+ const report = wiki.lint();
444
+ const noFm = report.issues.find(i => i.category === "structure" && i.page === "bare.md" && i.message.includes("frontmatter"));
445
+ expect(noFm).toBeDefined();
446
+ });
447
+ it("detects contradictions between pages", () => {
448
+ const wiki = freshWiki();
449
+ wiki.write("page-a.md", "---\ntitle: YOLO Facts A\ntype: concept\n---\nYOLO was released in 2015.");
450
+ wiki.write("page-b.md", "---\ntitle: YOLO Facts B\ntype: concept\n---\nYOLO was released in 2018.");
451
+ const report = wiki.lint();
452
+ // Should detect the date contradiction
453
+ expect(report.contradictions.length).toBeGreaterThan(0);
454
+ });
455
+ it("checks synthesis page integrity", () => {
456
+ const wiki = freshWiki();
457
+ wiki.write("synthesis-x.md", `---
458
+ title: Synthesis X
459
+ type: synthesis
460
+ derived_from:
461
+ - missing-source-page
462
+ ---
463
+ Combined insights.`);
464
+ const report = wiki.lint();
465
+ const synthIssue = report.issues.find(i => i.category === "integrity" && i.page === "synthesis-x.md");
466
+ expect(synthIssue).toBeDefined();
467
+ expect(synthIssue.message).toContain("missing-source-page");
468
+ });
469
+ });
470
+ // ═══════════════════════════════════════════════════════════════════
471
+ // CLASSIFY
472
+ // ═══════════════════════════════════════════════════════════════════
473
+ describe("wiki.classify", () => {
474
+ beforeEach(cleanUp);
475
+ afterEach(cleanUp);
476
+ it("classifies a person page", () => {
477
+ const wiki = freshWiki();
478
+ const result = wiki.classify("---\ntitle: Albert Einstein\n---\nBorn in 1879, researcher, professor of physics.");
479
+ expect(result.type).toBe("person");
480
+ expect(result.confidence).toBeGreaterThan(0);
481
+ });
482
+ it("classifies a concept page", () => {
483
+ const wiki = freshWiki();
484
+ const result = wiki.classify("---\ntitle: GIL\n---\nDefinition: The Global Interpreter Lock is a concept in Python.");
485
+ expect(result.type).toBe("concept");
486
+ });
487
+ it("classifies a how-to page", () => {
488
+ const wiki = freshWiki();
489
+ const result = wiki.classify("---\ntitle: How to Install Docker\n---\nStep 1: Install Docker. Step 2: Setup containers.");
490
+ expect(result.type).toBe("how-to");
491
+ });
492
+ it("respects existing type in frontmatter", () => {
493
+ const wiki = freshWiki();
494
+ const result = wiki.classify("---\ntitle: My Artifact\ntype: artifact\n---\nSome body text.");
495
+ expect(result.type).toBe("artifact");
496
+ expect(result.confidence).toBe(1.0);
497
+ });
498
+ it("defaults to note for ambiguous content", () => {
499
+ const wiki = freshWiki();
500
+ const result = wiki.classify("---\ntitle: Random Notes\n---\nJust some random text without clear signals.");
501
+ expect(result.type).toBe("note");
502
+ });
503
+ it("suggests tags", () => {
504
+ const wiki = freshWiki();
505
+ const result = wiki.classify("---\ntitle: PyTorch CNN\n---\nA CNN model built with PyTorch and Python for detection.");
506
+ expect(result.tags.length).toBeGreaterThan(0);
507
+ expect(result.tags).toEqual(expect.arrayContaining(["python"]));
508
+ });
509
+ });
510
+ describe("wiki.autoClassifyContent", () => {
511
+ beforeEach(cleanUp);
512
+ afterEach(cleanUp);
513
+ it("enriches content missing type and tags", () => {
514
+ const wiki = freshWiki();
515
+ const input = "---\ntitle: Docker Guide\n---\nStep 1: Install Docker. Step 2: run containers.";
516
+ const enriched = wiki.autoClassifyContent(input);
517
+ expect(enriched).toContain("type:");
518
+ expect(enriched).toContain("tags:");
519
+ });
520
+ it("does not overwrite existing type", () => {
521
+ const wiki = freshWiki();
522
+ const input = "---\ntitle: My Page\ntype: artifact\n---\nSome body.";
523
+ const enriched = wiki.autoClassifyContent(input);
524
+ expect(enriched).toContain("type: artifact");
525
+ });
526
+ });
527
+ // ═══════════════════════════════════════════════════════════════════
528
+ // SYNTHESIZE
529
+ // ═══════════════════════════════════════════════════════════════════
530
+ describe("wiki.synthesizeContext", () => {
531
+ beforeEach(cleanUp);
532
+ afterEach(cleanUp);
533
+ it("returns content from multiple pages", () => {
534
+ const wiki = freshWiki();
535
+ wiki.write("concept-a.md", "---\ntitle: Concept A\ntags: [ml]\n---\nContent A.");
536
+ wiki.write("concept-b.md", "---\ntitle: Concept B\ntags: [nlp]\n---\nContent B.");
537
+ const ctx = wiki.synthesizeContext(["concept-a.md", "concept-b.md"]);
538
+ expect(ctx.pages).toHaveLength(2);
539
+ expect(ctx.pages[0].title).toBe("Concept A");
540
+ expect(ctx.pages[1].title).toBe("Concept B");
541
+ });
542
+ it("generates suggestions for multiple pages", () => {
543
+ const wiki = freshWiki();
544
+ wiki.write("p1.md", "---\ntitle: P1\n---\nX");
545
+ wiki.write("p2.md", "---\ntitle: P2\n---\nY");
546
+ const ctx = wiki.synthesizeContext(["p1.md", "p2.md"]);
547
+ expect(ctx.suggestions.length).toBeGreaterThan(0);
548
+ });
549
+ it("skips missing pages gracefully", () => {
550
+ const wiki = freshWiki();
551
+ wiki.write("exists.md", "---\ntitle: Exists\n---\nHere");
552
+ const ctx = wiki.synthesizeContext(["exists.md", "nope.md"]);
553
+ expect(ctx.pages).toHaveLength(1);
554
+ });
555
+ });
556
+ // ═══════════════════════════════════════════════════════════════════
557
+ // SCHEMAS
558
+ // ═══════════════════════════════════════════════════════════════════
559
+ describe("wiki.schemas", () => {
560
+ beforeEach(cleanUp);
561
+ afterEach(cleanUp);
562
+ it("lists all 9 default schemas", () => {
563
+ const wiki = freshWiki();
564
+ const schemas = wiki.schemas();
565
+ expect(schemas.length).toBe(9);
566
+ const names = schemas.map(s => s.name).sort();
567
+ expect(names).toEqual([
568
+ "artifact", "comparison", "concept", "event",
569
+ "how-to", "note", "person", "summary", "synthesis",
570
+ ]);
571
+ });
572
+ it("each schema has name, description, and template", () => {
573
+ const wiki = freshWiki();
574
+ for (const schema of wiki.schemas()) {
575
+ expect(schema.name).toBeTruthy();
576
+ expect(schema.description).toBeTruthy();
577
+ expect(schema.template).toContain("template:");
578
+ }
579
+ });
580
+ });
581
+ // ═══════════════════════════════════════════════════════════════════
582
+ // LOG
583
+ // ═══════════════════════════════════════════════════════════════════
584
+ describe("wiki.getLog", () => {
585
+ beforeEach(cleanUp);
586
+ afterEach(cleanUp);
587
+ it("has init entry after fresh init", () => {
588
+ const wiki = freshWiki();
589
+ const log = wiki.getLog();
590
+ expect(log.length).toBeGreaterThan(0);
591
+ });
592
+ it("records write operations", () => {
593
+ const wiki = freshWiki();
594
+ wiki.write("logged.md", "---\ntitle: Logged\n---\nBody");
595
+ const log = wiki.getLog();
596
+ const writeEntry = log.find(e => e.operation === "write" || e.operation === "create");
597
+ expect(writeEntry).toBeDefined();
598
+ });
599
+ it("records raw-add operations", () => {
600
+ const wiki = freshWiki();
601
+ wiki.rawAdd("log-test.txt", { content: "data" });
602
+ const log = wiki.getLog();
603
+ const rawEntry = log.find(e => e.operation === "raw-add");
604
+ expect(rawEntry).toBeDefined();
605
+ });
606
+ it("respects limit", () => {
607
+ const wiki = freshWiki();
608
+ for (let i = 0; i < 10; i++) {
609
+ wiki.write(`bulk-${i}.md`, `---\ntitle: Bulk ${i}\n---\nBody ${i}`);
610
+ }
611
+ const log = wiki.getLog(3);
612
+ expect(log.length).toBeLessThanOrEqual(3);
613
+ });
614
+ });
615
+ // ═══════════════════════════════════════════════════════════════════
616
+ // INDEX & TIMELINE REBUILD
617
+ // ═══════════════════════════════════════════════════════════════════
618
+ describe("wiki.rebuildIndex", () => {
619
+ beforeEach(cleanUp);
620
+ afterEach(cleanUp);
621
+ it("rebuilds index with page counts", () => {
622
+ const wiki = freshWiki();
623
+ wiki.write("concept-a.md", "---\ntitle: A\ntype: concept\n---\nA");
624
+ wiki.write("person-b.md", "---\ntitle: B\ntype: person\n---\nB");
625
+ wiki.rebuildIndex();
626
+ const index = readFileSync(join(wiki.config.wikiDir, "index.md"), "utf-8");
627
+ expect(index).toContain("concept-a");
628
+ expect(index).toContain("person-b");
629
+ expect(index).toContain("2 pages");
630
+ });
631
+ });
632
+ describe("wiki.rebuildTimeline", () => {
633
+ beforeEach(cleanUp);
634
+ afterEach(cleanUp);
635
+ it("rebuilds timeline with entries", () => {
636
+ const wiki = freshWiki();
637
+ wiki.write("event-x.md", "---\ntitle: Event X\ntype: event\n---\nSomething happened.");
638
+ wiki.rebuildTimeline();
639
+ const timeline = readFileSync(join(wiki.config.wikiDir, "timeline.md"), "utf-8");
640
+ expect(timeline).toContain("Event X");
641
+ expect(timeline).toContain("[event]");
642
+ });
643
+ });
644
+ // ═══════════════════════════════════════════════════════════════════
645
+ // EDGE CASES & ROBUSTNESS
646
+ // ═══════════════════════════════════════════════════════════════════
647
+ describe("edge cases", () => {
648
+ beforeEach(cleanUp);
649
+ afterEach(cleanUp);
650
+ it("handles unicode content correctly", () => {
651
+ const wiki = freshWiki();
652
+ const content = "---\ntitle: 中文测试\ntags: [中文]\n---\n这是一个中文页面。";
653
+ wiki.write("chinese.md", content);
654
+ const page = wiki.read("chinese.md");
655
+ expect(page.title).toBe("中文测试");
656
+ expect(page.content).toContain("中文页面");
657
+ });
658
+ it("handles empty search query", () => {
659
+ const wiki = freshWiki();
660
+ expect(wiki.search("")).toEqual([]);
661
+ expect(wiki.search(" ")).toEqual([]);
662
+ });
663
+ it("handles pages with no body", () => {
664
+ const wiki = freshWiki();
665
+ wiki.write("empty-body.md", "---\ntitle: Empty\ntype: note\n---\n");
666
+ const page = wiki.read("empty-body.md");
667
+ expect(page).not.toBeNull();
668
+ expect(page.title).toBe("Empty");
669
+ });
670
+ it("handles raw file with subdirectory", () => {
671
+ const wiki = freshWiki();
672
+ const doc = wiki.rawAdd("papers/test.txt", { content: "nested" });
673
+ expect(doc.path).toBe("papers/test.txt");
674
+ const result = wiki.rawList();
675
+ expect(result.find(d => d.path === "papers/test.txt")).toBeDefined();
676
+ });
677
+ });
678
+ // ═══════════════════════════════════════════════════════════════════
679
+ // PATH SAFETY — Directory Traversal Prevention
680
+ // ═══════════════════════════════════════════════════════════════════
681
+ describe("safePath", () => {
682
+ it("allows simple filenames", () => {
683
+ const result = safePath("/base/dir", "file.txt");
684
+ expect(result).toBe("/base/dir/file.txt");
685
+ });
686
+ it("allows subdirectories", () => {
687
+ const result = safePath("/base/dir", "sub/file.txt");
688
+ expect(result).toBe("/base/dir/sub/file.txt");
689
+ });
690
+ it("rejects parent traversal (../)", () => {
691
+ expect(() => safePath("/base/dir", "../etc/passwd")).toThrow(/traversal/i);
692
+ });
693
+ it("rejects deep traversal (../../)", () => {
694
+ expect(() => safePath("/base/dir", "../../etc/shadow")).toThrow(/traversal/i);
695
+ });
696
+ it("rejects traversal disguised in subpath", () => {
697
+ expect(() => safePath("/base/dir", "sub/../../etc/passwd")).toThrow(/traversal/i);
698
+ });
699
+ it("rejects absolute paths", () => {
700
+ expect(() => safePath("/base/dir", "/etc/passwd")).toThrow(/absolute/i);
701
+ });
702
+ it("rejects null bytes", () => {
703
+ expect(() => safePath("/base/dir", "file\0.txt")).toThrow(/null/i);
704
+ });
705
+ it("rejects empty path", () => {
706
+ expect(() => safePath("/base/dir", "")).toThrow();
707
+ });
708
+ });
709
+ describe("path traversal via wiki methods", () => {
710
+ beforeEach(cleanUp);
711
+ afterEach(cleanUp);
712
+ it("rawAdd rejects traversal", () => {
713
+ const wiki = freshWiki();
714
+ expect(() => wiki.rawAdd("../escape.txt", { content: "evil" })).toThrow(/traversal/i);
715
+ });
716
+ it("rawRead rejects traversal", async () => {
717
+ const wiki = freshWiki();
718
+ await expect(wiki.rawRead("../../etc/passwd")).rejects.toThrow(/traversal/i);
719
+ });
720
+ it("wiki.read rejects traversal", () => {
721
+ const wiki = freshWiki();
722
+ expect(() => wiki.read("../../../etc/passwd")).toThrow(/traversal/i);
723
+ });
724
+ it("wiki.write rejects traversal", () => {
725
+ const wiki = freshWiki();
726
+ expect(() => wiki.write("../../.bashrc", "---\ntitle: Evil\n---\nHacked")).toThrow(/traversal/i);
727
+ });
728
+ it("wiki.delete rejects traversal", () => {
729
+ const wiki = freshWiki();
730
+ expect(() => wiki.delete("../important.conf")).toThrow(/traversal/i);
731
+ });
732
+ it("allows legitimate subdirectory paths", () => {
733
+ const wiki = freshWiki();
734
+ // These should NOT throw
735
+ wiki.rawAdd("papers/deep/file.txt", { content: "nested ok" });
736
+ wiki.write("topics/concept-a.md", "---\ntitle: Nested\n---\nOk");
737
+ expect(wiki.read("topics/concept-a.md")).not.toBeNull();
738
+ });
739
+ });
740
+ //# sourceMappingURL=wiki.test.js.map