Files
wehub-resource-sync 6db8fca185
docmd CI verification / verify (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:55 +08:00

334 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* Categorised test runner
*
* Replaces the monolithic `scripts/brute-test.js` +
* `scripts/brute-test-security.js` (now `tests/feature-integration.test.js`
* and `tests/security.test.js` after the scripts/ → tests/ move)
* with a set of smaller, focused test files under `tests/`. Each file
* exports a `test` object (created via `runTestFile()`) and a `results`
* object with `passed`, `failed`, `failures` getters.
*
* The runner:
* 1. Imports every test file in declared order.
* 2. For each file, prints a TUI section header (`<emoji> <name>`).
* 3. Calls `test.run()` and reads the per-file `results` object to
* print pass/fail counts.
* 4. Aggregates pass/fail across all files into a final summary.
* 5. Exits 1 if any file failed, 0 otherwise.
*
* Wired into `tools/prep.js` (called by `pnpm prep` via the status
* pipeline) so a single `pnpm prep` runs the entire categorised suite.
*
* The legacy feature-integration scenarios (zero-config, i18n,
* versioning, containers, code blocks, etc.) still run.
*
* Run: `node tests/runner.js`
* Or: `node tests/runner.js --only=exit-codes,plugin-add-remove` (filter)
* --------------------------------------------------------------------
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const CYAN = (s) => `\x1b[36m${s}\x1b[0m`;
const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
const RED = (s) => `\x1b[31m${s}\x1b[0m`;
const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
const args = process.argv.slice(2);
const only = (() => {
const flag = args.find((a) => a.startsWith('--only='));
if (!flag) return null;
return flag.slice('--only='.length).split(',').filter(Boolean);
})();
// `tools/verify.js` forwards `--skip-setup` to the runner in CI
// (the install + build steps run before verify). The runner does
// not need any setup work itself, so this is a no-op — we just
// drop it from the arg list so it does not leak into test files
// or show up as an unrecognised flag. Add other verify-only flags
// here as the verify script grows.
const _SKIP_FLAGS = new Set(['--skip-setup']);
for (const f of _SKIP_FLAGS) {
const i = args.indexOf(f);
if (i !== -1) args.splice(i, 1);
}
// ---------------------------------------------------------------------------
// Test file registry — order matters.
// - For in-process tests, the entry is the imported module.
// - For external tests, the entry has `{ external: true, command, args }`.
// - When `id` matches the `--only` filter, only those entries run.
// ---------------------------------------------------------------------------
const testFiles = [];
function addInProcess(id, name, module) {
if (only && !only.includes(id)) return;
testFiles.push({ id, name, module });
}
function addExternal(id, name, command, args) {
if (only && !only.includes(id)) return;
testFiles.push({ id, name, module: { external: true, command, args } });
}
// --- Section 1: CLI contracts (Phase 3 PR 3.A / 3.B / 3.C) -----------------
addInProcess(
'exit-codes',
'Exit-code contract (F6, M-12)',
await import('./cli-contracts/exit-codes.test.js')
);
addInProcess(
'plugin-add-remove',
'Plugin add/remove across config formats (F7, M-3)',
await import('./cli-contracts/plugin-add-remove.test.js')
);
addInProcess(
'validate-workspace',
'Validate rewrite + workspace errors + init example (F8, F9, M-1)',
await import('./cli-contracts/validate-workspace.test.js')
);
addInProcess(
'deploy',
'Deploy --force honours overwrite (N-2)',
await import('./cli-contracts/deploy.test.js')
);
addInProcess(
'stop',
'Stop sends SIGTERM and waits for graceful exit (M-11)',
await import('./cli-contracts/stop.test.js')
);
addInProcess(
'migrate',
'Migrate --dry-run is non-destructive (N-3)',
await import('./cli-contracts/migrate.test.js')
);
addInProcess(
'offline-links',
'Offline-mode internal links work in every hosting shape (#167)',
await import('./cli-contracts/offline-links.test.js')
);
addInProcess(
'plugin-contract',
'Plugin contract + public API fixes (Slice C.1 + C.2)',
await import('./cli-contracts/plugin-contract.test.js')
);
addInProcess(
'source-tools',
'Source tools + page shape consistency (Slice C.3)',
await import('./cli-contracts/source-tools.test.js')
);
addInProcess(
'i18n',
'i18n + workspace schema fixes (Slice D — M-6, T-Z6)',
await import('./cli-contracts/i18n.test.js')
);
addInProcess(
'llms-and-tui',
'llms.txt sanitisation + TUI banner options (Slice F — T-Z10, T-Z11, N-13, N-16)',
await import('./cli-contracts/llms-and-tui.test.js')
);
addInProcess(
'migrate-fix',
'Migration polish (Slice E — N-9, N-10, N-22)',
await import('./cli-contracts/migrate-fix.test.js')
);
addInProcess(
'plugin-assets-pipeline',
'Plugin asset pipeline (PAA-1, PAA-2, PAA-3 — async/await + capability)',
await import('./cli-contracts/plugin-assets-pipeline.test.js')
);
addInProcess(
'asset-base-url',
'Asset base-URL + engine-key (URL-1, URL-2 — <base> + KNOWN_KEYS)',
await import('./cli-contracts/asset-base-url.test.js')
);
addInProcess(
'runtime-deps',
'Runtime-deps shared auto-install pipeline (RD-1..RD-CWE, CWE-78 fix)',
await import('./cli-contracts/runtime-deps.test.js')
);
// --- Section 2: Container parser (Phase 2 PR 1+2+3) ----------------------
addExternal(
'container-normaliser',
'Container normaliser (F1F5)',
'pnpm',
['--filter', '@docmd/parser', 'test']
);
// --- Section 3: Utils (Path / HTML escape) -------------------------------
addExternal(
'utils',
'Utils (safePath, escHtml, attrEsc, jsonInject, scriptLiteral)',
'pnpm',
['--filter', '@docmd/utils', 'test']
);
// --- Section 4: Security (Phase 1 CVE suite) ------------------------------
addExternal(
'security',
'Security (Phase 1 CVE suite — 88 assertions)',
'node',
['tests/security.test.js']
);
// --- Section 5: Feature integration (legacy feature-integration.test.js) --------------
addExternal(
'features',
'Feature integration (29 scenarios — zero-config, i18n, versioning, navigation, code blocks, search, sitemap, etc.)',
'node',
['tests/feature-integration.test.js']
);
// --- Section 6: OKF plugin ------------------------------------------------
addExternal(
'okf-plugin',
'OKF plugin (Open Knowledge Format bundle generator)',
'pnpm',
['--filter', '@docmd/plugin-okf', 'test']
);
// --- Section 7: LLMS plugin -----------------------------------------------
// The LLMS plugin doesn't have a `pnpm test` script yet, so the runner
// invokes the test runner directly. Default-locale + i18n opt-in tests
// live at packages/plugins/llms/tests/llms.test.js.
addExternal(
'llms-plugin',
'LLMS plugin (llms.txt / llms-full.txt / llms.json — default-locale + i18n opt-in)',
'node',
['--test', 'packages/plugins/llms/tests/llms.test.js']
);
// ---------------------------------------------------------------------------
// Runner — execute each entry, print TUI section, aggregate results.
// ---------------------------------------------------------------------------
let totalPassed = 0;
let totalFailed = 0;
const allFailures = [];
const startTime = Date.now();
console.log('');
console.log(CYAN(BOLD('Categorised test suite')));
console.log(DIM(` ${testFiles.length} test file${testFiles.length === 1 ? '' : 's'}${new Date().toISOString().slice(0, 10)}`));
console.log('');
for (const { id, name, module } of testFiles) {
const sectionStart = Date.now();
console.log(CYAN(`┌─ ${name}`));
if (module.external) {
// Subprocess runner. Forward stdout, capture exit code.
const result = spawnSync(module.command, module.args, {
cwd: path.resolve(import.meta.dirname, '..'),
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8'
});
const out = (result.stdout || '') + (result.stderr || '');
const lines = out.split('\n');
// Show last 60 lines of subprocess output so the TUI stays
// readable but the developer can see which assertions ran.
const tail = lines.slice(-60).join('\n');
for (const line of tail.split('\n')) {
if (!line.trim()) continue;
console.log(CYAN('│') + ' ' + line);
}
if (result.status === 0) {
// Parse the assertion count. Two output formats are supported:
// 1. `tests/feature-integration.test.js` style: "X passed,
// Y failed out of Z"
// 2. `node:test` TAP-style: "pass N" / "fail N" (one per line)
// The `node:test` runner prints lines like:
// tests 60
// pass 60
// fail 0
// We try the brute-test pattern first, then the node:test pattern.
let passed = 0;
let failed = 0;
const bruteMatch = out.match(/(\d+)\s+passed,\s+(\d+)\s+failed/);
if (bruteMatch) {
passed = parseInt(bruteMatch[1], 10);
failed = parseInt(bruteMatch[2], 10);
} else {
const passLines = out.match(/pass\s+(\d+)/g) || [];
const failLines = out.match(/fail\s+(\d+)/g) || [];
for (const l of passLines) {
const v = parseInt(l.replace(/[^\d]/g, ''), 10);
if (!isNaN(v)) passed = Math.max(passed, v);
}
for (const l of failLines) {
const v = parseInt(l.replace(/[^\d]/g, ''), 10);
if (!isNaN(v)) failed = Math.max(failed, v);
}
}
totalPassed += passed;
totalFailed += failed;
const elapsed = Date.now() - sectionStart;
console.log(CYAN('│'));
console.log(`${CYAN('│')} ${GREEN(BOLD('[ PASS ]'))} ${passed} passed, ${failed} failed ${DIM('(' + elapsed + 'ms)')}`);
} else {
totalFailed += 1;
allFailures.push({ name, output: out.slice(-2000) });
const elapsed = Date.now() - sectionStart;
console.log(CYAN('│'));
console.log(`${CYAN('│')} ${RED(BOLD('[ FAIL ]'))} exit code ${result.status} ${DIM('(' + elapsed + 'ms)')}`);
}
} else {
// In-process runner. The test module's `test.run()` callback prints
// its own pass/fail per assertion; the module's `results` object
// reports the aggregate.
try {
await module.test.run();
const passed = module.results.passed;
const failed = module.results.failed;
const failures = module.results.failures;
totalPassed += passed;
totalFailed += failed;
for (const f of failures) allFailures.push({ name, output: f });
const elapsed = Date.now() - sectionStart;
console.log(CYAN('│'));
if (failed === 0) {
console.log(`${CYAN('│')} ${GREEN(BOLD('[ PASS ]'))} ${passed} passed, 0 failed ${DIM('(' + elapsed + 'ms)')}`);
} else {
console.log(`${CYAN('│')} ${RED(BOLD('[ FAIL ]'))} ${passed} passed, ${failed} failed ${DIM('(' + elapsed + 'ms)')}`);
}
} catch (e) {
totalFailed += 1;
allFailures.push({ name, output: e.message + '\n' + e.stack });
const elapsed = Date.now() - sectionStart;
console.log(CYAN('│'));
console.log(`${CYAN('│')} ${RED(BOLD('[ FAIL ]'))} threw: ${e.message} ${DIM('(' + elapsed + 'ms)')}`);
}
}
console.log(CYAN('└' + '─'.repeat(50)));
console.log('');
}
const totalMs = Date.now() - startTime;
console.log('');
console.log(CYAN('┌' + '─'.repeat(55)));
console.log(CYAN('│') + ' ' + CYAN(BOLD(`Test summary: ${totalPassed} passed, ${totalFailed} failed across ${testFiles.length} files`)));
console.log(CYAN('│') + ' ' + DIM(`Total time: ${totalMs}ms`));
if (allFailures.length > 0) {
console.log(CYAN('│'));
console.log(CYAN('│') + ' ' + RED('Failures:'));
for (const f of allFailures) {
console.log(CYAN('│') + ' ' + RED(` - ${f.name}`));
if (f.output) {
const snippet = f.output.split('\n').slice(0, 8).join('\n');
console.log(CYAN('│') + ' ' + DIM(snippet));
}
}
}
console.log(CYAN('└' + '─'.repeat(55)));
console.log('');
process.exit(totalFailed > 0 ? 1 : 0);