open-standards/wai/wai-app/e2e/create.e2e.mjs
Transaction Science 495651ddd2
Some checks failed
wai key · the real keychain / does this change touch it (push) Successful in 18s
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 12s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 22s
wai key · the real keychain / the real keychain · macos (push) Successful in 39s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Failing after 57s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Failing after 2m6s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 2m17s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 4m28s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 4m30s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 4m44s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 7m36s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 7m42s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-room room service / joule-moq-room · does this change touch it (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
wai-app and Create / wai-app · does this change touch it (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
wai-audit collector and player integration / does this change touch it (push) Has been cancelled
wai-audit collector and player integration / collector · linux (push) Has been cancelled
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Has been cancelled
WAI operation catalogue / does this change touch it (push) Has been cancelled
WAI operation catalogue / the catalogue and every door · linux (push) Has been cancelled
wai-fleet service / does this change touch it (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
wai-ledger service / does this change touch it (push) Has been cancelled
wai-ledger service / service · linux (push) Has been cancelled
wai-ledger service / service · macos (push) Has been cancelled
wai-quantum for JavaScript / does this change touch it (push) Has been cancelled
wai-quantum for JavaScript / javascript · linux (push) Has been cancelled
wai-quantum studio and desktop / does this change touch it (push) Has been cancelled
wai-quantum studio and desktop / studio · browser (push) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (push) Has been cancelled
wai-quantum studio and desktop / desktop app · linux (push) Has been cancelled
wai-quantum studio and desktop / desktop app · windows (push) Has been cancelled
wai-sink-android native sink library / does this change touch it (push) Has been cancelled
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Has been cancelled
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Has been cancelled
wai-sink-swift native sink package / does this change touch it (push) Has been cancelled
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Has been cancelled
WAI Studio / does this change touch it (push) Has been cancelled
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
wai-key-proxy / does this change touch it (push) Has been cancelled
wai-key-proxy / proxy · linux (push) Has been cancelled
wai key · Windows / does this change touch it (push) Successful in 13s
wai key · Windows / wai key and the toolchain's tests · windows (push) Successful in 2m10s
wai: operation receipts on every developer-facing write, and a signed origin claim for each generated picture
No new record type. An operation receipt is an asset-provenance receipt
(wai::provenance::AssetReceipt), unchanged: its steps are the project by the
canonical digest of the wai.json the operation ran under, each file it read,
and each file it wrote, by content hash and path. wai create, lock, build,
decode, wrap, unwrap -o, generate and key create|import|rotate|revoke|export
each write one in a build with quickstart, kept in a project's .wai/receipts/
(every template's .gitignore keeps it out) or beside the file written. --key
signs it; a key operation signs with the key it makes; otherwise a key made
for the run, and the receipt says so. A --key that does not sign stops the
operation before it writes anything.

wai verify reads every *.wai-receipt.json under its paths and refuses a
receipt that does not verify, a written file that is missing or changed, a
receipt kept in a project whose wai.json is not the one it names, and a built
tree that is not the one its build record lists.

wai generate, with synthesis, signs for each picture a wai-origin claim
(subject the picture's samples, step generate, operation
wai.generate.spectral, the model file by SHA-256 and type algorithmic,
oversight prompt_guided, source algorithmicMedia) whose record is the
picture's receipt, signed by the same key. extensions/synthesis.md §3.5 states
it, and how a claim names a model no prior pins, such as a person's own model
reached at an endpoint from Create: by the SHA-256 of the endpoint's URL and
the model's name, read as a name, never as a pin. wai verify checks the claim;
so does synthesis_verify.py claim, the second implementation.

The catalogue marks project.create, project.lock, project.build,
media.decode, media.generate, envelope.wrap, envelope.unwrap and key.* with
receipt and adds their --key inputs; ops.json and Studio's actions are
regenerated. Create's end-to-end test runs wai check and wai dev --check on
the exported project, reads its receipt with wai verify, and requires wai
verify to refuse each changed copy.

tests/cli_receipts.rs, in wai-conformance's project step, plants the faults:
a changed picture, decoded file, built file and key record; another project's
receipt; a claim beside another picture's receipt; an edited claim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:26:12 -05:00

464 lines
34 KiB
JavaScript

// Create, end to end, in the host's browser: a recorded model stream fed to the page, the
// app rendering as it arrives, its room joined on a real room service that `wai dev` runs, a
// poll opened and answered, the app taken away as app.oui and made a project by
// `wai create --from app.oui`, and that project checked and run with `wai dev`.
//
// node create.e2e.mjs --rt <runtime dir> --relay <wai-relay> --room <wai-room>
// --wai <wai, built with app> --web <wai dev's room client> --out <dir>
// [--browser <executable>] [--site <dist>]
//
// With --site, the WAI site's build is served instead of the runtime's directory, and its
// /create/ page is driven: the page as the site serves it, with its runtime under /wai-app/.
//
// Scenarios, in order:
// create the `show` recording replayed into Create, served by `wai dev --show` (its relay,
// its room service): widgets appear while the stream is still arriving and only grow
// in number; every snapshot's tree validates; the room widget's frame loads the room
// client while later lines still stream; the host joins (the room's clock records
// it), sends a chat, opens the poll; the page's sealed log holds each action and
// verifies; Export hands over app.oui, the program in its canonical form, and the
// command that makes it a project; `wai create --from app.oui` writes the project
// built the exported project: `wai app check` passes, and refuses it with its room not
// its app's, another audience, no room named, a runtime file changed, or a refused
// line; `wai check` and `wai dev --check` pass on it before it is offered; `wai
// verify` reads the receipt `wai create` left in .wai/receipts/, and refuses each
// copy changed after it was made; `wai dev` in the project runs its own relay and room service from wai.json,
// its keys in .wai/ (which .gitignore keeps out and the front never serves); there a
// host joins, chats and opens the poll, a viewer watches, reads the chat and votes,
// and both pages show the result the room's clock signed
// refused the `watch-refused` recording: the Chat and Poll a watch room cannot keep never
// render, each refused `disallowed-child` at its line, and export stays off
// own the visitor's own model: a stand-in chat/completions server on loopback streams
// the room recording; the page renders it, and the key it was given goes to that
// endpoint alone, in its Authorization header, and is stored nowhere
// agent the visitor's coding agent: `wai mcp --toolset app` driven over its stdio streams
// the show into the page through its bridge, the page connected with the pairing
// code; `app_export` writes the project `wai create --from` writes
//
// Every wait is bounded; nothing listens beyond loopback.
import fs from "node:fs";
import path from "node:path";
import net from "node:net";
import { createRequire } from "node:module";
import { spawn, execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LIB = path.resolve(HERE, "../../../joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs");
const { serveSameOrigin, browserArgs, memguardedBrowser, killStrays, until, sleep } = await import(LIB);
const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "1"]] : a), []));
const need = (k) => { if (!args[k]) { console.error(`create e2e: --${k} is required`); process.exit(2); } return path.resolve(args[k]); };
const RELAY = need("relay"), ROOM = need("room"), WAI = need("wai"), WEB = need("web");
// The runtime's directory (e2e/run.sh lays it out), served as the site with Create's
// development page at /create/index.html; or, with --site, a WAI site build, whose /create/
// page loads the runtime from /wai-app/.
const SITE = args.site ? path.resolve(args.site) : null;
const RT = SITE ? path.join(SITE, "wai-app") : need("rt");
const ROOT = SITE || RT;
const CREATE = SITE ? "/create/" : "/create/index.html";
const OUT = need("out");
const ONLY = (args.only || "create,built,refused,own,agent").split(",");
// Each run starts from no results: the room service's keygen refuses a key file a previous
// run left.
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const require = createRequire(path.join(process.env.PLAYWRIGHT_HOST || path.join(HERE, "node_modules"), "x"));
const { chromium } = require("playwright");
const STEP = 60000;
let failed = 0;
const results = [];
function check(name, ok, detail = "") {
results.push({ name, ok, detail });
if (!ok) failed++;
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
}
process.on("unhandledRejection", (e) => { console.error("unhandled:", (e && e.stack) || e); killStrays(); process.exit(1); });
const freePort = () => new Promise((ok) => { const s = net.createServer(); s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => ok(p)); }); });
const procs = [];
function run(bin, argv, name, dir, env = {}) {
const log = fs.openSync(path.join(dir, `${name}.log`), "w");
const p = spawn(bin, argv, { stdio: ["ignore", log, log], env: { ...process.env, NO_COLOR: "1", ...env }, cwd: env.CWD || dir });
p.logFile = path.join(dir, `${name}.log`);
procs.push(p);
return p;
}
const tail = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").slice(-8).join(" | "); } catch { return ""; } };
// `wai` in `cwd`: its exit status and its output, within `timeout` milliseconds.
const wai = (argv, cwd, timeout = STEP) => {
try {
return { code: 0, out: execFileSync(WAI, argv, { cwd, stdio: ["ignore", "pipe", "pipe"], timeout }).toString() };
} catch (e) {
return { code: e.status, out: `${e.stdout || ""}${e.stderr || ""}` };
}
};
// The JSON envelope a `--json` run printed: on one line, or laid out over several.
const envelope = (out) => {
const lines = out.split("\n");
const i = lines.findIndex((l) => l.startsWith("{"));
if (i < 0) return {};
try { return JSON.parse(lines[i]); } catch { /* laid out over several lines */ }
const j = lines.findIndex((l, k) => k > i && l.startsWith("}"));
try { return JSON.parse(lines.slice(i, j + 1).join("\n")); } catch { return {}; }
};
// `wai dev` in `cwd` (a project, or with --site a directory of pages), on a free port: its
// relay and room service, with this host's binaries and room client. Resolves once it is ready.
async function waiDev(cwd, name, extra = []) {
const port = await freePort();
const dir = path.join(OUT, name);
fs.mkdirSync(dir, { recursive: true });
const p = run(WAI, ["dev", "--port", String(port), "--web", WEB, "--relay", RELAY, "--room", ROOM, ...extra], name, dir, { CWD: cwd });
await until(`wai dev (${name}) is ready`, 90000, () => {
if (p.exitCode != null) throw new Error(`wai dev exited: ${tail(p.logFile)}`);
return /wai dev: ready on/.test(fs.readFileSync(p.logFile, "utf8"));
}, 300);
return { port, url: `http://127.0.0.1:${port}`, proc: p, dir };
}
let browser;
async function launch() {
const bin = args.browser ? path.resolve(args.browser) : null;
const opts = { headless: true, args: browserArgs() };
if (bin) opts.executablePath = memguardedBrowser(bin, OUT).executable;
browser = await chromium.launch(opts);
console.log(`browser: ${browser.version()}`);
}
async function page(url, name) {
const ctx = await browser.newContext({ permissions: ["camera", "microphone"], acceptDownloads: true });
const p = await ctx.newPage();
const logs = [];
p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`));
p.on("response", (r) => { if (r.status() >= 400) logs.push(`[http ${r.status()}] ${r.url()}`); });
p.dump = () => fs.writeFileSync(path.join(OUT, `${name}.console.log`), logs.join("\n"));
p.logs = logs;
await p.goto(url, { waitUntil: "domcontentloaded", timeout: STEP });
return p;
}
const roomOf = (p) => p.evaluate(() => { const r = (window.createTest || window.appTest).ctx.room.current; return r ? { phase: r.phase(), me: r.me(), co: r.co(), chats: r.chats(), checked: r.checked(), present: r.present() } : null; });
const recording = (n) => JSON.parse(fs.readFileSync(path.join(RT, "create/recorded", `wai-app-${n}.json`), "utf8"));
const SHOW = recording("show");
const SHOW_TEXT = SHOW.chunks.join("");
let shared = null; // the Create page's `wai dev`, the page, the exported project
async function scenarioCreate() {
const dir = path.join(OUT, "create");
fs.mkdirSync(dir, { recursive: true });
// The Create page, served by `wai dev` with a show's room: the site is the runtime.
const dev = await waiDev(ROOT, "create-dev", ["--site", ROOT, "--show", "--audience", "1", "--dir", path.join(OUT, "create-dev", "state")]);
check("create: wai dev runs a show's relay and room service behind the Create page", true, dev.url);
const srv = { url: dev.url, close: async () => {} };
const p = await page(`${srv.url}${CREATE}`, "create-host");
await until("Create loads its session", STEP, () => p.evaluate(() => !!window.createTest));
// Watch the page while the stream arrives: when each widget appears, and how much of the
// program had arrived then.
await p.evaluate(() => {
window.__seen = [];
new MutationObserver(() => {
for (const el of document.querySelectorAll("#app [data-widget]")) {
if (el.__seen) continue;
el.__seen = true;
window.__seen.push({ widget: el.dataset.widget, key: el.dataset.key, chars: window.createTest.session.program().length, state: document.getElementById("create").dataset.state });
}
}).observe(document.getElementById("app"), { childList: true, subtree: true });
});
await p.selectOption("#recording", "show");
await p.click("#build");
await until("the stream ends", STEP, () => p.evaluate(() => window.createTest.done >= 1));
const snaps = await p.evaluate(() => window.createTest.snapshots);
const seen = await p.evaluate(() => window.__seen);
const total = SHOW_TEXT.length;
const counts = snaps.map((s) => s.widgets);
check("create: the app renders while the stream arrives, the first widget after a fraction of it", seen.length >= 8 && seen[0].state === "streaming" && seen[0].chars < total / 4, `${seen.length} widgets; first ${seen[0] && seen[0].widget} at ${seen[0] && seen[0].chars} of ${total} characters`);
check("create: widgets appear in steps over the stream, never fewer than before", counts.every((c, i) => i === 0 || c >= counts[i - 1]) && new Set(counts).size >= 6 && seen.filter((x) => x.state === "streaming").length >= 6, `${snaps.length} snapshots; counts ${[...new Set(counts)].join(" → ")}`);
const room = seen.find((x) => x.widget === "Show");
check("create: the room widget (and its room client) appears while later lines still stream", !!room && room.chars < total / 2, room ? `Show at ${room.chars} of ${total}` : "no Show");
const fin = await p.evaluate(() => window.createTest.last);
check("create: the final snapshot has no error, one show, eight widgets", fin.finished && fin.errors.length === 0 && fin.room && fin.room.kind === "show" && fin.nodes.length === 8, JSON.stringify({ errors: fin.errors, room: fin.room && fin.room.kind, n: fin.nodes.length }));
// The host joins the show: the room client in its frame enters the room service's room.
await until("the room client is ready", STEP, () => p.evaluate(() => { const r = window.createTest.ctx.room.current; return r && r.phase() === "idle"; }), 300);
await p.click('[data-widget="Show"] [data-act="join"]');
const host = await until("the host is in the room, publishing", STEP, async () => { const r = await roomOf(p); return r && r.phase === "publishing" && r; }, 300);
const joined = await until("the room's clock records the host joining", STEP, () => p.evaluate((me) => {
const s = window.createTest.ctx.room.current.state();
return s.timeline.some((e) => e.kind === "join" && e.who === "room") && (s.room && s.room.present || []).some((x) => x.principal === me);
}, host.me), 300);
check("create: the host joins a real room: the room service granted its key, the relay took its proof, the room's clock recorded it", !!joined, `principal ${host.me.slice(0, 16)}…`);
// A chat, sent from the Chat widget, signed and verified back into the room's record.
await p.fill('[data-widget="Chat"] input', "Welcome to the show");
await p.click('[data-widget="Chat"] button[type="submit"]');
await until("the host's chat is in the room's verified record", STEP, async () => (await roomOf(p)).chats.some((c) => c.text === "Welcome to the show"), 300);
check("create: a chat sent from the widget comes back as a verified event of the room", true);
// The lead opens the poll.
await until("the host leads the show", STEP, async () => { const r = await roomOf(p); return r.co && r.co.lead; }, 300);
await p.click('[data-widget="Poll"] button.wai-primary');
const w = await until("the room's clock opens the poll's window", STEP, async () => { const r = await roomOf(p); return r.co && r.co.windows.find((x) => x.question === "Which song next?"); }, 300);
check("create: the poll opened from the widget is a window the room's clock signed", !!w, `window ${w.digest.slice(0, 16)}…, closes at ${w.close}`);
// The page's sealed log: every action, each verified as a link of the chain.
const log = await p.evaluate(() => ({ log: window.createTest.session.log(), v: window.createTest.session.verify(), actor: window.createTest.session.actor }));
const acts = log.log.map((e) => e.action);
check("create: the page sealed each action into its hash-chained log, which verifies", ["room.join", "chat.send", "poll.open"].every((a) => acts.includes(a)) && log.v.verified === log.log.length && log.log.every((e) => e.actor === log.actor && e.energy.provenance === "Estimator"), `${acts.join(", ")}; verified ${log.v.verified}`);
const shown = await p.evaluate(() => document.querySelector('[data-widget="Receipts"] .wai-verdict').dataset.ok);
check("create: the Receipts widget shows the page's own verification", shown === "true");
const cut = await p.evaluate(async () => {
const raw = JSON.parse(window.createTest.session.logRaw());
raw.splice(0, 1);
const at = new URL("./wai-app-session.js", new URL(document.getElementById("create").dataset.runtime, location.href));
return (await import(at.href)).verifyLog(JSON.stringify(raw));
});
check("create: the log with its first link cut out does not verify", JSON.parse(cut).broken_at === 0, cut);
// Export: the program, as app.oui, and the command that makes it a project.
const dl = p.waitForEvent("download", { timeout: STEP });
await p.click("#export");
const d = await dl;
const oui = path.join(dir, "app.oui");
await d.saveAs(oui);
const canonical = await p.evaluate(() => window.createTest.session.canonical());
const command = await p.evaluate(() => document.getElementById("export-command").textContent);
check("create: Export hands over app.oui, the program in its canonical form, and the command that makes it a project", d.suggestedFilename() === "app.oui" && fs.readFileSync(oui, "utf8") === canonical && /^wai create [a-z0-9-]+ --from app\.oui$/.test(command), command);
const project = path.join(dir, "exported");
const made = wai(["create", "exported", "--from", "app.oui", "--runtime", RT], dir);
const files = made.code === 0 ? fs.readdirSync(project).sort() : [];
const wj = made.code === 0 ? JSON.parse(fs.readFileSync(path.join(project, "wai.json"), "utf8")) : { dev: {} };
check("create: `wai create --from app.oui` writes the project: the program, wai.json naming its show, the page, its runtime, README, AGENTS.md and LICENSE",
made.code === 0 && ["app.oui", "wai.json", "index.html", "README.md", "AGENTS.md", "LICENSE", "vendor.json", "vendor", ".gitignore"].every((f) => files.includes(f)) && wj.app === "app.oui" && wj.dev.mode === "show" && wj.dev.audience === 1 && fs.readFileSync(path.join(project, "app.oui"), "utf8") === canonical,
made.code === 0 ? `${files.join(" ")}; dev ${JSON.stringify(wj.dev)}` : made.out);
const vendor = made.code === 0 ? JSON.parse(fs.readFileSync(path.join(project, "vendor.json"), "utf8")) : { files: [] };
check("create: the project carries the runtime under vendor/, and no key", vendor.files.length > 10 && !fs.readdirSync(project).some((f) => /key/i.test(f)), `${vendor.files.length} runtime files`);
shared = { dev, srv, p, project, window: w, dir };
}
async function scenarioBuilt() {
if (!shared) return check("built: needs the create scenario", false);
const { project, dir } = shared;
const ok = wai(["app", "check", "--json"], project);
const okv = envelope(ok.out);
check("built: `wai app check` passes on the exported project", ok.code === 0 && okv.ok === true, ((okv.data && okv.data.checked) || []).join("; "));
// Before it is offered: the project's own checks, and its room checked piece by piece.
const pc = wai(["check", "--json"], project);
const pcv = envelope(pc.out);
const failedChecks = ((pcv.data && pcv.data.checks) || []).filter((c) => c.status !== "held").map((c) => `${c.check}: ${c.status}`);
check("built: `wai check` passes on the exported project: its project file, its app and its lock", pc.code === 0 && pcv.ok === true && failedChecks.length === 0, failedChecks.join("; ") || `digest ${pcv.data && pcv.data.digest}`);
const dc = wai(["dev", "--check", "--json", "--port", "0", "--dir", path.join(OUT, "exported-devcheck"), "--web", WEB, "--relay", RELAY, "--room", ROOM], project, 180000);
const dcv = envelope(dc.out);
const dd = dcv.data || dcv;
const unheld = (dd.checks || []).filter((c) => !c.ok).map((c) => `${c.check}: ${c.detail}`);
check("built: `wai dev --check` starts the project's room from wai.json, checks every piece through its origin and stops", dc.code === 0 && dd.ready === true && (dd.checks || []).length > 0 && unheld.length === 0, dc.code === 0 ? `${dd.checks.length} checks held in ${dd.seconds} s` : (unheld.join("; ") || dc.out.slice(-300)));
// The receipt `wai create` left: what it wrote, under the project file as it is.
const ver = wai(["verify", ".wai/receipts"], project);
check("built: `wai verify` reads the receipt of the export, every file it names as written and the project file's digest", ver.code === 0 && /1 operation receipt verified/.test(ver.out) && /ok wrote app\.oui/.test(ver.out), ver.out.split("\n").slice(-3).join(" | "));
// Negative controls: the check refuses a project that is not whole.
const broken = path.join(dir, "exported-broken");
const edit = (f, from, to) => { const t = fs.readFileSync(f, "utf8"); if (!from.test(t)) throw new Error(`${f}: ${from}`); fs.writeFileSync(f, t.replace(from, to)); };
for (const [what, mutate, expect] of [
["whose room is not its app's", (d) => edit(path.join(d, "wai.json"), /"mode": "show", "audience": 1/, '"mode": "room"'), /dev\.mode is "room"/],
["whose show has another audience", (d) => edit(path.join(d, "wai.json"), /"audience": 1/, '"audience": 7'), /dev\.audience is 7/],
["that names no room", (d) => edit(path.join(d, "wai.json"), /, "mode": "show", "audience": 1/, ""), /names no room/],
["with a runtime file changed", (d) => fs.appendFileSync(path.join(d, "vendor/create/wai-app-components.js"), "\n// changed\n"), /wai-app-components\.js: its SHA-256/],
["with a line the registry refuses", (d) => edit(path.join(d, "app.oui"), /^(\w+) = Chat\(.*\)$/m, "$1 = Hologram()"), /line \d+: unknown-component/],
]) {
fs.rmSync(broken, { recursive: true, force: true });
fs.cpSync(project, broken, { recursive: true });
mutate(broken);
const r = wai(["app", "check", "--json"], broken);
const v = envelope(r.out);
const failures = ((v.data && v.data.failures) || []).join(" | ");
check(`built: \`wai app check\` refuses a project ${what}`, r.code === 1 && expect.test(failures), failures.slice(0, 200));
// Each copy was changed after `wai create` wrote it: its receipt says so.
const rv = wai(["verify", ".wai/receipts"], broken);
check(`built: \`wai verify\` refuses the receipt of a project ${what}`, rv.code === 1 && /FAIL wrote .*, which has changed since/.test(rv.out), rv.out.split("\n").filter((l) => /FAIL/.test(l)).join(" | ").slice(0, 200));
}
// The project's own development setup: `wai dev` in it, from its wai.json.
const dev = await waiDev(project, "exported-dev");
const keys = path.join(project, ".wai/dev/room-keys.toml");
const leaked = await fetch(`${dev.url}/.wai/dev/room-keys.toml`).then((r) => r.status).catch((e) => String(e));
check("built: wai dev makes the room's keys in .wai/, which .gitignore keeps out and the front never serves", fs.existsSync(keys) && fs.readFileSync(path.join(project, ".gitignore"), "utf8").includes(".wai/") && leaked !== 200, `GET /.wai/dev/room-keys.toml: ${leaked}`);
const h = await page(`${dev.url}/`, "exported-host");
await until("the exported app renders", STEP, () => h.evaluate(() => window.appTest && document.querySelectorAll("#wai-app [data-widget]").length === 8));
check("built: the exported app renders its eight widgets from app.oui", true);
await until("its room client is ready", STEP, () => h.evaluate(() => { const r = window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300);
await h.click('[data-widget="Show"] [data-act="join"]');
await until("the host is in the project's room, publishing", STEP, async () => { const r = await roomOf(h); return r && r.phase === "publishing" && r; }, 300);
await h.fill('[data-widget="Chat"] input', "Welcome to the show");
await h.click('[data-widget="Chat"] button[type="submit"]');
await until("the host's chat is in the room's verified record", STEP, async () => (await roomOf(h)).chats.some((c) => c.text === "Welcome to the show"), 300);
await until("the host leads the show", STEP, async () => { const r = await roomOf(h); return r.co && r.co.lead; }, 300);
const v = await page(`${dev.url}/`, "exported-viewer");
await until("the viewer's app renders", STEP, () => v.evaluate(() => window.appTest && document.querySelectorAll("#wai-app [data-widget]").length === 8));
await until("its room client is ready", STEP, () => v.evaluate(() => { const r = window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300);
await v.click('[data-widget="Show"] [data-act="watch"]');
const viewer = await until("the viewer watches", STEP, async () => { const r = await roomOf(v); return r && r.phase === "watching" && r; }, 300);
check("built: in the project's own room, a host joins and a viewer enters", !!viewer.me, viewer.me.slice(0, 16));
await until("the viewer reads the host's chat", STEP, async () => (await roomOf(v)).chats.some((c) => c.text === "Welcome to the show"), 300);
check("built: the viewer reads the host's chat, verified", true);
await h.click('[data-widget="Poll"] button.wai-primary');
await until("a window open now", STEP, async () => { const x = await roomOf(v); return x.co && x.co.windows.find((y) => y.question === "Which song next?" && y.close > x.co.now); }, 300);
await until("the viewer's poll offers its options", STEP, () => v.evaluate(() => [...document.querySelectorAll('[data-widget="Poll"] button.wai-option')].some((b) => !b.disabled)), 300);
await v.click('[data-widget="Poll"] button.wai-option[data-option="1"]');
const chosen = await until("the room's clock selects and signs the result", STEP * 2, async () => {
const t = await v.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent);
return /chose “The loud one”, with 1 vote/.test(t) && t;
}, 500);
const hostSees = await until("the host's page shows the same result", STEP, async () => { const t = await h.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent); return /The loud one/.test(t) && t; }, 500);
check("built: the viewer's vote is selected by the room's clock, and both pages show it", !!chosen && !!hostSees, chosen);
const vlog = await v.evaluate(() => ({ acts: window.appTest.session.log().map((e) => e.action), v: window.appTest.session.verify() }));
check("built: the exported app sealed the viewer's actions too", vlog.acts.includes("room.watch") && vlog.acts.includes("poll.vote") && vlog.v.verified === vlog.acts.length, vlog.acts.join(", "));
h.dump();
v.dump();
dev.proc.kill("SIGTERM");
}
async function scenarioRefused() {
const srv = shared ? shared.srv : await serveSameOrigin(ROOT, { service: "http://127.0.0.1:9", relayPort: 9 });
const p = await page(`${srv.url}${CREATE}`, "create-refused");
await until("Create loads", STEP, () => p.evaluate(() => !!window.createTest));
await p.evaluate(() => {
window.__ever = new Set();
new MutationObserver(() => { for (const el of document.querySelectorAll("#app [data-widget]")) window.__ever.add(el.dataset.widget); }).observe(document.getElementById("app"), { childList: true, subtree: true });
});
await p.selectOption("#recording", "watch-refused");
await p.click("#build");
await until("the stream ends", STEP, () => p.evaluate(() => window.createTest.done >= 1));
const ever = await p.evaluate(() => [...window.__ever]);
const fin = await p.evaluate(() => window.createTest.last);
const errs = fin.errors.map((e) => `${e.line}:${e.code}:${e.component}`);
check("refused: the Chat and the Poll a watch room cannot keep never render", !ever.includes("Chat") && !ever.includes("Poll") && ever.includes("WatchTogether") && ever.includes("Participants"), ever.join(", "));
check("refused: each is refused disallowed-child at its line", errs.includes("3:disallowed-child:Chat") && errs.includes("4:disallowed-child:Poll"), errs.join(" "));
const listed = await p.evaluate(() => [...document.querySelectorAll("#problems li")].map((li) => li.dataset.code));
const off = await p.evaluate(() => document.getElementById("export").disabled);
check("refused: the page lists the refusals, and export stays off", listed.filter((c) => c === "disallowed-child").length === 2 && off, listed.join(" "));
if (!shared) await srv.close();
}
// The visitor's own model: a stand-in `chat/completions` server on loopback (another origin
// than the page's) that streams the room recording as a model would, token by token. The key
// typed in the page goes to that endpoint alone, in its Authorization header, and is stored
// nowhere.
async function scenarioOwn() {
const dir = path.join(OUT, "own");
fs.mkdirSync(dir, { recursive: true });
const rec = recording("room");
const KEY = "test-key-0f3a9c";
const seen = [];
const http = await import("node:http");
const model = http.createServer((req, res) => {
const cors = { "access-control-allow-origin": req.headers.origin || "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "POST" };
if (req.method === "OPTIONS") { res.writeHead(204, cors); return res.end(); }
let body = "";
req.on("data", (d) => { body += d; });
req.on("end", async () => {
seen.push({ path: req.url, auth: req.headers.authorization, body: JSON.parse(body || "{}") });
res.writeHead(200, { ...cors, "content-type": "text/event-stream" });
res.write('data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n');
for (const c of rec.chunks) {
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: c } }] })}\n\n`);
await sleep(5);
}
res.end("data: [DONE]\n\n");
});
});
await new Promise((ok) => model.listen(0, "127.0.0.1", ok));
const endpoint = `http://127.0.0.1:${model.address().port}/v1/chat/completions`;
const srv = shared ? shared.srv : await serveSameOrigin(ROOT, { service: "http://127.0.0.1:9", relayPort: 9 });
const p = await page(`${srv.url}${CREATE}`, "create-own");
const requests = [];
p.on("request", (r) => requests.push({ url: r.url(), auth: r.headers().authorization || null }));
await until("Create loads", STEP, () => p.evaluate(() => !!window.createTest));
await p.check('input[name="source"][value="own"]');
await p.fill("#own-endpoint", endpoint);
await p.fill("#own-model", "a-local-model");
await p.fill("#own-key", KEY);
await p.fill("#prompt", rec.prompt);
await p.click("#build");
await until("the stream from the visitor's model ends", STEP, () => p.evaluate(() => window.createTest.done >= 1));
const fin = await p.evaluate(() => window.createTest.last);
const says = await p.evaluate(() => document.getElementById("model-says").textContent);
check("own: the page streams the app from the visitor's own model and renders it", fin.errors.length === 0 && fin.room && fin.room.kind === "live" && fin.nodes.length === 6, `${fin.nodes.length} widgets; "${says.slice(0, 90)}…"`);
const call = seen[0] || {};
check("own: the model is asked with the registry's guide as its instructions and the person's description", call.body && call.body.stream === true && call.body.model === "a-local-model" && /You build a WAI app/.test(call.body.messages[0].content) && call.body.messages[1].content === rec.prompt);
const withKey = requests.filter((r) => r.auth && r.auth.includes(KEY)).map((r) => r.url);
const stored = await p.evaluate((k) => JSON.stringify({ ...localStorage }).includes(k) || JSON.stringify({ ...sessionStorage }).includes(k) || document.cookie.includes(k), KEY);
check("own: the key goes to the visitor's endpoint alone, in its Authorization header, and is stored nowhere", call.auth === `Bearer ${KEY}` && withKey.length === 1 && withKey[0] === endpoint && !stored, `${requests.length} requests; the key in ${withKey.length}`);
p.dump();
model.close();
if (!shared) await srv.close();
}
// The visitor's coding agent: `wai mcp --toolset app` on this machine, driven over its stdio as
// an agent drives it; the page connects to its bridge with the pairing code, and the agent
// streams the show into it, then exports it as `wai create --from` does.
async function scenarioAgent() {
const dir = path.join(OUT, "agent");
fs.mkdirSync(dir, { recursive: true });
const mcp = spawn(WAI, ["mcp", "--toolset", "app", "--project", dir], { stdio: ["pipe", "pipe", "pipe"] });
procs.push(mcp);
let errText = "";
mcp.stderr.on("data", (d) => { errText += d; });
const first = await until("the MCP server names its bridge", 10000, () => errText.match(/http:\/\/127\.0\.0\.1:(\d+), pairing code (\w+)/));
const [, port, code] = first;
let buf = "";
const waiting = [];
mcp.stdout.on("data", (d) => {
buf += d;
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const w = waiting.shift();
if (w) w(JSON.parse(line));
}
});
let id = 0;
const rpc = (method, params) => new Promise((ok) => { waiting.push(ok); mcp.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }) + "\n"); });
await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "e2e", version: "0" } });
const srv = shared ? shared.srv : await serveSameOrigin(ROOT, { service: "http://127.0.0.1:9", relayPort: 9 });
const p = await page(`${srv.url}${CREATE}`, "create-agent");
await until("Create loads", STEP, () => p.evaluate(() => !!window.createTest));
await p.check('input[name="source"][value="agent"]');
await p.fill("#agent-bridge", `http://127.0.0.1:${port}`);
await p.fill("#agent-code", code);
await p.click("#build");
await until("the page is connected to the bridge", STEP, () => p.evaluate(() => /Connected/.test(document.getElementById("agent-state").textContent)), 200);
const r = await rpc("tools/call", { name: "app_stream", arguments: { program: SHOW_TEXT, chunk: 12, pace_ms: 10 } });
check("agent: the agent's stream reaches the connected page", r.result && !r.result.isError && r.result.structuredContent.delivered === 1, JSON.stringify(r.result && r.result.structuredContent).slice(0, 120));
await until("the page renders the agent's app", STEP, () => p.evaluate(() => window.createTest.done >= 1));
const fin = await p.evaluate(() => window.createTest.last);
const counts = await p.evaluate(() => window.createTest.snapshots.map((x) => x.widgets));
check("agent: the page renders the app as the agent streams it", fin.errors.length === 0 && fin.nodes.length === 8 && new Set(counts).size >= 5 && counts.every((c, i) => !i || c >= counts[i - 1]), `${counts.length} snapshots, ${fin.nodes.length} widgets`);
const wrong = await p.evaluate(async (u) => (await fetch(u)).status, `http://127.0.0.1:${port}/create/stream?code=wrong`).catch((e) => String(e));
check("agent: the bridge refuses a page without the pairing code", wrong === 403 || /Failed to fetch/.test(String(wrong)), String(wrong));
const e = await rpc("tools/call", { name: "app_export", arguments: { program: SHOW_TEXT, dir: "agent-export", runtime_dir: RT } });
const ex = path.join(dir, "agent-export");
const checked = wai(["app", "check"], ex);
check("agent: app_export writes the project `wai create --from` writes, and its check passes", e.result && !e.result.isError && fs.existsSync(path.join(ex, "wai.json")) && checked.code === 0, JSON.stringify(e.result && e.result.structuredContent).slice(0, 160));
p.dump();
mcp.stdin.end();
if (!shared) await srv.close();
}
try {
await launch();
for (const [name, f] of [["create", scenarioCreate], ["built", scenarioBuilt], ["refused", scenarioRefused], ["own", scenarioOwn], ["agent", scenarioAgent]]) {
if (!ONLY.includes(name)) continue;
console.log(`== ${name}`);
try { await f(); } catch (e) { check(`${name}: ran to the end`, false, (e && e.stack) || String(e)); }
}
if (shared) shared.p.dump();
} finally {
if (browser) await browser.close().catch(() => {});
for (const p of procs) if (p.exitCode == null) p.kill("SIGTERM");
for (const p of procs) if (p.exitCode == null) p.kill("SIGTERM");
killStrays();
}
fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify({ results, failed }, null, 1));
console.log(`create e2e: ${results.length - failed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);