All checks were successful
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 3m41s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 8s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 6s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 7s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 3m17s
WAI operation catalogue / does this change touch it (pull_request) Successful in 7s
WAI conformance / does this change touch it (pull_request) Successful in 8s
WAI energy receipts / does this change touch it (pull_request) Successful in 8s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 8s
wai-fleet service / does this change touch it (pull_request) Successful in 9s
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Successful in 8s
wai-key-proxy / does this change touch it (pull_request) Successful in 8s
wai-ledger service / does this change touch it (pull_request) Successful in 7s
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Successful in 7s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 9s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 9s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 7s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 7s
WAI Studio / does this change touch it (pull_request) Successful in 7s
wai-web wasm32 / does this change touch it (pull_request) Successful in 7s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 6m13s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 19s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 3m5s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 7m11s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 20s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 34s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 1m55s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 5s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m15s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 6s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 5s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 6s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m6s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 11m40s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 1m27s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (pull_request) Successful in 38s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 2m31s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 2m41s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 30s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 9m9s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m38s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 5m42s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m0s
wai-key-proxy / proxy · linux (pull_request) Successful in 1m5s
wai-fleet service / service · linux (pull_request) Successful in 2m3s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 22m22s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 2m2s
wai-ledger service / service · linux (pull_request) Successful in 1m33s
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Successful in 3m52s
wai-pack / wai-pack · linux (pull_request) Successful in 3m20s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m0s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 3m38s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 28s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 33s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 10m58s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 9m30s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m8s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 10m5s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m34s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 5m50s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 4m33s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 5m36s
wai-fleet service / service · macos (pull_request) Successful in 38s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 17m40s
wai-ledger service / service · macos (pull_request) Successful in 58s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 9m51s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 18m30s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 13m52s
wai-pack / wai-pack · macos (pull_request) Successful in 2m49s
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Successful in 10m16s
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Successful in 6m24s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 4m9s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 7m39s
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Successful in 33m27s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 55s
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Successful in 6m39s
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Successful in 5s
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Successful in 32m48s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m50s
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Successful in 32m47s
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Successful in 2m20s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 21s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 9m33s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 2m2s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 1m39s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 1m32s
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Successful in 6m58s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 3m4s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 59m15s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 16m40s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 7m34s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 4m6s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 7m30s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 15m49s
The catalogue's agent rules carry an operation's answer under `data` (envelope/1); the Create e2e still read the older `result`, so each refusal case found no failures and failed. The passing case only read `ok`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
438 lines
31 KiB
JavaScript
438 lines
31 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 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.
|
|
const wai = (argv, cwd) => {
|
|
try {
|
|
return { code: 0, out: execFileSync(WAI, argv, { cwd, stdio: ["ignore", "pipe", "pipe"] }).toString() };
|
|
} catch (e) {
|
|
return { code: e.status, out: `${e.stdout || ""}${e.stderr || ""}` };
|
|
}
|
|
};
|
|
const envelope = (out) => JSON.parse(out.split("\n").find((l) => l.startsWith("{")) || "{}");
|
|
|
|
// `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("; "));
|
|
// 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));
|
|
}
|
|
// 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);
|