Files
docmd-io--docmd/tests/cli-contracts/plugin-contract.test.js
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

361 lines
16 KiB
JavaScript

/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* Slice C.1 + C.2 — Plugin-contract and public-API fixes
*
* Covers:
* D-H1 buildSite(configPath) honours the configPath (and an
* explicit opts.cwd) instead of always using process.cwd().
* D-H2 @docmd/core re-exports buildWorkspace, detectWorkspace,
* and isWorkspace so the documented API actually imports.
* D-H3 generateScripts gets a `target: 'head'|'body'` third arg
* so plugins can render different content per slot.
* D-S4 generateMetaTags returns must be a string; objects produce
* a warning and are skipped instead of being stringified to
* "[object Object]" and injected into every page's <head>.
* D-S5 generateScripts plain-string returns are treated as the
* head slot; previously they were silently dropped on body.
* D-M1 translations returns must be a plain string→string map;
* strings used to spread into garbage numeric keys.
* D-H7 onBeforeParse chain continues when a plugin throws.
* D-S2 buildContextualUrl strips the `external:` prefix.
* D-M2 PostBuildContext.config + pages are typed.
* D-M6 URL utility re-export types are aligned.
* T-Z3 Unknown top-level config keys produce a warning.
*
* Run: `node tests/runner.js --only=plugin-contract`
* --------------------------------------------------------------------
*/
import {
DOCMD,
TEST_ROOT,
setup,
writeFile,
build,
runTestFile
} from '../shared.js';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'path';
let passed = 0;
let failed = 0;
const failures = [];
function assert(condition, message) {
if (!condition) {
failed++;
failures.push(message);
console.log(` ❌ ${message}`);
} else {
passed++;
console.log(` ✅ ${message}`);
}
}
export const test = runTestFile({
name: 'Plugin contract + public API fixes (Slice C.1 + C.2)',
emoji: '🔌',
run: async () => {
// D-H2 — re-exports. Direct import to confirm the symbols exist
// on the public package surface. We don't need to run a full
// build — the import-time check is the test.
{
const apiPkg = path.resolve(import.meta.dirname, '..', '..', 'packages', 'core', 'dist', 'index.js');
const source = fs.readFileSync(apiPkg, 'utf8');
assert(/export\s*\{\s*[^}]*buildWorkspace[^}]*\}\s*from/.test(source), 'D-H2: buildWorkspace re-exported from @docmd/core');
assert(/export\s*\{\s*[^}]*detectWorkspace[^}]*\}\s*from/.test(source), 'D-H2: detectWorkspace re-exported from @docmd/core');
assert(/export\s*\{\s*[^}]*isWorkspace[^}]*\}\s*from/.test(source), 'D-H2: isWorkspace re-exported from @docmd/core');
}
// D-H1 — buildSite honours the configPath. We build the site from
// a directory OTHER than cwd, and verify it actually picks up the
// docs from that directory (not from cwd/docs).
{
const proj = setup('plugin-contract-c1-dh1-configpath');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'docmd.config.json', JSON.stringify({ title: 'D-H1', src: './docs', out: './site' }, null, 2) + '\n');
// Also plant a decoy docs/ in cwd so a wrong cwd would be detected.
const decoyDir = path.join(TEST_ROOT, '__cwd-decoy');
fs.mkdirSync(path.join(decoyDir, 'docs'), { recursive: true });
writeFile(decoyDir, 'docs/index.md', '# DECOY\n');
const configPath = path.join(proj, 'docmd.config.json');
const cmd = `node ${DOCMD} build --config "${configPath}"`;
const result = (() => {
try {
return { ok: true, output: execSync(cmd, { cwd: decoyDir, stdio: 'pipe', encoding: 'utf8' }) };
} catch (e) {
return { ok: false, output: (e.stderr || '') + (e.stdout || '') };
}
})();
assert(result.ok, 'D-H1: build with --config <abs-path> from a foreign cwd succeeds');
assert(fs.existsSync(path.join(proj, 'site/index.html')), 'D-H1: build output written next to configPath, not cwd');
const builtHtml = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
assert(!/DECOY/.test(builtHtml), 'D-H1: built page is from configPath\'s docs/, not cwd\'s docs/');
}
// D-S4 — generateMetaTags returning an object produces a warning
// and does NOT inject "[object Object]" into <head>. The dispatcher
// also warns (exactly once) per build.
{
const proj = setup('plugin-contract-c1-ds4-object-return');
writeFile(proj, 'docs/index.md', '# Hi\n');
// Local-path plugins need a directory layout (package.json + index.js)
// so the loader can resolve via safePath + package.json main field.
writeFile(proj, 'plugins/evil-meta/package.json', JSON.stringify({
name: 'evil-meta', version: '1.0.0', type: 'module', main: 'index.js'
}) + '\n');
writeFile(proj, 'plugins/evil-meta/index.js', [
"export const plugin = { name: 'evil-meta', version: '1.0.0', capabilities: ['head'] };",
"export async function generateMetaTags() { return { random: 'object' }; }",
""
].join('\n'));
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'D-S4',
src: './docs',
out: './site',
plugins: { './plugins/evil-meta': {} }
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
assert(fs.existsSync(path.join(proj, 'site/index.html')), 'D-S4: build completes (plugin was loaded)');
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
assert(!/\[object Object\]/.test(html), 'D-S4: object return from generateMetaTags is NOT injected into <head>');
assert(/non-string/i.test(output), 'D-S4: TUI warns about the non-string return');
}
// D-S5 — generateScripts returning a plain string is honoured on
// the head side (previously dropped because `result?.bodyScriptsHtml`
// was undefined, AND head was `result?.headScriptsHtml` which was
// also undefined on a plain-string return).
{
const proj = setup('plugin-contract-c1-ds5-string-return');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'plugins/string-scripts/package.json', JSON.stringify({
name: 'string-scripts', version: '1.0.0', type: 'module', main: 'index.js'
}) + '\n');
writeFile(proj, 'plugins/string-scripts/index.js', [
"export const plugin = { name: 'string-scripts', version: '1.0.0', capabilities: ['head', 'body'] };",
"export async function generateScripts() { return '<script id=\"x\">42</script>'; }",
""
].join('\n'));
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'D-S5',
src: './docs',
out: './site',
plugins: { './plugins/string-scripts': {} }
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
assert(/<script id="x">42<\/script>/.test(html), 'D-S5: plain-string return from generateScripts appears in <head>');
// Body slot stays empty — the original contract is "string is head-only".
assert(!/<script id="x">42<\/script>[^<]*<\/body>/.test(html), 'D-S5: plain-string return does NOT also land in <body>');
}
// D-H3 — generateScripts gets a `target` arg. Plugin reads it and
// emits different content for head vs body. The body path uses the
// object form because the string form is head-only by contract
// (D-S5); the `target` arg is what lets the plugin know which slot
// it's being asked to render.
{
const proj = setup('plugin-contract-c1-dh3-target-arg');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'plugins/target-aware/package.json', JSON.stringify({
name: 'target-aware', version: '1.0.0', type: 'module', main: 'index.js'
}) + '\n');
writeFile(proj, 'plugins/target-aware/index.js', [
"export const plugin = { name: 'target-aware', version: '1.0.0', capabilities: ['head', 'body'] };",
"export async function generateScripts(config, options, target) {",
" if (target === 'head') return { headScriptsHtml: '<meta name=\"x-head\" content=\"yes\">', bodyScriptsHtml: '' };",
" return { headScriptsHtml: '', bodyScriptsHtml: '<div id=\"x-body\">body-marker</div>' };",
"}",
""
].join('\n'));
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'D-H3',
src: './docs',
out: './site',
plugins: { './plugins/target-aware': {} }
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
assert(/<meta name="x-head" content="yes">/.test(html), 'D-H3: target=head emit lands in <head>');
assert(/<div id="x-body">body-marker<\/div>/.test(html), 'D-H3: target=body emit lands in <body>');
}
// D-M1 — translations returning a string produces a warning and
// does NOT spread the string into the translation map.
{
const proj = setup('plugin-contract-c1-dm1-translations-string');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'plugins/bad-translations/package.json', JSON.stringify({
name: 'bad-translations', version: '1.0.0', type: 'module', main: 'index.js'
}) + '\n');
writeFile(proj, 'plugins/bad-translations/index.js', [
"export const plugin = { name: 'bad-translations', version: '1.0.0', capabilities: ['translations'] };",
"export function translations(localeId) { return 'not an object'; }",
""
].join('\n'));
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'D-M1',
src: './docs',
out: './site',
plugins: { './plugins/bad-translations': {} }
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
assert(fs.existsSync(path.join(proj, 'site/index.html')), 'D-M1: build completes (plugin was loaded)');
assert(/non-object/i.test(output), 'D-M1: TUI warns about non-object translations return');
}
// D-H7 — onBeforeParse chain continues when a plugin throws. Two
// plugins configured: A throws, B appends a marker. Final HTML
// should contain the marker (B was called) AND emit a parser-error
// line for A.
{
const proj = setup('plugin-contract-c1-dh7-chain');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'plugins/throws/package.json', JSON.stringify({
name: 'throws', version: '1.0.0', type: 'module', main: 'index.js'
}) + '\n');
writeFile(proj, 'plugins/throws/index.js', [
"export const plugin = { name: 'throws', version: '1.0.0', capabilities: ['build'] };",
"export async function onBeforeParse(md) { throw new Error('A-explodes'); }",
""
].join('\n'));
writeFile(proj, 'plugins/marker/package.json', JSON.stringify({
name: 'marker', version: '1.0.0', type: 'module', main: 'index.js'
}) + '\n');
writeFile(proj, 'plugins/marker/index.js', [
"export const plugin = { name: 'marker', version: '1.0.0', capabilities: ['build'] };",
"export async function onBeforeParse(md) { return md + '\\n<!-- chain-marker -->\\n'; }",
""
].join('\n'));
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'D-H7',
src: './docs',
out: './site',
plugins: {
'./plugins/throws': {},
'./plugins/marker': {}
}
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
assert(fs.existsSync(path.join(proj, 'site/index.html')), 'D-H7: build completes (plugins were loaded)');
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
assert(/chain-marker/.test(html), 'D-H7: marker plugin ran AFTER the throwing plugin (chain continued)');
assert(/A-explodes/.test(output) || /onBeforeParse/i.test(output), 'D-H7: A\'s throw is surfaced');
}
// D-S2 — buildContextualUrl strips `external:` prefix. Unit-test the
// helper directly so we don't need to spin up a whole build.
{
const helperPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'parser', 'dist', 'utils', 'url-utils.js');
const helper = await import(helperPath);
// Provide a minimal context.
const ctx = {
relativePathToRoot: './',
outputPrefix: '',
offline: false,
base: '/',
siteUrl: ''
};
// Pre-fix: 'external:https://github.com' would have produced
// './external:https://github.com'. Post-fix: returns the URL untouched.
const out = helper.buildContextualUrl('external:https://github.com/foo', ctx);
assert(out === 'https://github.com/foo', 'D-S2: buildContextualUrl strips external: prefix');
// Pass-through for normal external URLs.
assert(helper.buildContextualUrl('https://example.com', ctx) === 'https://example.com', 'D-S2: plain external URL still untouched');
// Hash-only untouched.
assert(helper.buildContextualUrl('#section', ctx) === '#section', 'D-S2: hash-only anchor untouched');
}
// T-Z3 — unknown top-level config key produces a warning.
{
const proj = setup('plugin-contract-c1-tz3-unknown-key');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'T-Z3',
src: './docs',
out: './site',
// `fooBarBaz` is not a known key.
fooBarBaz: 'whatever'
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
assert(/fooBarBaz/.test(output), 'T-Z3: unknown top-level key surfaces in warnings');
// Build still succeeds.
assert(fs.existsSync(path.join(proj, 'site/index.html')), 'T-Z3: unknown-key warning is non-fatal');
}
// T-Z3 (companion) — typo suggestion still works.
{
const proj = setup('plugin-contract-c1-tz3-typo');
writeFile(proj, 'docs/index.md', '# Hi\n');
writeFile(proj, 'docmd.config.json', JSON.stringify({
title: 'T-Z3-typo',
src: './docs',
out: './site',
// Common typo: `baseUrl` instead of `url`.
baseUrl: 'https://example.com'
}, null, 2) + '\n');
let output = '';
try {
output = execSync(`node ${DOCMD} build`, { cwd: proj, stdio: 'pipe', encoding: 'utf8' });
} catch (e) {
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
}
assert(/baseUrl/.test(output) && /url/.test(output), 'T-Z3: typo key surfaces a "Did you mean" suggestion');
}
}
});
export const results = {
get passed() { return passed; },
get failed() { return failed; },
get failures() { return [...failures]; }
};