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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|