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