This commit is contained in:
169
packages/wiki-compiler/tests/notion.test.ts
Normal file
169
packages/wiki-compiler/tests/notion.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Notion adapter unit tests (M-13)
|
||||
*
|
||||
* Pure-function coverage for the markdown→blocks converter, rich-text
|
||||
* builder, frontmatter stripper, and page-id extractor. Network paths
|
||||
* (`createNotionPage`, `writeToNotionWorkspace`) are not unit-tested —
|
||||
* they need a mocked fetch and belong in an integration test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
markdownToBlocks,
|
||||
toRichText,
|
||||
stripFrontmatter,
|
||||
extractNotionPageId,
|
||||
type NotionBlock,
|
||||
type NotionBlockPayload,
|
||||
} from '../src/adapters/notion.js';
|
||||
|
||||
/**
|
||||
* Read the rich-text payload a block carries under its dynamic `block.type`
|
||||
* key. The block stores it as `unknown` (open-ended Notion shape), so narrow
|
||||
* it to the known {@link NotionBlockPayload} at the test boundary.
|
||||
*/
|
||||
function payloadOf(block: NotionBlock): NotionBlockPayload {
|
||||
return block[block.type] as NotionBlockPayload;
|
||||
}
|
||||
|
||||
describe('stripFrontmatter', () => {
|
||||
it('removes a leading YAML block and extracts name', () => {
|
||||
const input = `---
|
||||
type: entity
|
||||
name: Project Alpha
|
||||
confidence: 0.9
|
||||
---
|
||||
|
||||
# Body starts here`;
|
||||
const out = stripFrontmatter(input);
|
||||
expect(out.title).toBe('Project Alpha');
|
||||
expect(out.body.trimStart()).toBe('# Body starts here');
|
||||
});
|
||||
|
||||
it('returns full markdown and no title when frontmatter is absent', () => {
|
||||
const input = `# Just a header\n\nNo frontmatter.`;
|
||||
const out = stripFrontmatter(input);
|
||||
expect(out.title).toBeUndefined();
|
||||
expect(out.body).toBe(input);
|
||||
});
|
||||
|
||||
it('handles frontmatter without a name field', () => {
|
||||
const input = `---\ntype: concept\n---\n\nBody`;
|
||||
const out = stripFrontmatter(input);
|
||||
expect(out.title).toBeUndefined();
|
||||
expect(out.body.trimStart()).toBe('Body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRichText', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(toRichText('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('wraps plain text in a single rich_text item', () => {
|
||||
const out = toRichText('Hello world');
|
||||
expect(out).toEqual([{ type: 'text', text: { content: 'Hello world' } }]);
|
||||
});
|
||||
|
||||
it('converts [text](url) into a linked rich_text item', () => {
|
||||
const out = toRichText('See [Notion](https://notion.so) docs.');
|
||||
expect(out).toHaveLength(3);
|
||||
expect(out[0]).toEqual({ type: 'text', text: { content: 'See ' } });
|
||||
expect(out[1]).toEqual({
|
||||
type: 'text',
|
||||
text: { content: 'Notion', link: { url: 'https://notion.so' } },
|
||||
});
|
||||
expect(out[2]).toEqual({ type: 'text', text: { content: ' docs.' } });
|
||||
});
|
||||
|
||||
it('applies bold, italic, and code annotations', () => {
|
||||
const out = toRichText('Plain **bold** *italic* `code` end');
|
||||
// split produces: "Plain ", "**bold**", " ", "*italic*", " ", "`code`", " end"
|
||||
const bold = out.find(t => t.text.content === 'bold');
|
||||
const italic = out.find(t => t.text.content === 'italic');
|
||||
const code = out.find(t => t.text.content === 'code');
|
||||
expect(bold?.annotations?.bold).toBe(true);
|
||||
expect(italic?.annotations?.italic).toBe(true);
|
||||
expect(code?.annotations?.code).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownToBlocks', () => {
|
||||
it('maps H1/H2/H3 to heading_1/heading_2/heading_3 blocks', () => {
|
||||
const md = `# H1 Heading\n\n## H2 Heading\n\n### H3 Heading`;
|
||||
const blocks = markdownToBlocks(md);
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(blocks[0].type).toBe('heading_1');
|
||||
expect(blocks[1].type).toBe('heading_2');
|
||||
expect(blocks[2].type).toBe('heading_3');
|
||||
expect(payloadOf(blocks[0]).rich_text[0].text.content).toBe('H1 Heading');
|
||||
});
|
||||
|
||||
it('maps "- item" and "* item" bullets to bulleted_list_item', () => {
|
||||
const md = `- First\n- Second\n* Third`;
|
||||
const blocks = markdownToBlocks(md);
|
||||
expect(blocks).toHaveLength(3);
|
||||
for (const b of blocks) expect(b.type).toBe('bulleted_list_item');
|
||||
expect(payloadOf(blocks[0]).rich_text[0].text.content).toBe('First');
|
||||
});
|
||||
|
||||
it('maps "> quote" to quote block', () => {
|
||||
const md = `> A quote line`;
|
||||
const blocks = markdownToBlocks(md);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('quote');
|
||||
expect(payloadOf(blocks[0]).rich_text[0].text.content).toBe('A quote line');
|
||||
});
|
||||
|
||||
it('consolidates adjacent non-special lines into a single paragraph', () => {
|
||||
const md = `First line.\nSecond line continues.\n\nNew paragraph here.`;
|
||||
const blocks = markdownToBlocks(md);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0].type).toBe('paragraph');
|
||||
expect(blocks[1].type).toBe('paragraph');
|
||||
expect(payloadOf(blocks[0]).rich_text[0].text.content).toBe('First line. Second line continues.');
|
||||
});
|
||||
|
||||
it('flushes paragraphs when a heading interrupts the block', () => {
|
||||
const md = `A paragraph line.\n# Heading\nNext paragraph.`;
|
||||
const blocks = markdownToBlocks(md);
|
||||
expect(blocks.map(b => b.type)).toEqual(['paragraph', 'heading_1', 'paragraph']);
|
||||
});
|
||||
|
||||
it('preserves inline links inside paragraphs', () => {
|
||||
const md = `See [Notion](https://notion.so) for more.`;
|
||||
const blocks = markdownToBlocks(md);
|
||||
expect(blocks[0].type).toBe('paragraph');
|
||||
const richText = payloadOf(blocks[0]).rich_text;
|
||||
const linkItem = richText.find((t) => t.text.link);
|
||||
expect(linkItem?.text.content).toBe('Notion');
|
||||
expect(linkItem?.text.link?.url).toBe('https://notion.so');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(markdownToBlocks('')).toEqual([]);
|
||||
expect(markdownToBlocks(' \n\n ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNotionPageId', () => {
|
||||
it('accepts a dashed UUID', () => {
|
||||
const id = '12345678-1234-1234-1234-123456789abc';
|
||||
expect(extractNotionPageId(id)).toBe(id);
|
||||
});
|
||||
|
||||
it('accepts an undashed 32-hex id', () => {
|
||||
const id = '123456781234123412341234567890ab';
|
||||
expect(extractNotionPageId(id)).toBe(id);
|
||||
});
|
||||
|
||||
it('extracts the id from a notion URL', () => {
|
||||
const url = 'https://www.notion.so/Workspace/Some-Page-Title-123456781234123412341234567890ab';
|
||||
expect(extractNotionPageId(url)).toBe('123456781234123412341234567890ab');
|
||||
});
|
||||
|
||||
it('returns null for a non-hex input', () => {
|
||||
expect(extractNotionPageId('not-a-notion-page')).toBeNull();
|
||||
expect(extractNotionPageId('')).toBeNull();
|
||||
});
|
||||
});
|
||||
210
packages/wiki-compiler/tests/obsidian.test.ts
Normal file
210
packages/wiki-compiler/tests/obsidian.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Obsidian adapter unit tests (M-12).
|
||||
*
|
||||
* Covers: filesystem layout, wikilink alias transform, index generation,
|
||||
* idempotent re-run (files overwritten cleanly), skip of index/health pages.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { writeToObsidianVault } from '../src/adapters/obsidian.js';
|
||||
import type { PageRecord } from '../src/types.js';
|
||||
|
||||
function seedPages(): PageRecord[] {
|
||||
return [
|
||||
{
|
||||
slug: 'project-alpha',
|
||||
pageType: 'entity',
|
||||
name: 'Project Alpha',
|
||||
contentHash: 'h1',
|
||||
markdown: `---
|
||||
type: entity
|
||||
name: Project Alpha
|
||||
confidence: 0.9
|
||||
---
|
||||
|
||||
# Project Alpha
|
||||
|
||||
Project Alpha is led by [[Marko]] and involves [[Egzakta Advisory]].
|
||||
|
||||
See also [[Strategy Consulting]] for broader context.
|
||||
`,
|
||||
frameIds: '[1,2,3]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 3,
|
||||
},
|
||||
{
|
||||
slug: 'marko',
|
||||
pageType: 'entity',
|
||||
name: 'Marko',
|
||||
contentHash: 'h2',
|
||||
markdown: `---
|
||||
type: entity
|
||||
name: Marko
|
||||
---
|
||||
|
||||
# Marko
|
||||
|
||||
The lead consultant.`,
|
||||
frameIds: '[1]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 1,
|
||||
},
|
||||
{
|
||||
slug: 'egzakta-advisory',
|
||||
pageType: 'entity',
|
||||
name: 'Egzakta Advisory',
|
||||
contentHash: 'h3',
|
||||
markdown: `---
|
||||
type: entity
|
||||
name: Egzakta Advisory
|
||||
---
|
||||
|
||||
# Egzakta Advisory
|
||||
|
||||
Strategy consulting firm.`,
|
||||
frameIds: '[2]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 1,
|
||||
},
|
||||
{
|
||||
slug: 'strategy-consulting',
|
||||
pageType: 'concept',
|
||||
name: 'Strategy Consulting',
|
||||
contentHash: 'h4',
|
||||
markdown: `---
|
||||
type: concept
|
||||
---
|
||||
|
||||
# Strategy Consulting
|
||||
|
||||
Services offered.`,
|
||||
frameIds: '[3]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 1,
|
||||
},
|
||||
{
|
||||
// Virtual index page — should be SKIPPED by the writer.
|
||||
slug: 'index',
|
||||
pageType: 'index',
|
||||
name: 'Wiki Index',
|
||||
contentHash: 'h5',
|
||||
markdown: 'should not be written',
|
||||
frameIds: '[]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('writeToObsidianVault', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-obsidian-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes one file per non-virtual page in a per-type subdirectory', () => {
|
||||
const result = writeToObsidianVault(seedPages(), tmpDir);
|
||||
|
||||
expect(result.outDir).toBe(tmpDir);
|
||||
// 3 entities + 1 concept + 1 _index.md = 5 total (index/health skipped)
|
||||
expect(result.filesWritten).toBe(5);
|
||||
expect(result.byType.entity).toBe(3);
|
||||
expect(result.byType.concept).toBe(1);
|
||||
expect(result.byType.index).toBeUndefined();
|
||||
|
||||
expect(fs.existsSync(path.join(tmpDir, 'entity', 'project-alpha.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'entity', 'marko.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'entity', 'egzakta-advisory.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'concept', 'strategy-consulting.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, '_index.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('transforms [[Display Name]] wikilinks to [[slug|Display Name]]', () => {
|
||||
writeToObsidianVault(seedPages(), tmpDir);
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'entity', 'project-alpha.md'), 'utf-8');
|
||||
|
||||
// Linked-by-name entities get the alias form.
|
||||
expect(content).toContain('[[marko|Marko]]');
|
||||
expect(content).toContain('[[egzakta-advisory|Egzakta Advisory]]');
|
||||
expect(content).toContain('[[strategy-consulting|Strategy Consulting]]');
|
||||
// Raw display-name wikilinks should NOT remain.
|
||||
expect(content).not.toContain('[[Marko]]');
|
||||
expect(content).not.toContain('[[Egzakta Advisory]]');
|
||||
});
|
||||
|
||||
it('leaves already-slug wikilinks alone', () => {
|
||||
const pages: PageRecord[] = [{
|
||||
slug: 'main',
|
||||
pageType: 'entity',
|
||||
name: 'Main',
|
||||
contentHash: 'h',
|
||||
markdown: `# Main\n\nLinks to [[marko]] which is already a slug.`,
|
||||
frameIds: '[]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 1,
|
||||
}, {
|
||||
slug: 'marko',
|
||||
pageType: 'entity',
|
||||
name: 'Marko',
|
||||
contentHash: 'h2',
|
||||
markdown: '# Marko',
|
||||
frameIds: '[]',
|
||||
compiledAt: '2026-04-20T00:00:00.000Z',
|
||||
sourceCount: 1,
|
||||
}];
|
||||
writeToObsidianVault(pages, tmpDir);
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'entity', 'main.md'), 'utf-8');
|
||||
expect(content).toContain('[[marko]]');
|
||||
expect(content).not.toContain('[[marko|marko]]');
|
||||
});
|
||||
|
||||
it('writes _index.md with pages grouped by type', () => {
|
||||
writeToObsidianVault(seedPages(), tmpDir);
|
||||
const indexContent = fs.readFileSync(path.join(tmpDir, '_index.md'), 'utf-8');
|
||||
|
||||
expect(indexContent).toContain('# Waggle Wiki Index');
|
||||
expect(indexContent).toContain('## Entities (3)');
|
||||
expect(indexContent).toContain('## Concepts (1)');
|
||||
expect(indexContent).toContain('[[project-alpha|Project Alpha]]');
|
||||
expect(indexContent).toContain('[[strategy-consulting|Strategy Consulting]]');
|
||||
// The virtual index page should NOT appear in the index itself.
|
||||
expect(indexContent).not.toContain('[[index|');
|
||||
});
|
||||
|
||||
it('preserves YAML frontmatter as-is', () => {
|
||||
writeToObsidianVault(seedPages(), tmpDir);
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'entity', 'project-alpha.md'), 'utf-8');
|
||||
expect(content).toMatch(/^---\n/);
|
||||
expect(content).toMatch(/type: entity\nname: Project Alpha\nconfidence: 0\.9\n---/);
|
||||
});
|
||||
|
||||
it('is idempotent — re-running overwrites files cleanly', () => {
|
||||
writeToObsidianVault(seedPages(), tmpDir);
|
||||
const first = fs.readFileSync(path.join(tmpDir, 'entity', 'project-alpha.md'), 'utf-8');
|
||||
|
||||
// Mutate one page's markdown and rerun.
|
||||
const pages = seedPages();
|
||||
pages[0].markdown = pages[0].markdown.replace('Strategy Consulting', 'Ops Consulting');
|
||||
writeToObsidianVault(pages, tmpDir);
|
||||
const second = fs.readFileSync(path.join(tmpDir, 'entity', 'project-alpha.md'), 'utf-8');
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toContain('Ops Consulting');
|
||||
});
|
||||
|
||||
it('creates outDir if it does not exist', () => {
|
||||
const nested = path.join(tmpDir, 'does', 'not', 'exist', 'yet');
|
||||
expect(fs.existsSync(nested)).toBe(false);
|
||||
const result = writeToObsidianVault(seedPages(), nested);
|
||||
expect(fs.existsSync(nested)).toBe(true);
|
||||
expect(result.filesWritten).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user