350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
// @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",
|
||
"targetIntegratedLUFS",
|
||
"truePeakCeilingDBTP",
|
||
"finalLoudness",
|
||
"scalarFilter",
|
||
"measurementFilter",
|
||
"internalTruePeakCeilingDBTP",
|
||
"gainDB",
|
||
"iterations",
|
||
"version",
|
||
])(
|
||
"현장음 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 === "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 = "";
|
||
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 });
|
||
}
|
||
},
|
||
);
|