diff --git a/playground/tests/e2e-headless/targets.ts b/playground/tests/e2e-headless/targets.ts new file mode 100644 index 00000000..29d7bc74 --- /dev/null +++ b/playground/tests/e2e-headless/targets.ts @@ -0,0 +1,26 @@ +// UI targets for the headless full-bundle driver — the single place selector +// knowledge lives; update here when the playground UI moves. + +// Deep-links straight to DiagnosisView (playground/src/app/page.tsx), skipping +// the left-rail click-through. +export const DIAGNOSIS_PATH = "/?view=diagnosis"; + +// Diagnosis never auto-starts on mount (DiagnosisView) — this button's onClick +// is the only trigger. +export const RUN_ALL_SELECTOR = '[data-testid="diagnosis-run"]'; + +// Connection status chip rendered by StatusChip (playground/src/app/page.tsx). +export const STATUS_CHIP_SELECTOR = ".status__label"; + +// Summary text reads "{passCount} success · {failCount} failed" (DiagnosisView). +export const FAILED_COUNT_SELECTOR = '[data-testid="diagnosis-summary"]'; + +// Parses the "N success · M failed" summary text. +export const FAILED_COUNT_PATTERN = /(\d+)\s+failed\b/; + +// Set once the run has finished rendering its report (DiagnosisView). +export const REPORT_READY_SELECTOR = + '[data-testid="diagnosis-report-markdown"][data-report-ready="true"]'; + +// A single Diagnosis result row in the failed state (DiagnosisView). +export const FAILED_ROW_SELECTOR = '[data-testid="diagnosis-row"][data-status="fail"]'; diff --git a/rust/crates/truapi-host-cli/e2e/full-bundle.sh b/rust/crates/truapi-host-cli/e2e/full-bundle.sh new file mode 100755 index 00000000..f584e60a --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/full-bundle.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Full-bundle driver: the real playground bundle, served statically and run +# browserless under bun + happy-dom, drives a pairing host through the +# MessagePort->WS bridge; a signing host answers the pairing deeplink; every +# operation lands in METRICS_JSONL. +# +# make headless && e2e/full-bundle.sh +# +# Env: +# PRODUCT_ID product id the pairing host serves (default truapi-playground.dot) +# FRAME frame-server address (default 127.0.0.1:9955) +# METRICS_JSONL metrics sink (default /tmp/full-bundle-metrics.jsonl) +# SKIP_BUILD set to reuse an existing playground/out +# PLAYGROUND_OUT static export dir the shim serves (default playground/out) +# FULL_BUNDLE_DEADLINE_MS shim deadline ms (default 1200000 -- TS-side default) +# E2E_LIVE_CHAIN live-chain routing for Chain/* (default 1) +# HOST_CLI_SIGNER_MNEMONIC as in run.sh (e2e/.env supported) +# TRUAPI_HOST_BASE_PATH signing host only -- the pairing host always gets a fresh temp path +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" +ENV_FILE="$(dirname "$0")/.env" +[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } + +PRODUCT_ID="${PRODUCT_ID:-truapi-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" +export METRICS_JSONL="${METRICS_JSONL:-/tmp/full-bundle-metrics.jsonl}" +# Live-chain routing for the Chain/* methods, matching the dotli baseline. +# Upstream keeps this opt-in because live chainHead streaming over the shared +# product connection can still drop mid-run (see src/chain.rs) — expect the +# head-subscription methods to be the flaky tail. +export E2E_LIVE_CHAIN="${E2E_LIVE_CHAIN:-1}" + +[ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } + +if [ -z "${SKIP_BUILD:-}" ] || [ ! -d "$ROOT/playground/out" ]; then + (cd "$ROOT/js/packages/truapi" && npm run build) + (cd "$ROOT/playground" && yarn build) +fi + +LOG="$(mktemp)" +PAIR_PID="" +SIGNER_PID="" +WATCHER_PID="" +KEEPALIVE_PID="" +# Fresh pairing-host state per run: the host persists its paired session under +# the base path, so a reused path silently restores the previous run's session +# and the run is not reproducible. The signing host keeps its own (default) +# base path on purpose, to reuse its attested account across runs. +PAIR_BASE="$(mktemp -d)" +cleanup() { + rm -f "$LOG.signerlog" + [ -n "$WATCHER_PID" ] && kill "$WATCHER_PID" 2>/dev/null || true + [ -n "$SIGNER_PID" ] && kill "$SIGNER_PID" 2>/dev/null || true + # Independent of $SIGNER_PID: if the script exits before the main flow + # reaps the signer (e.g. an early/interrupted exit), this is the only path + # left that still catches it. + if [ -f "$LOG.signer" ]; then + kill "$(cat "$LOG.signer")" 2>/dev/null || true + rm -f "$LOG.signer" + fi + if [ -n "$PAIR_PID" ]; then + pkill -TERM -P "$PAIR_PID" 2>/dev/null || true + kill -TERM "$PAIR_PID" 2>/dev/null || true + sleep 0.5 + pkill -KILL -P "$PAIR_PID" 2>/dev/null || true + kill -KILL "$PAIR_PID" 2>/dev/null || true + fi + rm -rf "$PAIR_BASE" + # The keepalive tail runs under a process-substitution wrapper shell; kill + # the wrapper's children before the wrapper itself (killing only the wrapper + # reparents the tail to init and leaks it). + if [ -n "$KEEPALIVE_PID" ]; then + pkill -TERM -P "$KEEPALIVE_PID" 2>/dev/null || true + kill -TERM "$KEEPALIVE_PID" 2>/dev/null || true + fi + # Success path removes $LOG itself before this trap runs; if it's still + # here, this exit was a failure -- keep it for post-mortem instead of + # silently deleting the only evidence. + # `|| true`: as the trap's last command, a false [ -f ] would otherwise + # become the script's exit status and turn every SUCCESSFUL run into exit 1. + [ -f "$LOG" ] && echo "host log preserved: $LOG" >&2 || true +} +trap cleanup EXIT + +: > "$METRICS_JSONL" +# stdin keepalive: with >(tee "$LOG") 2>&1 & +PAIR_PID=$! + +# Don't race the shim run against a socket that isn't bound yet: wait +# for the host's own "listening" line, checking every 0.5s for up to 60s that +# it hasn't died in the meantime. +for _ in $(seq 1 120); do + grep -q '^FRAMES_LISTENING' "$LOG" && break + kill -0 "$PAIR_PID" 2>/dev/null || { echo "pairing host exited before FRAMES_LISTENING" >&2; exit 1; } + sleep 0.5 +done +grep -q '^FRAMES_LISTENING' "$LOG" || { echo "pairing host: no FRAMES_LISTENING within 60s" >&2; exit 1; } + +# The deeplink appears on the first requestLogin, so watch for it concurrently +# and answer with the signing host (its output goes to $LOG.signerlog so the +# allowance wait below can grep it). +( + for _ in $(seq 1 600); do + deeplink="$(grep -m1 -oE 'PAIRING_DEEPLINK .+' "$LOG" | cut -d' ' -f2- || true)" + if [ -n "$deeplink" ]; then + "$BIN" signing-host --deeplink "$deeplink" --auto-accept > >(tee "$LOG.signerlog") 2>&1 & + echo "$!" > "$LOG.signer" + exit 0 + fi + sleep 0.5 + done + echo "watcher: no deeplink within 300s" >&2 +) & +WATCHER_PID=$! + +# Login-first, like the dotli baseline and the fleet: a minimal requestLogin +# script triggers pairing, then we wait for the device-key statement-store +# allowance — submits race the allowance registration otherwise. +echo "pre-login: requesting session" +(cd "$ROOT/rust/crates/truapi-host-cli/js" && TRUAPI_FRAME_URL="ws://$FRAME" \ + TRUAPI_PRODUCT_ID="$PRODUCT_ID" \ + TRUAPI_SCRIPT="$ROOT/rust/crates/truapi-host-cli/js/scripts/pre-login.ts" \ + bun runner.ts) || { echo "pre-login failed" >&2; exit 1; } +for _ in $(seq 1 240); do + grep -q "SIGNING_HOST_ALLOWANCE device seq=" "$LOG.signerlog" 2>/dev/null && break + sleep 0.5 +done +grep -q "SIGNING_HOST_ALLOWANCE device seq=" "$LOG.signerlog" 2>/dev/null \ + || echo "pre-login: device allowance not confirmed within 120s; continuing" >&2 + +STATUS=0 +(cd "$ROOT/rust/crates/truapi-host-cli/js" && { [ -d node_modules ] || bun install; } \ + && TRUAPI_FRAME_URL="ws://$FRAME" bun scripts/load-bundle.ts) || STATUS=$? +[ -f "$LOG.signer" ] && SIGNER_PID="$(cat "$LOG.signer")" && rm -f "$LOG.signer" + +[ "$STATUS" -eq 0 ] || exit "$STATUS" +[ -s "$METRICS_JSONL" ] || { echo "no metrics recorded in $METRICS_JSONL" >&2; exit 1; } +# A single "category":"signing" grep passes even if every signing op failed. +# Gate on each battery-critical op individually succeeding (op field always +# precedes outcome in HostMetricRecord's field order, so one pattern per line +# suffices -- see rust/crates/truapi-host-cli/src/metrics.rs). +for op in signing_sign_raw signing_sign_payload signing_create_transaction; do + grep -q "\"op\":\"$op\".*\"outcome\":\"success\"" "$METRICS_JSONL" || { + echo "no successful $op recorded in $METRICS_JSONL" >&2; exit 1; } +done +echo "full-bundle spike OK -> $METRICS_JSONL ($(wc -l < "$METRICS_JSONL") records)" +rm -f "$LOG" diff --git a/rust/crates/truapi-host-cli/js/bun.lock b/rust/crates/truapi-host-cli/js/bun.lock new file mode 100644 index 00000000..1be3f987 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "truapi-host-cli-js", + "dependencies": { + "@happy-dom/global-registrator": "20.11.0", + "happy-dom": "20.11.0", + }, + }, + }, + "packages": { + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.0" } }, "sha512-rHb/2dOy3APuHKNU4aqPQt+HbEt6BkulTTyCks89z5a+EipJecnFFd+3cz3meaFfCOXWsoxplPTLrGjFIEhd0A=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "happy-dom": ["happy-dom@20.11.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + } +} diff --git a/rust/crates/truapi-host-cli/js/package.json b/rust/crates/truapi-host-cli/js/package.json new file mode 100644 index 00000000..3cb2430f --- /dev/null +++ b/rust/crates/truapi-host-cli/js/package.json @@ -0,0 +1,8 @@ +{ + "name": "truapi-host-cli-js", + "private": true, + "dependencies": { + "@happy-dom/global-registrator": "20.11.0", + "happy-dom": "20.11.0" + } +} diff --git a/rust/crates/truapi-host-cli/js/scripts/load-bundle.ts b/rust/crates/truapi-host-cli/js/scripts/load-bundle.ts new file mode 100644 index 00000000..69208d30 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/load-bundle.ts @@ -0,0 +1,174 @@ +// Browserless full-bundle driver: runs the UNCHANGED playground static export +// under happy-dom on the main realm and bridges its MessagePort to the +// headless host's WS frame server. One Uint8Array = one binary WS frame. +// Realm discipline matters: a separate realm breaks the transport's +// `instanceof Uint8Array` guard and frames drop silently. +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + DIAGNOSIS_PATH, + FAILED_COUNT_PATTERN, + FAILED_COUNT_SELECTOR, + FAILED_ROW_SELECTOR, + REPORT_READY_SELECTOR, + RUN_ALL_SELECTOR, + STATUS_CHIP_SELECTOR, +} from "../../../../../playground/tests/e2e-headless/targets"; + +const OUT = resolve( + process.env.PLAYGROUND_OUT ?? join(import.meta.dir, "../../../../../playground/out"), +); +const FRAME_URL = process.env.TRUAPI_FRAME_URL ?? "ws://127.0.0.1:9955"; +const DEADLINE_MS = Number(process.env.FULL_BUNDLE_DEADLINE_MS ?? 1_200_000); +const HYDRATE_SETTLE_MS = 3_000; +const POLL_MS = 2_000; + +if (!existsSync(join(OUT, "index.html")) || !existsSync(join(OUT, "_next/static/chunks"))) { + console.error( + `missing playground export under ${OUT} — set PLAYGROUND_OUT or unset SKIP_BUILD to rebuild`, + ); + process.exit(2); +} + +// Serve the export so happy-dom's CSS/font fetches resolve. +const MIME: Record = { + ".html": "text/html", ".js": "text/javascript", ".css": "text/css", + ".woff2": "font/woff2", ".txt": "text/plain", ".json": "application/json", +}; +// Captured before happy-dom can shadow it: Bun.serve rejects happy-dom's own +// Response class if used here instead. +const NativeResponse = globalThis.Response; +const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + let path = new URL(req.url).pathname; + if (path === "/") path = "/index.html"; + const file = Bun.file(join(OUT, path)); + if (!(await file.exists())) return new NativeResponse("not found", { status: 404 }); + const ext = path.slice(path.lastIndexOf(".")); + return new NativeResponse(file, { headers: { "content-type": MIME[ext] ?? "application/octet-stream" } }); + }, +}); + +// Native WebSocket, captured before happy-dom can shadow it. +const NativeWebSocket = globalThis.WebSocket; +GlobalRegistrator.register({ url: `http://127.0.0.1:${server.port}${DIAGNOSIS_PATH}` }); + +// Defensive cap: happy-dom / the bundle can console.error whole objects on +// hydration hiccups; keep every argument compact and bounded so no future path can flood stdout. +const rawConsoleError = console.error.bind(console); +console.error = (...args: unknown[]) => { + const compact = args.map((a) => + typeof a === "object" && a !== null ? String(a) : a, + ); + rawConsoleError(...compact.map((a) => String(a).slice(0, 2_000))); +}; + +// Bridge: product MessagePort <-> host WS frame server. +const ws = new NativeWebSocket(FRAME_URL); +ws.binaryType = "arraybuffer"; +const channel = new MessageChannel(); +const pending: Uint8Array[] = []; +let wsOpen = false; +ws.addEventListener("open", () => { + wsOpen = true; + for (const frame of pending.splice(0)) ws.send(frame); +}); +ws.addEventListener("message", (event: MessageEvent) => { + channel.port2.postMessage(new Uint8Array(event.data as ArrayBuffer)); +}); +ws.addEventListener("close", () => { + console.log("SHIM_STATUS ws closed"); + // A closed socket can never finish the run; don't poll for 20 minutes. + if (!document.querySelector(REPORT_READY_SELECTOR)) { + console.log("SHIM_RESULT fail (ws closed)"); + process.exit(2); + } +}); +ws.addEventListener("error", () => { + console.log("SHIM_STATUS ws error"); + process.exit(2); +}); +channel.port2.onmessage = (event: MessageEvent) => { + const frame = event.data as Uint8Array; + if (wsOpen) ws.send(frame); + else pending.push(frame); +}; +channel.port2.start?.(); + +declare global { + interface Window { + __HOST_WEBVIEW_MARK__: boolean; + __HOST_API_PORT__: MessagePort; + } +} + +window.__HOST_WEBVIEW_MARK__ = true; +window.__HOST_API_PORT__ = channel.port1; + +// Execute the shipped bundle in this realm: index.html scripts in document +// order, then every hashed chunk not already referenced (eager evaluation +// satisfies dynamic imports with zero network). +const html = readFileSync(join(OUT, "index.html"), "utf8"); +document.documentElement.innerHTML = html + .replace(/^/i, "") + .replace(/^]*>|<\/html>$/g, ""); +const indirectEval = eval; +const executed = new Set(); +for (const script of Array.from(document.querySelectorAll("script"))) { + const src = script.getAttribute("src"); + if (src) { + const clean = src.split("?")[0]; + executed.add(clean.replace(/^\//, "")); + indirectEval(readFileSync(join(OUT, clean), "utf8") + `\n//# sourceURL=${clean}`); + } else if (script.textContent) { + indirectEval(script.textContent); + } +} +const chunkDir = join(OUT, "_next/static/chunks"); +for (const name of readdirSync(chunkDir).filter((n) => n.endsWith(".js")).sort()) { + const rel = `_next/static/chunks/${name}`; + if (executed.has(rel)) continue; + indirectEval(readFileSync(join(chunkDir, name), "utf8") + `\n//# sourceURL=/${rel}`); +} + +// Let React hydrate, then drive the product's own Diagnosis run. +await new Promise((r) => setTimeout(r, HYDRATE_SETTLE_MS)); +console.log("SHIM_STATUS chip:", document.querySelector(STATUS_CHIP_SELECTOR)?.textContent ?? "n/a"); +const runButton = document.querySelector(RUN_ALL_SELECTOR) as { click(): void } | null; +if (!runButton) { + console.log("SHIM_RESULT fail (run control not found)"); + process.exit(2); +} +runButton.click(); + +const deadline = Date.now() + DEADLINE_MS; +let lastSummary = ""; +while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, POLL_MS)); + const summary = document.querySelector(FAILED_COUNT_SELECTOR)?.textContent ?? ""; + if (summary && summary !== lastSummary) { + console.log("SHIM_SUMMARY", summary); + lastSummary = summary; + } + if (document.querySelector(REPORT_READY_SELECTOR)) { + const failed = Number(FAILED_COUNT_PATTERN.exec(lastSummary)?.[1] ?? NaN); + const ok = failed <= 1; // parity with the known baseline: 43 passed, 1 failed + if (!ok) printFailedRows(); + console.log(`SHIM_RESULT ${ok ? "ok" : "fail"} (summary: ${lastSummary})`); + process.exit(ok ? 0 : 1); + } +} +printFailedRows(); +console.log(`SHIM_RESULT fail (deadline; last summary: ${lastSummary})`); +process.exit(1); + +// Which rows failed, so a red run is interpretable from stdout alone. +function printFailedRows(): void { + for (const row of Array.from(document.querySelectorAll(FAILED_ROW_SELECTOR))) { + const text = (row.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 200); + console.log("SHIM_FAILED_ROW", text); + } +} diff --git a/rust/crates/truapi-host-cli/js/scripts/pre-login.ts b/rust/crates/truapi-host-cli/js/scripts/pre-login.ts new file mode 100644 index 00000000..13a067c6 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/pre-login.ts @@ -0,0 +1,10 @@ +/// +// Establishes the host session before the main driver runs: the pairing host +// emits its deeplink on the first requestLogin, and the runtime holds the +// resulting session for every later product connection. Mirrors how the dotli +// e2e baseline signs in before driving Diagnosis. +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!(login.isOk() && login.value === "Success")) { + throw new Error(`pre-login failed: ${login.isOk() ? String(login.value) : JSON.stringify(login.error)}`); +} +console.log("PRELOGIN_OK");