Files
Find-Word/tests/browser/audio.spec.ts
T

187 lines
6.9 KiB
TypeScript
Raw Normal View History

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로 옮긴다.
2026-10-14 10:01:46 +00:00
test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
page,
browser,
}, info) => {
const external: string[] = [];
const errors: string[] = [];
2026-10-14 10:01:46 +00:00
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);
2026-10-14 10:01:46 +00:00
if (isAudioRequest(url) || url.pathname.startsWith("/ambience/"))
requests.push(url.href);
});
2026-10-14 10:01:46 +00:00
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("/");
2026-10-14 12:12:13 +00:00
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.5");
await expect(page.locator(".version")).toHaveText("v0.2.5");
2026-10-14 10:01:46 +00:00
const plan = JSON.parse(
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
);
const published = await page.evaluate(async () => ({
2026-10-14 12:12:13 +00:00
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(),
2026-10-14 10:01:46 +00:00
}));
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();
2026-10-14 10:01:46 +00:00
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(
2026-10-14 10:01:46 +00:00
Array.from({ length: 1000 }, (_, seed) =>
createQuestions(seed, "mixed").map((question) => question.audioUrl),
).flat(),
),
];
expect(targetUrls).toHaveLength(100);
2026-10-14 10:01:46 +00:00
const ambience = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
);
const urls = [
introUrl,
2026-10-14 10:01:46 +00:00
...ambience.files.map(
(entry: { file: string }) =>
2026-10-14 12:12:13 +00:00
new URL(`/ambience/${entry.file}?v=0.2.5`, page.url()).href,
2026-10-14 10:01:46 +00:00
),
...targetUrls.map((url) => new URL(url, page.url()).href),
];
expect(
new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
2026-10-14 12:12:13 +00:00
).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 }[];
};
2026-10-14 12:12:13 +00:00
expect(manifest.version).toBe("0.2.5");
2026-10-14 10:01:46 +00:00
// 두 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(
2026-10-14 10:01:46 +00:00
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(
2026-10-14 10:01:46 +00:00
(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;
},
2026-10-14 10:01:46 +00:00
{ urls, entries },
);
2026-10-14 10:01:46 +00:00
expect(rows).toHaveLength(109);
expect([...requests].sort()).toEqual([...urls].sort());
expect(external).toEqual([]);
expect(errors).toEqual([]);
2026-10-14 10:01:46 +00:00
expect(failed).toEqual([]);
const path = info.outputPath("browser-audio-integrity.json");
writeFileSync(
path,
JSON.stringify(
2026-10-14 10:01:46 +00:00
{ chrome: browser.version(), external, errors, failed, files: rows },
null,
2,
) + "\n",
);
2026-10-14 10:01:46 +00:00
await info.attach("109 versioned WAV integrity", {
path,
contentType: "application/json",
});
});