Files

187 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, writeFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
import { createQuestions } from "../../src/game";
import { isAudioRequest } from "./audio-requests";
// 과거 evidence의 검증 스크립트는 보존하고, query URL 전수 검사를 정규 suite로 옮긴다.
test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
page,
browser,
}, info) => {
const external: string[] = [];
const errors: string[] = [];
const failed: string[] = [];
const requests: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.origin !== "http://127.0.0.1:4173") external.push(url.href);
if (isAudioRequest(url) || url.pathname.startsWith("/ambience/"))
requests.push(url.href);
});
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("requestfailed", (request) => failed.push(request.url()));
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.5");
await expect(page.locator(".version")).toHaveText("v0.2.5");
const plan = JSON.parse(
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
);
const published = await page.evaluate(async () => ({
manifest: await (await fetch("/ambience/manifest.json?v=0.2.5")).json(),
notice: await (await fetch("/ambience/AMBIENCE_NOTICES.md?v=0.2.5")).text(),
}));
expect(published.manifest).toEqual(
JSON.parse(readFileSync("public/ambience/manifest.json", "utf8")),
);
for (const asset of plan.assets) {
const start = asset.selectedSegmentStartSeconds;
expect(Number.isFinite(start)).toBe(true);
expect(start).toBeGreaterThanOrEqual(0);
expect(start + 12).toBeLessThanOrEqual(asset.directMediaDurationSeconds);
const entry = published.manifest.files.find(
(e: { environmentId: string }) => e.environmentId === asset.environmentId,
);
expect(entry.selectedSegmentStartSeconds).toBe(start);
expect(entry.conversion.startSeconds).toBe(start);
expect(entry.conversion.durationSeconds).toBe(12);
const section = published.notice
.split("## ")
.find((s: string) =>
s.startsWith(`${asset.displayName} — ${asset.environmentId}\n`),
);
expect(section).toContain(`source seconds ${start}–${start + 12}.`);
}
// intro는 테스트에서 URL을 재조립하지 않고 배포 앱의 실제 요청을 관찰한다.
const introRequest = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/audio/intro.wav",
);
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
const introUrl = (await introRequest).url();
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
timeout: 30000,
});
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
await page.getByRole("button", { name: "처음 화면으로" }).click();
const targetUrls = [
...new Set(
Array.from({ length: 1000 }, (_, seed) =>
createQuestions(seed, "mixed").map((question) => question.audioUrl),
).flat(),
),
];
expect(targetUrls).toHaveLength(100);
const ambience = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
);
const urls = [
introUrl,
...ambience.files.map(
(entry: { file: string }) =>
new URL(`/ambience/${entry.file}?v=0.2.5`, page.url()).href,
),
...targetUrls.map((url) => new URL(url, page.url()).href),
];
expect(
new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
).toEqual(new Set(["0.2.5"]));
expect(
urls.every((url) => new URL(url).origin === new URL(page.url()).origin),
).toBe(true);
const manifest = JSON.parse(
readFileSync("public/audio/manifest.json", "utf8"),
) as {
version: string;
files: { file: string; sha256: string; bytes: number; duration: number }[];
};
expect(manifest.version).toBe("0.2.5");
// 두 manifest의 파일명 충돌 없이 경로를 명시해 109개 응답을 독립 대조한다.
const entries = [
...manifest.files.map((entry) => ({
...entry,
path: `/audio/${entry.file}`,
})),
...ambience.files.map(
(entry: {
file: string;
sha256: string;
bytes: number;
duration: number;
}) => ({ ...entry, path: `/ambience/${entry.file}` }),
),
];
expect(urls.map((url) => new URL(url).pathname).sort()).toEqual(
entries.map((entry) => entry.path).sort(),
);
requests.length = 0;
const rows = await page.evaluate(
async ({ urls, entries }) => {
const context = new OfflineAudioContext(1, 1, 24000);
const rows = [];
for (const url of urls) {
// query를 파일명으로 취급하지 않는다. 원문 파일과 응답 해시를 독립 대조한다.
const entry = entries.find(
(entry) => entry.path === new URL(url).pathname,
)!;
const response = await fetch(url);
if (!response.ok) throw Error(`${url}: HTTP ${response.status}`);
const bytes = await response.arrayBuffer();
const sha256 = Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)),
(b) => b.toString(16).padStart(2, "0"),
).join("");
if (sha256 !== entry.sha256 || bytes.byteLength !== entry.bytes)
throw Error(`${url}: integrity mismatch`);
const buffer = await context.decodeAudioData(bytes);
if (
buffer.sampleRate !== 24000 ||
buffer.numberOfChannels !== 1 ||
Math.abs(buffer.duration - entry.duration) > 1e-9
)
throw Error(`${url}: decode format/duration mismatch`);
let power = 0,
peak = 0;
for (const sample of buffer.getChannelData(0)) {
power += sample * sample;
peak = Math.max(peak, Math.abs(sample));
}
const rms = Math.sqrt(power / buffer.length);
if (rms <= 100 / 32768 || peak >= 32767 / 32768)
throw Error(`${url}: signal failure`);
rows.push({
url,
sha256,
bytes: entry.bytes,
sampleRate: buffer.sampleRate,
channels: buffer.numberOfChannels,
duration: buffer.duration,
rms,
peak,
});
}
return rows;
},
{ urls, entries },
);
expect(rows).toHaveLength(109);
expect([...requests].sort()).toEqual([...urls].sort());
expect(external).toEqual([]);
expect(errors).toEqual([]);
expect(failed).toEqual([]);
const path = info.outputPath("browser-audio-integrity.json");
writeFileSync(
path,
JSON.stringify(
{ chrome: browser.version(), external, errors, failed, files: rows },
null,
2,
) + "\n",
);
await info.attach("109 versioned WAV integrity", {
path,
contentType: "application/json",
});
});