blob: c2c84ccdf51c5c5b0390ac3150e173a679dc984b [file] [edit]
// Runs a baked riv on a browser page, where script audio processors render
// in real time on the page's audio worklet (rive_audio_worklet.mjs), and
// checks the page's console for lines.
// node browser_audio.mjs <harness out dir> <file.riv> [--browser <name>]
// [--no-isolation] [--runner-context]
// [--carried-worklet]
// [--offline <blocks>] [--hash <blocks>]
// [--hard-blocks <n>] [--job-threads <n>]
// [--parallel-parts <n>] [--expect <line>]...
// --browser is chrome, the default, run from the binary CHROME names or else
// the macOS install, or firefox or webkit, run through playwright-core: npm
// ci here, then npx playwright-core install firefox webkit. --no-isolation
// serves the page without cross origin isolation, which a build with
// threads refuses at once. --runner-context hands the runner no
// AudioContext, so it makes its own, and firefox and webkit click only once
// the page logs a warning. --carried-worklet names no worklet and serves none
// of its files, so the runner starts the one the build carries. --offline renders that many blocks through each
// processor on the page, as run.mjs's --audio does, and --hash logs a hash of
// the first that many blocks the worklet renders, as harness_audio does.
// --hard-blocks sets the audio hard limit, --job-threads the job pool's
// threads and --parallel-parts a parallel call's parts, as run.mjs's do.
import { spawn } from 'child_process';
import { createServer } from 'http';
import { mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { basename, dirname, extname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
const [outDir, rivPath, ...rest] = process.argv.slice(2);
const valueOf = (flag) => rest.find((arg, i) => rest[i - 1] === flag);
const expected = rest.filter((arg, i) => rest[i - 1] === '--expect');
const browserName = valueOf('--browser') ?? 'chrome';
const isolated = !rest.includes('--no-isolation');
const runnerContext = rest.includes('--runner-context');
const carriedWorklet = rest.includes('--carried-worklet');
const offline = Number(valueOf('--offline') ?? 0);
const hash = Number(valueOf('--hash') ?? 0);
const hardBlocks = valueOf('--hard-blocks');
const jobThreads = valueOf('--job-threads');
const parallelParts = valueOf('--parallel-parts');
const chrome =
process.env.CHROME ??
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const here = dirname(fileURLToPath(import.meta.url));
const webDir = resolve(here, '../../src/wasm/web');
const sources = {
'file.riv': rivPath,
'worklet_tap.mjs': join(here, 'worklet_tap.mjs'),
};
for (const name of carriedWorklet ? [] : [
'rive_audio_worklet.mjs',
'rive_lane_imports.mjs',
'rive_shared_layout.mjs',
'rive_shared_lane.mjs',
'rive_lane.mjs',
]) {
sources[name] = join(webDir, name);
}
// The page: load the harness, name the worklet, import the file, then run
// frames while the worklet renders, until every expected line shows.
const page = `<!doctype html><meta charset="utf-8"><button>Start audio</button>
<script type="module">
import createHarness from './web_scripting_page.mjs';
const lines = [];
const print = (text) => { lines.push(text); console.log('PAGE ' + text); };
console.warn = (text) => print('warning: ' + text);
// At harness_audio's rate, and started by a click as a page's audio is.
const context = ${runnerContext} ? undefined : new AudioContext({ sampleRate: 48000 });
document.querySelector('button').onclick = () => context?.resume();
const Module = await createHarness({ print, printErr: print }).catch((error) => {
// A build with threads refuses a page that cannot share memory.
print('harness refused: ' + error.message);
console.log('PAGE DONE');
return new Promise(() => {});
});
const hardBlocks = ${JSON.stringify(hardBlocks)};
if (hardBlocks !== undefined) {
Module._harness_audio_hard_blocks(Number(hardBlocks));
}
const jobThreads = ${JSON.stringify(jobThreads)};
if (jobThreads !== undefined) {
Module._harness_job_threads(Number(jobThreads));
}
Module.riveScripting.parallelParts = ${JSON.stringify(parallelParts)};
if (!${carriedWorklet}) {
Module.riveScripting.audioWorkletUrl = 'rive_audio_worklet.mjs';
}
Module.riveScripting.audioContext = context;
const hash = ${hash};
if (hash > 0) {
await context.audioWorklet.addModule('worklet_tap.mjs');
const tap = new AudioWorkletNode(context, 'rive-tap', {
outputChannelCount: [2],
channelCount: 2,
channelCountMode: 'explicit',
processorOptions: { blocks: hash },
});
tap.connect(context.destination);
tap.port.onmessage = ({ data }) => {
const [left, right] = data.sums.map((sum) => sum.toFixed(2));
const hex = data.hash.toString(16).padStart(8, '0');
print(\`worklet rendered \${hash} blocks sums \${left} \${right} hash \${hex}\`);
};
// Every node the runner makes feeds the tap as well.
const Node = AudioWorkletNode;
globalThis.AudioWorkletNode = class extends Node {
constructor(...args) {
super(...args);
this.connect(tap);
}
};
}
const riv = new Uint8Array(await (await fetch('file.riv')).arrayBuffer());
const ptr = Module._malloc(riv.length);
Module.HEAPU8.set(riv, ptr);
Module._harness_import(ptr, riv.length);
Module._free(ptr);
const offline = ${offline};
if (offline > 0) {
Module._harness_render_audio_offline();
}
await Module.riveScripting.prepare();
Module._harness_run(1);
const frame = async () => {
await new Promise((done) => setTimeout(done, 10));
Module._harness_frames(1);
};
if (offline > 0) {
// A processor still in its init counts as pending.
for (let turn = 0; turn < 1000 && Module._harness_pending(); turn++) {
await frame();
}
Module._harness_audio(offline);
}
const expected = ${JSON.stringify(expected)};
for (let turn = 0; turn < 1000; turn++) {
if (expected.every((want) => lines.some((line) => line.includes(want)))) {
break;
}
await frame();
}
print('page audio ' + JSON.stringify(Module.riveScripting.audioState()));
print('page parallel ' + JSON.stringify(Module.riveScripting.parallelState()));
Module._harness_release();
console.log('PAGE DONE');
</script>`;
const types = {
'.html': 'text/html',
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.wasm': 'application/wasm',
'.riv': 'application/octet-stream',
};
const server = createServer((request, response) => {
const name = basename(new URL(request.url, 'http://x').pathname) || 'index.html';
let body;
try {
body =
name === 'index.html'
? page
: readFileSync(sources[name] ?? join(resolve(outDir), name));
} catch {
response.writeHead(404).end();
return;
}
const headers = { 'Content-Type': types[extname(name)] ?? 'application/octet-stream' };
// Shared memory, so pthreads and the worklet, needs cross origin isolation.
if (isolated) {
headers['Cross-Origin-Opener-Policy'] = 'same-origin';
headers['Cross-Origin-Embedder-Policy'] = 'require-corp';
}
response.writeHead(200, headers);
response.end(body);
});
await new Promise((done) => server.listen(0, '127.0.0.1', done));
const url = `http://127.0.0.1:${server.address().port}/index.html`;
const lines = [];
let settle;
// True once the page is done, false if the browser goes first.
const done = new Promise((resolve) => {
settle = resolve;
setTimeout(() => settle(false), 60000).unref();
});
let warn;
const warned = new Promise((resolve) => (warn = resolve));
// One console message from the page: its own lines, and any error.
const take = (text, error = false) => {
if (!text.startsWith('PAGE ')) {
if (error) {
lines.push(text);
}
return;
}
const line = text.slice(5);
if (line === 'DONE') {
settle(true);
}
if (line.startsWith('warning: ')) {
warn();
}
lines.push(line);
};
// Chrome's own binary, with its console on stderr. Returns how to close it.
function openChrome() {
const profile = mkdtempSync(join(tmpdir(), 'rive-audio-chrome-'));
const browser = spawn(chrome, [
'--headless=new',
'--autoplay-policy=no-user-gesture-required',
'--enable-logging=stderr',
'--v=0',
'--no-first-run',
`--user-data-dir=${profile}`,
url,
]);
const exited = new Promise((resolve) => browser.on('exit', resolve));
browser.on('close', () => settle(false));
let pending = '';
browser.stderr.on('data', (chunk) => {
pending += chunk.toString();
const parts = pending.split('\n');
pending = parts.pop();
for (const part of parts) {
if (part.includes('CONSOLE')) {
// A message of several lines closes its quote on its last.
const open = part.indexOf('"');
const close = part.lastIndexOf('"');
take(part.slice(open + 1, close > open ? close : undefined), part.includes('rror'));
}
}
});
return async () => {
browser.kill();
await exited;
// Chrome's helpers can still write the profile after it exits; a
// leftover temporary folder is harmless.
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
} catch {}
};
}
// Firefox or WebKit through playwright, which clicks the page's button as a
// user would. Returns how to close it.
async function openPlaywright() {
const engines = await import('playwright-core');
const engine = { firefox: engines.firefox, webkit: engines.webkit }[browserName];
if (engine === undefined) {
throw new Error(`no browser ${browserName}`);
}
const browser = await engine.launch();
browser.on('disconnected', () => settle(false));
const tab = await browser.newPage();
tab.on('console', (message) => take(message.text(), message.type() === 'error'));
tab.on('pageerror', (error) => lines.push(`page error: ${error.message}`));
await tab.goto(url);
if (runnerContext) {
// A gesture only after the runner's context began without one.
await Promise.race([warned, done]);
}
await tab.click('button');
return () => browser.close();
}
let close = async () => {};
try {
close = browserName === 'chrome' ? openChrome() : await openPlaywright();
} catch (error) {
lines.push(String(error.message ?? error));
settle(false);
}
const finished = await done;
await close();
server.close();
const missing = expected.filter((want) => !lines.some((line) => line.includes(want)));
// A thread past the pool starts only once the page yields.
const exhausted = lines.some((line) => line.includes('thread pool is exhausted'));
if (!finished || missing.length > 0 || exhausted) {
const how = !finished ? 'did not finish' : exhausted ? 'ran out of pool threads' : 'finished';
console.error(`FAIL: ${browserName} ${how}, missing log lines: ${JSON.stringify(missing)}`);
console.error(lines.join('\n'));
process.exit(1);
}
console.log(`browser audio ok in ${browserName}: ${lines.length} log lines`);
for (const line of lines) {
console.log(' | ' + line);
}