#!/usr/bin/env node /** * test-all.mjs — Comprehensive test suite for career-ops * * Run before merging any PR or pushing changes. * Tests: syntax, scripts, dashboard, data contract, personal data, paths. * * Usage: * node test-all.mjs # Run all tests * node test-all.mjs --quick # Skip dashboard build (faster) * node test-all.mjs --only # Run ONLY discovered tests/**\/*.test.mjs * # files whose path contains * # (e.g. --only providers/themuse). * * LOUD WARNING: `--only` runs ONLY discovered tests/ files — every inline * core section above (syntax, scripts, dashboard, data contract, personal * data, paths, etc.) is SKIPPED. A green `--only` run is NOT a green * suite. Always run the full suite (no flags) before pushing. * * Provider tests live in tests/providers/{name}.test.mjs and are * auto-discovered — no registration needed. To add a test for a new * provider, create that one file; do not add a section to this file. */ import { execSync, execFileSync, spawn } from 'child_process'; import { readFileSync, existsSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, statSync, unlinkSync, realpathSync, symlinkSync } from 'fs'; import { join, dirname, basename, delimiter } from 'path'; import { tmpdir } from 'os'; import { fileURLToPath, pathToFileURL } from 'url'; import { pass, fail, warn, run, fileExists, finish, ROOT, QUICK, NODE, getBash, toBashPath } from './tests/helpers.mjs'; /** * Read a repo-relative text file as UTF-8. * * @param {string} path - Path relative to the career-ops repository root. * @returns {string} File contents. */ function readFile(path) { const fullPath = join(ROOT, path); let content = readFileSync(fullPath, 'utf-8'); if (content.trim().startsWith('..') && content.trim().split('\n').length === 1) { const target = join(dirname(fullPath), content.trim()); if (existsSync(target)) { content = readFileSync(target, 'utf-8'); } } return content; } /** * Normalize CRLF line endings to LF (#1771). * * On Windows checkouts with core.autocrlf=true, repo text files arrive with * CRLF endings. Doc assertions that anchor on `\n` (JS `.` never matches `\r`) * then fail on pristine main. Normalizing at read time keeps the assertions * byte-ending agnostic without touching any regex. * * @param {string} text - Raw file contents. * @returns {string} Contents with LF-only line endings. */ const normalizeEol = (text) => text.replace(/\r\n/g, '\n'); /** * Read a repo text file with line endings normalized to LF (#1771). * Use for doc-content reads that feed `\n`-anchored regex assertions. * Do NOT use where byte-exact content matters. * * @param {string} path - Path relative to the career-ops repository root. * @returns {string} File contents with LF-only line endings. */ const readTextLF = (path) => normalizeEol(readFile(path)); // ── Auto-discovered test files (issue #1440) ───────────────────────────── // Deterministic: recursive readdirSync with default lexicographic sort of // entry names — same order on every run and OS. No glob library, no // registration list. Discovery is limited to tests/ so root-level // standalone *.test.mjs files are never picked up. const TESTS_DIR = join(ROOT, 'tests'); function discoverTests(dir) { const out = []; const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) out.push(...discoverTests(full)); else if (entry.name.endsWith('.test.mjs')) out.push(full); } return out; } async function runDiscovered(filter = null) { let files = discoverTests(TESTS_DIR); if (filter) { const norm = (p) => p.slice(TESTS_DIR.length + 1).replace(/\\/g, '/'); files = files.filter((f) => norm(f).includes(filter)); } if (files.length === 0) { // Fail hard: a path typo must never silently turn CI green. console.log(` ❌ no test files matched${filter ? ` --only "${filter}"` : ''} under tests/`); process.exit(1); } for (const f of files) await import(pathToFileURL(f).href); } const onlyIdx = process.argv.indexOf('--only'); const ONLY = onlyIdx !== -1 ? (process.argv[onlyIdx + 1] ?? '') : null; if (ONLY !== null) { if (ONLY === '' || ONLY.startsWith('--')) { console.log(' ❌ --only requires a path substring, e.g. --only providers/themuse'); process.exit(1); } console.log('\n🧪 career-ops test suite (--only ' + ONLY + ')\n'); await runDiscovered(ONLY); finish(); } console.log('\n🧪 career-ops test suite\n'); // ── 1. SYNTAX CHECKS ──────────────────────────────────────────── console.log('1. Syntax checks'); const mjsFiles = readdirSync(ROOT).filter(f => f.endsWith('.mjs')); for (const f of mjsFiles) { const result = run(NODE, ['--check', f]); if (result !== null) { pass(`${f} syntax OK`); } else { fail(`${f} has syntax errors`); } } // ── 2. SCRIPT EXECUTION ───────────────────────────────────────── console.log('\n2. Script execution (graceful on empty data)'); const scripts = [ { name: 'cv-sync-check.mjs', expectExit: 1, allowFail: true }, // fails without cv.md (normal in repo) { name: 'verify-pipeline.mjs', expectExit: 0 }, // --dry-run: these scripts resolve ROOT from import.meta.url and write // data/applications.md (or data/pipeline.md) in place. On a provisioned working // copy with a real tracker present, running them without --dry-run mutates user // data. Harmless in this repo (no tracker shipped), risky for end users who run // tests inside their active career-ops workspace. { name: 'normalize-statuses.mjs --dry-run', expectExit: 0 }, { name: 'dedup-tracker.mjs --dry-run', expectExit: 0 }, { name: 'merge-tracker.mjs --dry-run', expectExit: 0 }, { name: 'reconcile-pipeline.mjs --dry-run', expectExit: 0 }, { name: 'analyze-patterns.mjs --self-test', expectExit: 0 }, { name: 'upskill.mjs --self-test', expectExit: 0 }, { name: 'detect-reposts.mjs --self-test', expectExit: 0 }, { name: 'process-quality.mjs --self-test', expectExit: 0 }, { name: 'salary-gap.mjs --self-test', expectExit: 0 }, { name: 'funnel-velocity.mjs --self-test', expectExit: 0 }, { name: 'img-to-pdf.mjs --self-test', expectExit: 0 }, { name: 'assessment-log.mjs --self-test', expectExit: 0 }, { name: 'build-cv-html.mjs --test', expectExit: 0 }, { name: 'updater-migration-tests.mjs', expectExit: 0 }, { name: 'tracker-columns-tests.mjs', expectExit: 0 }, { name: 'agent-inbox-tests.mjs', expectExit: 0 }, { name: 'followup-seed-tests.mjs', expectExit: 0 }, { name: 'set-status-tests.mjs', expectExit: 0 }, // Root-level standalone suites shipped in SYSTEM_PATHS but previously never // executed by CI (issue #1624). All are fast (<0.5s each), so they run in // both quick and full mode like their siblings above. { name: 'test-trust-validator.mjs', expectExit: 0 }, { name: 'test-salary-filter.mjs', expectExit: 0 }, { name: 'detect-reposts.test.mjs', expectExit: 0 }, { name: 'followup-cadence.test.mjs', expectExit: 0 }, { name: 'process-quality.test.mjs', expectExit: 0 }, { name: 'reply-matcher.test.mjs', expectExit: 0 }, { name: 'validate-portals.mjs --file templates/portals.example.yml', expectExit: 0 }, { name: 'validate-system-paths-coverage.mjs --self-test', expectExit: 0 }, { name: 'validate-system-paths-coverage.mjs', expectExit: 0 }, // Missing-file run: must exit 0 gracefully and hit no network. Do not use the // default portals.yml because end-user workspaces often have a real user-layer // portals file that would trigger a live remote sweep during tests. { name: 'verify-portals.mjs --file .tmp-test-missing-portals.yml', expectExit: 0 }, { name: 'update-system.mjs check', expectExit: 0 }, { name: 'archive-posting.mjs --help', expectExit: 0 }, ]; for (const { name, allowFail } of scripts) { const result = run(NODE, name.split(' '), { stdio: ['pipe', 'pipe', 'pipe'] }); if (result !== null) { pass(`${name} runs OK`); } else if (allowFail) { warn(`${name} exited with error (expected without user data)`); } else { fail(`${name} crashed`); } } try { const tmp = mkdtempSync(join(tmpdir(), 'career-ops-cv-facts-')); const hiddenScriptMetric = join(tmp, 'hidden-script-metric.html'); const visibleMetric = join(tmp, 'visible-metric.html'); writeFileSync( hiddenScriptMetric, '