// @vitest-environment node import { execFileSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; // 실제 배포는 하지 않는다. worktree 내부 임시 폴더에 public 자산만 복사한 뒤 // 정상·누락·변조를 검사하여 빌드 검증기가 실패를 정말 감지하는지 먼저 확인한다. describe("production 자산 검증기", () => { it("101개 음성 포함을 확인하고 누락·변조는 실패시킨다", () => { mkdirSync(".cache", { recursive: true }); const temporary = mkdtempSync(".cache/build-test-"); const verify = () => execFileSync( "node", [ "--input-type=module", "-e", `import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`, ], { encoding: "utf8", stdio: "pipe" }, ); try { cpSync("public", temporary, { recursive: true }); expect(verify().trim()).toBe("101"); writeFileSync(join(temporary, "audio/intro.wav"), "broken"); expect(verify).toThrow(); rmSync(join(temporary, "audio/intro.wav")); expect(verify).toThrow(); } finally { rmSync(temporary, { recursive: true, force: true }); } }); it.each(["누락", "축약"])( "dist의 런타임 notice %s을 실패시킨다", (damage) => { mkdirSync(".cache", { recursive: true }); const temporary = mkdtempSync(".cache/notice-test-"); const verify = () => execFileSync( "node", [ "--input-type=module", "-e", `import {verifyBuild} from './scripts/check-build.mjs'; console.log(verifyBuild(${JSON.stringify(temporary)}));`, ], { encoding: "utf8", stdio: "pipe" }, ); try { cpSync("public", temporary, { recursive: true }); expect(verify().trim()).toBe("101"); const notice = join(temporary, "THIRD_PARTY_NOTICES.md"); if (damage === "누락") rmSync(notice, { force: true }); else writeFileSync(notice, "React / ReactDOM / scheduler — MIT License"); expect(verify).toThrow(/notice/i); } finally { rmSync(temporary, { recursive: true, force: true }); } }, ); }); // 새 배경 경로는 음성과 독립적이므로 실제 dist 사본의 누락/변조/과잉 번들을 검증한다. it.each(["wav", "manifest", "notice", "extra"])( "현장음 %s 손상은 빌드 실패로 처리한다", (damage) => { const temporary = mkdtempSync(".cache/ambience-build-"); const verify = () => execFileSync( "node", [ "--input-type=module", "-e", `import {verifyBuild} from './scripts/check-build.mjs'; verifyBuild(${JSON.stringify(temporary)});`, ], { encoding: "utf8", stdio: "pipe" }, ); try { cpSync("public", temporary, { recursive: true }); expect(verify).not.toThrow(); if (damage === "wav") writeFileSync(join(temporary, "ambience/street.wav"), "bad"); if (damage === "manifest") writeFileSync(join(temporary, "ambience/manifest.json"), "{}"); if (damage === "notice") rmSync(join(temporary, "ambience/AMBIENCE_NOTICES.md")); if (damage === "extra") writeFileSync( join(temporary, "ambience/original.ogg"), "should not ship", ); expect(verify).toThrow(/현장음/); } finally { rmSync(temporary, { recursive: true, force: true }); } }, ); // public/manifest와 dist를 함께 훼손해도 독립적인 콘텐츠 목록과 개수가 막아야 한다. it.each(["empty", "truncated", "duplicate", "renamed", "extra", "missing"])( "음성 목록과 폴더를 함께 바꾼 %s 배포를 거부한다", (damage) => { mkdirSync(".cache", { recursive: true }); const temporary = mkdtempSync(".cache/voice-inventory-"); try { cpSync("public", join(temporary, "public"), { recursive: true }); cpSync("public", join(temporary, "dist"), { recursive: true }); for (const file of ["THIRD_PARTY_NOTICES.md", "package-lock.json"]) cpSync(file, join(temporary, file)); mkdirSync(join(temporary, ".agent")); cpSync( ".agent/AMBIENCE-SOURCES-v0.2.3.json", join(temporary, ".agent/AMBIENCE-SOURCES-v0.2.3.json"), ); symlinkSync(resolve("node_modules"), join(temporary, "node_modules")); const manifest = JSON.parse( readFileSync("public/audio/manifest.json", "utf8"), ); const first = manifest.files[0]; for (const directory of ["public", "dist"]) { const path = join(temporary, directory, "audio"); if (damage === "empty" || damage === "truncated") { const removed = damage === "empty" ? manifest.files : [first]; for (const entry of removed) rmSync(join(path, entry.file)); } if (damage === "renamed") { cpSync(join(path, first.file), join(path, "unexpected.wav")); rmSync(join(path, first.file)); } } if (damage === "empty") manifest.files = []; if (damage === "truncated") manifest.files.shift(); if (damage === "duplicate") manifest.files[0] = manifest.files[1]; if (damage === "renamed") manifest.files[0] = { ...first, file: "unexpected.wav" }; if (damage === "extra") cpSync( join(temporary, "dist/audio", first.file), join(temporary, "dist/audio/extra.wav"), ); if (damage === "missing") rmSync(join(temporary, "dist/audio", first.file)); for (const directory of ["public", "dist"]) writeFileSync( join(temporary, directory, "audio/manifest.json"), JSON.stringify(manifest), ); expect(() => execFileSync( "node", [ "--input-type=module", "-e", `import {verifyBuild} from ${JSON.stringify(resolve("scripts/check-build.mjs"))}; verifyBuild("dist");`, ], { cwd: temporary, stdio: "pipe" }, ), ).toThrow(); } finally { rmSync(temporary, { recursive: true, force: true }); } }, ); // 사본 비교만으로는 양쪽 고지가 함께 축약되는 것을 감지할 수 없다. it.each([ "street", "mall", "theater", "department", "market", "subway", "cafe", "rain", ])( "%s 고지는 각 항목에 저작자·원문·다운로드·라이선스·파생권리·변경을 모두 포함한다", (id) => { mkdirSync(".cache", { recursive: true }); const temporary = mkdtempSync(".cache/notice-fields-"); try { for (const dir of ["public", "dist"]) cpSync("public", join(temporary, dir), { recursive: true }); for (const file of ["THIRD_PARTY_NOTICES.md", "package-lock.json"]) cpSync(file, join(temporary, file)); mkdirSync(join(temporary, ".agent")); cpSync( ".agent/AMBIENCE-SOURCES-v0.2.3.json", join(temporary, ".agent/AMBIENCE-SOURCES-v0.2.3.json"), ); symlinkSync(resolve("node_modules"), join(temporary, "node_modules")); const noticePath = existsSync("public/ambience/AMBIENCE_NOTICES.md") ? "ambience/AMBIENCE_NOTICES.md" : "AMBIENCE_NOTICES.md"; const manifest = JSON.parse( readFileSync("public/ambience/manifest.json", "utf8"), ); // 완전한 기준 고지에서 한 항목의 필드만 지운다. 다른 항목에 같은 URL이 남아도 거부해야 한다. let complete = readFileSync(join("public", noticePath), "utf8"); for (const entry of manifest.files) { if (!complete.includes(`Creator: ${entry.creator}\n`)) complete = complete.replace( `Source: ${entry.pageUrl}`, `Creator: ${entry.creator}\nOutput license: ${entry.outputLicense}\nSource: ${entry.pageUrl}`, ); } const writeNotice = (value: string) => { for (const dir of ["public", "dist"]) writeFileSync(join(temporary, dir, noticePath), value); }; const verify = () => execFileSync( "node", [ "--input-type=module", "-e", `import {verifyBuild} from ${JSON.stringify(resolve("scripts/check-build.mjs"))}; verifyBuild("dist");`, ], { cwd: temporary, stdio: "pipe" }, ); writeNotice(complete); expect(verify).not.toThrow(); const entry = manifest.files.find( (e: { environmentId: string }) => e.environmentId === id, ); const sections = complete.split("## "); const index = sections.findIndex((s) => s.startsWith(`${entry.displayName} — ${id}\n`), ); for (const field of [ "Creator:", "Source:", "Download:", "License:", "Output license:", "Changes:", ]) { const modified = [...sections]; modified[index] = modified[index]!.split("\n") .filter((line) => !line.startsWith(field)) .join("\n"); writeNotice(modified.join("## ")); expect.soft(verify, `${id}: ${field}`).toThrow(); } } finally { rmSync(temporary, { recursive: true, force: true }); } }, ); it.each([ "sourceSha256", "expectedSourceSha256", "sourceBytes", "directMediaDurationSeconds", "selectedSegmentStartSeconds", "startSeconds", "durationSeconds", "outputLicense", "operations", ])( "현장음 manifest 사본을 함께 바꿔도 승인 %s 계약 위반을 거부한다", (field) => { const temporary = mkdtempSync(".cache/ambience-plan-"); try { for (const dir of ["public", "dist"]) cpSync("public", join(temporary, dir), { recursive: true }); for (const file of ["THIRD_PARTY_NOTICES.md", "package-lock.json"]) cpSync(file, join(temporary, file)); mkdirSync(join(temporary, ".agent")); cpSync( ".agent/AMBIENCE-SOURCES-v0.2.3.json", join(temporary, ".agent/AMBIENCE-SOURCES-v0.2.3.json"), ); symlinkSync(resolve("node_modules"), join(temporary, "node_modules")); const verify = () => execFileSync( "node", [ "--input-type=module", "-e", `import {verifyBuild} from ${JSON.stringify(resolve("scripts/check-build.mjs"))}; verifyBuild("dist");`, ], { cwd: temporary, stdio: "pipe" }, ); expect(verify).not.toThrow(); const manifest = JSON.parse( readFileSync("public/ambience/manifest.json", "utf8"), ); if (field === "operations") manifest.files[0].conversion.operations = ""; else if (field === "startSeconds" || field === "durationSeconds") { const entry = manifest.files[0]; const previous = `source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`; entry.conversion[field] += 1; const updated = `source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`; for (const dir of ["public", "dist"]) { const path = join(temporary, dir, "ambience/AMBIENCE_NOTICES.md"); writeFileSync( path, readFileSync(path, "utf8").replace(previous, updated), ); } } else manifest.files[0][field] = "invalid"; for (const dir of ["public", "dist"]) writeFileSync( join(temporary, dir, "ambience/manifest.json"), JSON.stringify(manifest), ); expect(verify).toThrow(); } finally { rmSync(temporary, { recursive: true, force: true }); } }, );