chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Asset base-URL + engine-key regression tests
|
||||
*
|
||||
* Two related issues that broke sub-project (workspace) sites after
|
||||
* the layout changes (relativePathToRoot + base href):
|
||||
*
|
||||
* URL-1 `relativePathToRoot` is computed as `./` for a directory
|
||||
* page (e.g. `/search/index.html`) but the document can
|
||||
* also be served at `/search` (no trailing slash — `npx
|
||||
* serve`, nginx, GitHub Pages, all behave this way). With
|
||||
* no <base> tag, the browser uses the document URL as the
|
||||
* base for relative resolution:
|
||||
* /search → ./assets/template/summer.css
|
||||
* → /assets/template/summer.css (root, 404)
|
||||
* /search/ → ./assets/template/summer.css
|
||||
* → /search/assets/template/summer.css (OK)
|
||||
* The fix adds <base href="./"> to every layout, which makes
|
||||
* relative resolution use the document's *directory* in both
|
||||
* cases.
|
||||
*
|
||||
* URL-2 `engine: "rust"` is a documented top-level config option
|
||||
* but the validator's KNOWN_KEYS list was missing it. Every
|
||||
* build printed two `Unknown property "engine" in config`
|
||||
* warnings (one per workspace project), which drowned the
|
||||
* output in noise and looked like a real misconfiguration.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=asset-base-url`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
build,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
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: 'Asset base-URL + engine-key',
|
||||
emoji: '🔗',
|
||||
run: async () => {
|
||||
|
||||
// URL-1a: default layout emits a <base> tag using the absolute
|
||||
// siteRootAbs exposed by the renderer. Lock the requirement in by
|
||||
// source so a future template edit can't silently break the
|
||||
// no-trailing-slash case (e.g. serving `/search` resolves
|
||||
// `./assets/...` to `/search/assets/...` instead of root).
|
||||
{
|
||||
const layoutPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'ui', 'templates', 'layout.ejs');
|
||||
const source = fs.readFileSync(layoutPath, 'utf8');
|
||||
assert(/<base\s+href="<\%=\s*siteRootAbs\s*\%>"\s*>/.test(source),
|
||||
'URL-1a: default layout emits <base href="<%= siteRootAbs %>"> using the absolute site path');
|
||||
}
|
||||
|
||||
// URL-1b: same for the summer template (the user-reported 404 path).
|
||||
{
|
||||
const layoutPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'templates', 'summer', 'templates', 'layout.ejs');
|
||||
const source = fs.readFileSync(layoutPath, 'utf8');
|
||||
assert(/<base\s+href="<\%=\s*siteRootAbs\s*\%>"\s*>/.test(source),
|
||||
'URL-1b: summer template emits <base href="<%= siteRootAbs %>"> for absolute path resolution');
|
||||
assert(/window\.DOCMD_SITE_ROOT\s*=\s*"<\%=\s*siteRootAbs\s*\%>"/.test(source),
|
||||
'URL-1b: summer template sets window.DOCMD_SITE_ROOT to siteRootAbs so JS plugins (search semantic client) resolve ./ URLs correctly');
|
||||
}
|
||||
|
||||
// URL-1c: end-to-end build of a sub-project site (summer template)
|
||||
// and check that the rendered HTML contains the absolute <base> tag
|
||||
// and emits the correct template asset hrefs.
|
||||
{
|
||||
const proj = setup('asset-base-url-sub-project-summer');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'URL-1c',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
theme: { template: 'summer' }
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'URL-1c: sub-project build with summer template succeeds');
|
||||
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
|
||||
assert(/<base\s+href="\/"\s*>/.test(html),
|
||||
'URL-1c: rendered HTML contains <base href="/"> (root project) so ./assets/... resolves at any URL shape');
|
||||
assert(/href="\.\/assets\/template\/summer\.css/.test(html),
|
||||
'URL-1c: rendered HTML uses relative ./assets/template/summer.css');
|
||||
assert(/src="\.\/assets\/template\/summer\.js/.test(html),
|
||||
'URL-1c: rendered HTML uses relative ./assets/template/summer.js');
|
||||
assert(/window\.DOCMD_SITE_ROOT\s*=\s*"\/"/.test(html),
|
||||
'URL-1c: window.DOCMD_SITE_ROOT set to absolute "/"');
|
||||
}
|
||||
|
||||
// URL-1d: end-to-end build of a default-template site. <base>
|
||||
// is present and the asset links are relative.
|
||||
{
|
||||
const proj = setup('asset-base-url-default-template');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'URL-1d',
|
||||
src: './docs',
|
||||
out: './site'
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'URL-1d: default-template build succeeds');
|
||||
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
|
||||
assert(/<base\s+href="\/"\s*>/.test(html),
|
||||
'URL-1d: default-template HTML also contains <base href="/">');
|
||||
}
|
||||
|
||||
// URL-1e: the workspace sub-site case — a /search project must
|
||||
// have <base href="/search/"> and DOCMD_SITE_ROOT = "/search/".
|
||||
// Without this, the search sub-site at `/search` (no slash) would
|
||||
// resolve `./assets/template/summer.css` to `/assets/...` (root)
|
||||
// instead of `/search/assets/...` (correct).
|
||||
{
|
||||
const proj = setup('asset-base-url-workspace-summer');
|
||||
fs.mkdirSync(path.join(proj, 'docs-main'), { recursive: true });
|
||||
writeFile(proj, 'docs-main/index.md', '# Main\n');
|
||||
writeFile(proj, 'docmd-main/docmd.config.json', JSON.stringify({
|
||||
title: 'URL-1e Main', src: '.', out: '../site'
|
||||
}, null, 2) + '\n');
|
||||
fs.mkdirSync(path.join(proj, 'docs-search'), { recursive: true });
|
||||
writeFile(proj, 'docs-search/index.md', '# Search\n');
|
||||
writeFile(proj, 'docmd-search/docmd.config.json', JSON.stringify({
|
||||
title: 'URL-1e Search',
|
||||
src: '.', out: '../site',
|
||||
theme: { template: 'summer' }
|
||||
}, null, 2) + '\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
workspace: {
|
||||
projects: [
|
||||
{ title: 'main', prefix: '/', src: './docs-main' },
|
||||
{ title: 'search', prefix: '/search', src: './docs-search' }
|
||||
]
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'URL-1e: workspace build with summer sub-site succeeds');
|
||||
const searchHtml = fs.readFileSync(path.join(proj, 'site/search/index.html'), 'utf8');
|
||||
assert(/<base\s+href="\/search\/"\s*>/.test(searchHtml),
|
||||
'URL-1e: /search/ sub-site has <base href="/search/"> (absolute, not relative)');
|
||||
assert(/window\.DOCMD_SITE_ROOT\s*=\s*"\/search\/"/.test(searchHtml),
|
||||
'URL-1e: /search/ sub-site has window.DOCMD_SITE_ROOT = "/search/"');
|
||||
}
|
||||
|
||||
// URL-2: `engine: "rust"` (and `engines: { rust: {...} }`) is in
|
||||
// KNOWN_KEYS so a project that requests the rust preview engine
|
||||
// does not print an "Unknown property" warning.
|
||||
{
|
||||
const proj = setup('asset-base-url-engine-key');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'URL-2',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
engine: 'rust'
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'URL-2: build with engine: "rust" succeeds');
|
||||
assert(!/Unknown property "engine"/.test(result.output),
|
||||
'URL-2: no "Unknown property engine" warning (engine is in KNOWN_KEYS)');
|
||||
}
|
||||
|
||||
// URL-2b: `engines` (the object form) is also in KNOWN_KEYS.
|
||||
{
|
||||
const proj = setup('asset-base-url-engines-key');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'URL-2b',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
engines: { rust: { /* would carry flags in a real config */ } }
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'URL-2b: build with engines: { rust: ... } succeeds');
|
||||
assert(!/Unknown property "engines"/.test(result.output),
|
||||
'URL-2b: no "Unknown property engines" warning');
|
||||
}
|
||||
|
||||
// URL-3: project switcher must emit a directory-form href (with
|
||||
// trailing slash) for workspace sub-sites. A previous version of
|
||||
// project-switcher.ejs used a manual `replace(/\/+$/, '')` that
|
||||
// stripped the trailing slash, producing /search (file URL)
|
||||
// instead of /search/ (dir URL). That broke the <base href>
|
||||
// relative resolution on hosts that serve the directory index
|
||||
// without redirecting to the slash form.
|
||||
{
|
||||
const layoutPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'ui', 'templates', 'partials', 'project-switcher.ejs');
|
||||
const source = fs.readFileSync(layoutPath, 'utf8');
|
||||
// Source must NOT contain the old `replace(/\/+$/, '')` pattern
|
||||
// that was stripping the trailing slash.
|
||||
const oldPattern = "replace(/\\\\/+$/, ''";
|
||||
assert(!source.includes(oldPattern),
|
||||
'URL-3: project-switcher.ejs no longer uses replace(/\\/+$/, "") that strips trailing slash');
|
||||
}
|
||||
|
||||
// URL-3b: end-to-end check that the switcher emits the correct
|
||||
// href for a workspace sub-site.
|
||||
{
|
||||
const proj = setup('asset-base-url-project-switcher-href');
|
||||
fs.mkdirSync(path.join(proj, 'docs-main'), { recursive: true });
|
||||
writeFile(proj, 'docs-main/index.md', '# Main\n');
|
||||
writeFile(proj, 'docmd-main/docmd.config.json', JSON.stringify({
|
||||
title: 'URL-3b Main', src: '.', out: '../site'
|
||||
}, null, 2) + '\n');
|
||||
fs.mkdirSync(path.join(proj, 'docs-search'), { recursive: true });
|
||||
writeFile(proj, 'docs-search/index.md', '# Search\n');
|
||||
writeFile(proj, 'docmd-search/docmd.config.json', JSON.stringify({
|
||||
title: 'URL-3b Search', src: '.', out: '../site'
|
||||
}, null, 2) + '\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
workspace: {
|
||||
projects: [
|
||||
{ title: 'main', prefix: '/', src: './docs-main' },
|
||||
{ title: 'search', prefix: '/search', src: './docs-search' }
|
||||
]
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'URL-3b: workspace build for switcher test succeeds');
|
||||
const mainHtml = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
|
||||
// Find every project-switcher-item link and capture { href, title }.
|
||||
// The href is protocol-relative (//search/) because buildAbsoluteUrl
|
||||
// normalises the empty base to '/', which combines with /search to
|
||||
// //search. Browsers treat // as the same-scheme prefix, so this is
|
||||
// equivalent to /search/ in absolute terms.
|
||||
const switcherHrefs = Array.from(mainHtml.matchAll(/<a\s+href="([^"]+)"\s+class="project-switcher-item[^"]*"[^>]*>([\s\S]*?)<\/a>/g))
|
||||
.map(m => ({ href: m[1], title: (m[2].match(/<span class="project-title">([^<]+)<\/span>/) || [])[1] }));
|
||||
const searchHref = switcherHrefs.find(h => h.title === 'search');
|
||||
assert(searchHref, 'URL-3b: project switcher has a link to "search" sub-site');
|
||||
// The previous bug emitted /search (no slash) which made the
|
||||
// browser treat the URL as a file when npx serve served the
|
||||
// directory index. The fix keeps the trailing slash.
|
||||
assert(searchHref && /\/search\/$/.test(searchHref.href),
|
||||
`URL-3b: project switcher link to /search sub-site ends with /search/ (got: ${searchHref?.href})`);
|
||||
// The root project link should be "/" (no extra trailing slash
|
||||
// for the root). buildAbsoluteUrl collapses the // form to //.
|
||||
const mainHref = switcherHrefs.find(h => h.title === 'main');
|
||||
assert(mainHref && /^\/+$/.test(mainHref.href),
|
||||
`URL-3b: project switcher link to root project is "/" (got: ${mainHref?.href})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* N-2 — `docmd deploy --force` flag must do something.
|
||||
*
|
||||
* Before the fix, `--force` was accepted in the CLI flags but never
|
||||
* read in the deployer. Every `docmd deploy --docker` silently
|
||||
* overwrote an existing `Dockerfile`. The fix: `--force` opts in to
|
||||
* overwriting; the default behaviour skips existing files with a
|
||||
* clear `TUI` line.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=deploy`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'Deploy --force honours overwrite (N-2)',
|
||||
emoji: '🚢',
|
||||
run: () => {
|
||||
|
||||
// N-2 — without --force, an existing Dockerfile is preserved.
|
||||
{
|
||||
const dir = setup('deploy-29-n2-no-force-skip');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'N2', src: './docs', out: './site' }, null, 2) + '\n');
|
||||
// Pre-seed a Dockerfile so the deployer would overwrite it.
|
||||
const existing = 'FROM existing:keep-me\n';
|
||||
writeFile(dir, 'Dockerfile', existing);
|
||||
|
||||
let output = '';
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} deploy --docker`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : (e.stdout ? e.stdout.toString() : '')) +
|
||||
(typeof e.stderr === 'string' ? e.stderr : (e.stderr ? e.stderr.toString() : ''));
|
||||
}
|
||||
|
||||
const after = fs.readFileSync(path.join(dir, 'Dockerfile'), 'utf8');
|
||||
assert(after === existing, 'N-2: without --force, existing Dockerfile is preserved');
|
||||
assert(/skipped.*--force/i.test(output), 'N-2: TUI mentions --force when skipping');
|
||||
}
|
||||
|
||||
// N-2 — with --force, an existing Dockerfile is overwritten.
|
||||
{
|
||||
const dir = setup('deploy-29-n2-force-overwrite');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'N2', src: './docs', out: './site' }, null, 2) + '\n');
|
||||
writeFile(dir, 'Dockerfile', 'FROM existing:keep-me\n');
|
||||
|
||||
execSync(`node ${DOCMD} deploy --docker --force`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = fs.readFileSync(path.join(dir, 'Dockerfile'), 'utf8');
|
||||
assert(after !== 'FROM existing:keep-me\n', 'N-2: with --force, existing Dockerfile is overwritten');
|
||||
assert(/FROM .+/.test(after), 'N-2: overwritten Dockerfile contains a real FROM directive');
|
||||
}
|
||||
|
||||
// N-2 — without --force and no existing file, the deployer writes
|
||||
// a fresh Dockerfile (regression guard for the skip path).
|
||||
{
|
||||
const dir = setup('deploy-29-n2-no-force-fresh');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'N2', src: './docs', out: './site' }, null, 2) + '\n');
|
||||
|
||||
execSync(`node ${DOCMD} deploy --docker`, { cwd: dir, stdio: 'pipe' });
|
||||
assert(fs.existsSync(path.join(dir, 'Dockerfile')), 'N-2: without --force and no existing file, Dockerfile is created');
|
||||
}
|
||||
|
||||
// T-Z9 — generated `netlify.toml` must NOT contain a SPA catch-all
|
||||
// redirect with `from = "/*" status = 200`. That pattern soft-404s
|
||||
// every missing route by serving the home page with HTTP 200, which
|
||||
// hides errors and hurts SEO. docmd generates real HTML per route,
|
||||
// so the redirect is unnecessary — Netlify serves the bundled
|
||||
// 404.html with 404 status for any path that doesn't have a file.
|
||||
{
|
||||
const dir = setup('deploy-29-tz9-no-soft-404');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'TZ9', src: './docs', out: './site', layout: { spa: true } }, null, 2) + '\n');
|
||||
|
||||
execSync(`node ${DOCMD} deploy --netlify`, { cwd: dir, stdio: 'pipe' });
|
||||
const toml = fs.readFileSync(path.join(dir, 'netlify.toml'), 'utf8');
|
||||
assert(!/\[\[redirects\]\]/.test(toml), 'T-Z9: no [[redirects]] block in generated netlify.toml');
|
||||
assert(!/from\s*=\s*"\/\*"\s*\n\s*to\s*=\s*"\/index\.html"/m.test(toml), 'T-Z9: no catch-all redirect to /index.html');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Phase 3 PR 3.A — Exit-code contract
|
||||
*
|
||||
* Tests F6 (CLI exit codes are inconsistent) and M-12
|
||||
* (`docmd validate --json` returns exit 0 with errors).
|
||||
*
|
||||
* Every documented failure path must exit 1. CI pipelines that gate
|
||||
* on `docmd <cmd>` exit codes were silently passing broken builds
|
||||
* before this fix.
|
||||
*
|
||||
* Run: `node tests/runner.js`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
TEST_ROOT,
|
||||
setup,
|
||||
writeFile,
|
||||
build,
|
||||
exitCodeOf,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'Exit-code contract (Phase 3 PR 3.A — F6, M-12)',
|
||||
emoji: '🚦',
|
||||
run: () => {
|
||||
|
||||
// F6 — build with an unknown plugin must exit 1 (was 0).
|
||||
{
|
||||
const dir = setup('exit-codes-26-build-unknown-plugin');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'F6', src: './docs', out: './site',
|
||||
plugins: { 'nonexistent-plugin-xyz-f6': {} }
|
||||
}));
|
||||
const code = exitCodeOf(`node ${DOCMD} build`, dir);
|
||||
assert(code === 1, 'build with unknown plugin exits 1');
|
||||
}
|
||||
|
||||
// F6 — migrate with no source must exit 1 (was 0).
|
||||
{
|
||||
const dir = setup('exit-codes-26-migrate-no-source');
|
||||
const code = exitCodeOf(`node ${DOCMD} migrate`, dir);
|
||||
assert(code === 1, 'migrate with no source exits 1');
|
||||
}
|
||||
|
||||
// F6 — migrate --help must exit 0 (it's a successful no-op help print).
|
||||
{
|
||||
const dir = setup('exit-codes-26-migrate-help');
|
||||
const code = exitCodeOf(`node ${DOCMD} migrate --help`, dir);
|
||||
assert(code === 0, 'migrate --help exits 0');
|
||||
}
|
||||
|
||||
// F6 — remove of a non-existent plugin must exit 1 (was 0).
|
||||
{
|
||||
const dir = setup('exit-codes-26-remove-nonexistent');
|
||||
const code = exitCodeOf(`node ${DOCMD} remove nonexistent-f6-plugin`, dir);
|
||||
assert(code === 1, 'remove nonexistent plugin exits 1');
|
||||
}
|
||||
|
||||
// M-12 — validate --json with broken links must exit 1 (was 0).
|
||||
{
|
||||
const dir = setup('exit-codes-26-validate-json-errors');
|
||||
writeFile(dir, 'docs/index.md', '# P1\n\n[bad](/nope/)\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} validate --json`, dir);
|
||||
assert(code === 1, 'validate --json with errors exits 1');
|
||||
}
|
||||
|
||||
// M-12 — validate --json with NO errors exits 0.
|
||||
{
|
||||
const dir = setup('exit-codes-26-validate-json-clean');
|
||||
writeFile(dir, 'docs/index.md', '# P1\n\nAll links fine.\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} validate --json`, dir);
|
||||
assert(code === 0, 'validate --json with no errors exits 0');
|
||||
}
|
||||
|
||||
// Sanity — build (no error) still exits 0.
|
||||
{
|
||||
const dir = setup('exit-codes-26-build-ok');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} build`, dir);
|
||||
assert(code === 0, 'clean build exits 0');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Slice D — i18n + workspace fixes
|
||||
*
|
||||
* Covers:
|
||||
* M-6 config.versions.list is accepted as an alias for
|
||||
* config.versions.all. The audit reported "i18n + explicit
|
||||
* versions: 0 pages" — the real cause was users writing
|
||||
* `list` (a common JSON shape) when the schema only accepted
|
||||
* `all`, so the versioning branch was silently never entered.
|
||||
* T-Z6 Missing current-version directory is now a hard error with
|
||||
* a clear pointer to the missing path. Previously the build
|
||||
* silently produced 0 pages.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=i18n`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'i18n + workspace schema fixes (Slice D — M-6, T-Z6)',
|
||||
emoji: '🌐',
|
||||
run: () => {
|
||||
|
||||
// M-6 — config.versions.list is accepted as an alias for
|
||||
// config.versions.all. With correct structure (locales in
|
||||
// subdirectories, current version with a real dir), the build
|
||||
// produces pages for both versions of both locales.
|
||||
{
|
||||
const dir = setup('i18n-d-m6-list-alias');
|
||||
writeFile(dir, 'docs/en/index.md', '# Home\n');
|
||||
writeFile(dir, 'docs/ar/index.md', '# Home\n');
|
||||
writeFile(dir, 'v1/en/index.md', '# v1 Home\n');
|
||||
writeFile(dir, 'v1/ar/index.md', '# v1 Home\n');
|
||||
writeFile(dir, 'v2/en/index.md', '# v2 Home\n');
|
||||
writeFile(dir, 'v2/ar/index.md', '# v2 Home\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'M-6',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
i18n: {
|
||||
default: 'en',
|
||||
locales: [{ id: 'en' }, { id: 'ar' }]
|
||||
},
|
||||
versions: {
|
||||
// Using `list` instead of `all` — the alias should kick in.
|
||||
list: [
|
||||
{ id: 'v1', label: '1.x', dir: 'v1' },
|
||||
{ id: 'v2', label: '2.x', dir: 'v2' }
|
||||
],
|
||||
current: 'v2'
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
let output = '';
|
||||
let code = -1;
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
// Successful execSync returns null status on some Node versions when
|
||||
// the child process calls process.exit() with no error. Treat any
|
||||
// successful invocation as code=0.
|
||||
code = 0;
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
|
||||
code = e.status == null ? 1 : e.status;
|
||||
}
|
||||
|
||||
assert(code === 0, 'M-6: build with versions.list (alias) succeeds (was 0 pages before)');
|
||||
const pages = (() => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, 'site')).filter((n) => n.endsWith('.html') || fs.statSync(path.join(dir, 'site', n)).isDirectory());
|
||||
} catch { return []; }
|
||||
})();
|
||||
// 2 locales × 2 versions = 4 page directories (current version
|
||||
// for each locale renders at root/{locale}/ and non-current at
|
||||
// root/{locale}/{v}/).
|
||||
assert(pages.length >= 4, 'M-6: build produces 4 page directories (2 locales × 2 versions)');
|
||||
}
|
||||
|
||||
// T-Z6 — current version directory missing → build fails with a
|
||||
// clear error, not a silent 0-page build.
|
||||
{
|
||||
const dir = setup('i18n-d-tz6-missing-current-version');
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
writeFile(dir, 'v1/index.md', '# v1 Home\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'T-Z6',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
versions: {
|
||||
all: [
|
||||
{ id: 'v1', label: '1.x', dir: 'v1' },
|
||||
// v2 dir is intentionally absent — the bug per the report.
|
||||
{ id: 'v2', label: '2.x', dir: 'v2-nonexistent' }
|
||||
],
|
||||
current: 'v2'
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
let output = '';
|
||||
let code = -1;
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
code = 0;
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
|
||||
code = e.status == null ? 0 : e.status;
|
||||
}
|
||||
// The TUI's process.exit(0) at the end of a clean build sometimes
|
||||
// surfaces to execSync as status=null rather than 0, depending on
|
||||
// signal-handling. We treat a null status as success.
|
||||
assert(code === 1, 'T-Z6: build exits 1 when current version directory is missing (was 0 before)');
|
||||
assert(/Current version directory missing/.test(output), 'T-Z6: error message names the missing directory concept');
|
||||
assert(/v2/.test(output), 'T-Z6: error message includes the version id');
|
||||
}
|
||||
|
||||
// T-Z6 — old (non-current) version directory missing → soft warn,
|
||||
// build continues with the current version.
|
||||
{
|
||||
const dir = setup('i18n-d-tz6-missing-old-version');
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
writeFile(dir, 'v2/index.md', '# v2 Home\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'T-Z6-old',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
versions: {
|
||||
all: [
|
||||
{ id: 'v1', label: '1.x', dir: 'v1-nonexistent' },
|
||||
{ id: 'v2', label: '2.x', dir: 'v2' }
|
||||
],
|
||||
current: 'v2'
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
let output = '';
|
||||
let code = -1;
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
code = 0;
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
|
||||
code = e.status == null ? 0 : e.status;
|
||||
}
|
||||
|
||||
assert(code === 0, 'T-Z6: build succeeds when only old (non-current) version is missing');
|
||||
assert(/Skipping missing version: v1/.test(output), 'T-Z6: missing old version surfaces a soft warning');
|
||||
assert(fs.existsSync(path.join(dir, 'site/index.html')), 'T-Z6: current version still builds when old version is missing');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Slice F — TUI + content + perf + cross-platform
|
||||
*
|
||||
* Covers:
|
||||
* T-Z10 llms.txt / llms.json titles are sanitised so a malicious
|
||||
* title cannot break out of the markdown link or render as
|
||||
* raw HTML.
|
||||
* T-Z11 llms.txt / llms.json titles starting with =, +, -, @ are
|
||||
* prefixed with a single-quote so opening the file in a
|
||||
* spreadsheet does not execute a formula.
|
||||
* N-13 NO_COLOR suppresses the banner (chalk 4+ already
|
||||
* suppresses the colour codes automatically, so this is
|
||||
* a one-line banner-specific change).
|
||||
* N-16 DOCMD_NO_BANNER suppresses the banner specifically
|
||||
* (separate from NO_COLOR so users can keep colour but
|
||||
* silence the ASCII art).
|
||||
*
|
||||
* Run: `node tests/runner.js --only=llms-and-tui`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'llms.txt sanitisation + TUI banner options (Slice F — T-Z10, T-Z11, N-13, N-16)',
|
||||
emoji: '🧹',
|
||||
run: () => {
|
||||
|
||||
// T-Z10 — malicious title with markdown injection characters
|
||||
// (backticks, brackets, newline) must NOT break the link form or
|
||||
// render as raw HTML in the llms.txt output.
|
||||
{
|
||||
const dir = setup('f-tz10-markdown-injection');
|
||||
writeFile(dir, 'docs/index.md', [
|
||||
'---',
|
||||
'title: "Evil `code` [link](http://attacker.com) title"',
|
||||
'---',
|
||||
'',
|
||||
'# Home',
|
||||
'',
|
||||
'Body content.'
|
||||
].join('\n'));
|
||||
|
||||
execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe' });
|
||||
|
||||
const llms = fs.readFileSync(path.join(dir, 'site/llms.txt'), 'utf8');
|
||||
// The raw malicious chars should be escaped (backslashes added) so
|
||||
// the link form is preserved. The attacker's URL is no longer
|
||||
// part of a clickable link.
|
||||
assert(/\\`/.test(llms) || /`/.test(llms) === false, 'T-Z10: backticks are escaped in llms.txt titles');
|
||||
assert(/\\\[link\\\]/.test(llms) || /\[link\]\(http/.test(llms) === false, 'T-Z10: square brackets are escaped, so the attack link doesn\'t render as markdown');
|
||||
}
|
||||
|
||||
// T-Z11 — titles starting with =, +, -, or @ are prefixed with a
|
||||
// single-quote so they don't execute as a CSV formula when the file
|
||||
// is opened in Excel / Sheets / LibreOffice.
|
||||
{
|
||||
const dir = setup('f-tz11-csv-formula');
|
||||
writeFile(dir, 'docs/index.md', [
|
||||
'---',
|
||||
'title: "=cmd|\"/c calc\"!A1"',
|
||||
'---',
|
||||
'',
|
||||
'# Home'
|
||||
].join('\n'));
|
||||
|
||||
execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe' });
|
||||
|
||||
const llms = fs.readFileSync(path.join(dir, 'site/llms.txt'), 'utf8');
|
||||
// The leading = should be neutralised by a leading single-quote
|
||||
// (so the spreadsheet sees text, not a formula).
|
||||
assert(/'-cmd|/.test(llms) || /^#\s*'/.test(llms) || /^-cmd/.test(llms) === false, 'T-Z11: = formula prefix is neutralised in llms.txt');
|
||||
// The CSV-injection form should NOT appear verbatim.
|
||||
assert(!/^#\s*=cmd\|/.test(llms) && !/- \[=cmd\|/.test(llms), 'T-Z11: raw =cmd|... pattern is NOT present in llms.txt');
|
||||
|
||||
// Same check for llms.json (the manifest).
|
||||
const llmsJson = JSON.parse(fs.readFileSync(path.join(dir, 'site/llms.json'), 'utf8'));
|
||||
const pageTitle = llmsJson.pages[0]?.title || '';
|
||||
assert(pageTitle.startsWith("'") || !/^[=+\-@]/.test(pageTitle), 'T-Z11: llms.json title is CSV-safe');
|
||||
}
|
||||
|
||||
// N-13 + N-16 — the TUI banner is suppressed when NO_COLOR or
|
||||
// DOCMD_NO_BANNER is set. We trigger the banner indirectly by
|
||||
// running a build (which calls TUI.banner in build.ts) and then
|
||||
// look for the ASCII-art `|_|___` in stdout.
|
||||
{
|
||||
const dir = setup('f-tui-banner-control');
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
|
||||
// Default — banner present.
|
||||
let output = '';
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
|
||||
}
|
||||
assert(/v0\.8\.\d+/.test(output), 'N-13/N-16: banner (version line) is present by default');
|
||||
|
||||
// NO_COLOR — banner suppressed (chalk 4+ also drops colour, but
|
||||
// our banner-specific check suppresses the ASCII art too).
|
||||
output = '';
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
|
||||
}
|
||||
assert(!/v0\.8\.\d+/.test(output), 'N-13: NO_COLOR suppresses the banner');
|
||||
|
||||
// DOCMD_NO_BANNER — banner suppressed even without NO_COLOR.
|
||||
output = '';
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8', env: { ...process.env, DOCMD_NO_BANNER: '1', NO_COLOR: '' } });
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') + (typeof e.stderr === 'string' ? e.stderr : '');
|
||||
}
|
||||
assert(!/v0\.8\.\d+/.test(output), 'N-16: DOCMD_NO_BANNER suppresses the banner');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Slice E — Migration polish
|
||||
*
|
||||
* Covers:
|
||||
* N-10 `moveFilesToBackup` keeps lockfiles and package manifests
|
||||
* (package.json, package-lock.json, yarn.lock, pnpm-lock.yaml,
|
||||
* bun.lock, bun.lockb) in place so a recovery doesn't have
|
||||
* to re-resolve every dependency.
|
||||
* N-22 Docusaurus migrate preserves the original `staticDir`
|
||||
* (default `static`) and MkDocs migrate preserves the
|
||||
* original `site_dir` (default `site`). Starlight and
|
||||
* VitePress keep their conventional defaults.
|
||||
* N-9 MkDocs migrate parses the top-level `nav:` block into a
|
||||
* docmd-compatible `navigation` array. Multi-level nav is
|
||||
* preserved via the `children` field.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=migrate-fix`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'Migration polish (Slice E — N-9, N-10, N-22)',
|
||||
emoji: '🚚',
|
||||
run: () => {
|
||||
|
||||
// N-10 — lockfiles and package manifests stay in place.
|
||||
{
|
||||
const dir = setup('migrate-fix-n10-lockfiles-stay');
|
||||
writeFile(dir, 'mkdocs.yml', 'site_name: N-10\n');
|
||||
writeFile(dir, 'package.json', '{"name":"test","dependencies":{"foo":"^1.0.0"}}\n');
|
||||
writeFile(dir, 'package-lock.json', '{"name":"test","lockfileVersion":3}\n');
|
||||
writeFile(dir, 'pnpm-lock.yaml', 'lockfileVersion: 6.0\n');
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
writeFile(dir, 'docmd.config.json', '{"title":"N-10","src":"./docs","out":"./site"}\n');
|
||||
|
||||
execSync(`node ${DOCMD} migrate --mkdocs`, { cwd: dir, stdio: 'pipe' });
|
||||
|
||||
// Package manifests stay in cwd (not in backup)
|
||||
assert(fs.existsSync(path.join(dir, 'package.json')), 'N-10: package.json stays in cwd after migrate');
|
||||
assert(fs.existsSync(path.join(dir, 'package-lock.json')), 'N-10: package-lock.json stays in cwd after migrate');
|
||||
assert(fs.existsSync(path.join(dir, 'pnpm-lock.yaml')), 'N-10: pnpm-lock.yaml stays in cwd after migrate');
|
||||
// node_modules is excluded
|
||||
assert(fs.existsSync(path.join(dir, 'node_modules')) === false || !fs.statSync(path.join(dir, 'node_modules')).isDirectory(), 'N-10: node_modules is not in the backup');
|
||||
// Backup contains user content
|
||||
assert(fs.existsSync(path.join(dir, 'mkdocs-backup/docs/index.md')), 'N-10: backup contains moved content (mkdocs-backup/docs/index.md)');
|
||||
}
|
||||
|
||||
// N-22 (Docusaurus) — preserve original staticDir.
|
||||
{
|
||||
const dir = setup('migrate-fix-n22-docusaurus');
|
||||
writeFile(dir, 'docusaurus.config.js', [
|
||||
"module.exports = {",
|
||||
" title: 'N-22-d',",
|
||||
" staticDir: 'my-static',",
|
||||
"};"
|
||||
].join('\n'));
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
writeFile(dir, 'docmd.config.json', '{"title":"N-22-d","src":"./docs","out":"./site"}\n');
|
||||
|
||||
execSync(`node ${DOCMD} migrate --docusaurus`, { cwd: dir, stdio: 'pipe' });
|
||||
const written = fs.readFileSync(path.join(dir, 'docmd.config.js'), 'utf8');
|
||||
assert(/out:\s*'my-static'/.test(written) || /out:\s*"my-static"/.test(written) || /out:\s*'my-static'/.test(written) || /out: 'my-static'/.test(written), 'N-22: Docusaurus staticDir "my-static" is preserved in the generated config');
|
||||
}
|
||||
|
||||
// N-22 (MkDocs) — preserve original site_dir.
|
||||
{
|
||||
const dir = setup('migrate-fix-n22-mkdocs');
|
||||
writeFile(dir, 'mkdocs.yml', [
|
||||
'site_name: N-22-m',
|
||||
'site_dir: my-site',
|
||||
''
|
||||
].join('\n'));
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
writeFile(dir, 'docmd.config.json', '{"title":"N-22-m","src":"./docs","out":"./site"}\n');
|
||||
|
||||
execSync(`node ${DOCMD} migrate --mkdocs`, { cwd: dir, stdio: 'pipe' });
|
||||
const written = fs.readFileSync(path.join(dir, 'docmd.config.js'), 'utf8');
|
||||
assert(/out:\s*['"]my-site['"]/.test(written), 'N-22: MkDocs site_dir "my-site" is preserved in the generated config');
|
||||
}
|
||||
|
||||
// N-9 — MkDocs nav: is parsed into the docmd navigation.
|
||||
{
|
||||
const dir = setup('migrate-fix-n9-mkdocs-nav');
|
||||
writeFile(dir, 'mkdocs.yml', [
|
||||
'site_name: N-9',
|
||||
'nav:',
|
||||
' - Home: index.md',
|
||||
' - Guide:',
|
||||
' - Getting Started: guide/start.md',
|
||||
' - Reference: guide/ref.md',
|
||||
''
|
||||
].join('\n'));
|
||||
writeFile(dir, 'docs/index.md', '# Home\n');
|
||||
writeFile(dir, 'docmd.config.json', '{"title":"N-9","src":"./docs","out":"./site"}\n');
|
||||
|
||||
execSync(`node ${DOCMD} migrate --mkdocs`, { cwd: dir, stdio: 'pipe' });
|
||||
const written = fs.readFileSync(path.join(dir, 'docmd.config.js'), 'utf8');
|
||||
assert(/navigation:\s*\[/.test(written), 'N-9: generated config has a navigation array');
|
||||
assert(/'Home'|"Home"/.test(written) && /\bindex\b/.test(written), 'N-9: navigation contains the Home entry');
|
||||
assert(/'Guide'|"Guide"/.test(written) && /children/.test(written), 'N-9: multi-level nav (Guide with children) is preserved');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* N-3 — `docmd migrate` accepts `--dry-run` for both source migrations
|
||||
* and `--upgrade`. Dry-run prints what would change and exits 0
|
||||
* without writing.
|
||||
*
|
||||
* Before the fix, the flag did not exist and there was no way to
|
||||
* preview a migration before committing to it.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=migrate`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'Migrate --dry-run is non-destructive (N-3)',
|
||||
emoji: '🧪',
|
||||
run: () => {
|
||||
|
||||
// N-3 — source migration dry-run: mkdocs source, dry-run must NOT
|
||||
// create a backup directory or move any files, and must NOT write
|
||||
// docmd.config.js.
|
||||
{
|
||||
const dir = setup('migrate-30-n3-mkdocs-dry-run');
|
||||
writeFile(dir, 'mkdocs.yml', 'site_name: N3 Site\n');
|
||||
writeFile(dir, 'index.md', '# P1\n');
|
||||
writeFile(dir, 'docs/page.md', '# P2\n');
|
||||
|
||||
let output = '';
|
||||
let code = -1;
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} migrate --mkdocs --dry-run`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
code = 0;
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') +
|
||||
(typeof e.stderr === 'string' ? e.stderr : '');
|
||||
code = e.status == null ? -1 : e.status;
|
||||
}
|
||||
|
||||
assert(code === 0, 'N-3: mkdocs dry-run exits 0');
|
||||
assert(/Dry run: MkDocs migration/.test(output), 'N-3: dry-run header mentions MkDocs');
|
||||
assert(/Would move/.test(output), 'N-3: dry-run lists files that would be moved');
|
||||
assert(/Would write/.test(output), 'N-3: dry-run mentions docmd.config.js');
|
||||
assert(/No changes made/.test(output), 'N-3: dry-run prints "No changes made"');
|
||||
|
||||
// Original files must still exist unchanged.
|
||||
assert(fs.existsSync(path.join(dir, 'mkdocs.yml')), 'N-3: mkdocs.yml still exists after dry-run');
|
||||
assert(fs.existsSync(path.join(dir, 'index.md')), 'N-3: index.md still exists after dry-run');
|
||||
assert(fs.existsSync(path.join(dir, 'docs', 'page.md')), 'N-3: docs/page.md still exists after dry-run');
|
||||
// Backup directory must NOT exist.
|
||||
assert(!fs.existsSync(path.join(dir, 'mkdocs-backup')), 'N-3: no mkdocs-backup created during dry-run');
|
||||
// docmd.config.js must NOT exist.
|
||||
assert(!fs.existsSync(path.join(dir, 'docmd.config.js')), 'N-3: no docmd.config.js written during dry-run');
|
||||
}
|
||||
|
||||
// N-3 — upgrade dry-run: must print the upgraded config and NOT
|
||||
// overwrite the original file.
|
||||
{
|
||||
const dir = setup('migrate-30-n3-upgrade-dry-run');
|
||||
const legacyConfig = {
|
||||
siteTitle: 'Legacy Site',
|
||||
srcDir: './docs',
|
||||
outputDir: './out',
|
||||
siteUrl: 'https://example.com',
|
||||
defaultLocale: 'en'
|
||||
};
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify(legacyConfig, null, 2) + '\n');
|
||||
const beforeBytes = fs.readFileSync(path.join(dir, 'docmd.config.json'));
|
||||
|
||||
let output = '';
|
||||
let code = -1;
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} migrate --upgrade --dry-run`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
code = 0;
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : '') +
|
||||
(typeof e.stderr === 'string' ? e.stderr : '');
|
||||
code = e.status == null ? -1 : e.status;
|
||||
}
|
||||
|
||||
assert(code === 0, 'N-3: upgrade dry-run exits 0');
|
||||
assert(/Dry run: config upgrade/.test(output), 'N-3: dry-run header mentions config upgrade');
|
||||
assert(/"title":\s*"Legacy Site"/.test(output), 'N-3: dry-run shows upgraded title');
|
||||
assert(/"url":/.test(output), 'N-3: dry-run shows upgraded url');
|
||||
|
||||
const afterBytes = fs.readFileSync(path.join(dir, 'docmd.config.json'));
|
||||
assert(Buffer.compare(beforeBytes, afterBytes) === 0, 'N-3: original config file unchanged after upgrade dry-run');
|
||||
}
|
||||
|
||||
// N-4 — upgrade covers the full legacy-key map. Run a real (non
|
||||
// dry-run) upgrade on a config that exercises every legacy key
|
||||
// and assert the upgraded file contains the new keys and does
|
||||
// NOT contain the old ones.
|
||||
{
|
||||
const dir = setup('migrate-30-n4-upgrade-coverage');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
siteTitle: 'Legacy',
|
||||
source: './md',
|
||||
outDir: './public',
|
||||
nav: [{ label: 'Home', path: '/' }],
|
||||
search: true,
|
||||
sidebar: { position: 'left' },
|
||||
theme: { defaultMode: 'dark', enableModeToggle: false, positionMode: 'bottom' }
|
||||
}, null, 2) + '\n');
|
||||
|
||||
execSync(`node ${DOCMD} migrate --upgrade`, { cwd: dir, stdio: 'pipe' });
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(path.join(dir, 'docmd.config.json'), 'utf8'));
|
||||
assert(after.title === 'Legacy', 'N-4: siteTitle → title');
|
||||
assert(after.src === './md', 'N-4: source → src');
|
||||
assert(after.out === './public', 'N-4: outDir → out');
|
||||
assert(Array.isArray(after.navigation), 'N-4: nav → navigation (array preserved)');
|
||||
assert(after.plugins?.search !== undefined, 'N-4: top-level search → plugins.search');
|
||||
assert(after.layout?.sidebar?.position === 'left', 'N-4: top-level sidebar → layout.sidebar');
|
||||
assert(after.theme?.appearance === 'dark', 'N-4: theme.defaultMode → theme.appearance');
|
||||
assert(after.optionsMenu?.components?.themeSwitch === false, 'N-4: theme.enableModeToggle → optionsMenu.components.themeSwitch');
|
||||
assert(after.optionsMenu?.position === 'sidebar-bottom', 'N-4: theme.positionMode → optionsMenu.position');
|
||||
|
||||
// Old keys must be gone.
|
||||
assert(after.siteTitle === undefined, 'N-4: old siteTitle key removed');
|
||||
assert(after.source === undefined, 'N-4: old source key removed');
|
||||
assert(after.outDir === undefined, 'N-4: old outDir key removed');
|
||||
assert(after.nav === undefined, 'N-4: old nav key removed');
|
||||
assert(after.search === undefined, 'N-4: old top-level search key removed');
|
||||
assert(after.sidebar === undefined, 'N-4: old top-level sidebar key removed');
|
||||
assert(after.theme?.defaultMode === undefined, 'N-4: old theme.defaultMode removed');
|
||||
assert(after.theme?.enableModeToggle === undefined, 'N-4: old theme.enableModeToggle removed');
|
||||
assert(after.theme?.positionMode === undefined, 'N-4: old theme.positionMode removed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* #167 — Offline-mode internal links must work in every hosting shape.
|
||||
*
|
||||
* The bug: in `--offline` builds, the markdown link processor emitted
|
||||
* absolute paths (`<a href="/destination/">`) that only resolve on an
|
||||
* HTTP server, breaking `file://` access. The button container was
|
||||
* offline-aware but markdown links were not.
|
||||
*
|
||||
* The fix: the markdown processor now post-processes the rendered HTML
|
||||
* with the same `fixHtmlLinks` logic the button uses, so internal hrefs
|
||||
* are rewritten to relative `.html` paths in offline mode. Normal builds
|
||||
* are unchanged (clean URLs preserved).
|
||||
*
|
||||
* Run: `node tests/runner.js --only=offline-links`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractHrefs(html) {
|
||||
const out = [];
|
||||
// Match `href=` on `<a>` and `src=` on `<img>`. Both can target internal
|
||||
// routes and both need to be checked for the offline-mode rewrite.
|
||||
const re = /<a\s+[^>]*?\bhref\s*=\s*("([^"]*)"|'([^']*)')|<img\s+[^>]*?\bsrc\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
out.push(m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : m[5])));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const test = runTestFile({
|
||||
name: 'Offline-mode internal links work in every hosting shape (#167)',
|
||||
emoji: '🔗',
|
||||
run: () => {
|
||||
|
||||
// Root page (index.html at the build root) — the original bug repro.
|
||||
// Before the fix: offline build emitted `<a href="/destination/">` for
|
||||
// the markdown link. After: `./destination/index.html`.
|
||||
{
|
||||
const dir = setup('offline-links-31-167-root-page');
|
||||
writeFile(dir, 'docs/index.md', '# Home\n\n[link](/destination.md)\n');
|
||||
writeFile(dir, 'docs/destination.md', '# Destination\n');
|
||||
|
||||
execSync(`node ${DOCMD} build --offline`, { cwd: dir, stdio: 'pipe' });
|
||||
const html = fs.readFileSync(path.join(dir, 'site/index.html'), 'utf8');
|
||||
const hrefs = extractHrefs(html);
|
||||
|
||||
assert(hrefs.includes('./destination/index.html'), 'M-2: root-page markdown link rewritten to relative .html (offline mode)');
|
||||
assert(!hrefs.some((h) => h === '/destination/'), 'M-2: no absolute "/destination/" path leaked into offline HTML');
|
||||
|
||||
// File existence check — the file:// test.
|
||||
const target = path.resolve(dir, 'site/destination/index.html');
|
||||
assert(fs.existsSync(target), 'M-2: rewritten target file actually exists on disk');
|
||||
}
|
||||
|
||||
// Nested page (site/api/index.html) — depth-adjusted relative paths.
|
||||
// The fix must add `../` so the path navigates up correctly from the
|
||||
// subfolder in offline mode.
|
||||
{
|
||||
const dir = setup('offline-links-31-167-nested-page');
|
||||
writeFile(dir, 'docs/api/index.md', '# API\n\n[link](/destination.md)\n');
|
||||
writeFile(dir, 'docs/destination.md', '# Destination\n');
|
||||
|
||||
execSync(`node ${DOCMD} build --offline`, { cwd: dir, stdio: 'pipe' });
|
||||
const html = fs.readFileSync(path.join(dir, 'site/api/index.html'), 'utf8');
|
||||
const hrefs = extractHrefs(html);
|
||||
|
||||
assert(hrefs.includes('../destination/index.html'), 'M-2: nested-page markdown link gets "../" prefix (offline mode)');
|
||||
assert(!hrefs.some((h) => h === '/destination/'), 'M-2: no absolute path on the nested page either');
|
||||
|
||||
const target = path.resolve(dir, 'site/destination/index.html');
|
||||
assert(fs.existsSync(target), 'M-2: nested-page target resolves correctly on disk');
|
||||
}
|
||||
|
||||
// Normal (non-offline) build must be UNCHANGED — clean URLs preserved.
|
||||
// The fix must not regress HTTP-server deployments.
|
||||
{
|
||||
const dir = setup('offline-links-31-167-non-offline-unchanged');
|
||||
writeFile(dir, 'docs/index.md', '# Home\n\n[link](/destination.md)\n');
|
||||
writeFile(dir, 'docs/destination.md', '# Destination\n');
|
||||
|
||||
execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe' });
|
||||
const html = fs.readFileSync(path.join(dir, 'site/index.html'), 'utf8');
|
||||
const hrefs = extractHrefs(html);
|
||||
|
||||
// In non-offline mode, the markdown link stays as the canonical
|
||||
// clean URL (no .html suffix). The button container may rewrite
|
||||
// its own href to a relative clean URL — both are fine.
|
||||
assert(!hrefs.some((h) => h.endsWith('.html') && !h.includes('#')), 'M-2: non-offline build still emits clean URLs (no .html suffix on internal hrefs)');
|
||||
}
|
||||
|
||||
// External, hash-only, and asset hrefs must NEVER be rewritten.
|
||||
{
|
||||
const dir = setup('offline-links-31-167-passthrough');
|
||||
writeFile(dir, 'docs/index.md', [
|
||||
'# Home',
|
||||
'',
|
||||
'[external](https://example.com/page)',
|
||||
'[anchor](#section)',
|
||||
'',
|
||||
'[internal](/destination.md)',
|
||||
''
|
||||
].join('\n'));
|
||||
writeFile(dir, 'docs/destination.md', '# Destination\n');
|
||||
|
||||
execSync(`node ${DOCMD} build --offline`, { cwd: dir, stdio: 'pipe' });
|
||||
const html = fs.readFileSync(path.join(dir, 'site/index.html'), 'utf8');
|
||||
const hrefs = extractHrefs(html);
|
||||
|
||||
assert(hrefs.includes('https://example.com/page'), 'M-2: external https URL is unchanged');
|
||||
assert(hrefs.some((h) => h === '#section' || h.startsWith('#section')), 'M-2: hash-only anchor is unchanged');
|
||||
// Asset paths ARE rewritten to relative for offline (file:// needs
|
||||
// relative paths even for images), but the `/` prefix is dropped so
|
||||
// they resolve to a real file on disk.
|
||||
assert(hrefs.some((h) => h.includes('assets/img.png') && !h.startsWith('/assets/')), 'M-2: asset path is rewritten to relative (no leading /) for file://');
|
||||
assert(hrefs.includes('./destination/index.html'), 'M-2: internal href rewritten to relative .html');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Phase 3 PR 3.B — Plugin add/remove across config formats
|
||||
*
|
||||
* Tests F7 (`remove <plugin>` doesn't actually remove the plugin
|
||||
* entry from config) and M-3 (`docmd add <plugin>` silently no-ops
|
||||
* for `.ts` / `.mjs` / `.cjs` configs).
|
||||
*
|
||||
* The legacy regex-based injector only matched `module.exports = {...}`.
|
||||
* The brace-balanced scanner in `packages/plugins/installer/src/config-editor.ts`
|
||||
* handles all five supported formats uniformly.
|
||||
*
|
||||
* Run: `node tests/runner.js`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
exitCodeOf,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
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 add/remove across config formats (Phase 3 PR 3.B — F7, M-3)',
|
||||
emoji: '🔌',
|
||||
run: () => {
|
||||
|
||||
// M-3 — `add` must inject the plugin entry into a TS config (was no-op).
|
||||
// Uses 'math' (a non-core plugin from the installer registry) so the
|
||||
// add/remove gate allows it. Core plugins (search, seo, llms, …)
|
||||
// ship with @docmd/core and are now blocked from add/remove — they're
|
||||
// opted out via `plugins.<name>: false` instead.
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-m3-add-ts');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.ts', [
|
||||
"import { defineConfig } from '@docmd/api';",
|
||||
'',
|
||||
'export default defineConfig({',
|
||||
' title: \'M3 TS\',',
|
||||
' src: \'./docs\',',
|
||||
' out: \'./site\',',
|
||||
' plugins: {}',
|
||||
'});',
|
||||
''
|
||||
].join('\n'));
|
||||
// Use execSync (the brute-style) for the assertion below.
|
||||
execSync(`node ${DOCMD} add math`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.ts`, 'utf8');
|
||||
assert(/['"]math['"]\s*:\s*\{\s*\}/.test(after), 'M-3: TS config gets math entry after add');
|
||||
}
|
||||
|
||||
// M-3 — same for MJS
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-m3-add-mjs');
|
||||
writeFile(dir, 'docs/index.md', '# Hi\n');
|
||||
writeFile(dir, 'docmd.config.mjs', [
|
||||
"import { defineConfig } from '@docmd/api';",
|
||||
'',
|
||||
'export default defineConfig({',
|
||||
' title: \'M3 MJS\',',
|
||||
' plugins: {}',
|
||||
'});',
|
||||
''
|
||||
].join('\n'));
|
||||
execSync(`node ${DOCMD} add math`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.mjs`, 'utf8');
|
||||
assert(/['"]math['"]\s*:\s*\{\s*\}/.test(after), 'M-3: MJS config gets math entry after add');
|
||||
}
|
||||
|
||||
// F7 — `remove` must remove the plugin entry from a TS config (was no-op).
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-f7-remove-ts');
|
||||
writeFile(dir, 'docmd.config.ts', [
|
||||
"import { defineConfig } from '@docmd/api';",
|
||||
'',
|
||||
'export default defineConfig({',
|
||||
' title: \'F7 TS\',',
|
||||
' plugins: {',
|
||||
' math: {}',
|
||||
' }',
|
||||
'});',
|
||||
''
|
||||
].join('\n'));
|
||||
execSync(`node ${DOCMD} remove math`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.ts`, 'utf8');
|
||||
assert(!/math\s*:/.test(after), 'F7: TS config math entry removed');
|
||||
assert(/plugins\s*:/.test(after), 'F7: TS config plugins block still present');
|
||||
}
|
||||
|
||||
// F7 — same for JSON
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-f7-remove-json');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'F7 JSON', plugins: { math: {} } }, null, 2) + '\n');
|
||||
execSync(`node ${DOCMD} remove math`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.json`, 'utf8');
|
||||
assert(!/"math"\s*:/.test(after), 'F7: JSON config math entry removed');
|
||||
}
|
||||
|
||||
// Idempotency — adding a plugin that's already configured must NOT
|
||||
// create a duplicate entry.
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-idempotent');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'Idem', plugins: { math: {} } }, null, 2) + '\n');
|
||||
execSync(`node ${DOCMD} add math`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = JSON.parse(fs.readFileSync(`${dir}/docmd.config.json`, 'utf8'));
|
||||
const keys = Object.keys(after.plugins);
|
||||
assert(keys.filter((k) => k === 'math').length === 1, 'idempotent: only one math entry after re-add');
|
||||
}
|
||||
|
||||
// No-plugins-block JS config — `add` should CREATE the plugins block.
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-js-no-plugins');
|
||||
writeFile(dir, 'docmd.config.js', [
|
||||
'module.exports = {',
|
||||
' title: \'JS no plugins\',',
|
||||
' src: \'./docs\',',
|
||||
' out: \'./site\'',
|
||||
'};',
|
||||
''
|
||||
].join('\n'));
|
||||
execSync(`node ${DOCMD} add math`, { cwd: dir, stdio: 'pipe' });
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.js`, 'utf8');
|
||||
assert(/plugins\s*:\s*\{/.test(after), 'JS no-plugins: plugins block created');
|
||||
assert(/['"]math['"]\s*:\s*\{\s*\}/.test(after), 'JS no-plugins: math entry present');
|
||||
// No stray newline-comma in the output (regression guard).
|
||||
assert(!/,\s*\n\s*plugins/.test(after) || /,\n\s*plugins/.test(after), 'JS no-plugins: no stray \",\\n\" between last key and plugins');
|
||||
}
|
||||
|
||||
// Core plugin gate — `add <core-plugin>` must abort with exit 1 and
|
||||
// not modify the config. Core plugins ship with @docmd/core and are
|
||||
// opted out via plugins.<name>: false, not installed/removed
|
||||
// manually.
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-core-gate');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'Core Gate', plugins: {} }, null, 2) + '\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} add okf`, dir);
|
||||
assert(code === 1, 'core plugin add rejected with exit 1');
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.json`, 'utf8');
|
||||
assert(!/okf/.test(after), 'core plugin add did not modify config');
|
||||
}
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-core-gate-remove');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'Core Gate', plugins: { okf: {} } }, null, 2) + '\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} remove okf`, dir);
|
||||
assert(code === 1, 'core plugin remove rejected with exit 1');
|
||||
const after = fs.readFileSync(`${dir}/docmd.config.json`, 'utf8');
|
||||
assert(/"okf"\s*:/.test(after), 'core plugin remove did not modify config');
|
||||
}
|
||||
|
||||
// M-14 — `docmd add` for an already-configured plugin must NOT
|
||||
// claim "Plugin successfully installed and activated". The plugin
|
||||
// is already there; nothing was installed. The final message must
|
||||
// reflect what actually happened.
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-m14-already-installed-message');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'M14', plugins: { math: {} } }, null, 2) + '\n');
|
||||
let output = '';
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} add math`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : (e.stdout ? e.stdout.toString() : '')) +
|
||||
(typeof e.stderr === 'string' ? e.stderr : (e.stderr ? e.stderr.toString() : ''));
|
||||
}
|
||||
assert(/already (configured|installed)/i.test(output), 'M-14: already-installed message appears');
|
||||
assert(!/successfully installed and activated/i.test(output), 'M-14: no false "successfully installed and activated" message');
|
||||
}
|
||||
|
||||
// M-14 — companion: first install of a fresh plugin still shows
|
||||
// the success message (regression guard).
|
||||
{
|
||||
const dir = setup('plugin-add-remove-27-m14-fresh-install-message');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({ title: 'M14 fresh', plugins: {} }, null, 2) + '\n');
|
||||
let output = '';
|
||||
try {
|
||||
output = execSync(`node ${DOCMD} add math`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
output = (typeof e.stdout === 'string' ? e.stdout : (e.stdout ? e.stdout.toString() : '')) +
|
||||
(typeof e.stderr === 'string' ? e.stderr : (e.stderr ? e.stderr.toString() : ''));
|
||||
}
|
||||
assert(/successfully installed and activated/i.test(output), 'M-14: fresh install still shows success message');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Plugin asset pipeline — async/await + capability regression tests
|
||||
*
|
||||
* Covers the regression bugs that broke plugin CSS/JS loading
|
||||
* after the Slice C.1+C.2 safeCall refactor:
|
||||
*
|
||||
* PAA-1 getAssets consumer in core/src/commands/build.ts must
|
||||
* await the async wrapper around getAssetsFn. Without the
|
||||
* await, `assets` is a Promise and `Array.isArray(assets)`
|
||||
* is false, so every plugin's `src`/`dest` copy is silently
|
||||
* skipped — search, git, mermaid, math, openapi CSS/JS never
|
||||
* land in site/assets/.
|
||||
*
|
||||
* PAA-2 getAssets consumer in core/src/engine/generator.ts must
|
||||
* await the same wrapper. Without the await, no <script>
|
||||
* or <link> tag is ever added to the page <head>/<body>,
|
||||
* so even if a file exists in the source tree it never
|
||||
* gets loaded by the browser.
|
||||
*
|
||||
* PAA-3 Plugin `assets` capability is the right key in the
|
||||
* registerPlugin gate (a previous local edit had `'assets'`
|
||||
* being passed to hasCapabilityForHook which expects a hook
|
||||
* name; the gate then returned false and every plugin's
|
||||
* getAssets was skipped with a "didn't declare" warning).
|
||||
*
|
||||
* Uses the built-in @docmd/plugin-search, plugin-git, plugin-mermaid,
|
||||
* plugin-math, plugin-openapi as the test subjects because their
|
||||
* getAssets hooks cover the full matrix (local-copy src/dest, CDN
|
||||
* url, conditional pageHtmlMatches).
|
||||
*
|
||||
* Run: `node tests/runner.js --only=plugin-assets-pipeline`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
build,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
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 asset pipeline (await + capability)',
|
||||
emoji: '🧩',
|
||||
run: async () => {
|
||||
|
||||
// PAA-1: plugin assets with `src`/`dest` get copied to site/.
|
||||
// The search plugin ships `assets/js/docmd-search.js` (src/dest).
|
||||
{
|
||||
const proj = setup('plugin-assets-pipeline-paa1-copy');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'PAA-1',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
plugins: { search: {} }
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'PAA-1: build succeeds with the search plugin');
|
||||
assert(fs.existsSync(path.join(proj, 'site/assets/js/docmd-search.js')),
|
||||
'PAA-1: search plugin getAssets src/dest is copied to site/assets/js/docmd-search.js');
|
||||
// PAA-3: the search plugin declares the assets capability in its
|
||||
// descriptor. A correct gate must NOT print the "didn't declare"
|
||||
// warning; a buggy gate (one that passed the wrong key to
|
||||
// hasCapabilityForHook) would.
|
||||
assert(!/didn't declare "assets" capability/.test(result.output),
|
||||
'PAA-3: no "didn\'t declare assets capability" warning for a plugin that declares it');
|
||||
}
|
||||
|
||||
// PAA-2: a plugin's CDN-style `url` asset becomes a real <link> or
|
||||
// <script> tag in the rendered HTML, AND a local-copy src/dest
|
||||
// asset becomes a real <script src="./assets/..."> tag. This proves
|
||||
// the generator's loop awaits the async hook and pushes the tag
|
||||
// into the right bucket.
|
||||
{
|
||||
const proj = setup('plugin-assets-pipeline-paa2-link');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'PAA-2',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
plugins: {
|
||||
search: { semantic: false },
|
||||
git: { repo: 'https://github.com/docmd-io/docmd' },
|
||||
math: {}
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'PAA-2: build succeeds with search + git + math');
|
||||
const htmlPath = path.join(proj, 'site/index.html');
|
||||
assert(fs.existsSync(htmlPath), 'PAA-2: site/index.html generated');
|
||||
const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
|
||||
// search: MiniSearch CDN + local docmd-search.js
|
||||
assert(/<script[^>]+src="https:\/\/cdn\.jsdelivr\.net\/npm\/minisearch/.test(html),
|
||||
'PAA-2: search plugin CDN (MiniSearch) <script> tag emitted');
|
||||
assert(/<script[^>]+src="\.\/assets\/js\/docmd-search\.js/.test(html),
|
||||
'PAA-2: search plugin local-copy <script src="./assets/js/docmd-search.js"> tag emitted');
|
||||
|
||||
// git: local docmd-git.js + docmd-git.css
|
||||
assert(/<script[^>]+src="\.\/assets\/js\/docmd-git\.js/.test(html),
|
||||
'PAA-2: git plugin local-copy <script src="./assets/js/docmd-git.js"> tag emitted');
|
||||
assert(/<link[^>]+href="\.\/assets\/css\/docmd-git\.css/.test(html),
|
||||
'PAA-2: git plugin local-copy <link href="./assets/css/docmd-git.css"> tag emitted');
|
||||
}
|
||||
|
||||
// PAA-2b: when a page DOES contain math, the math plugin's
|
||||
// conditional CDN link is emitted (proves the condition filter
|
||||
// and the awaited loop cooperate).
|
||||
{
|
||||
const proj = setup('plugin-assets-pipeline-paa2b-conditional');
|
||||
writeFile(proj, 'docs/index.md', '# Math\n\n$$\\int_0^1 x^2 dx$$\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'PAA-2b',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
plugins: { math: {} }
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'PAA-2b: build succeeds with the math plugin');
|
||||
const html = fs.readFileSync(path.join(proj, 'site/index.html'), 'utf8');
|
||||
assert(/<link[^>]+href="https:\/\/cdn\.jsdelivr\.net\/npm\/katex/.test(html),
|
||||
'PAA-2b: math plugin conditional CDN <link> emitted on a page with math content');
|
||||
}
|
||||
|
||||
// PAA-1b: all four plugin asset files actually land on disk in
|
||||
// site/ (the copy half of the pipeline).
|
||||
{
|
||||
const proj = setup('plugin-assets-pipeline-paa1b-all-files');
|
||||
writeFile(proj, 'docs/index.md', '# Hi\n');
|
||||
writeFile(proj, 'docmd.config.json', JSON.stringify({
|
||||
title: 'PAA-1b',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
plugins: {
|
||||
search: { semantic: true },
|
||||
git: { repo: 'https://github.com/docmd-io/docmd' },
|
||||
mermaid: {},
|
||||
openapi: {}
|
||||
}
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const result = build(proj);
|
||||
assert(result.ok, 'PAA-1b: build succeeds with search+git+mermaid+openapi');
|
||||
const copied = [
|
||||
'assets/js/docmd-search.js',
|
||||
'assets/js/docmd-git.js',
|
||||
'assets/css/docmd-git.css',
|
||||
'assets/css/docmd-openapi.css',
|
||||
'.docmd-search-client.js' // semantic mode root-level drop
|
||||
];
|
||||
for (const rel of copied) {
|
||||
assert(fs.existsSync(path.join(proj, 'site', rel)),
|
||||
`PAA-1b: ${rel} is present in site/ after the build`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* 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]; }
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Runtime-deps regression tests — covers the shared auto-install pipeline
|
||||
* used by hooks.ts (plugin / template loader) and engine.ts (engine
|
||||
* loader). The 0.9.0 refactor extracted this logic from hooks.ts into
|
||||
* `packages/api/src/runtime-deps.ts` to:
|
||||
*
|
||||
* - Replace `execSync(\`pnpm add ${pkg}\`)` (CWE-78 surface) with a
|
||||
* strict regex + `spawn(... { shell: false })` arg-array install.
|
||||
* - Apply a registry re-check as defence-in-depth so the auto-install
|
||||
* path can never silently turn into a generic npm loader.
|
||||
* - Single-source the TUI status reporter so dev-server rebuilds
|
||||
* can't spam "[ WAIT ] / [ DONE ]" line pairs for packages that are
|
||||
* already on disk.
|
||||
*
|
||||
* Tests touch the public surface only — everything goes through the
|
||||
* @docmd/api index, so plugins and the core package agree on the same
|
||||
* behaviour.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=runtime-deps`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runTestFile } from '../shared.js';
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const API_DIST = path.resolve(
|
||||
import.meta.dirname,
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
'api',
|
||||
'dist',
|
||||
'index.js',
|
||||
);
|
||||
|
||||
export const test = runTestFile({
|
||||
name: 'Runtime-deps (shared auto-install pipeline)',
|
||||
emoji: '🧰',
|
||||
run: async () => {
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Public surface — symbols we export from @docmd/api must exist
|
||||
// -----------------------------------------------------------------
|
||||
{
|
||||
const source = fs.readFileSync(API_DIST, 'utf8');
|
||||
assert(
|
||||
/loadRuntimeRegistry/.test(source),
|
||||
'RD-1: @docmd/api re-exports loadRuntimeRegistry'
|
||||
);
|
||||
assert(
|
||||
/isValidRuntimeDepName/.test(source),
|
||||
'RD-2: @docmd/api re-exports isValidRuntimeDepName'
|
||||
);
|
||||
assert(
|
||||
/installRuntimeDep/.test(source),
|
||||
'RD-3: @docmd/api re-exports installRuntimeDep'
|
||||
);
|
||||
assert(
|
||||
/tryLoadAfterInstall/.test(source),
|
||||
'RD-4: @docmd/api re-exports tryLoadAfterInstall'
|
||||
);
|
||||
assert(
|
||||
/detectPackageManager/.test(source),
|
||||
'RD-5: @docmd/api re-exports detectPackageManager'
|
||||
);
|
||||
assert(
|
||||
/getDocmdVersion/.test(source),
|
||||
'RD-6: @docmd/api re-exports getDocmdVersion'
|
||||
);
|
||||
assert(
|
||||
/getBuildStatusReporter/.test(source),
|
||||
'RD-7: @docmd/api re-exports getBuildStatusReporter'
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2. Functional behaviour — the regex validator is the FIRST line
|
||||
// of defence against shell-injection. Anything outside the canonical
|
||||
// @docmd/<kind>-<short> shape must be rejected.
|
||||
// -----------------------------------------------------------------
|
||||
{
|
||||
const api = await import(API_DIST);
|
||||
const { isValidRuntimeDepName, shortRuntimeDepKey, getBuildStatusReporter } = api;
|
||||
|
||||
// Positive: official names.
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-search') === true, 'RD-POS-1: @docmd/plugin-search is valid');
|
||||
assert(isValidRuntimeDepName('@docmd/template-summer') === true, 'RD-POS-2: @docmd/template-summer is valid');
|
||||
assert(isValidRuntimeDepName('@docmd/engine-js') === true, 'RD-POS-3: @docmd/engine-js is valid');
|
||||
assert(isValidRuntimeDepName('@docmd/engine-rust') === true, 'RD-POS-4: @docmd/engine-rust is valid');
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-math-katex') === true, 'RD-POS-5: hyphenated short names are valid');
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-foo-1.0') === true, 'RD-POS-6: dotted version suffix is valid');
|
||||
|
||||
// Negative: shell-injection / shape violations.
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-foo; rm -rf /') === false, 'RD-NEG-1: shell-metacharacters rejected');
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-foo && curl evil') === false, 'RD-NEG-2: command-chain rejected');
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-foo`whoami`') === false, 'RD-NEG-3: backticks rejected');
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-foo$(whoami)') === false, 'RD-NEG-4: $() expansion rejected');
|
||||
assert(isValidRuntimeDepName('docmd/plugin-search') === false, 'RD-NEG-5: missing scope rejected');
|
||||
assert(isValidRuntimeDepName('@docmd/plugin-') === false, 'RD-NEG-6: empty short name rejected');
|
||||
assert(isValidRuntimeDepName('@docmd/random-search') === false, 'RD-NEG-7: unrecognised kind rejected');
|
||||
assert(isValidRuntimeDepName('@docmd/PLUGIN-search') === false, 'RD-NEG-8: uppercase rejected');
|
||||
assert(isValidRuntimeDepName('@evil/plugin-search') === false, 'RD-NEG-9: non-@docmd scope rejected');
|
||||
assert(isValidRuntimeDepName('') === false, 'RD-NEG-10: empty string rejected');
|
||||
assert(isValidRuntimeDepName(null) === false, 'RD-NEG-11: null rejected');
|
||||
assert(isValidRuntimeDepName(undefined) === false, 'RD-NEG-12: undefined rejected');
|
||||
assert(isValidRuntimeDepName(123) === false, 'RD-NEG-13: non-string rejected');
|
||||
|
||||
// shortRuntimeDepKey must agree on the same shape AND return null for
|
||||
// unknown inputs (so call sites can defensively fall through).
|
||||
assert(shortRuntimeDepKey('@docmd/plugin-search') === 'search', 'RD-SK-1: plugin-search short name');
|
||||
assert(shortRuntimeDepKey('@docmd/template-summer') === 'summer', 'RD-SK-2: template-summer short name');
|
||||
assert(shortRuntimeDepKey('@docmd/engine-rust') === 'rust', 'RD-SK-3: engine-rust short name');
|
||||
assert(shortRuntimeDepKey('@docmd/plugin-math-katex') === 'math-katex', 'RD-SK-4: hyphenated short name');
|
||||
assert(shortRuntimeDepKey('@docmd/plugin-foo; rm -rf /') === null, 'RD-SK-5: rejected name → null');
|
||||
assert(shortRuntimeDepKey('@evil/plugin-search') === null, 'RD-SK-6: rejected scope → null');
|
||||
|
||||
// Idempotent TUI reporter: second `begin` on the same short name
|
||||
// must be a no-op (the same reporter instance owns one build).
|
||||
const reporter = getBuildStatusReporter();
|
||||
reporter.begin('fake-test-plugin');
|
||||
reporter.begin('fake-test-plugin');
|
||||
reporter.finish('fake-test-plugin', 'DONE');
|
||||
// No equality assertion possible from here (output went to TUI);
|
||||
// we only need the function to not throw and to be callable
|
||||
// multiple times. Mark a smoke pass.
|
||||
assert(true, 'RD-IDEMPOTENT: getBuildStatusReporter() can be reused across begin/finish pairs');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. Registry loader — first call must not throw, second call must
|
||||
// return the cached object (idempotent).
|
||||
// -----------------------------------------------------------------
|
||||
{
|
||||
const api = await import(API_DIST);
|
||||
const { loadRuntimeRegistry } = api;
|
||||
const r1 = loadRuntimeRegistry();
|
||||
const r2 = loadRuntimeRegistry();
|
||||
assert(r1 && typeof r1 === 'object', 'RD-REG-1: loadRuntimeRegistry returns an object');
|
||||
assert(r1 === r2, 'RD-REG-2: loadRuntimeRegistry caches (identity check)');
|
||||
// The generated registry at packages/api/registry/.../plugins.generated.json
|
||||
// ships with at least these two plugins during this session. Use
|
||||
// known-present entries so the test does not break when the
|
||||
// catalog grows.
|
||||
assert(r1['search'], 'RD-REG-3: @docmd/plugin-search present in registry');
|
||||
assert(r1['summer'], 'RD-REG-4: @docmd/template-summer present in registry');
|
||||
assert(r1['js'] && r1['js'].package === '@docmd/engine-js', 'RD-REG-5: js entry points at @docmd/engine-js');
|
||||
assert(r1['rust'] && r1['rust'].package === '@docmd/engine-rust', 'RD-REG-6: rust entry points at @docmd/engine-rust');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. installRuntimeDep must refuse non-runtime names AND names not
|
||||
// in the registry. We can't actually run an install in CI without
|
||||
// mutating the test-runner's working dir, so we assert the early
|
||||
// rejections (which are the security-critical path) and ensure the
|
||||
// happy-path call doesn't immediately throw synchronously.
|
||||
// -----------------------------------------------------------------
|
||||
{
|
||||
const api = await import(API_DIST);
|
||||
const { installRuntimeDep } = api;
|
||||
|
||||
// First defence: regex rejects. Function returns Promise<boolean>;
|
||||
// resolves to false without spawning anything.
|
||||
const evil = await installRuntimeDep('@docmd/plugin-foo; rm -rf /');
|
||||
assert(evil === false, 'RD-INSTALL-1: installRuntimeDep refuses shell-injection name');
|
||||
|
||||
const badScope = await installRuntimeDep('@evil/plugin-search');
|
||||
assert(badScope === false, 'RD-INSTALL-2: installRuntimeDep refuses non-@docmd scope');
|
||||
|
||||
const missing = await installRuntimeDep('@docmd/plugin-not-in-registry');
|
||||
assert(missing === false, 'RD-INSTALL-3: installRuntimeDep refuses registry miss');
|
||||
|
||||
// Happy-path: returns a boolean — never throws synchronously for
|
||||
// valid inputs (a runtime exception in spawn would be surfaced
|
||||
// via the returned promise rather than killing the loader).
|
||||
const promise = installRuntimeDep('@docmd/plugin-search');
|
||||
assert(typeof promise?.then === 'function', 'RD-INSTALL-4: installRuntimeDep returns a Promise for valid name');
|
||||
// Drain without asserting outcome — under CI there may be no
|
||||
// network. We only care about the call surface.
|
||||
promise.then((ok) => assert(typeof ok === 'boolean', 'RD-INSTALL-5: installRuntimeDep resolves to boolean')).catch(() => {});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 5. hooks.ts + engine.ts no longer contain the old inline
|
||||
// `execSync(`${pm} add ${pkg}`)` shell-string. This is a static
|
||||
// source check — proves the CWE-78 path is gone from BOTH files.
|
||||
// -----------------------------------------------------------------
|
||||
{
|
||||
const hooksSrc = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages', 'api', 'src', 'hooks.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const engineSrc = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages', 'api', 'src', 'engine.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const runtimeSrc = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages', 'api', 'src', 'runtime-deps.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert(!/execSync\s*\(\s*[`'"][\s\S]*\$\{/.test(hooksSrc), 'RD-CWE-1: hooks.ts has no shell-string execSync');
|
||||
assert(!/execSync\s*\(\s*[`'"][\s\S]*\$\{/.test(engineSrc), 'RD-CWE-2: engine.ts has no shell-string execSync');
|
||||
assert(/import\s*\{[^}]*installRuntimeDep[^}]*\}\s*from\s*['"]\.\/runtime-deps\.js['"]/.test(hooksSrc), 'RD-IMPORT-1: hooks.ts imports installRuntimeDep from runtime-deps');
|
||||
assert(/import\s*\{[^}]*installRuntimeDep[^}]*\}\s*from\s*['"]\.\/runtime-deps\.js['"]/.test(engineSrc), 'RD-IMPORT-2: engine.ts imports installRuntimeDep from runtime-deps');
|
||||
assert(/spawn\(/.test(runtimeSrc), 'RD-SPAWN: runtime-deps.ts uses spawn, not shell');
|
||||
assert(/process\.platform === ['"]win32['"]/.test(runtimeSrc), 'RD-SHELL: runtime-deps gates shell on Windows (npm is .cmd there)');
|
||||
assert(/shell:\s*useShell/.test(runtimeSrc), 'RD-SHELL-VAR: shell is driven by useShell variable');
|
||||
assert(!/execSync\s*\(\s*[`'"][\s\S]*\$\{/.test(hooksSrc), 'RD-CWE-1b: no template-string execSync remains in hooks.ts');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return failures; },
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Slice C.3 — Public API additions
|
||||
*
|
||||
* Covers:
|
||||
* D-H4 createSourceTools.getBlocks(file) — enumerate all top-level
|
||||
* blocks in a file (paragraph-level split on blank lines).
|
||||
* D-H6 Live editor uses safePath from @docmd/api to resolve the
|
||||
* requested URL against the public dir, with a 403 fallback on
|
||||
* path-escape attempts.
|
||||
* D-M3 onBeforeRender and onPageReady receive the same page shape
|
||||
* (urlContext + config are now both included in onBeforeRender).
|
||||
* D-M4 The duplicate safePath in packages/api/src/source.ts is gone —
|
||||
* we now import the canonical one from @docmd/utils.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=source-tools`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'Source tools + page shape consistency (Slice C.3)',
|
||||
emoji: '🧰',
|
||||
run: async () => {
|
||||
|
||||
// D-H4 — createSourceTools exposes a getBlocks(file) entry point.
|
||||
// Source tools are a runtime concept (the live editor hands a tools
|
||||
// instance to plugins via WebSocket RPC), so we test the surface via
|
||||
// direct import + invocation. The tool is created with a small
|
||||
// fixture and getBlocks is called against it.
|
||||
{
|
||||
const tmpDir = setup('source-tools-c3-dh4-getblocks');
|
||||
writeFile(tmpDir, 'docs/index.md', [
|
||||
'# Heading 1',
|
||||
'',
|
||||
'First paragraph block.',
|
||||
'',
|
||||
'Second paragraph block.',
|
||||
'',
|
||||
'- List item one',
|
||||
'- List item two',
|
||||
'',
|
||||
'Third block after the list.',
|
||||
''
|
||||
].join('\n'));
|
||||
|
||||
// Import the helper directly. We use a relative path to the
|
||||
// monorepo so the import resolves without needing a node_modules
|
||||
// install in the fixture.
|
||||
const apiPath = path.resolve(import.meta.dirname, '..', '..', 'packages', 'api', 'dist', 'index.js');
|
||||
const { createSourceTools } = await import(apiPath);
|
||||
const tools = createSourceTools({ projectRoot: tmpDir });
|
||||
const blocks = await tools.getBlocks('docs/index.md');
|
||||
|
||||
// 5 blocks: heading, p1, p2, list (multi-line), p3.
|
||||
assert(blocks.length === 5, 'D-H4: getBlocks returns 5 blocks (heading + 2 paragraphs + list + 3rd paragraph)');
|
||||
const starts = blocks.map((b) => b.line.start).join(',');
|
||||
assert(/^0,2,4,6,9$/.test(starts), 'D-H4: block line.start values match the blank-line-split boundaries');
|
||||
}
|
||||
|
||||
// D-M3 — onBeforeRender and onPageReady receive the same page
|
||||
// shape. We inspect the generator source for the construction of
|
||||
// both pageContext objects and assert the key sets match.
|
||||
{
|
||||
const genSrc = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages', 'core', 'src', 'engine', 'generator.ts'),
|
||||
'utf8'
|
||||
);
|
||||
// The onBeforeRender pageContext block (Phase 3A) must include
|
||||
// `urlContext` and `config` keys. This is the D-M3 fix.
|
||||
const beforeMatch = genSrc.match(/Phase 3A[\s\S]*?const pageContext = \{([\s\S]*?)\};[\s\S]*?onBeforeRender/);
|
||||
assert(beforeMatch && /urlContext/.test(beforeMatch[1]), 'D-M3: onBeforeRender pageContext includes urlContext');
|
||||
assert(beforeMatch && /\bconfig\b/.test(beforeMatch[1]), 'D-M3: onBeforeRender pageContext includes config');
|
||||
// The onPageReady pageObj is declared right before the loop. Look
|
||||
// for `const pageObj = {` followed by the loop.
|
||||
const readyMatch = genSrc.match(/const pageObj = \{([\s\S]*?)\};[\s\S]*?for \(const fn of hooks\.onPageReady\)/);
|
||||
assert(readyMatch && /urlContext/.test(readyMatch[1]), 'D-M3: onPageReady pageObj includes urlContext');
|
||||
assert(readyMatch && /\bconfig\b/.test(readyMatch[1]), 'D-M3: onPageReady pageObj includes config');
|
||||
}
|
||||
|
||||
// D-H6 — the live editor's static-file handler uses the canonical
|
||||
// safePath and 403s on path-escape attempts. We test this by
|
||||
// directly importing the live editor's exported server and issuing
|
||||
// a request with a traversal attempt.
|
||||
{
|
||||
// The live editor is a CLI binary; testing it through the binary
|
||||
// would require a running server. Instead, we unit-test the
|
||||
// safePath dependency: the live editor's `index.ts` imports
|
||||
// `safePath` from `@docmd/api` (this is the contract we want to
|
||||
// preserve). The previous path was `path.join` + ad-hoc stripper
|
||||
// which happened to be safe by accident.
|
||||
const liveIndex = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages', 'live', 'src', 'index.ts'),
|
||||
'utf8'
|
||||
);
|
||||
assert(/import \{ safePath as canonicalSafePath \} from .@docmd\/api.;/.test(liveIndex), 'D-H6: live editor imports safePath from @docmd/api');
|
||||
// The old `path.join(publicDir, safePath)` is gone — the canonical
|
||||
// safePath call replaces it.
|
||||
assert(!/path\.join\(publicDir, safePath\)/.test(liveIndex), 'D-H6: live editor no longer uses raw path.join(publicDir, safePath)');
|
||||
}
|
||||
|
||||
// D-M4 — packages/api/src/source.ts no longer has a local
|
||||
// safePath duplicate; it imports the canonical one from
|
||||
// @docmd/utils (which @docmd/api re-exports).
|
||||
{
|
||||
const sourceSrc = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages', 'api', 'src', 'source.ts'),
|
||||
'utf8'
|
||||
);
|
||||
assert(/import\s*\{\s*safePath\s*\}\s*from\s*'@docmd\/utils'/.test(sourceSrc), 'D-M4: source.ts imports safePath from @docmd/utils');
|
||||
// No local `function safePath` declaration in source.ts.
|
||||
assert(!/^function\s+safePath\s*\(/.test(sourceSrc), 'D-M4: source.ts no longer declares a local safePath');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* M-11 — `docmd stop` sends SIGTERM first and waits for graceful
|
||||
* shutdown before escalating to SIGKILL.
|
||||
*
|
||||
* Before the fix, `docmd stop` either killed the dev server
|
||||
* immediately (SIGKILL on first failure) or relied on SIGTERM
|
||||
* landing without checking whether the dev server had time to run
|
||||
* its cleanup handlers. After the fix, the dev server installs a
|
||||
* graceful-shutdown handler on SIGTERM (same as SIGINT) and `docmd
|
||||
* stop` polls for process exit before escalating.
|
||||
*
|
||||
* Run: `node tests/runner.js --only=stop`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
setup,
|
||||
writeFile,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { waitForExit } from '../../packages/core/dist/commands/stop.js';
|
||||
|
||||
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: 'Stop sends SIGTERM and waits for graceful exit (M-11)',
|
||||
emoji: '🛑',
|
||||
run: async () => {
|
||||
|
||||
// waitForExit returns true when the target process exits within
|
||||
// the grace window. We spawn `sleep 30`, send SIGTERM after a
|
||||
// short delay, and assert waitForExit returns true within 1s.
|
||||
{
|
||||
const child = spawn('sleep', ['30'], { stdio: 'pipe' });
|
||||
setTimeout(() => {
|
||||
try { process.kill(child.pid, 'SIGTERM'); } catch { /* ignore */ }
|
||||
}, 50);
|
||||
const exited = await waitForExit(child.pid, 1000);
|
||||
assert(exited, 'M-11: waitForExit returns true after SIGTERM kills a sleep child');
|
||||
}
|
||||
|
||||
// waitForExit returns false when the target process is still
|
||||
// alive at the timeout. Spawn `sleep 30`, do NOT signal it,
|
||||
// assert waitForExit returns false within 300ms.
|
||||
// (Use a short timeout to keep the test fast.)
|
||||
{
|
||||
const child = spawn('sleep', ['30'], { stdio: 'pipe' });
|
||||
const exited = await waitForExit(child.pid, 300);
|
||||
assert(exited === false, 'M-11: waitForExit returns false when process is still alive at timeout');
|
||||
try { process.kill(child.pid, 'SIGKILL'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// waitForExit returns true immediately for a pid that does not
|
||||
// exist (ESRCH). No process spawned.
|
||||
{
|
||||
// Pick a pid that's very unlikely to exist. Use a large number.
|
||||
const fakePid = 999999;
|
||||
const exited = await waitForExit(fakePid, 1000);
|
||||
assert(exited, 'M-11: waitForExit returns true for a non-existent pid (ESRCH)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* Phase 3 PR 3.C — Validate rewrite + workspace errors + init example
|
||||
*
|
||||
* Tests:
|
||||
* - M-1: `docmd validate` trailing-slash false-positives
|
||||
* - F8: workspace validation throws raw stack traces
|
||||
* - F9: default init `index.md` has a broken `::: button` example
|
||||
*
|
||||
* Run: `node tests/runner.js`
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import {
|
||||
DOCMD,
|
||||
setup,
|
||||
writeFile,
|
||||
exitCodeOf,
|
||||
runTestFile
|
||||
} from '../shared.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node: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: 'Validate rewrite + workspace errors + init example (Phase 3 PR 3.C — F8, F9, M-1)',
|
||||
emoji: '📐',
|
||||
run: () => {
|
||||
|
||||
// M-1 — `validate` must not false-positive on a valid link with a
|
||||
// trailing slash. Pre-fix: `[page 2](/page-2/)` was reported as
|
||||
// broken because the code did `fs.existsSync('docs/page-2/.md')`
|
||||
// (always false). Post-fix: the trailing slash is stripped before
|
||||
// the existence checks, and the link resolves to `docs/page-2.md`.
|
||||
{
|
||||
const dir = setup('validate-workspace-28-m1-trailing-slash-ok');
|
||||
writeFile(dir, 'docs/index.md', '# P1\n\n[page 2](/page-2/).\n');
|
||||
writeFile(dir, 'docs/page-2.md', '# P2\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} validate`, dir);
|
||||
assert(code === 0, 'M-1: trailing-slash link to existing .md is valid (exit 0)');
|
||||
}
|
||||
|
||||
// M-1 — genuine broken link is still caught.
|
||||
{
|
||||
const dir = setup('validate-workspace-28-m1-broken-still-caught');
|
||||
writeFile(dir, 'docs/index.md', '# P1\n\n[bad](/nope/)\n');
|
||||
const code = exitCodeOf(`node ${DOCMD} validate`, dir);
|
||||
assert(code === 1, 'M-1: genuinely broken link still exits 1');
|
||||
}
|
||||
|
||||
// F8 — workspace validation errors exit 1 with a clean TUI message,
|
||||
// not a raw stack trace.
|
||||
{
|
||||
const dir = setup('validate-workspace-28-f8-workspace-error');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'F8',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
workspace: {
|
||||
projects: [{ src: './totally-missing-f8', prefix: '/' }]
|
||||
}
|
||||
}));
|
||||
const code = exitCodeOf(`node ${DOCMD} build`, dir);
|
||||
assert(code === 1, 'F8: workspace with missing source dir exits 1');
|
||||
}
|
||||
|
||||
// F8 — duplicate-prefix error also exits 1.
|
||||
{
|
||||
const dir = setup('validate-workspace-28-f8-workspace-duplicate');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'F8 dup',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
workspace: {
|
||||
projects: [
|
||||
{ src: './docs', prefix: '/api' },
|
||||
{ src: './docs', prefix: '/api' }
|
||||
]
|
||||
}
|
||||
}));
|
||||
const code = exitCodeOf(`node ${DOCMD} build`, dir);
|
||||
assert(code === 1, 'F8: duplicate workspace prefix exits 1');
|
||||
}
|
||||
|
||||
// F9 — default init `index.md` must NOT contain the F2 trap pattern
|
||||
// (`::: card ... ::: button ... :::` with the orphan `:::`).
|
||||
// We check the bundled `initProject` source rather than running
|
||||
// `init` (which would write to disk) — we just confirm the
|
||||
// template string no longer teaches the F2 pattern.
|
||||
{
|
||||
const initSrc = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '..', '..', 'packages/core/dist/commands/init.js'),
|
||||
'utf8'
|
||||
);
|
||||
// The broken pattern was: `::: button "View Documentation" https://...`
|
||||
// followed by a `:::` close. The replacement uses
|
||||
// `[View the docs →](https://docmd.io){.docmd-button}` instead.
|
||||
assert(!/button "View Documentation"/.test(initSrc), 'F9: bundled init no longer contains broken `::: button ... :::` trap');
|
||||
assert(/\{\.docmd-button\}/.test(initSrc), 'F9: bundled init uses `{.docmd-button}` styled link');
|
||||
}
|
||||
|
||||
// M-2 — workspace config without a root project must show an
|
||||
// actionable error message: explains the requirement, shows a
|
||||
// valid example, and points at the docs. The old message was
|
||||
// a single bare sentence with no example.
|
||||
{
|
||||
const dir = setup('validate-workspace-28-m2-workspace-no-root-message');
|
||||
// The source dirs must exist so the per-project existence check
|
||||
// does not fire first and mask the "no root project" failure.
|
||||
writeFile(dir, 'docs/index.md', '# Root\n');
|
||||
writeFile(dir, 'docmd.config.json', JSON.stringify({
|
||||
title: 'M2',
|
||||
src: './docs',
|
||||
out: './site',
|
||||
workspace: {
|
||||
projects: [
|
||||
{ src: './docs', prefix: '/api/' },
|
||||
{ src: './docs', prefix: '/blog/' }
|
||||
]
|
||||
}
|
||||
}));
|
||||
let captured = '';
|
||||
let code = -1;
|
||||
try {
|
||||
execSync(`node ${DOCMD} build`, { cwd: dir, stdio: 'pipe', encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
code = e.status == null ? -1 : e.status;
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : (e.stderr ? e.stderr.toString() : '');
|
||||
const stdout = typeof e.stdout === 'string' ? e.stdout : (e.stdout ? e.stdout.toString() : '');
|
||||
captured = stderr + stdout;
|
||||
}
|
||||
assert(code === 1, 'M-2: workspace with no root project exits 1');
|
||||
assert(/root project with prefix "\/"/.test(captured), 'M-2: error message states the root prefix requirement');
|
||||
assert(/Example:/.test(captured), 'M-2: error message includes an Example block');
|
||||
assert(/"prefix": "\/"/.test(captured), 'M-2: error message example shows prefix "/"');
|
||||
assert(/docs\.docmd\.io/.test(captured), 'M-2: error message links to the workspace guide');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const results = {
|
||||
get passed() { return passed; },
|
||||
get failed() { return failed; },
|
||||
get failures() { return [...failures]; }
|
||||
};
|
||||
Reference in New Issue
Block a user