206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { compileAssetArchive } from "../../packages/asset-compiler/src/index.js";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
function json(path: string, value: unknown) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function csv(path: string, rows: Array<Record<string, string>>) {
|
|
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
const encode = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${headers.map(encode).join(",")}\n${rows.map((row) => headers.map((header) => encode(row[header] ?? "")).join(",")).join("\n")}\n`);
|
|
}
|
|
|
|
function hash(path: string) {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
|
|
}
|
|
|
|
function filesBelow(root: string): string[] {
|
|
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(root, entry.name);
|
|
return entry.isDirectory() ? filesBelow(path) : [path];
|
|
});
|
|
}
|
|
|
|
function snapshot(roots: string[]) {
|
|
return roots.flatMap((root) => filesBelow(root).map((path) => {
|
|
const stats = statSync(path);
|
|
return { path: `${basename(root)}/${relative(root, path).replaceAll("\\", "/")}`, sha256: hash(path), size: stats.size, mtime_ms: stats.mtimeMs };
|
|
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
}
|
|
|
|
interface Fixture {
|
|
evidenceTrap: string;
|
|
handoffRoot: string;
|
|
manifestPath: string;
|
|
outputRoot: string;
|
|
root: string;
|
|
sourceRoot: string;
|
|
}
|
|
|
|
function createFixture(): Fixture {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp5-01-"));
|
|
temporaryRoots.push(root);
|
|
const handoffRoot = join(root, "handoff");
|
|
const sourceRoot = join(root, "sources");
|
|
const outputRoot = join(root, "output");
|
|
const evidenceTrap = join(root, "evidence", "executed.txt");
|
|
mkdirSync(handoffRoot, { recursive: true });
|
|
writeFileSync(join(handoffRoot, "HANDOFF.md"), "fixture handoff\n");
|
|
json(join(handoffRoot, "validation.json"), { all_valid: true, schema_version: 1 });
|
|
|
|
const textRoot = join(sourceRoot, "text");
|
|
csv(join(textRoot, "flower", "catalog.csv"), [{
|
|
canonical_dir: "templates/FLOWER001", canonical_id: "FLOWER001", category: "flower", default_text: "春日,计划",
|
|
display_name: "春日,计划", family: "text_template", font_paths: "fonts/original.ztf", preview_path: "preview.png",
|
|
resource_class: "zip_template",
|
|
}]);
|
|
json(join(textRoot, "flower", "templates", "FLOWER001", "metadata.json"), {
|
|
canonical_id: "FLOWER001", category: "flower", default_text: "春日,计划", display_name: "春日,计划",
|
|
dynamic_keys: [], family: "text_template",
|
|
files: { fonts: ["fonts/original.ztf"], layer: "layer.pb", package: "package", preview: "preview.png" },
|
|
resource_class: "zip_template", runtime: { editable_text: true }, schema_version: 1,
|
|
source: { archive: join(root, "evidence", "historical") },
|
|
});
|
|
writeFileSync(join(textRoot, "unreferenced-source.bin"), Buffer.alloc(128 * 1024, 7));
|
|
|
|
const fontRoot = join(sourceRoot, "fonts");
|
|
const fontDirectory = join(fontRoot, "resources", "font_packages", "FONT001_Test");
|
|
csv(join(fontRoot, "reports", "font_panel_catalog.csv"), [{
|
|
candidate_id: "FONT001", display_name: "Test Font", font_family: "Dada Test", local_sha256: "A".repeat(64),
|
|
panel_order: "1", resource_dir: fontDirectory, resource_status: "verified_extracted",
|
|
}]);
|
|
json(join(fontDirectory, "metadata.json"), {
|
|
candidate_id: "FONT001", display_name: "Test Font", font_family: "Dada Test", font_file_count: 1,
|
|
local_sha256: "A".repeat(64), panel_order: 1, resource_status: "verified_extracted",
|
|
});
|
|
|
|
const colorRoot = join(sourceRoot, "colors");
|
|
csv(join(colorRoot, "catalog.csv"), [{
|
|
canonical_dir: "styles/COLOR001", canonical_id: "COLOR001", category: "color", default_text: "",
|
|
display_name: "style_01", family: "color_card", preview_path: "preview.png", resource_class: "parameter_renderer",
|
|
}]);
|
|
json(join(colorRoot, "styles", "COLOR001", "metadata.json"), {
|
|
canonical_id: "COLOR001", category: "color", default_text: "", display_name: "style_01", dynamic_keys: [],
|
|
family: "color_card", files: { preview: "preview.png", renderer_source: "renderer_source.py" },
|
|
resource_class: "parameter_renderer", runtime: { stable_style_id: "style_01" }, schema_version: 1,
|
|
});
|
|
|
|
const dynamicRoot = join(sourceRoot, "dynamic");
|
|
csv(join(dynamicRoot, "catalog.csv"), [{
|
|
canonical_dir: "templates/DYN001", canonical_id: "DYN001", category: "location", default_text: "",
|
|
display_name: "location", dynamic_keys: "title", family: "interactive_sticker", resource_class: "dynamic_resource",
|
|
}]);
|
|
const dynamicDirectory = join(dynamicRoot, "templates", "DYN001");
|
|
json(join(dynamicDirectory, "metadata.json"), {
|
|
canonical_id: "DYN001", category: "location", default_text: "", display_name: "location", dynamic_keys: ["title"],
|
|
family: "interactive_sticker", files: { lua: ["resource/trap.lua"], prefab: ["resource/trap.prefab"] },
|
|
resource_class: "dynamic_resource", runtime: { has_dynamic_binding: true }, schema_version: 1,
|
|
});
|
|
mkdirSync(join(dynamicDirectory, "resource"), { recursive: true });
|
|
writeFileSync(join(dynamicDirectory, "resource", "trap.lua"), `io.open([[${evidenceTrap}]], "w")`);
|
|
writeFileSync(join(dynamicDirectory, "resource", "trap.prefab"), "must remain conversion evidence only");
|
|
|
|
const manifestPath = join(handoffRoot, "sticker_web_catalog_manifest.json");
|
|
json(manifestPath, {
|
|
collections: [
|
|
{ catalogs: { flower: relative(textRoot, join(textRoot, "flower", "catalog.csv")) }, id: "text_templates", root: relative(handoffRoot, textRoot) },
|
|
{ catalog: relative(fontRoot, join(fontRoot, "reports", "font_panel_catalog.csv")), id: "font_panel", root: relative(handoffRoot, fontRoot) },
|
|
{ catalog: "catalog.csv", id: "color_cards", root: relative(handoffRoot, colorRoot) },
|
|
{ catalog: "catalog.csv", id: "interactive_stickers", root: relative(handoffRoot, dynamicRoot) },
|
|
],
|
|
schema_version: 1,
|
|
validation: "validation.json",
|
|
web_handoff: "HANDOFF.md",
|
|
});
|
|
return { evidenceTrap, handoffRoot, manifestPath, outputRoot, root, sourceRoot };
|
|
}
|
|
|
|
describe("TDD-WP5-MAN-001 readonly asset compiler", () => {
|
|
it("produces declarative, redacted, content-addressed output without changing or copying source", () => {
|
|
const fixture = createFixture();
|
|
const before = snapshot([fixture.handoffRoot, fixture.sourceRoot]);
|
|
const result = compileAssetArchive({ manifestPath: fixture.manifestPath, outputDirectory: fixture.outputRoot, releaseVersion: "fixture-v1" });
|
|
const after = snapshot([fixture.handoffRoot, fixture.sourceRoot]);
|
|
|
|
expect(after).toEqual(before);
|
|
expect(existsSync(fixture.evidenceTrap)).toBe(false);
|
|
expect(result.report).toMatchObject({ copied_source_files: 0, executed_source_files: 0, source_mutations: 0 });
|
|
|
|
const manifestText = readFileSync(join(fixture.outputRoot, "output-manifest.json"), "utf8");
|
|
const outputManifest = JSON.parse(manifestText) as { items: Array<Record<string, unknown>>; schema_version: string };
|
|
expect(outputManifest.schema_version).toBe("asset-release-compiler/v1");
|
|
expect(outputManifest.items.map((item) => item.canonical_id)).toEqual(["COLOR001", "DYN001", "FLOWER001", "FONT001"]);
|
|
expect(outputManifest.items.every((item) => item.release_status === "imported" && item.validation_status === "metadata_validated")).toBe(true);
|
|
|
|
const allOutput = filesBelow(fixture.outputRoot);
|
|
expect(allOutput.every((path) => path.endsWith(".json"))).toBe(true);
|
|
expect(allOutput.some((path) => path.endsWith(".lua") || path.endsWith(".prefab") || path.endsWith(".bin"))).toBe(false);
|
|
const serializedOutput = allOutput.map((path) => readFileSync(path, "utf8")).join("\n");
|
|
expect(serializedOutput).not.toContain(resolve(fixture.root));
|
|
expect(serializedOutput).not.toContain("file://");
|
|
expect(serializedOutput).not.toContain("historical");
|
|
expect(serializedOutput).not.toContain("unreferenced-source.bin");
|
|
|
|
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_ASSET_COMPILER;
|
|
if (evidenceDirectory) {
|
|
mkdirSync(evidenceDirectory, { recursive: true });
|
|
for (const file of ["compiler-report.json", "source-before.json", "source-after.json", "output-manifest.json"]) {
|
|
copyFileSync(join(fixture.outputRoot, file), join(evidenceDirectory, file));
|
|
}
|
|
}
|
|
});
|
|
|
|
it("is reproducible and reuses existing derived JSON by content hash", () => {
|
|
const fixture = createFixture();
|
|
const secondOutput = join(fixture.root, "second-output");
|
|
compileAssetArchive({ manifestPath: fixture.manifestPath, outputDirectory: fixture.outputRoot, releaseVersion: "fixture-v1" });
|
|
compileAssetArchive({ manifestPath: fixture.manifestPath, outputDirectory: secondOutput, releaseVersion: "fixture-v1" });
|
|
expect(readFileSync(join(fixture.outputRoot, "output-manifest.json"), "utf8")).toBe(readFileSync(join(secondOutput, "output-manifest.json"), "utf8"));
|
|
|
|
const repeated = compileAssetArchive({ manifestPath: fixture.manifestPath, outputDirectory: fixture.outputRoot, releaseVersion: "fixture-v1" });
|
|
expect(repeated.report.derived_files).toEqual({ created: 0, reused: 4, total: 4 });
|
|
});
|
|
|
|
it("rejects evidence collections, traversal and output inside a source root", () => {
|
|
const fixture = createFixture();
|
|
const manifest = JSON.parse(readFileSync(fixture.manifestPath, "utf8")) as { collections: unknown[] };
|
|
manifest.collections.push({ catalog: "../evidence/catalog.csv", id: "evidence", root: "../evidence" });
|
|
json(fixture.manifestPath, manifest);
|
|
expect(() => compileAssetArchive({ manifestPath: fixture.manifestPath, outputDirectory: fixture.outputRoot, releaseVersion: "fixture-v1" })).toThrow(/unsupported collection/i);
|
|
|
|
const clean = createFixture();
|
|
const catalogPath = join(clean.sourceRoot, "dynamic", "catalog.csv");
|
|
const row = { canonical_dir: "../../evidence", canonical_id: "DYN001", category: "location", default_text: "", display_name: "location", dynamic_keys: "title", family: "interactive_sticker", resource_class: "dynamic_resource" };
|
|
csv(catalogPath, [row]);
|
|
expect(() => compileAssetArchive({ manifestPath: clean.manifestPath, outputDirectory: clean.outputRoot, releaseVersion: "fixture-v1" })).toThrow(/unsafe relative path|outside.*source root/i);
|
|
|
|
const nested = createFixture();
|
|
expect(() => compileAssetArchive({ manifestPath: nested.manifestPath, outputDirectory: join(nested.sourceRoot, "text", "derived"), releaseVersion: "fixture-v1" })).toThrow(/output.*source root/i);
|
|
});
|
|
});
|