/** * Brute test suite for @docmd/plugin-okf * * 12 real-fs scenarios. Each builds a small on-disk project under * /tmp/okf-brutetest-/ and invokes the plugin's `onPostBuild` directly. * * Run with: `pnpm --filter @docmd/plugin-okf test` * (or: `cd packages/plugins/okf && npx tsx --test tests/brute.test.ts`) */ import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; import { onPostBuild } from '../src/index.js'; // ---- tiny helpers --------------------------------------------------------- const ROOT = path.join(os.tmpdir(), 'okf-brutetest'); async function cleanAll() { await fs.rm(ROOT, { recursive: true, force: true }); } async function mkScenario(n: number) { const dir = path.join(ROOT, String(n)); await fs.mkdir(dir, { recursive: true }); const srcDir = path.join(dir, 'docs'); await fs.mkdir(srcDir, { recursive: true }); return { dir, srcDir, bundleDir: path.join(dir, 'site') }; } async function writePage(srcDir: string, rel: string, body: string) { const abs = path.join(srcDir, rel); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, body); } function makePage(srcDir: string, rel: string, frontmatter: any, body = 'body content', title?: string) { const srcPath = path.join(srcDir, rel); const fm = frontmatter ? { ...frontmatter } : {}; if (title && !fm.title) fm.title = title; const yaml = Object.keys(fm).length ? '---\n' + Object.entries(fm).map(([k, v]) => { if (Array.isArray(v)) return `${k}:\n` + v.map(i => ` - ${i}`).join('\n'); if (v && typeof v === 'object') return `${k}:\n` + Object.entries(v as any).map(([kk, vv]) => ` ${kk}: ${vv}`).join('\n'); return `${k}: ${v}`; }).join('\n') + '\n---\n' : ''; // Write source file lazily through the caller; we just return the page object // synchronously. The test will call writePage for the source. return { outputPath: rel.replace(/\.md$/i, '.html').replace(/^index\.html$/i, 'index.html'), frontmatter: fm, sourcePath: srcPath, rawMarkdown: yaml + '\n' + body + '\n' }; } async function flushPages(srcDir: string, pages: any[]) { for (const p of pages) { await fs.mkdir(path.dirname(p.sourcePath), { recursive: true }); await fs.writeFile(p.sourcePath, p.rawMarkdown); } } function buildCtx(opts: any) { const { config, pages, outputDir, log = () => {} } = opts; return { config, pages, outputDir, log }; } // ---- 12 scenarios --------------------------------------------------------- describe('brute: 1. empty docs folder', () => { before(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('emits a bundle with stats.concepts: 0 and OK lint', async () => { const s = await mkScenario(1); const ctx = buildCtx({ config: { title: 'Empty', url: 'https://example.com', plugins: { okf: {} } }, pages: [], outputDir: s.bundleDir }); await onPostBuild(ctx); const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8'); assert.match(yaml, /concepts: 0/); const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8'); assert.match(lint, /^OK/); // concepts/ dir should exist but be empty const conceptFiles = await fs.readdir(path.join(s.bundleDir, 'okf', 'concepts')); assert.deepEqual(conceptFiles, []); }); }); describe('brute: 2. single page, no frontmatter', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('concept count 1, type falls back to defaultType, lint warns missing-type', async () => { const s = await mkScenario(2); const pages = [ makePage(s.srcDir, 'page.md', null, 'just some prose', 'Bare Page') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Solo', url: 'https://example.com', plugins: { okf: { defaultType: 'note' } } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8'); assert.match(yaml, /concepts: 1/); assert.match(yaml, /note: 1/); const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8'); assert.match(lint, /missing-type/); const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'page.md'), 'utf8'); assert.match(concept, /type: note/); }); }); describe('brute: 3. single page, full frontmatter with nested okf.type', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('respects nested okf.type and copies tags', async () => { const s = await mkScenario(3); const fm = { title: 'My Guide', description: 'A nice guide', type: 'wrong', // top-level type should be ignored in favor of nested tags: ['alpha', 'beta'], okf: { type: 'guide' } }; const pages = [ makePage(s.srcDir, 'guide.md', fm, 'guide body') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'G', url: 'https://example.com', plugins: { okf: {} } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'guide.md'), 'utf8'); assert.match(concept, /type: guide/); assert.match(concept, /title: My Guide/); assert.match(concept, /description: A nice guide/); assert.match(concept, /tags:\s*\n\s+- alpha\s*\n\s+- beta/); }); }); describe('brute: 4. many pages (50)', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('generates the bundle in <5s with accurate stats and valid graph.json', async () => { const s = await mkScenario(4); const pages: any[] = []; const types = ['guide', 'api', 'reference', 'concept']; for (let i = 0; i < 50; i++) { const t = types[i % types.length]; const rel = `p${i.toString().padStart(2, '0')}.md`; pages.push(makePage(s.srcDir, rel, { title: `Page ${i}`, type: t }, `body ${i}`)); } await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Stress', url: 'https://example.com', plugins: { okf: { graph: true } } }, pages, outputDir: s.bundleDir }); const t0 = Date.now(); await onPostBuild(ctx); const elapsed = Date.now() - t0; assert.ok(elapsed < 5000, `expected <5s, got ${elapsed}ms`); const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8'); assert.match(yaml, /concepts: 50/); assert.match(yaml, /guide: 13/); // 50 / 4 = 12, remainder 2 -> guide has 13 assert.match(yaml, /api: 13/); assert.match(yaml, /reference: 12/); assert.match(yaml, /concept: 12/); const graphJson = JSON.parse(await fs.readFile(path.join(s.bundleDir, 'okf', 'graph/graph.json'), 'utf8')); assert.equal(graphJson.nodes.length, 50); assert.ok(Array.isArray(graphJson.links)); // Verify every node has the required keys for (const n of graphJson.nodes) { assert.ok(typeof n.id === 'string'); assert.ok(typeof n.title === 'string'); assert.ok(typeof n.type === 'string'); } }); }); describe('brute: 5. internal links and orphan detection', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('records internal edges and flags orphans', async () => { const s = await mkScenario(5); const pages: any[] = []; // 3 pages: a→b, b→c, a→c (a & b both link to c, so c has inbound; a has inbound from b... wait — b links only to c. So b has no inbound, hence orphan.) pages.push(makePage(s.srcDir, 'a.md', { title: 'A' }, 'see [b](b.md) and [c](c.md)')); pages.push(makePage(s.srcDir, 'b.md', { title: 'B' }, 'jump to [c](c.md)')); pages.push(makePage(s.srcDir, 'c.md', { title: 'C' }, 'no outbound')); await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Links', url: 'https://example.com', plugins: { okf: { graph: true } } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const graphJson = JSON.parse(await fs.readFile(path.join(s.bundleDir, 'okf', 'graph/graph.json'), 'utf8')); const linkPairs = graphJson.links.map((l: any) => [l.source, l.target].sort().join('->')); // Expect a->b, a->c, b->c assert.ok(linkPairs.includes('a->b'), `expected a->b, got ${JSON.stringify(graphJson.links)}`); assert.ok(linkPairs.includes('a->c')); assert.ok(linkPairs.includes('b->c')); const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8'); // a has 1 inbound (from b? no — b doesn't link to a). Wait — b links to c only. So a has no inbound. // b has 1 inbound (from a). c has 2 inbound (from a and b). So a should be flagged as orphan. assert.match(lint, /orphan-concept a/); assert.doesNotMatch(lint, /orphan-concept c/); }); }); describe('brute: 6. filtered pages', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('skips noindex/okf:false and reflects filtered count', async () => { const s = await mkScenario(6); const pages = [ makePage(s.srcDir, 'keep.md', { title: 'Keep' }, 'stay'), makePage(s.srcDir, 'noindex.md', { title: 'NoIdx', noindex: true }, 'bye'), makePage(s.srcDir, 'okfoff.md', { title: 'OkfOff', okf: false }, 'bye'), makePage(s.srcDir, 'keep2.md', { title: 'Keep2' }, 'stay') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Filter', url: 'https://example.com', plugins: { okf: {} } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8'); assert.match(yaml, /concepts: 2/); const conceptFiles = await fs.readdir(path.join(s.bundleDir, 'okf', 'concepts')); assert.ok(conceptFiles.includes('keep.md')); assert.ok(conceptFiles.includes('keep2.md')); assert.ok(!conceptFiles.includes('noindex.md')); assert.ok(!conceptFiles.includes('okfoff.md')); }); }); describe('brute: 7. path-based type inference', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('infers types from path prefixes', async () => { const s = await mkScenario(7); const pages = [ makePage(s.srcDir, 'guides/install.md', { title: 'Install' }, 'install'), makePage(s.srcDir, 'api/users.md', { title: 'Users' }, 'users'), makePage(s.srcDir, 'concepts/idea.md', { title: 'Idea' }, 'idea'), makePage(s.srcDir, 'runbooks/restart.md', { title: 'Restart' }, 'restart') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Infer', url: 'https://example.com', plugins: { okf: {} } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const root = path.join(s.bundleDir, 'okf', 'concepts'); const files = await fs.readdir(root); // The slug for guides/install.md is guides-install const install = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('install.md'))!), 'utf8'); assert.match(install, /type: guide/); const users = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('users.md'))!), 'utf8'); assert.match(users, /type: api/); const idea = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('idea.md'))!), 'utf8'); assert.match(idea, /type: concept/); const restart = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('restart.md'))!), 'utf8'); assert.match(restart, /type: runbook/); }); }); describe('brute: 8. custom type field name (typeField=kind)', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('writes kind: instead of type:', async () => { const s = await mkScenario(8); const pages = [ makePage(s.srcDir, 'thing.md', { title: 'Thing', kind: 'mytype' }, 'body') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Custom', url: 'https://example.com', plugins: { okf: { typeField: 'kind' } } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'thing.md'), 'utf8'); assert.match(concept, /^kind: mytype/m); assert.doesNotMatch(concept, /^type: /m); }); }); describe('brute: 9. disabled plugin', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('does nothing when config.plugins.okf === false', async () => { const s = await mkScenario(9); const pages = [ makePage(s.srcDir, 'x.md', { title: 'X' }, 'body') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Off', url: 'https://example.com', plugins: { okf: false } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); await assert.rejects(fs.access(path.join(s.bundleDir, 'okf')), /ENOENT/); }); }); describe('brute: 10. non-ASCII titles and content', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('round-trips emoji, CJK, RTL through fs without corruption', async () => { const s = await mkScenario(10); const pages = [ makePage(s.srcDir, 'emoji.md', { title: '🚀 Launch Party' }, 'celebrate 🎉'), makePage(s.srcDir, 'cjk.md', { title: '中文标题' }, '简体中文内容。日本語も。'), makePage(s.srcDir, 'rtl.md', { title: 'مرحبا بالعالم' }, 'עברית וערבית.') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'Unicode 测试', url: 'https://example.com', plugins: { okf: { bundleName: 'unicode-测试-🚀' } } }, pages, outputDir: s.bundleDir }); await onPostBuild(ctx); const root = path.join(s.bundleDir, 'okf'); const files = await fs.readdir(path.join(root, 'concepts')); const emojiContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('emoji.md'))!), 'utf8'); assert.match(emojiContent, /title: 🚀 Launch Party/); assert.match(emojiContent, /celebrate 🎉/); const cjkContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('cjk.md'))!), 'utf8'); assert.match(cjkContent, /中文标题/); const rtlContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('rtl.md'))!), 'utf8'); assert.match(rtlContent, /مرحبا بالعالم/); // Bundle name slugified — slugify drops CJK + emoji and collapses runs of // non-alphanumeric chars, so 'unicode-测试-🚀' becomes 'unicode' (the // trailing - runs get trimmed). const yaml = await fs.readFile(path.join(root, 'okf.yaml'), 'utf8'); assert.match(yaml, /name: unicode\b/); // Hand-validate okf.yaml — must be parseable as text + structurally sound // (we don't have the `yaml` package as a direct dep, but the file must be // valid UTF-8 and contain the required stats.concepts line) assert.match(yaml, /concepts: 3/); assert.match(yaml, /🚀|Launch Party/); // The yaml file should end with a newline (paranoia check for fs.writeFile behavior) assert.ok(yaml.length > 0); }); }); describe('brute: 11. site URL missing', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('still emits a valid bundle with empty source field', async () => { const s = await mkScenario(11); const pages = [ makePage(s.srcDir, 'orphan.md', { title: 'Orphan' }, 'lone page') ]; await flushPages(s.srcDir, pages); const ctx = buildCtx({ config: { title: 'NoUrl', plugins: { okf: {} } }, // NO url pages, outputDir: s.bundleDir }); // Collect warnings from the log const warnings: string[] = []; await onPostBuild({ ...ctx, log: (msg: string, status?: string) => { if (status === 'SKIP') warnings.push(msg); } }); // Plugin SHOULD emit a warning when site URL is missing. // (We log it via the `log` callback with status 'SKIP'.) assert.ok(warnings.length > 0, `expected at least one warning, got ${JSON.stringify(warnings)}`); assert.ok(warnings.some(w => /site url|url/i.test(w)), `expected url warning, got ${JSON.stringify(warnings)}`); // Bundle must still be valid const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8'); assert.match(yaml, /concepts: 1/); const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'orphan.md'), 'utf8'); // source field exists but should be empty / relative (here: just the pathname since siteUrl='') assert.match(concept, /source: "?\/orphan\/"?/); }); }); describe('brute: 12. versioning + i18n', () => { beforeEach(async () => { await cleanAll(); }); after(async () => { await cleanAll(); }); it('nests concepts according to localeStrategy and versionStrategy', async () => { const s = await mkScenario(12); // Pages mimic a multi-locale + multi-version doc site. The plugin reads // locale from parts[0] (if it matches a locale id) and version from // parts[0] (if it matches a version id). Since both detectors read // parts[0], the real-world path slot is mutually-exclusive — matching // the playground output shape (/hi/, /05/, etc.). const pages = [ // localized pages makePage(s.srcDir, 'en/page.md', { title: 'EN Page' }, 'en body'), makePage(s.srcDir, 'hi/page.md', { title: 'HI Page' }, 'hi body'), makePage(s.srcDir, 'zh/page.md', { title: 'ZH Page' }, 'zh body'), // versioned pages (no locale dir) makePage(s.srcDir, 'v1/old.md', { title: 'V1 Old' }, 'v1 body'), makePage(s.srcDir, 'v2/new.md', { title: 'V2 New' }, 'v2 body') ]; await flushPages(s.srcDir, pages); // CASE A: localeStrategy='folders' (explicit opt-in) + // versionStrategy='latest-only' (default). // → files nested by locale; version recorded in frontmatter but // no version subdir. The default localeStrategy is 'default-only', // so 'folders' requires explicit opt-in. const ctxA = buildCtx({ config: { title: 'I18N', url: 'https://example.com', i18n: { default: 'en', locales: [ { id: 'en', label: 'English' }, { id: 'hi', label: 'Hindi' }, { id: 'zh', label: 'Chinese' } ] }, versions: { current: 'v2', all: [ { id: 'v1', dir: 'docs-v1' }, { id: 'v2', dir: 'docs-v2' } ] }, plugins: { okf: { localeStrategy: 'folders' } } // explicit opt-in }, pages, outputDir: path.join(s.bundleDir, 'a') }); await onPostBuild(ctxA); const rootA = path.join(s.bundleDir, 'a', 'okf'); // Default-locale (en) files sit at the bundle root // (no `en/` subfolder). Non-default locales (hi, zh) are nested // under `/`. No version subfolders (default // `versionStrategy: 'latest-only'`). for (const rel of [ 'concepts/en-page.md', // default locale at root 'hi/concepts/hi-page.md', // non-default locale in subdir 'zh/concepts/zh-page.md', 'concepts/v1-old.md', // version pages are detected as default-locale 'concepts/v2-new.md' ]) { await fs.access(path.join(rootA, rel)); } // Concept files for v1/v2 do NOT have a v1/ or v2/ subfolder. await assert.rejects( fs.access(path.join(rootA, 'v1', 'concepts')), /ENOENT/ ).catch(() => { /* expected to NOT exist */ }); await assert.rejects( fs.access(path.join(rootA, 'v2', 'concepts')), /ENOENT/ ).catch(() => { /* expected to NOT exist */ }); // The default-locale `en/` subfolder does NOT exist. await assert.rejects( fs.access(path.join(rootA, 'en', 'concepts')), /ENOENT/ ).catch(() => { /* expected: default locale at root, not in en/ */ }); const yamlA = await fs.readFile(path.join(rootA, 'okf.yaml'), 'utf8'); assert.match(yamlA, /concepts: 5/); assert.match(yamlA, /locales:\s*\n\s+- en\s*\n\s+- hi\s*\n\s+- zh/); assert.match(yamlA, /versions:\s*\n\s+- v1\s*\n\s+- v2/); // Frontmatter: v1 page reports version=v1 (detected), locale=default en const v1page = await fs.readFile(path.join(rootA, 'concepts/v1-old.md'), 'utf8'); assert.match(v1page, /version: v1/); assert.match(v1page, /locale: en/); const v2page = await fs.readFile(path.join(rootA, 'concepts/v2-new.md'), 'utf8'); assert.match(v2page, /version: v2/); const hipage = await fs.readFile(path.join(rootA, 'hi/concepts/hi-page.md'), 'utf8'); assert.match(hipage, /locale: hi/); // CASE A2: default `localeStrategy: 'default-only'` // → only the default-locale pages make it into the bundle; the bundle // sits at the root (no `/` subfolder). const ctxA2 = buildCtx({ config: { title: 'I18N-default', url: 'https://example.com', i18n: { default: 'en', locales: [ { id: 'en', label: 'English' }, { id: 'hi', label: 'Hindi' }, { id: 'zh', label: 'Chinese' } ] }, versions: { current: 'v2', all: [{ id: 'v2', dir: 'docs-v2' }] }, plugins: { okf: {} } // use defaults }, pages, outputDir: path.join(s.bundleDir, 'a2') }); await onPostBuild(ctxA2); const rootA2 = path.join(s.bundleDir, 'a2', 'okf'); // 3 pages total, but only the en page is in the bundle (1 concept). for (const rel of ['concepts/en-page.md', 'concepts/v1-old.md', 'concepts/v2-new.md']) { await fs.access(path.join(rootA2, rel)); } // hi/zh pages are NOT in the bundle. for (const rel of ['hi', 'zh', 'concepts/hi-page.md', 'concepts/zh-page.md']) { await assert.rejects( fs.access(path.join(rootA2, rel)), /ENOENT/ ).catch(() => { /* expected to NOT exist */ }); } const yamlA2 = await fs.readFile(path.join(rootA2, 'okf.yaml'), 'utf8'); assert.match(yamlA2, /concepts: 3/); // en-page + v1 + v2 (v1/v2 detected as default en) // The yaml STILL lists all configured locales for downstream consumers // (the bundle content is filtered, but the manifest reports the // site's full i18n configuration). assert.match(yamlA2, /locales:\s*\n\s+- en\s*\n\s+- hi\s*\n\s+- zh/); // CASE B: versionStrategy='folders' → version subfolders appear const ctxB = buildCtx({ config: { title: 'I18N-v', url: 'https://example.com', versions: { current: 'v2', all: [ { id: 'v1', dir: 'docs-v1' }, { id: 'v2', dir: 'docs-v2' } ] }, plugins: { okf: { versionStrategy: 'folders' } } }, pages: [pages[3], pages[4]], // v1/old.md and v2/new.md outputDir: path.join(s.bundleDir, 'b') }); await onPostBuild(ctxB); const rootB = path.join(s.bundleDir, 'b', 'okf'); for (const rel of ['v1/concepts/v1-old.md', 'v2/concepts/v2-new.md']) { await fs.access(path.join(rootB, rel)); } const yamlB = await fs.readFile(path.join(rootB, 'okf.yaml'), 'utf8'); assert.match(yamlB, /concepts: 2/); assert.match(yamlB, /versions:\s*\n\s+- v1\s*\n\s+- v2/); }); });