Files
santifer--career-ops/tests/helpers.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

211 lines
8.1 KiB
JavaScript

// tests/helpers.mjs — shared assertion helpers + counters for the test suite.
// Moved verbatim from test-all.mjs (issue #1440); no framework by design:
// the suite must run on a fresh clone with only Node.
import { execFileSync } from 'child_process';
import { existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const ROOT = join(__dirname, '..'); // repo root (tests/ lives one level down)
export const QUICK = process.argv.includes('--quick');
export const NODE = process.execPath;
let passed = 0;
let failed = 0;
let warnings = 0;
/**
* Record and print one passing test assertion.
*
* The suite uses these small counters instead of a framework so it can run in
* any freshly cloned career-ops checkout with only Node.js available.
*
* @param {string} msg - Human-readable success message for the terminal log.
* @returns {void}
*/
export function pass(msg) { console.log(` ✅ ${msg}`); passed++; }
/**
* Record and print one failing test assertion.
*
* Failures increment the shared counter that controls the final process exit
* code, while still allowing later checks to run and show the full problem set.
*
* @param {string} msg - Human-readable failure message for the terminal log.
* @returns {void}
*/
export function fail(msg) { console.log(` ❌ ${msg}`); failed++; }
/**
* Record and print one non-fatal warning.
*
* Warnings are used for expected local-environment gaps, such as missing user
* data in a clean repo, where the check should stay visible but not fail CI.
*
* @param {string} msg - Human-readable warning message for the terminal log.
* @returns {void}
*/
export function warn(msg) { console.log(` ⚠️ ${msg}`); warnings++; }
/** Current counter snapshot. */
export function results() { return { passed, failed, warnings }; }
/**
* Print the summary line and exit with the suite's exit code.
* Moved verbatim from the tail of test-all.mjs — output must stay byte-identical.
*/
export function finish() {
console.log('\n' + '='.repeat(50));
console.log(`📊 Results: ${passed} passed, ${failed} failed, ${warnings} warnings`);
if (failed > 0) {
console.log('🔴 TESTS FAILED — do NOT push/merge until fixed\n');
process.exit(1);
} else if (warnings > 0) {
console.log('🟡 Tests passed with warnings — review before pushing\n');
process.exit(0);
} else {
console.log('🟢 All tests passed — safe to push/merge\n');
process.exit(0);
}
}
// The only executables the test harness is allowed to spawn. run() maps its
// cmd argument onto these literals (never passing the argument itself through
// to the OS), so a test can never be tricked into executing an arbitrary
// binary — and CodeQL's uncontrolled-command-line finding is closed by
// construction rather than dismissed (alerts #36/#41/#42).
const WINDOWS_BASH_CANDIDATES = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
];
/**
* Map a requested executable onto the harness allowlist, returning the
* trusted literal (not the caller-supplied string).
*
* @param {string} cmd - Requested executable.
* @returns {string} Allowlisted executable path/name.
*/
function resolveAllowedExecutable(cmd) {
if (cmd === process.execPath || cmd === 'node') return process.execPath;
if (cmd === 'bash') return 'bash';
if (cmd === 'git') return 'git';
if (cmd === 'go') return 'go';
if (cmd === 'wsl') return 'wsl';
for (const candidate of WINDOWS_BASH_CANDIDATES) {
if (cmd === candidate) return candidate;
}
throw new Error(`run(): executable not in the test-helper allowlist: ${cmd}`);
}
/**
* Run an allowlisted executable and return trimmed stdout on success.
*
* Always execFileSync with an argument vector — no shell is ever involved, so
* arguments are never shell-parsed. The string-command/execSync form was
* removed (it had no callers). Failures return null so the caller decides
* whether to count the result as a failure or warning.
*
* @param {string} cmd - Executable to run (must be on the allowlist above).
* @param {string[]} [args=[]] - Argument vector.
* @param {object} [opts={}] - Extra child_process options.
* @returns {string|null} Trimmed stdout, or null when the command fails.
*/
export function run(cmd, args = [], opts = {}) {
const exe = resolveAllowedExecutable(cmd);
try {
return execFileSync(exe, args, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim();
} catch (e) {
return null;
}
}
/**
* Check whether a repo-relative file exists.
*
* @param {string} path - Path relative to the career-ops repository root.
* @returns {boolean} True when the file exists.
*/
export function fileExists(path) { return existsSync(join(ROOT, path)); }
let bashCache = null;
/**
* Resolve the bash executable to use for shell-script checks, lazily.
*
* The Windows probes below shell out up to four times (the WSL probe can even
* boot the WSL VM). Every test file imports this module, so doing the probes
* eagerly at module load would repeat that cost once per spawned test process.
* Resolution therefore happens on first call and is memoized for the rest of
* the process; suites that never touch bash never pay for it.
*
* @returns {string} Bash executable path or command name.
*/
export function getBash() {
if (bashCache !== null) return bashCache;
if (process.platform !== 'win32') return (bashCache = 'bash');
try {
// Probe via argv vector — no shell string, nothing to interpolate.
execFileSync('wsl', ['-e', 'bash', '-c', 'true'], { stdio: 'ignore' });
return (bashCache = 'bash');
} catch {}
for (const cmd of [...WINDOWS_BASH_CANDIDATES, 'bash']) {
try {
execFileSync(cmd, ['-c', 'true'], { stdio: 'ignore' });
return (bashCache = cmd);
} catch {}
}
return (bashCache = 'bash');
}
export function toBashPath(wpath) {
if (process.platform !== 'win32') return wpath;
const forwardSlashed = wpath.replace(/\\/g, '/');
// Try cygpath first: it ships with Git for Windows, which is also what
// provides `bash` on PATH on most Windows dev machines (see getBash()
// above). cygpath emits /c/... paths that match Git Bash's mount scheme.
// wslpath emits /mnt/c/... paths, which only resolve inside WSL's own
// bash -- if WSL happens to be installed but `bash` on PATH still
// resolves to Git Bash, a wslpath-first order silently produces a path
// Git Bash can't find (see #1409). Only fall back to wslpath (and only
// pay the cost of booting the WSL VM) when cygpath is unavailable.
try {
// execFileSync: the path is passed as an argv element, never interpolated
// into a shell string, so quotes/spaces in it can't be re-parsed.
const cygpathCmd = existsSync('C:\\Program Files\\Git\\usr\\bin\\cygpath.exe') ? 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe' : 'cygpath';
const out = execFileSync(cygpathCmd, ['-u', forwardSlashed], { stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim();
if (out) return out;
} catch {}
try {
execFileSync('wsl', ['-e', 'bash', '-c', 'true'], { stdio: 'ignore' });
const out = execFileSync('wsl', ['wslpath', '-u', forwardSlashed], { stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim();
if (out) return out;
} catch {}
return wpath.replace(/^[A-Za-z]:/, m => '/' + m[0].toLowerCase()).replace(/\\/g, '/');
}
/**
* Capture console.error output produced by an async callback.
*
* Several provider fetch() paths report truncation/failure via console.error;
* their tests need to assert on those messages. This wraps the
* save/override/restore dance in one place — console.error is restored in
* finally, even when the callback throws, so one test's override can never
* leak into the next.
*
* @param {() => Promise<any>|any} fn - Callback to run while capturing.
* @returns {Promise<{result: any, errors: any[]}>} Callback result + captured messages.
*/
export async function captureConsoleErrors(fn) {
const errors = [];
const original = console.error;
console.error = (msg) => errors.push(msg);
try {
const result = await fn();
return { result, errors };
} finally {
console.error = original;
}
}