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