126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
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("intro·목표 101개 versioned WAV를 실제 Chrome에서 해시·디코드 검증한다", async ({
|
|
page,
|
|
browser,
|
|
}, info) => {
|
|
const external: string[] = [];
|
|
const errors: 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)) requests.push(url.href);
|
|
});
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
await page.goto("/");
|
|
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.1.1");
|
|
await expect(page.locator(".version")).toHaveText("v0.1.1");
|
|
// 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 page.getByRole("button", { name: "처음으로" }).click();
|
|
const targetUrls = [
|
|
...new Set(
|
|
Array.from({ length: 200 }, (_, seed) =>
|
|
createQuestions(seed, "mixed").map((question) => question.audioUrl),
|
|
).flat(),
|
|
),
|
|
];
|
|
expect(targetUrls).toHaveLength(100);
|
|
const urls = [
|
|
introUrl,
|
|
...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.1.1"]));
|
|
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.1.1");
|
|
expect(urls.map((url) => new URL(url).pathname).sort()).toEqual(
|
|
manifest.files.map((entry) => `/audio/${entry.file}`).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) => `/audio/${entry.file}` === 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: manifest.files },
|
|
);
|
|
expect(rows).toHaveLength(101);
|
|
expect([...requests].sort()).toEqual([...urls].sort());
|
|
expect(external).toEqual([]);
|
|
expect(errors).toEqual([]);
|
|
const path = info.outputPath("browser-audio-integrity.json");
|
|
writeFileSync(
|
|
path,
|
|
JSON.stringify(
|
|
{ chrome: browser.version(), external, errors, files: rows },
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
await info.attach("101 versioned WAV integrity", {
|
|
path,
|
|
contentType: "application/json",
|
|
});
|
|
});
|