ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
200 lines
10 KiB
JavaScript
200 lines
10 KiB
JavaScript
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { chromium } from "playwright-core";
|
|
|
|
const opts = Object.fromEntries(
|
|
process.argv.slice(2).map((a) => {
|
|
const [k, v] = a.replace(/^--/, "").split("=");
|
|
return [k, v ?? "true"];
|
|
}),
|
|
);
|
|
const site = path.resolve(opts.site ?? "../../site");
|
|
const executablePath = process.env.CHROMIUM || "/usr/bin/chromium";
|
|
const seconds = +(opts.seconds ?? 10);
|
|
const demoSeconds = +(opts["demo-seconds"] ?? 2.5);
|
|
if (!Number.isFinite(demoSeconds) || demoSeconds < 2.5) throw new Error("demo-seconds must be at least 2.5");
|
|
const blocks = (opts.blocks ?? "").split(",").filter(Boolean).map(Number);
|
|
const mime = {
|
|
".html": "text/html",
|
|
".js": "text/javascript",
|
|
".css": "text/css",
|
|
".wasm": "application/wasm",
|
|
".json": "application/json",
|
|
};
|
|
const isolate = {
|
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
};
|
|
const failures = [];
|
|
|
|
function fail(msg) {
|
|
failures.push(msg);
|
|
console.error("FAIL " + msg);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
// Playwright evaluate/waitForFunction can synthesize gestures; audio/autoplay probes must not activate the page before real pointer input, even during startup.
|
|
async function scriptWithoutGesture(page) {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const js = async (expression) => {
|
|
const r = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, userGesture: false });
|
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
|
|
return r.result.value;
|
|
};
|
|
const until = async (expression, timeout) => {
|
|
const end = Date.now() + timeout;
|
|
while (!(await js(expression))) {
|
|
if (Date.now() > end) throw new Error("timed out waiting for " + expression);
|
|
await sleep(100);
|
|
}
|
|
};
|
|
return { js, until };
|
|
}
|
|
|
|
function serve(root, headers) {
|
|
return new Promise((resolve) => {
|
|
const srv = http.createServer((req, res) => {
|
|
let p = decodeURIComponent(new URL(req.url, "http://local").pathname);
|
|
if (p.endsWith("/")) p += "index.html";
|
|
const file = path.join(root, p);
|
|
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
|
res.writeHead(404);
|
|
res.end();
|
|
return;
|
|
}
|
|
res.writeHead(200, { "Content-Type": mime[path.extname(file)] || "application/octet-stream", ...headers });
|
|
fs.createReadStream(file).pipe(res);
|
|
});
|
|
srv.listen(0, "127.0.0.1", () => resolve(srv));
|
|
});
|
|
}
|
|
|
|
async function run(browser, label, headers, wantShared, depth = 0) {
|
|
const srv = await serve(site, headers);
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
|
page.on("pageerror", (e) => fail(`${label}: page error: ${e.message}`));
|
|
const { js, until } = await scriptWithoutGesture(page);
|
|
const stats = async () => JSON.parse(await js("globalThis.aegoAudio.stats()"));
|
|
try {
|
|
await page.goto(`http://127.0.0.1:${srv.address().port}/play/`);
|
|
await until("!!(globalThis.aegoAudio && globalThis.aegoAudio.start)", 120000);
|
|
const isolated = await js("globalThis.crossOriginIsolated === true");
|
|
if (isolated !== wantShared) fail(`${label}: crossOriginIsolated is ${isolated}`);
|
|
const enforced = await js("(async () => { const c = new AudioContext(); await new Promise((r) => setTimeout(r, 300)); const s = c.state; await c.close(); return s !== 'running'; })()");
|
|
if (depth) await js(`globalThis.aegoAudioBlocks = ${depth}`);
|
|
const err = await js("globalThis.aegoAudio.start()");
|
|
if (err) throw new Error("start: " + err);
|
|
await sleep(500);
|
|
const before = await stats();
|
|
if (!enforced) console.log(`${label}: this browser starts an AudioContext without a gesture, the gesture check is skipped`);
|
|
else if (before.running || before.queued !== 0) fail(`${label}: audio ran before a gesture: ${JSON.stringify(before)}`);
|
|
await page.mouse.click(5, 5);
|
|
await until("JSON.parse(globalThis.aegoAudio.stats()).running", 10000);
|
|
await sleep(seconds * 1000);
|
|
const s = await stats();
|
|
if (s.error) fail(`${label}: ${s.error}`);
|
|
if (s.shared !== wantShared) fail(`${label}: shared transport is ${s.shared}`);
|
|
if (Math.abs(s.rms - 0.354) > 0.05) fail(`${label}: rms ${s.rms}`);
|
|
if (!Number.isInteger(s.targetBlocks) || s.targetBlocks <= 0 || !Number.isInteger(s.blockFrames) || s.blockFrames <= 0) {
|
|
throw new Error(`invalid actual audio target: ${JSON.stringify(s)}`);
|
|
}
|
|
const used = s.targetBlocks;
|
|
if (depth && used !== depth) fail(`${label}: requested ${depth} blocks but the pump uses ${used}`);
|
|
console.log(`${label}: rate ${s.rate}, shared ${s.shared}, ${used} blocks (${((used * s.blockFrames * 1000) / s.rate).toFixed(0)} ms), underruns ${s.underruns} (pump ${s.pumpUnderruns}) in ${seconds} s, rms ${s.rms.toFixed(3)}, mix max ${s.mixMaxUs} us`);
|
|
if (!Number.isInteger(s.pumpUnderruns) || s.pumpUnderruns < 0) fail(`${label}: invalid pump underrun count`);
|
|
if (!Number.isInteger(s.underruns) || s.underruns < 0) fail(`${label}: invalid worklet underrun count`);
|
|
if (s.underruns > 0) fail(`${label}: ${s.underruns} underruns in ${seconds} s at ${used} blocks`);
|
|
if (s.pumpUnderruns > 0) fail(`${label}: ${s.pumpUnderruns} predicted pump underruns in ${seconds} s at ${used} blocks`);
|
|
await js("globalThis.aegoAudio.stop()");
|
|
} catch (e) {
|
|
fail(`${label}: ${e.message}`);
|
|
} finally {
|
|
await page.close();
|
|
srv.close();
|
|
}
|
|
}
|
|
|
|
async function demo(browser, label, headers, wantShared) {
|
|
if (!fs.existsSync(path.join(site, "demo", "index.html"))) {
|
|
fail(`${label}: site/demo is missing`);
|
|
return;
|
|
}
|
|
const srv = await serve(site, headers);
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
|
|
page.on("pageerror", (e) => fail(`${label}: page error: ${e.message}`));
|
|
const { js, until } = await scriptWithoutGesture(page);
|
|
const stats = async () => JSON.parse(await js("globalThis.aegoAudioStats()"));
|
|
const base = `http://127.0.0.1:${srv.address().port}/demo/`;
|
|
try {
|
|
await page.goto(base);
|
|
await until("globalThis.aegoBoot !== undefined && typeof globalThis.aegoAudioStats === 'function'", 120000);
|
|
const quiet = await js("document.documentElement.dataset.sound === 'off'");
|
|
if (quiet && !(await stats()).muted) fail(`${label}: the page asks for silence, but the demo starts with sound`);
|
|
await js("globalThis.aegoAudioMute(false)");
|
|
const enforced = await js(`(async () => {
|
|
const c = new AudioContext();
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
const s = c.state;
|
|
await c.close();
|
|
return s !== "running";
|
|
})()`);
|
|
const before = await stats();
|
|
const activated = await js("navigator.userActivation && navigator.userActivation.hasBeenActive");
|
|
if (activated) fail(`${label}: the harness activated the page before the real click`);
|
|
console.log(`${label}: before click running ${before.running}, underruns ${before.underruns}, consumed ${before.consumed}, page activated ${!!activated}`);
|
|
if (!enforced) console.log(`${label}: this browser starts an AudioContext without a gesture, the gesture check is skipped`);
|
|
else if (before.running) fail(`${label}: audio ran before a gesture`);
|
|
await page.click("#game");
|
|
await sleep(demoSeconds * 1000);
|
|
const s = await stats();
|
|
if (s.error) fail(`${label}: ${s.error}`);
|
|
if (s.shared !== wantShared) fail(`${label}: shared transport is ${s.shared}`);
|
|
if (!Number.isInteger(s.underruns) || s.underruns < 0) fail(`${label}: invalid worklet underrun count`);
|
|
const pump = await js("globalThis.aegoGame?.mixStats?.() ?? null");
|
|
if (!pump || !Number.isInteger(pump.underruns) || pump.underruns < 0) fail(`${label}: invalid game pump underrun count`);
|
|
else if (pump.underruns > 0) fail(`${label}: ${pump.underruns} predicted pump underruns in the game`);
|
|
if (s.underruns > 0) fail(`${label}: ${s.underruns} underruns in the game, which fills audio from the frame loop`);
|
|
if (!s.running || s.rms < 0.01) fail(`${label}: music is silent: ${JSON.stringify(s)}`);
|
|
console.log(`${label}: rate ${s.rate}, shared ${s.shared}, rms ${s.rms.toFixed(3)}, underruns ${s.underruns} (pump ${pump?.underruns})`);
|
|
if (await page.isHidden("#mute")) fail(`${label}: the mute button is hidden`);
|
|
await page.click("#mute");
|
|
await sleep(300);
|
|
const m = await stats();
|
|
if (!m.muted) fail(`${label}: mute did not apply`);
|
|
if (m.out !== 0) fail(`${label}: muted output is not silent: ${JSON.stringify(m)}`);
|
|
await page.reload();
|
|
await until("typeof globalThis.aegoAudioStats === 'function'", 120000);
|
|
if (!(await stats()).muted) fail(`${label}: mute was not remembered`);
|
|
await js("globalThis.aegoAudioMute(false)");
|
|
} catch (e) {
|
|
fail(`${label}: ${e.message}`);
|
|
} finally {
|
|
await page.close();
|
|
srv.close();
|
|
}
|
|
}
|
|
|
|
const browser = await chromium.launch({
|
|
executablePath,
|
|
headless: opts.headed !== "true",
|
|
args: ["--mute-audio", ...(opts.gpu === "hard" ? [] : ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"]), "--ignore-gpu-blocklist", "--autoplay-policy=user-gesture-required"],
|
|
});
|
|
try {
|
|
await run(browser, "messages", {}, false);
|
|
for (const b of blocks) await run(browser, `messages at ${b} blocks`, {}, false, b);
|
|
await run(browser, "shared", isolate, true);
|
|
await demo(browser, "demo", {}, false);
|
|
await demo(browser, "demo shared", isolate, true);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
if (failures.length) {
|
|
console.error(`${failures.length} failure(s)`);
|
|
process.exit(1);
|
|
}
|
|
console.log("web audio: ok");
|