#!/usr/bin/env node /** * set-status-tests.mjs — regression tests for the set-status.mjs CLI (#1428). * * set-status.mjs is the canonical write path for tracker status updates, so * these tests pin down the full CLI contract: row resolution (by number, by * company, --role disambiguation), strict state validation against * templates/states.yml, idempotent note appends, dry-run, JSON output, exit * codes, and layout tolerance (9-col and 10-col Location trackers). * * Tests provision a throwaway tracker via the CAREER_OPS_TRACKER / * CAREER_OPS_TRACKER_LOCK env overrides (same sandbox pattern as * tracker-columns-tests.mjs). * * Exit-code contract under test: * 0 — success (including no-op re-runs) * 1 — usage error or non-canonical state * 2 — row not found (bad number, unknown company) * 3 — ambiguous company match (candidates listed on stderr / in JSON) */ import { execFileSync } from 'child_process'; import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, chmodSync } from 'fs'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { acquireTrackerLock } from './tracker-utils.mjs'; const ROOT = dirname(fileURLToPath(import.meta.url)); const NODE = process.execPath; let passed = 0; let failed = 0; function pass(m) { console.log(`PASS ${m}`); passed++; } function fail(m) { console.error(`FAIL ${m}`); failed++; } // Run set-status.mjs with the tracker redirected to a sandbox. Returns // { code, stdout, stderr }. function runSetStatus(args, sandbox, extraEnv = {}) { const env = { ...process.env, CAREER_OPS_TRACKER: sandbox.tracker, CAREER_OPS_TRACKER_LOCK: sandbox.lock, ...extraEnv, }; try { const stdout = execFileSync(NODE, [join(ROOT, 'set-status.mjs'), ...args], { cwd: ROOT, env, encoding: 'utf-8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], }); return { code: 0, stdout, stderr: '' }; } catch (e) { return { code: e.status ?? 1, stdout: e.stdout || '', stderr: e.stderr || '' }; } } // Create a sandbox dir holding a tracker file. function makeSandbox(trackerContent) { const dir = mkdtempSync(join(tmpdir(), 'co-setstatus-')); const tracker = join(dir, 'applications.md'); writeFileSync(tracker, trackerContent); // The lock env value must live under tmpdir and use the career-ops prefix // (see trackerLockDirFor) or it is ignored — which would still be safe, // just contending on the real default lock. const lock = join(dir, 'career-ops-merge-tracker-test.lock'); return { dir, tracker, lock }; } function readTracker(sandbox) { return readFileSync(sandbox.tracker, 'utf-8'); } const TRACKER_9 = `# Applications Tracker | # | Date | Company | Role | Score | Status | PDF | Report | Notes | |---|------|---------|------|-------|--------|-----|--------|-------| | 1 | 2026-06-01 | Acme | Backend Engineer | 4.2/5 | Evaluated | ✅ | [1](../reports/001-acme-2026-06-01.md) | strong infra fit | | 2 | 2026-06-02 | Globex | Platform Engineer | 4.0/5 | Evaluated | ✅ | [2](../reports/002-globex-2026-06-02.md) | — | | 3 | 2026-06-03 | Acme | Data Engineer | 3.9/5 | Evaluated | ❌ | [3](../reports/003-acme-2026-06-03.md) | pipeline heavy | `; const TRACKER_10 = `# Applications Tracker | # | Date | Company | Role | Location | Score | Status | PDF | Report | Notes | |---|------|---------|------|----------|-------|--------|-----|--------|-------| | 1 | 2026-06-01 | Initech | AI Engineer | Remote | 4.5/5 | Evaluated | ✅ | [1](../reports/001-initech-2026-06-01.md) | — | `; // Two unrelated rows share tracker number 5 (the #1704 bug: merge-tracker.mjs // once trusted a stale TSV number as-is when it was numerically ahead of that // run's max, even though the number was already used by an unrelated row // merged in a separate, earlier invocation). const TRACKER_DUP_NUM = `# Applications Tracker | # | Date | Company | Role | Score | Status | PDF | Report | Notes | |---|------|---------|------|-------|--------|-----|--------|-------| | 5 | 2026-05-29 | University of Alberta | Curriculum Coordinator | 3.8/5 | Evaluated | ❌ | — | — | | 5 | 2026-06-03 | Esri Canada | Manager Talent and Organizational Development | 4.1/5 | Evaluated | ❌ | — | — | `; // ── 1. Update by report number ────────────────────────────────── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['2', 'Applied'], sb); const content = readTracker(sb); if (r.code === 0 && /\| 2 \| 2026-06-02 \| Globex \| Platform Engineer \| 4.0\/5 \| Applied \|/.test(content)) { pass('by-num: status updated to Applied'); } else { fail(`by-num: code=${r.code}; row not updated correctly\n${r.stdout}${r.stderr}`); } if (content.includes('| 1 | 2026-06-01 | Acme | Backend Engineer | 4.2/5 | Evaluated |')) { pass('by-num: other rows untouched'); } else { fail('by-num: other rows were modified'); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 2. Update by company name (single match) ──────────────────── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['globex', 'Responded'], sb); if (r.code === 0 && /\| Globex \| Platform Engineer \| 4.0\/5 \| Responded \|/.test(readTracker(sb))) { pass('by-company: fuzzy company resolves single match'); } else { fail(`by-company: code=${r.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 3. State aliases resolve to canonical labels ──────────────── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['2', 'aplicado'], sb); if (r.code === 0 && /\| Globex \| Platform Engineer \| 4.0\/5 \| Applied \|/.test(readTracker(sb))) { pass('alias: "aplicado" resolves to canonical "Applied"'); } else { fail(`alias: code=${r.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 4. Non-canonical state rejected ───────────────────────────── { const sb = makeSandbox(TRACKER_9); const before = readTracker(sb); const r = runSetStatus(['2', 'Ghosted'], sb); if (r.code === 1 && readTracker(sb) === before && /Evaluated/.test(r.stderr) && /SKIP/.test(r.stderr)) { pass('bad-state: exit 1, valid states listed, tracker untouched'); } else { fail(`bad-state: code=${r.code} (want 1)\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 5. Not found: number and company ──────────────────────────── { const sb = makeSandbox(TRACKER_9); const r1 = runSetStatus(['99', 'Applied'], sb); if (r1.code === 2) { pass('not-found: unknown report number exits 2'); } else { fail(`not-found num: code=${r1.code} (want 2)\n${r1.stdout}${r1.stderr}`); } const r2 = runSetStatus(['hooli', 'Applied'], sb); if (r2.code === 2) { pass('not-found: unknown company exits 2'); } else { fail(`not-found company: code=${r2.code} (want 2)\n${r2.stdout}${r2.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 6. Ambiguous company: candidates listed, --role disambiguates ─ { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['acme', 'Applied'], sb); if (r.code === 3 && r.stderr.includes('#1') && r.stderr.includes('#3') && r.stderr.includes('Backend Engineer') && r.stderr.includes('Data Engineer')) { pass('ambiguous: exit 3 with numbered candidate list'); } else { fail(`ambiguous: code=${r.code} (want 3)\n${r.stdout}${r.stderr}`); } const r2 = runSetStatus(['acme', 'Applied', '--role', 'Data Engineer'], sb); if (r2.code === 0 && /\| 3 \| 2026-06-03 \| Acme \| Data Engineer \| 3.9\/5 \| Applied \|/.test(readTracker(sb))) { pass('ambiguous: --role disambiguates to the right row'); } else { fail(`ambiguous --role: code=${r2.code}\n${r2.stdout}${r2.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 6b. Duplicate tracker #: bare number refuses to guess (#1704) ─ { const sb = makeSandbox(TRACKER_DUP_NUM); const before = readTracker(sb); const r = runSetStatus(['5', 'Rejected'], sb); if (r.code === 3 && readTracker(sb) === before && r.stderr.includes('University of Alberta') && r.stderr.includes('Esri Canada')) { pass('dup-num: bare #5 matching 2 rows exits 3, tracker untouched, both companies listed'); } else { fail(`dup-num: code=${r.code} (want 3)\n${r.stdout}${r.stderr}`); } // --role disambiguates exactly like the company-selector ambiguous path. const r2 = runSetStatus(['5', 'Rejected', '--role', 'Manager Talent and Organizational Development'], sb); if (r2.code === 0 && /\| 5 \| 2026-06-03 \| Esri Canada \|.*\| Rejected \|/.test(readTracker(sb))) { pass('dup-num: --role disambiguates to the right row'); } else { fail(`dup-num --role: code=${r2.code}\n${r2.stdout}${r2.stderr}`); } // The OTHER row (University of Alberta) must stay untouched by the --role // disambiguated write above. if (readTracker(sb).includes('| 5 | 2026-05-29 | University of Alberta | Curriculum Coordinator | 3.8/5 | Evaluated |')) { pass('dup-num: unrelated row with the same # untouched after disambiguation'); } else { fail(`dup-num: unrelated row was modified\n${readTracker(sb)}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 6c. Duplicate tracker # + --json: structured candidates ───── { const sb = makeSandbox(TRACKER_DUP_NUM); const r = runSetStatus(['5', 'Rejected', '--json'], sb); let parsed = null; try { parsed = JSON.parse(r.stdout || r.stderr); } catch {} if (r.code === 3 && parsed && parsed.code === 'ambiguous' && Array.isArray(parsed.candidates) && parsed.candidates.length === 2) { pass('dup-num json: structured ambiguous error with 2 candidates'); } else { fail(`dup-num json: code=${r.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 7. Note append: idempotent, separator, pipe-sanitized ─────── { const sb = makeSandbox(TRACKER_9); runSetStatus(['1', 'Applied', '--note', 'sent via referral'], sb); const after1 = readTracker(sb); if (/\| strong infra fit; sent via referral \|/.test(after1)) { pass('note: appended to existing notes with "; "'); } else { fail(`note: append missing\n${after1}`); } // Retry with the identical note must not duplicate it. runSetStatus(['1', 'Applied', '--note', 'sent via referral'], sb); const after2 = readTracker(sb); if ((after2.match(/sent via referral/g) || []).length === 1) { pass('note: identical retry does not duplicate the note'); } else { fail('note: retry duplicated the note'); } // A note that itself contains "; " must also stay idempotent. runSetStatus(['3', 'Responded', '--note', 'Called; left voicemail'], sb); runSetStatus(['3', 'Responded', '--note', 'Called; left voicemail'], sb); if ((readTracker(sb).match(/left voicemail/g) || []).length === 1) { pass('note: retry with semicolon-bearing note does not duplicate'); } else { fail('note: semicolon-bearing note duplicated on retry'); } // Pipes/newlines in a note would corrupt the table — must be sanitized. runSetStatus(['2', 'Applied', '--note', 'weird | note'], sb); const after3 = readTracker(sb); if (!/weird \| note/.test(after3) && /weird \/ note/.test(after3)) { pass('note: literal pipe sanitized'); } else { fail('note: pipe not sanitized'); } // A literal newline would split the row into two lines and break the table: // the stored row must stay a single line with the newline collapsed. runSetStatus(['2', 'Applied', '--note', 'first line\nsecond line'], sb); const after4 = readTracker(sb); const row2 = after4.split('\n').filter(l => /^\| 2 \|/.test(l)); if (row2.length === 1 && row2[0].includes('first line second line')) { pass('note: literal newline sanitized to a single table row'); } else { fail(`note: newline broke the row\n${after4}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 7b. Note dedup is delimiter-aware, not substring ──────────── { const sb = makeSandbox(TRACKER_9); // Row 1 notes: "strong infra fit". A note that is a mere substring of an // existing entry is NOT a duplicate — only whole "; "-delimited entries // (or the entire field) count. const r = runSetStatus(['1', 'Applied', '--note', 'infra'], sb); if (r.code === 0 && /\| strong infra fit; infra \|/.test(readTracker(sb))) { pass('note-dedup: substring of an existing entry still appends'); } else { fail(`note-dedup: substring wrongly suppressed\n${readTracker(sb)}`); } // The exact same note re-added must still be suppressed. "infra" appears // twice after the append (inside "infra fit" + the new entry); a duplicate // append would make it three. runSetStatus(['1', 'Applied', '--note', 'infra'], sb); if ((readTracker(sb).match(/infra/g) || []).length === 2) { pass('note-dedup: exact retry still idempotent'); } else { fail(`note-dedup: exact retry duplicated\n${readTracker(sb)}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 8. No-op re-run: exit 0, file byte-identical ──────────────── { const sb = makeSandbox(TRACKER_9); runSetStatus(['2', 'Applied'], sb); const before = readTracker(sb); const r = runSetStatus(['2', 'Applied', '--json'], sb); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (r.code === 0 && readTracker(sb) === before && parsed && parsed.changed === false) { pass('no-op: same status again exits 0, changed:false, file untouched'); } else { fail(`no-op: code=${r.code} changed=${parsed?.changed}\n${r.stdout}${r.stderr}`); } // #1430 hook must fire only on a real transition into Applied — a re-run // must not invite the consumer to seed a duplicate follow-up. if (parsed && parsed.followupSeedCandidate === undefined) { pass('no-op: followupSeedCandidate absent on idempotent Applied re-run'); } else { fail(`no-op: followupSeedCandidate leaked on re-run\n${r.stdout}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 9. Dry-run: reports change, writes nothing ────────────────── { const sb = makeSandbox(TRACKER_9); const before = readTracker(sb); const r = runSetStatus(['2', 'Applied', '--dry-run', '--json'], sb); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (r.code === 0 && readTracker(sb) === before && parsed && parsed.changed === true && parsed.dryRun === true) { pass('dry-run: reports change without writing'); } else { fail(`dry-run: code=${r.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 10. JSON output shape + #1430 follow-up hook ──────────────── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['2', 'Applied', '--json'], sb); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (parsed && parsed.num === 2 && parsed.company === 'Globex' && parsed.oldStatus === 'Evaluated' && parsed.newStatus === 'Applied' && parsed.changed === true && parsed.followupSeedCandidate === true) { pass('json: full output shape + followupSeedCandidate on Applied'); } else { fail(`json: bad shape\n${r.stdout}${r.stderr}`); } const r2 = runSetStatus(['1', 'Rejected', '--json'], sb); let parsed2 = null; try { parsed2 = JSON.parse(r2.stdout); } catch {} if (parsed2 && parsed2.followupSeedCandidate === undefined) { pass('json: no followupSeedCandidate on non-Applied transitions'); } else { fail(`json: followupSeedCandidate leaked on Rejected\n${r2.stdout}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 11. Ambiguous + --json: machine-readable candidates ───────── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['acme', 'Applied', '--json'], sb); let parsed = null; try { parsed = JSON.parse(r.stdout || r.stderr); } catch {} if (r.code === 3 && parsed && parsed.code === 'ambiguous' && Array.isArray(parsed.candidates) && parsed.candidates.length === 2 && parsed.candidates[0].num === 1) { pass('json ambiguous: error object with candidates array'); } else { fail(`json ambiguous: code=${r.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 12. 10-column Location layout ─────────────────────────────── { const sb = makeSandbox(TRACKER_10); const r = runSetStatus(['1', 'Interview', '--note', 'onsite loop scheduled'], sb); const content = readTracker(sb); if (r.code === 0 && /\| Initech \| AI Engineer \| Remote \| 4.5\/5 \| Interview \|/.test(content) && /onsite loop scheduled/.test(content)) { pass('location-layout: 10-col tracker updates the right columns'); } else { fail(`location-layout: code=${r.code}\n${content}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 13. Usage errors ──────────────────────────────────────────── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus([], sb); if (r.code === 1 && /Usage/i.test(r.stderr + r.stdout)) { pass('usage: no args exits 1 with usage text'); } else { fail(`usage: code=${r.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 13b. --note/--role must not eat a following flag as their value ─ { const sb = makeSandbox(TRACKER_9); const before = readTracker(sb); // "--note --dry-run" once consumed "--dry-run" as the note text — silently // disabling dry-run and turning a preview into a real write. It must be a // usage error, and nothing may be written. const r = runSetStatus(['2', 'Applied', '--note', '--dry-run'], sb); if (r.code === 1 && readTracker(sb) === before) { pass('flag-eating: --note followed by a flag exits 1 without writing'); } else { fail(`flag-eating: code=${r.code} (want 1) written=${readTracker(sb) !== before}\n${r.stdout}${r.stderr}`); } // Missing value at the end of argv is the same usage error. const r2 = runSetStatus(['2', 'Applied', '--role'], sb); if (r2.code === 1 && /--role/.test(r2.stderr) && readTracker(sb) === before) { pass('flag-eating: trailing --role without value exits 1'); } else { fail(`flag-eating trailing: code=${r2.code}\n${r2.stdout}${r2.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 13c. Usage errors honor --json with a structured payload ──── { const sb = makeSandbox(TRACKER_9); const r = runSetStatus(['--json'], sb); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (r.code === 1 && parsed && parsed.code === 'usage' && typeof parsed.error === 'string') { pass('json usage: structured usage-error payload on stdout'); } else { fail(`json usage: code=${r.code} stdout=${r.stdout}\n${r.stderr}`); } // failUsage can fire mid-parse ("--note --json" fails before --json is // reached), so JSON mode must be detected from the raw argv. const r2 = runSetStatus(['2', 'Applied', '--note', '--json'], sb); let parsed2 = null; try { parsed2 = JSON.parse(r2.stdout); } catch {} if (r2.code === 1 && parsed2 && parsed2.code === 'usage') { pass('json usage: --json detected even when parsing fails mid-argv'); } else { fail(`json usage mid-parse: code=${r2.code} stdout=${r2.stdout}\n${r2.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 14. Lock timeout: structured exit 4, JSON error, no write ── { const sb = makeSandbox(TRACKER_9); const before = readTracker(sb); // A live-owner lock (our own PID) can never be recovered, so the CLI must // time out and fail through the structured error path instead of throwing. mkdirSync(sb.lock, { recursive: true }); writeFileSync(join(sb.lock, 'owner.json'), JSON.stringify({ pid: process.pid, token: 'test', tracker: sb.tracker })); const r = runSetStatus(['2', 'Applied', '--json'], sb, { CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS: '300' }); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (r.code === 4 && parsed && parsed.code === 'lock-timeout' && readTracker(sb) === before) { pass('lock-timeout: exit 4 with structured JSON error, tracker untouched'); } else { fail(`lock-timeout: code=${r.code} (want 4)\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 14b. Non-timeout lock failure is NOT reported as lock-timeout ─ { const sb = makeSandbox(TRACKER_9); // Point the lock at a path whose parent is a regular FILE (inside tmpdir, // with the required prefix, so trackerLockDirFor accepts it). mkdirSync // then fails with ENOTDIR/ENOENT — a config error, not a busy lock — and // must map to exit 1 / lock-error, keeping exit 4 reserved for retryable // timeouts. const blocker = join(sb.dir, 'career-ops-merge-tracker-blocker'); writeFileSync(blocker, 'not a directory'); const badLock = join(blocker, 'career-ops-merge-tracker-bad.lock'); const r = runSetStatus(['2', 'Applied', '--json'], { ...sb, lock: badLock }); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (r.code === 1 && parsed && parsed.code === 'lock-error') { pass('lock-error: filesystem lock failure exits 1, not lock-timeout'); } else { fail(`lock-error: code=${r.code} (want 1) json=${parsed?.code}\n${r.stdout}${r.stderr}`); } rmSync(sb.dir, { recursive: true, force: true }); } // ── 15. Orphaned recovery guard does not block stale-lock recovery ─ { const dir = mkdtempSync(join(tmpdir(), 'co-setstatus-guard-')); const lockDir = join(dir, 'career-ops-merge-tracker-guardtest.lock'); // Stale lock: dead owner PID → recoverable. mkdirSync(lockDir, { recursive: true }); writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: 999999999, token: 'dead', tracker: 'x' })); // Orphaned guard left behind by a killed process (no owner.json → judged by age). mkdirSync(`${lockDir}.recover`, { recursive: true }); await new Promise(r => setTimeout(r, 150)); try { const lock = await acquireTrackerLock(lockDir, { timeoutMs: 3000, retryMs: 25, staleMs: 50, tracker: 'x' }); if (lock.staleRecovered) { pass('recover-guard: orphaned guard is aged out and stale lock still recovers'); } else { fail('recover-guard: lock acquired but stale recovery did not run'); } lock.release(); } catch (e) { fail(`recover-guard: acquisition failed — orphaned guard blocked recovery (${e.message})`); } rmSync(dir, { recursive: true, force: true }); } // ── 16. Write failure surfaces as a structured error, not a stack ─ { if (process.platform !== 'win32' && process.getuid?.() === 0) { pass('write-failure: skipped (running as root — directory permissions are not enforced)'); } else { const dir = mkdtempSync(join(tmpdir(), 'co-setstatus-wf-')); const roDir = join(dir, 'ro'); mkdirSync(roDir); const tracker = join(roDir, 'applications.md'); writeFileSync(tracker, TRACKER_9); const lock = join(dir, 'career-ops-merge-tracker-wf.lock'); // Make the tracker's directory readable but unwritable, so the atomic // temp-file write fails after a successful read. On Windows, directory // read-only bits don't block file creation — deny write-data/append-data // for Everyone (*S-1-1-0) via icacls instead. const denyWrite = () => process.platform === 'win32' ? execFileSync('icacls', [roDir, '/deny', '*S-1-1-0:(WD,AD)']) : chmodSync(roDir, 0o555); const restore = () => process.platform === 'win32' ? execFileSync('icacls', [roDir, '/remove:d', '*S-1-1-0']) : chmodSync(roDir, 0o755); denyWrite(); try { const r = runSetStatus(['2', 'Applied', '--json'], { tracker, lock }); let parsed = null; try { parsed = JSON.parse(r.stdout); } catch {} if (r.code === 1 && parsed && parsed.code === 'write-failure') { pass('write-failure: structured JSON error instead of a raw stack'); } else { fail(`write-failure: code=${r.code} json=${parsed?.code}\n${r.stdout}${r.stderr}`); } } finally { restore(); rmSync(dir, { recursive: true, force: true }); } } } console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);