open-standards/wai/quickstart/test/lib.mjs
Transaction Science 632f8cf394 wai: the developer's path, hello, create, dev and verify, a floor build with no system library, and nine examples
wai-rs gains the feature floor (the mandatory floor of SPEC 5: PNG, FLAC and
zstd, decoded with no system codec library) and quickstart (the floor, and the
receipt checks hello and verify run). The wai command line gains four verbs,
one module each, named in one table (dx::VERBS):

- hello: name, dispatch, rebuild and receipt on a picture made here, the
  receipt laid out as http-delivery 8 lays receipts beside an object;
- create: a new project from one of the examples, whose files the binary
  holds; tests/cli_dx.rs holds them to wai/examples/ byte for byte;
- dev: the relay, the room service and the room page on one loopback origin,
  as a production front serves them; --check checks each piece through it;
- verify: the receipt indexes under a directory, checked offline.

The project file a template writes, wai.json, is read in one place
(dx/project.rs). wai/examples/ holds video-room, watch-together, messaging,
encoder-ingest, package-and-deliver, verify-receipts, apple-app, android-app
and remote-desktop, each with a README and a test that runs it through
wai create and wai dev. wai/quickstart/ builds the room client and the
archive that holds wai, the relay, the room service, the remote host and the
packager.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:58:39 -04:00

130 lines
5.2 KiB
JavaScript

// What the examples' browser tests share: `wai dev` started in a project directory on a port
// of its own, a headless browser with a fake camera and microphone, and the checks each test
// prints and counts.
//
// Environment:
// WAI_BIN the `wai` binary (default: `wai` on PATH)
// WAI_RELAY_BIN, WAI_ROOM_BIN, WAI_WEB passed to `wai dev` (it also finds them beside `wai`)
// BROWSER_BIN the browser's executable (default: the automation package's own)
// PLAYWRIGHT_HOST the automation package's node_modules
// Every address is loopback, and every wait has a bound.
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(path.join(process.env.PLAYWRIGHT_HOST || path.join(path.dirname(new URL(import.meta.url).pathname), "node_modules"), "x"));
const { chromium } = require("playwright");
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const STEP = 60000;
export async function until(what, ms, f, every = 250) {
const end = Date.now() + ms;
let last;
for (;;) {
try {
last = await f();
if (last) return last;
} catch (e) {
last = e;
}
if (Date.now() > end) throw new Error(`${what}: not within ${ms} ms (last: ${last instanceof Error ? last.message : JSON.stringify(last)?.slice(0, 400)})`);
await sleep(every);
}
}
const results = [];
export function check(what, ok, detail = "") {
results.push({ what, ok: !!ok, detail });
console.log(`${ok ? "ok " : "FAIL"} ${what}${detail ? ` (${detail})` : ""}`);
}
/// Print the tally and exit: 0 when every check held and there was at least one.
export function finish(name) {
const bad = results.filter((r) => !r.ok).length;
console.log(`${name}: ${results.length - bad} of ${results.length} checks held`);
process.exit(bad || !results.length ? 1 : 0);
}
export function tmpdir(label) {
return fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), `wai-example-${label}-`));
}
/// A copy of an example made by `wai create`, as a person makes one.
export async function create(template, parent) {
const dir = path.join(parent, template);
await run([process.env.WAI_BIN || "wai", "create", dir, "--template", template]);
return dir;
}
export function run(argv, opts = {}) {
return new Promise((ok, no) => {
const p = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], ...opts });
let out = "";
p.stdout.on("data", (b) => (out += b));
p.stderr.on("data", (b) => (out += b));
p.on("exit", (code) => (code === 0 ? ok(out) : no(new Error(`${argv.join(" ")}: exit ${code}: ${out.slice(-600)}`))));
});
}
/// `wai dev` in `cwd` with `args`, on a free port, in a process group of its own so that
/// stopping it stops the relay and the service it started. Resolves once it says it is ready.
export async function dev(cwd, args = []) {
const p = spawn(process.env.WAI_BIN || "wai", ["dev", "--port", "0", "--for", "900", ...args], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
p.stdout.on("data", (b) => (out += b));
p.stderr.on("data", (b) => (out += b));
const exited = new Promise((r) => p.on("exit", (code) => r(code)));
const url = await until("wai dev ready", 60000, () => {
if (p.exitCode != null) throw new Error(`wai dev exited ${p.exitCode}: ${out.slice(-800)}`);
const m = out.match(/wai dev: ready on (http:\/\/127\.0\.0\.1:\d+)/);
return m && m[1];
}, 100);
return {
url,
output: () => out,
async stop() {
if (p.exitCode == null) {
try { process.kill(-p.pid, "SIGINT"); } catch { /* gone */ }
const e = await Promise.race([exited, sleep(15000).then(() => null)]);
if (e === null) try { process.kill(-p.pid, "SIGKILL"); } catch { /* gone */ }
}
},
};
}
export async function launch() {
const opts = {
headless: true,
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--autoplay-policy=no-user-gesture-required", "--disable-background-timer-throttling", "--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows"],
};
if (process.env.BROWSER_BIN) opts.executablePath = process.env.BROWSER_BIN;
return chromium.launch(opts);
}
/// A page of its own (its own context, so its own key), with the camera and microphone granted.
export async function open(browser, url, name) {
const ctx = await browser.newContext({ permissions: ["camera", "microphone"] });
const p = await ctx.newPage();
p.name = name;
p.logs = [];
p.on("console", (m) => p.logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => p.logs.push(`[pageerror] ${e.stack || e}`));
await p.goto(url, { waitUntil: "domcontentloaded" });
return p;
}
/// The frame of `p` whose URL holds `part`.
export async function frame(p, part) {
return until(`${p.name}: the frame ${part}`, STEP, () => p.frames().find((f) => f.url().includes(part)), 100);
}
/// Each page's console, written beside the test's other output when a check failed.
export function dump(pages, dir) {
for (const p of pages) {
try { fs.writeFileSync(path.join(dir, `${p.name}.console.log`), p.logs.join("\n")); } catch { /* none */ }
}
}