paperclip/scripts/storybook-visual-baseline.mjs

348 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const defaultManifestPath = join(repoRoot, "tests", "storybook-visual", "baseline-manifest.json");
const manifestPath = resolvePath(
process.env.STORYBOOK_VISUAL_BASELINE_MANIFEST ?? defaultManifestPath,
);
const defaultCacheDir = join(repoRoot, "tests", "storybook-visual", ".cache");
const cacheDir = resolvePath(process.env.STORYBOOK_VISUAL_BASELINE_CACHE_DIR ?? defaultCacheDir);
const defaultSnapshotDir = join(repoRoot, "tests", "storybook-visual", ".snapshots");
const snapshotDir = resolvePath(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR ?? defaultSnapshotDir);
const command = process.argv[2];
const flags = parseFlags(process.argv.slice(3));
try {
if (command === "download") {
await download();
} else if (command === "verify") {
await verify();
} else if (command === "pack") {
await pack();
} else if (command === "upload") {
await upload();
} else {
usage();
process.exit(command ? 1 : 0);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
function resolvePath(path) {
return isAbsolute(path) ? path : resolve(repoRoot, path);
}
function parseFlags(args) {
const result = new Map();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument: ${arg}`);
}
const equalsIndex = arg.indexOf("=");
if (equalsIndex !== -1) {
result.set(arg.slice(2, equalsIndex), arg.slice(equalsIndex + 1));
continue;
}
const key = arg.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
result.set(key, "true");
} else {
result.set(key, next);
index += 1;
}
}
return result;
}
function usage() {
console.log(`Usage: node scripts/storybook-visual-baseline.mjs <command>
Commands:
download Fetch, checksum, and unpack the manifest archive into the snapshot dir.
verify Check the unpacked snapshot count and cached archive checksum.
pack Create a deterministic snapshots.tgz from the snapshot dir.
upload Upload a packed archive to S3 with immutable overwrite checks.
Environment:
STORYBOOK_VISUAL_BASELINE_MANIFEST Manifest path.
STORYBOOK_VISUAL_BASELINE_CACHE_DIR Cache path.
STORYBOOK_VISUAL_SNAPSHOT_DIR Playwright snapshot dir.
STORYBOOK_VISUAL_S3_URI s3://bucket/key target for upload.
STORYBOOK_VISUAL_PUBLIC_URL Public HTTPS URL to write into manifest instructions.
`);
}
function readManifest() {
if (!existsSync(manifestPath)) {
throw new Error(`Missing baseline manifest: ${manifestPath}`);
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (manifest.version !== 1) {
throw new Error(`Unsupported baseline manifest version: ${manifest.version}`);
}
if (!Number.isInteger(manifest.snapshotCount) || manifest.snapshotCount < 0) {
throw new Error("Manifest snapshotCount must be a non-negative integer.");
}
return manifest;
}
function archivePathFor(manifest) {
const hash = manifest.archive?.sha256;
return join(cacheDir, "archives", `${hash || "unconfigured"}-snapshots.tgz`);
}
async function download() {
const manifest = readManifest();
assertConfiguredArchive(manifest);
mkdirSync(dirname(archivePathFor(manifest)), { recursive: true });
const archivePath = archivePathFor(manifest);
if (!existsSync(archivePath) || sha256File(archivePath) !== manifest.archive.sha256) {
await fetchArchive(manifest.archive.url, archivePath);
}
verifyArchiveFile(manifest, archivePath);
rmSync(snapshotDir, { recursive: true, force: true });
mkdirSync(snapshotDir, { recursive: true });
run("tar", ["-xzf", archivePath, "-C", snapshotDir], "unpack baseline archive");
verifySnapshotCount(manifest, snapshotDir);
console.log(`Downloaded ${manifest.baselineId} to ${relative(repoRoot, snapshotDir)}`);
}
async function verify() {
const manifest = readManifest();
assertConfiguredArchive(manifest);
const archivePath = archivePathFor(manifest);
if (!existsSync(archivePath)) {
throw new Error(
`Missing cached archive ${archivePath}. Run \`pnpm storybook-visual:baseline download\` first.`,
);
}
verifyArchiveFile(manifest, archivePath);
verifySnapshotCount(manifest, snapshotDir);
console.log(
`Verified ${manifest.snapshotCount} snapshots for ${manifest.baselineId} in ${relative(
repoRoot,
snapshotDir,
)}`,
);
}
async function pack() {
const sourceDir = resolvePath(flags.get("source") ?? snapshotDir);
if (!existsSync(sourceDir)) {
throw new Error(`Snapshot source does not exist: ${sourceDir}`);
}
const count = countPngFiles(sourceDir);
if (count === 0) {
throw new Error(`No PNG snapshots found in ${sourceDir}`);
}
const out = resolvePath(
flags.get("out") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"),
);
mkdirSync(dirname(out), { recursive: true });
const tempDir = mkdtempSync(join(tmpdir(), "storybook-visual-pack-"));
const tempArchive = join(tempDir, "snapshots.tgz");
try {
run(
"tar",
[
"--sort=name",
"--mtime=@0",
"--owner=0",
"--group=0",
"--numeric-owner",
"--use-compress-program=gzip -n",
"-cf",
tempArchive,
"-C",
sourceDir,
".",
],
"pack deterministic baseline archive",
);
rmSync(out, { force: true });
run("cp", [tempArchive, out], "write packed archive");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
const sha256 = sha256File(out);
const byteSize = statSync(out).size;
const publicUrl = flags.get("public-url") ?? process.env.STORYBOOK_VISUAL_PUBLIC_URL ?? "";
const objectKey = `baselines/storybook-visual/${sha256}/snapshots.tgz`;
console.log(`Packed ${count} PNG snapshots into ${relative(repoRoot, out)}`);
console.log("");
console.log("Manifest archive update:");
console.log(
JSON.stringify(
{
snapshotCount: count,
archive: {
url: publicUrl || `https://<cloudfront-host>/${objectKey}`,
sha256,
byteSize,
objectKey,
},
},
null,
2,
),
);
}
async function upload() {
const archive = resolvePath(flags.get("archive") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"));
const s3Uri = flags.get("s3-uri") ?? process.env.STORYBOOK_VISUAL_S3_URI;
if (!s3Uri) {
throw new Error("Missing --s3-uri or STORYBOOK_VISUAL_S3_URI for upload.");
}
if (!s3Uri.startsWith("s3://")) {
throw new Error(`Upload target must be an s3:// URI: ${s3Uri}`);
}
if (!existsSync(archive)) {
throw new Error(`Archive does not exist: ${archive}`);
}
const sha256 = sha256File(archive);
const { bucket, key } = parseS3Uri(s3Uri);
const head = spawnSync(
"aws",
["s3api", "head-object", "--bucket", bucket, "--key", key, "--output", "json"],
{ encoding: "utf8" },
);
if (head.status === 0) {
const metadata = JSON.parse(head.stdout || "{}").Metadata ?? {};
if (metadata.sha256 === sha256) {
console.log(`Archive already exists at ${s3Uri} with matching sha256 ${sha256}.`);
return;
}
throw new Error(`Refusing to overwrite existing S3 object with different sha256: ${s3Uri}`);
}
run(
"aws",
[
"s3",
"cp",
archive,
s3Uri,
"--metadata",
`sha256=${sha256}`,
"--cache-control",
"public, max-age=31536000, immutable",
"--content-type",
"application/gzip",
],
"upload baseline archive",
);
console.log(`Uploaded ${basename(archive)} to ${s3Uri}`);
}
function assertConfiguredArchive(manifest) {
const archive = manifest.archive ?? {};
if (!archive.url || !archive.sha256 || !archive.byteSize) {
throw new Error(
`Baseline manifest ${relative(
repoRoot,
manifestPath,
)} does not point at a published archive yet. Run \`pnpm storybook-visual:baseline pack\`, upload the immutable archive, then update the manifest archive url/sha256/byteSize/snapshotCount.`,
);
}
}
async function fetchArchive(url, destination) {
if (url.startsWith("file://")) {
await pipeline(createReadStream(fileURLToPath(url)), createWriteStream(destination));
return;
}
if (!url.startsWith("https://") && !url.startsWith("http://")) {
throw new Error(`Unsupported archive URL: ${url}`);
}
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download baseline archive: ${response.status} ${response.statusText}`);
}
await pipeline(response.body, createWriteStream(destination));
}
function verifyArchiveFile(manifest, archivePath) {
const actualSha = sha256File(archivePath);
if (actualSha !== manifest.archive.sha256) {
throw new Error(
`Baseline checksum mismatch: expected ${manifest.archive.sha256}, got ${actualSha}`,
);
}
const actualSize = statSync(archivePath).size;
if (actualSize !== manifest.archive.byteSize) {
throw new Error(
`Baseline byte size mismatch: expected ${manifest.archive.byteSize}, got ${actualSize}`,
);
}
}
function verifySnapshotCount(manifest, dir) {
const count = countPngFiles(dir);
if (count !== manifest.snapshotCount) {
throw new Error(
`Baseline snapshot count mismatch: expected ${manifest.snapshotCount}, got ${count} in ${dir}`,
);
}
}
function sha256File(path) {
const hash = createHash("sha256");
hash.update(readFileSync(path));
return hash.digest("hex");
}
function countPngFiles(dir) {
if (!existsSync(dir)) return 0;
let count = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
count += countPngFiles(path);
} else if (entry.isFile() && entry.name.endsWith(".png")) {
count += 1;
}
}
return count;
}
function parseS3Uri(uri) {
const withoutScheme = uri.slice("s3://".length);
const slash = withoutScheme.indexOf("/");
if (slash === -1) throw new Error(`S3 URI must include a key: ${uri}`);
return { bucket: withoutScheme.slice(0, slash), key: withoutScheme.slice(slash + 1) };
}
function run(cmd, args, label) {
const result = spawnSync(cmd, args, {
cwd: repoRoot,
stdio: "inherit",
env: { ...process.env, COPYFILE_DISABLE: "1" },
});
if (result.status !== 0) {
throw new Error(`Failed to ${label}.`);
}
}