// 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 */ } } }