/*
RFC-0656: Unit tests for stable-mode normalizers (PDF, source map, JSON) and fingerprintTree stable mode.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-0656: initial stable normalizer test suite.
*/
import { test, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { fingerprintTree } from "../semantic.ts";
import { normalizePdf } from "../normalizers/pdf.ts";
import { normalizeSourceMap } from "../normalizers/sourcemap.ts";
import { normalizeJsonStable } from "../normalizers/json-stable.ts";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), "fingerprint-stable-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function createPdfWithMetadata(creationDate: Date, modDate: Date): Promise {
const pdf = await PDFDocument.create();
pdf.addPage([200, 300]);
pdf.setCreationDate(creationDate);
pdf.setModificationDate(modDate);
return pdf.save();
}
test("PDF: identical PDFs produce identical normalized bytes", async () => {
const pdf1 = await createPdfWithMetadata(
new Date("2026-01-01T00:00:00Z"),
new Date("2026-01-01T00:00:00Z"),
);
const pdf2 = await createPdfWithMetadata(
new Date("2026-01-01T00:00:00Z"),
new Date("2026-01-01T00:00:00Z"),
);
const norm1 = await normalizePdf(pdf1);
const norm2 = await normalizePdf(pdf2);
expect(Buffer.from(norm1).toString("hex")).toBe(Buffer.from(norm2).toString("hex"));
});
test("PDF: different timestamps produce identical normalized bytes", async () => {
const pdf1 = await createPdfWithMetadata(
new Date("2026-01-01T00:00:00Z"),
new Date("2026-01-01T00:00:00Z"),
);
const pdf2 = await createPdfWithMetadata(
new Date("2026-06-15T12:30:00Z"),
new Date("2026-06-15T12:30:00Z"),
);
const norm1 = await normalizePdf(pdf1);
const norm2 = await normalizePdf(pdf2);
expect(Buffer.from(norm1).toString("hex")).toBe(Buffer.from(norm2).toString("hex"));
});
test("PDF: normalized bytes differ from original (metadata stripped)", async () => {
const pdf = await createPdfWithMetadata(
new Date("2026-01-01T00:00:00Z"),
new Date("2026-01-01T00:00:00Z"),
);
const normalized = await normalizePdf(pdf);
expect(Buffer.from(normalized).toString("hex")).not.toBe(Buffer.from(pdf).toString("hex"));
});
test("Source map: absolute paths normalized to relative produce same hash", () => {
const map1 = JSON.stringify({
version: 3,
sources: ["/home/user/project/src/index.ts", "/home/user/project/src/helper.ts"],
sourceRoot: "/home/user/project/",
mappings: "AAAA",
names: ["foo", "bar"],
});
const map2 = JSON.stringify({
version: 3,
sources: ["/opt/build/project/src/index.ts", "/opt/build/project/src/helper.ts"],
sourceRoot: "/opt/build/project/",
mappings: "AAAA",
names: ["foo", "bar"],
});
const distRoot1 = "/home/user/project";
const distRoot2 = "/opt/build/project";
const hash1 = normalizeSourceMap(map1, distRoot1);
const hash2 = normalizeSourceMap(map2, distRoot2);
expect(hash1).toBe(hash2);
});
test("Source map: sourceRoot is stripped", () => {
const mapWithRoot = JSON.stringify({
version: 3,
sources: ["src/index.ts"],
sourceRoot: "/some/absolute/path/",
mappings: "AAAA",
});
const mapWithoutRoot = JSON.stringify({
version: 3,
sources: ["src/index.ts"],
mappings: "AAAA",
});
const hash1 = normalizeSourceMap(mapWithRoot, "/dummy");
const hash2 = normalizeSourceMap(mapWithoutRoot, "/dummy");
expect(hash1).toBe(hash2);
});
test("Source map: content changes alter hash", () => {
const map1 = JSON.stringify({ version: 3, sources: ["src/a.ts"], mappings: "AAAA" });
const map2 = JSON.stringify({ version: 3, sources: ["src/b.ts"], mappings: "AAAA" });
const hash1 = normalizeSourceMap(map1, "/dummy");
const hash2 = normalizeSourceMap(map2, "/dummy");
expect(hash1).not.toBe(hash2);
});
test("JSON stable: timestamp fields removed produce same hash", () => {
const json1 = JSON.stringify({ name: "test", createdAt: "2026-01-01T00:00:00Z", value: 42 });
const json2 = JSON.stringify({ name: "test", buildTimestamp: "2026-06-15T12:00:00Z", value: 42 });
const hash1 = normalizeJsonStable(json1);
const hash2 = normalizeJsonStable(json2);
expect(hash1).toBe(hash2);
});
test("JSON stable: key order does not affect hash", () => {
const json1 = JSON.stringify({ b: 2, a: 1, createdAt: "2026-01-01" });
const json2 = JSON.stringify({ a: 1, b: 2, generatedAt: "2026-06-15" });
const hash1 = normalizeJsonStable(json1);
const hash2 = normalizeJsonStable(json2);
expect(hash1).toBe(hash2);
});
test("JSON stable: content changes alter hash", () => {
const json1 = JSON.stringify({ name: "test", createdAt: "2026-01-01" });
const json2 = JSON.stringify({ name: "changed", createdAt: "2026-01-01" });
const hash1 = normalizeJsonStable(json1);
const hash2 = normalizeJsonStable(json2);
expect(hash1).not.toBe(hash2);
});
test("JSON stable: nested timestamp fields are stripped", () => {
const json1 = JSON.stringify({ outer: { inner: "value", createdAt: "2026-01-01" } });
const json2 = JSON.stringify({ outer: { inner: "value", buildTimestamp: "2026-06-15" } });
const hash1 = normalizeJsonStable(json1);
const hash2 = normalizeJsonStable(json2);
expect(hash1).toBe(hash2);
});
test("fingerprintTree stable: two builds with different timestamps produce identical distTreeHash", async () => {
const dir1 = path.join(tmpDir, "build1");
const dir2 = path.join(tmpDir, "build2");
await mkdir(dir1, { recursive: true });
await mkdir(dir2, { recursive: true });
const pdf1 = await createPdfWithMetadata(new Date("2026-01-01"), new Date("2026-01-01"));
const pdf2 = await createPdfWithMetadata(new Date("2026-06-15"), new Date("2026-06-15"));
await writeFile(path.join(dir1, "document.pdf"), pdf1);
await writeFile(path.join(dir2, "document.pdf"), pdf2);
await writeFile(
path.join(dir1, "index.js.map"),
JSON.stringify({
version: 3,
sources: ["../src/index.ts", "../src/helper.ts"],
mappings: "AAAA",
createdAt: "2026-01-01",
}),
);
await writeFile(
path.join(dir2, "index.js.map"),
JSON.stringify({
version: 3,
sources: ["../src/index.ts", "../src/helper.ts"],
mappings: "AAAA",
buildTimestamp: "2026-06-15",
}),
);
await writeFile(
path.join(dir1, "build-identity.json"),
JSON.stringify({ releaseId: "r001", createdAt: "2026-01-01T00:00:00Z" }),
);
await writeFile(
path.join(dir2, "build-identity.json"),
JSON.stringify({ releaseId: "r001", generatedAt: "2026-06-15T12:00:00Z" }),
);
await writeFile(path.join(dir1, "index.html"), "Hello");
await writeFile(path.join(dir2, "index.html"), "Hello");
const result1 = await fingerprintTree(dir1, { mode: "stable", root: dir1 });
const result2 = await fingerprintTree(dir2, { mode: "stable", root: dir2 });
expect(result1.value).toBe(result2.value);
expect(result1.mode).toBe("stable");
});
test("fingerprintTree stable: content changes alter distTreeHash", async () => {
const dir1 = path.join(tmpDir, "build1");
const dir2 = path.join(tmpDir, "build2");
await mkdir(dir1, { recursive: true });
await mkdir(dir2, { recursive: true });
await writeFile(path.join(dir1, "index.html"), "Hello");
await writeFile(path.join(dir2, "index.html"), "Goodbye");
const result1 = await fingerprintTree(dir1, { mode: "stable", root: dir1 });
const result2 = await fingerprintTree(dir2, { mode: "stable", root: dir2 });
expect(result1.value).not.toBe(result2.value);
});
test("fingerprintTree stable: empty directory produces deterministic hash", async () => {
const dir = path.join(tmpDir, "empty");
await mkdir(dir, { recursive: true });
const result1 = await fingerprintTree(dir, { mode: "stable", root: dir });
const result2 = await fingerprintTree(dir, { mode: "stable", root: dir });
expect(result1.value).toBe(result2.value);
expect(result1.files).toHaveLength(0);
});
test("fingerprintTree stable: corrupt PDF falls back to byte hash with warning", async () => {
const dir = path.join(tmpDir, "corrupt");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "bad.pdf"), Buffer.from("not a real pdf"));
const result = await fingerprintTree(dir, { mode: "stable", root: dir });
expect(result.warnings).toBeDefined();
expect(result.warnings!.length).toBeGreaterThan(0);
expect(result.files[0]!.mode).toBe("byte");
expect(result.files[0]!.normalizer).toBe("binary");
});