Files

350 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2026-10-14 06:19:24 +00:00
// @vitest-environment node
import { execFileSync } from "node:child_process";
2026-10-14 10:01:46 +00:00
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
2026-10-14 06:19:24 +00:00
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 });
}
},
);
});
2026-10-14 10:01:46 +00:00
// 새 배경 경로는 음성과 독립적이므로 실제 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",
2026-10-14 12:12:13 +00:00
"targetIntegratedLUFS",
"truePeakCeilingDBTP",
"finalLoudness",
"scalarFilter",
"measurementFilter",
"internalTruePeakCeilingDBTP",
"gainDB",
"iterations",
"version",
2026-10-14 10:01:46 +00:00
])(
"현장음 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"),
);
2026-10-14 12:12:13 +00:00
if (field === "targetIntegratedLUFS" || field === "truePeakCeilingDBTP")
manifest.normalization[field] = -1;
else if (field === "finalLoudness")
manifest.files[0].conversion.loudness.final.integratedLUFS = -12;
else if (field === "scalarFilter")
manifest.files[0].conversion.loudness.iterations[0].filter =
"loudnorm=I=-32.5";
else if (field === "measurementFilter")
manifest.normalization.measurementFilter = "loudnorm";
else if (field === "internalTruePeakCeilingDBTP")
manifest.normalization.internalTruePeakCeilingDBTP = -6;
else if (field === "gainDB")
manifest.files[0].conversion.loudness.iterations[0].gainDB += 1;
else if (field === "iterations")
manifest.files[0].conversion.loudness.iterations = [];
else if (field === "version") manifest.version = "0.2.3";
else if (field === "operations")
manifest.files[0].conversion.operations = "";
2026-10-14 10:01:46 +00:00
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 });
}
},
);