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

237 lines
12 KiB
JavaScript

/**
* --------------------------------------------------------------------
* 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; },
};