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>) { 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"); const fixtureFontBytes = Buffer.from("dada-test-font-package"); const fixtureFontSha256 = createHash("sha256").update(fixtureFontBytes).digest("hex").toUpperCase(); 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") }, }); mkdirSync(join(textRoot, "flower", "templates", "FLOWER001", "fonts"), { recursive: true }); writeFileSync(join(textRoot, "flower", "templates", "FLOWER001", "fonts", "original.ztf"), fixtureFontBytes); 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: fixtureFontSha256, 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: fixtureFontSha256, 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>; 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("safely relocates legacy absolute font package paths after an archive move", () => { const fixture = createFixture(); const catalogPath = join(fixture.sourceRoot, "fonts", "reports", "font_panel_catalog.csv"); const metadata = JSON.parse(readFileSync(join(fixture.sourceRoot, "fonts", "resources", "font_packages", "FONT001_Test", "metadata.json"), "utf8")) as { local_sha256: string }; csv(catalogPath, [{ candidate_id: "FONT001", display_name: "Test Font", font_family: "Dada Test", local_sha256: metadata.local_sha256, panel_order: "1", resource_dir: "C:/Users/legacy/Desktop/sticker_text/fonts/resources/font_packages/FONT001_Test", resource_status: "verified_extracted", }]); expect(() => compileAssetArchive({ manifestPath: fixture.manifestPath, outputDirectory: fixture.outputRoot, releaseVersion: "fixture-v1", })).not.toThrow(); }); 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); }); });