Files
docmd-io--docmd/packages/plugins/okf/tests/brute.test.ts
T
wehub-resource-sync 6db8fca185
docmd CI verification / verify (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:55 +08:00

585 lines
23 KiB
TypeScript

/**
* Brute test suite for @docmd/plugin-okf
*
* 12 real-fs scenarios. Each builds a small on-disk project under
* /tmp/okf-brutetest-<n>/ 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 `<locale>/`. 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 `<locale>/` 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/);
});
});