You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
125 lines
5.3 KiB
125 lines
5.3 KiB
#!/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);
|
|
}
|