#!/usr/bin/env node // Vendors testdata/ from the Go reference implementation (g.activething.com/go/DateKeys) // at one pinned commit and verifies it. The Go fixtures and vectors are the source of // truth: this project never generates its own. // // node scripts/sync-testdata.mjs sync [--repo ../datekeys-go] [--commit HEAD] // node scripts/sync-testdata.mjs check [--against ../datekeys-go] // // sync copies every blob under testdata/ at the commit (read with git, so uncommitted // changes in the Go checkout are never picked up) and writes testdata/SOURCE.json with // the full commit id and the SHA-256 of each file. check verifies that the files on disk // match SOURCE.json exactly, with no missing or extra files; with --against it also // re-reads every blob from the Go repository at the recorded commit. // No dependencies: Node and git only. import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const MODULE = 'g.activething.com/go/DateKeys'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const TESTDATA = join(ROOT, 'testdata'); const SOURCE = join(TESTDATA, 'SOURCE.json'); const DEFAULT_REPO = resolve(ROOT, '..', 'datekeys-go'); function git(repo, args) { return execFileSync('git', ['-C', repo, ...args], { encoding: 'buffer', maxBuffer: 1 << 30 }); } function sha256(buf) { return createHash('sha256').update(buf).digest('hex'); } // Files under testdata/ at commit, as paths relative to testdata/ with forward slashes. function listBlobs(repo, commit) { const out = git(repo, ['ls-tree', '-r', '-z', '--full-tree', commit, '--', 'testdata']).toString('utf8'); return out.split('\0').filter(Boolean).map(line => { const [meta, path] = line.split('\t'); const [mode, type] = meta.split(' '); if (type !== 'blob' || mode === '120000') throw new Error(`unsupported entry ${path} (${mode} ${type})`); return path.slice('testdata/'.length); }).sort(); } function readBlob(repo, commit, path) { return git(repo, ['cat-file', 'blob', `${commit}:testdata/${path}`]); } // Files on disk under testdata/, excluding SOURCE.json. function listLocal(dir = TESTDATA) { let files = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) files = files.concat(listLocal(full)); else if (full !== SOURCE) files.push(relative(TESTDATA, full).split(sep).join('/')); } return files.sort(); } function sync(repo, rev) { const commit = git(repo, ['rev-parse', '--verify', `${rev}^{commit}`]).toString('utf8').trim(); const paths = listBlobs(repo, commit); if (paths.length === 0) throw new Error(`no testdata/ at ${commit}`); // Read everything before touching the local copy, so a failed read leaves it intact. const blobs = paths.map(path => [path, readBlob(repo, commit, path)]); rmSync(TESTDATA, { recursive: true, force: true }); const files = {}; for (const [path, data] of blobs) { const target = join(TESTDATA, ...path.split('/')); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, data); files[path] = sha256(data); } writeFileSync(SOURCE, JSON.stringify({ module: MODULE, commit, files }, null, 2) + '\n'); console.log(`testdata: ${paths.length} files from ${MODULE} at ${commit}`); } function check(against) { const source = JSON.parse(readFileSync(SOURCE, 'utf8')); const expected = Object.keys(source.files).sort(); const actual = listLocal(); const errors = []; for (const path of expected) { if (!actual.includes(path)) { errors.push(`missing ${path}`); continue; } if (sha256(readFileSync(join(TESTDATA, ...path.split('/')))) !== source.files[path]) errors.push(`modified ${path}`); } for (const path of actual) if (!(path in source.files)) errors.push(`extra ${path}`); if (against) { const upstream = listBlobs(against, source.commit); if (upstream.join('\n') !== expected.join('\n')) errors.push(`file list differs from ${source.commit}`); for (const path of upstream) { if (sha256(readBlob(against, source.commit, path)) !== source.files[path]) errors.push(`differs from upstream ${path}`); } } if (errors.length) { for (const e of errors) console.error(`testdata: ${e}`); process.exit(1); } console.log(`testdata: ${expected.length} files match ${source.module} at ${source.commit}${against ? ' (verified against the repository)' : ''}`); } function option(args, name, fallback) { const i = args.indexOf(name); if (i === -1) return fallback; if (i + 1 >= args.length) throw new Error(`${name} needs a value`); return args[i + 1]; } const [command, ...args] = process.argv.slice(2); try { if (command === 'sync') sync(resolve(option(args, '--repo', DEFAULT_REPO)), option(args, '--commit', 'HEAD')); else if (command === 'check') { const against = option(args, '--against', null); check(against && resolve(against)); } else { console.error('usage: sync-testdata.mjs sync [--repo PATH] [--commit REV] | check [--against PATH]'); process.exit(2); } } catch (err) { console.error(`testdata: ${err.message}`); process.exit(1); }