#!/usr/bin/env node /** * seed-fixture.mjs — materialize a realistic user-data fixture into an install. * * Fixture states live in test-fixtures/upgrade// and mirror the * user-layer files a real install of that era contains. Returns a SHA-256 * manifest so callers can assert byte-identity later. * * Usage: * node seed-fixture.mjs [--state state-v1.18] * node seed-fixture.mjs --self-test */ import { createHash } from 'crypto'; import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, existsSync, mkdirSync } from 'fs'; import { tmpdir } from 'os'; import { join, dirname, relative, sep } from 'path'; import { fileURLToPath } from 'url'; import { isMainModule } from './lib/is-main-module.mjs'; import { walkTree, isNestedCheckout } from './lib/walk-tree.mjs'; const ROOT = dirname(fileURLToPath(import.meta.url)); const FIXTURES = join(ROOT, 'test-fixtures', 'upgrade'); export const DEFAULT_STATE = 'state-v1.18'; function walk(dir, base = dir) { // walkTree sorts every level, so files[] and the manifest key order are // deterministic across platforms (readdir returns filesystem order, which // varies). `links: 'follow'` keeps the statSync semantics this had. return walkTree(dir, { links: 'follow' }) .map((p) => relative(base, p).split(sep).join('/')); } export function listStates() { if (!existsSync(FIXTURES)) return []; // Sort for deterministic state ordering across platforms (readdirSync // returns filesystem order, which varies). // // A nested checkout is filtered here rather than by walkTree, because these // entries are not walked — each becomes a walk ROOT, which walkTree // deliberately never tests (a gate run from inside a worktree must check that // worktree). A stray clone or worktree parked among the fixture states would // otherwise become an allowlisted `--state` value pointing at a whole second // repository (#3762). return readdirSync(FIXTURES).sort() .filter((n) => statSync(join(FIXTURES, n)).isDirectory() && !isNestedCheckout(join(FIXTURES, n))); } // Strict allowlist: a state must be one of the real fixture subdirectories. // Membership in listStates() (basenames only) inherently rejects both unknown // states and traversal strings ('../x', 'state/../..'), so no join() ever // escapes FIXTURES. function assertKnownState(state) { const states = listStates(); if (!states.includes(state)) { throw new Error(`Unknown fixture state: ${state} (have: ${states.join(', ') || 'none'})`); } } export function seedFixture(targetDir, { state = DEFAULT_STATE } = {}) { if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) throw new Error(`targetDir is not an existing directory: ${targetDir}`); assertKnownState(state); const src = join(FIXTURES, state); // expected.json is harness metadata, not user data — never seed it. const files = walk(src).filter((f) => f !== 'expected.json'); const manifest = {}; for (const f of files) { const dest = join(targetDir, f); mkdirSync(dirname(dest), { recursive: true }); cpSync(join(src, f), dest); manifest[f] = createHash('sha256').update(readFileSync(dest)).digest('hex'); } return { state, files, manifest }; } export function loadExpectations(state = DEFAULT_STATE) { assertKnownState(state); return JSON.parse(readFileSync(join(FIXTURES, state, 'expected.json'), 'utf-8')); } function selfTest() { let failed = 0; const check = (ok, msg) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) failed++; }; const states = listStates(); // Guard against a vacuous pass: zero fixture states would run zero checks and // still print "green". listStates() legitimately returns [] for discovery, so // the assertion lives here in the test, not in listStates() itself. check(states.length > 0, `discovered at least one fixture state (found ${states.length})`); for (const state of states) { const dir = mkdtempSync(join(tmpdir(), 'seed-fixture-')); try { const { files, manifest } = seedFixture(dir, { state }); check(files.length >= 8, `${state}: seeds ${files.length} files (>=8)`); check(!files.includes('expected.json'), `${state}: expected.json not seeded`); const REQUIRED = ['cv.md', 'config/profile.yml', 'modes/_profile.md', 'portals.yml', 'data/applications.md']; for (const r of REQUIRED) check(files.includes(r), `${state}: required file seeded: ${r}`); const again = seedFixture(dir, { state }); check(JSON.stringify(again.manifest) === JSON.stringify(manifest), `${state}: manifest is deterministic`); const exp = loadExpectations(state); check(Number.isInteger(exp.tracker_rows) && exp.tracker_rows > 0, `${state}: expected.json parses with tracker_rows`); const tracker = readFileSync(join(dir, 'data/applications.md'), 'utf-8'); const rows = tracker.split('\n').filter((l) => /^\|\s*\d+\s*\|/.test(l)).length; check(rows === exp.tracker_rows, `${state}: tracker has ${rows} rows == expected ${exp.tracker_rows}`); } finally { rmSync(dir, { recursive: true, force: true }); } } console.log(failed ? `${failed} check(s) failed` : 'seed-fixture self-test green'); process.exit(failed ? 1 : 0); } if (isMainModule(import.meta.url)) { const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { console.log(`Usage: node seed-fixture.mjs [--state name] node seed-fixture.mjs --self-test node seed-fixture.mjs --help Options: --state Fixture state to materialize (default: state-v1.18) --self-test Run internal fixture integrity checks --help, -h Print this usage block and exit`); process.exit(0); } if (args[0] === '--self-test') selfTest(); else if (args[0] && !args[0].startsWith('--')) { const stateIdx = args.indexOf('--state'); if (stateIdx > -1 && (args[stateIdx + 1] === undefined || args[stateIdx + 1].startsWith('--'))) { console.error('Missing value for --state'); process.exit(1); } try { const res = seedFixture(args[0], stateIdx > -1 ? { state: args[stateIdx + 1] } : {}); console.log(JSON.stringify(res, null, 2)); } catch (err) { // Concise, actionable message — never leak a raw stack trace to the CLI. console.error(`seed-fixture: ${err.message}`); process.exit(1); } } else { console.error('Usage: node seed-fixture.mjs [--state name] | --self-test'); process.exit(1); } }