facet-wrapped/examples/gallery_probe.rs
//! **Gallery probe** — mount every graph and map component of the BUILT bundle in a
//! real, headful, WebGPU browser and read back what each one published.
//!
//! The Streamlit robot (`py/tests/robot_streamlit.py`) needs playwright, which is not
//! installed on the build boxes (and python on the host is not allowed). This probe is
//! the same measurement in Rust: it serves the wasm bundle (`facet-wrapped/dist`) and the
//! shipped loader page (`pkg-facet-wrapped/.../assets/index.html`) from a
//! `127.0.0.1` listener — a secure context, so WebGPU is available — opens headful
//! Chrome on it, and a driver page mounts each component in turn in an iframe of the
//! LOADER, through the same `facet:render` message a Streamlit host sends (with the
//! gallery's own payload from `py/facett_demo/demo_payloads.json`). Each
//! iframe's `facet_state()`, `facet_error()`, `facet_backend()`, `facet_adapter()`,
//! `facet_canvas_api()` and (with `--dev`) `facet_navgraph()` is POSTed back.
//!
//! The verdict per component, all from what it publishes (owner ruling 2026-09-28: a
//! GPU always exists; a graph or map component painting on the CPU in a headful
//! browser is a defect; no adapter must read "no GPU" plainly):
//!
//! * mounted, no error, a state;
//! * the canvas is WebGPU on a hardware adapter (`software` false);
//! * no lane readout says CPU (`lane_flag: cpu`, `gpu_lines: false` after frames,
//! `legibility.gpu_cloud: false`, a `layout_fallback`);
//! * with `--dev`: the navgraph spine `facet-wrapped` = `1`, the component = `1.1`.
//!
//! ```bash
//! cargo xtask wrap-wasm
//! WAYLAND_DISPLAY=wayland-0 cargo run -p facet-wrapped --example gallery_probe -- --dev
//! # --groups graph,map (default) | all --chrome <binary> --out <file.json>
//! # --group-dist docs=facet-wrapped-group/dist-docs --group-dist model3d=… (non-core bundles,
//! # routed by the emitted `bundles.json` beside the loader; adds one wrong-bundle refusal check per group)
//! ```
//!
//! Exit code 0 only when every probed component passes.
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
struct Args {
dist: PathBuf,
loader: PathBuf,
payloads: PathBuf,
chrome: String,
groups: Vec<String>,
names: Vec<String>,
dev: bool,
out: Option<PathBuf>,
timeout_s: u64,
port: u16,
no_browser: bool,
headless: bool,
render_node: Option<String>,
verbose: bool,
/// `(group, dist dir)` for every NON-core bundle group to serve (`--group-dist docs=<dir>`).
/// Its entries are read off that dist's own `manifest.json`; its URL prefix is the
/// group's `dir` in the routing table beside the loader.
group_dists: Vec<(String, PathBuf)>,
/// `--no-gpu-flags` (with `--headless`): Chrome WITHOUT the GPU switches — the
/// SwiftShader repro. Every mount must then refuse with "no GPU: software adapter …"
/// in `facet_error()`, never panic.
no_gpu_flags: bool,
}
fn args() -> Args {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf();
let mut a = Args {
dist: root.join("facet-wrapped/dist"),
loader: root.join("pkg-facet-wrapped/src/facett/assets/index.html"),
payloads: root.join("py/facett_demo/demo_payloads.json"),
chrome: "google-chrome-stable".into(),
groups: vec!["graph".into(), "map".into()],
names: Vec::new(),
dev: false,
out: None,
timeout_s: 60,
port: 0,
no_browser: false,
headless: false,
render_node: None,
verbose: false,
group_dists: Vec::new(),
no_gpu_flags: false,
};
let mut it = std::env::args().skip(1);
while let Some(k) = it.next() {
match k.as_str() {
"--dist" => a.dist = it.next().expect("--dist <dir>").into(),
"--loader" => a.loader = it.next().expect("--loader <file>").into(),
"--payloads" => a.payloads = it.next().expect("--payloads <file>").into(),
"--chrome" => a.chrome = it.next().expect("--chrome <bin>"),
"--groups" => a.groups = it.next().expect("--groups a,b").split(',').map(str::to_string).collect(),
// Probe exactly these components, in THIS order (a first-mount-only failure
// shows up as whichever name comes first).
"--names" => a.names = it.next().expect("--names a,b").split(',').map(str::to_string).collect(),
"--dev" => a.dev = true,
"--out" => a.out = Some(it.next().expect("--out <file>").into()),
"--timeout" => a.timeout_s = it.next().expect("--timeout <s>").parse().expect("seconds"),
// Serve on a fixed port and open no browser: point a GPU browser elsewhere at it
// (an ssh tunnel keeps it 127.0.0.1, which is a secure context).
"--port" => a.port = it.next().expect("--port <n>").parse().expect("port"),
"--no-browser" => a.no_browser = true,
// HEADLESS Chrome (no window on a screen someone works at) on the real GPU —
// see `chrome_args`. `--render-node` names the DRM node (default: the helper's
// discovery — $FACETT_BENCH_RENDER_NODE, else the first /dev/dri/renderD*).
"--headless" => a.headless = true,
"--no-gpu-flags" => a.no_gpu_flags = true,
"--render-node" => a.render_node = Some(it.next().expect("--render-node <path>")),
"--verbose" => a.verbose = true,
// A non-core bundle group's dist (the recipe's `dist_dir` for that group, e.g.
// what `cargo xtask wrap-wasm` wrote): served at the group's routing `dir`.
"--group-dist" => {
let v = it.next().expect("--group-dist <group>=<dir>");
let (g, d) = v.split_once('=').expect("--group-dist <group>=<dir>");
a.group_dists.push((g.to_string(), d.into()));
}
other => panic!("unknown argument {other}"),
}
}
a
}
/// The Chrome flag set, one per mode. Every run is a SEPARATE instance: the caller adds a
/// throwaway `--user-data-dir`, so the probe never opens a tab in anyone's own Chrome.
///
/// * **headless** (a screen someone works at): the ONE headless flag set,
/// [`facet_wrapped::browser_bench::headless_gpu_args_on`] — shared with the browser
/// bench, so the two cannot drift.
/// * **headful** (a desktop session): the discrete GPU by power preference, plus the ONE
/// headful flag set, [`facet_wrapped::browser_bench::headful_gpu_args`] (Vulkan +
/// ANGLE on Vulkan + `--enable-gpu --ignore-gpu-blocklist` + the session's ozone
/// platform) — shared with the browser bench. Without the Vulkan flags a headful
/// Wayland window on oden's RTX 4090 got no WebGPU adapter on any mount (0/48,
/// 2026-09-29).
fn chrome_args(headless: bool, render_node: Option<&str>) -> Vec<String> {
let mut v: Vec<String> = ["--no-first-run", "--no-default-browser-check", "--password-store=basic"]
.map(String::from)
.to_vec();
if headless {
v.push("--enable-unsafe-webgpu".into());
v.extend(facet_wrapped::browser_bench::headless_gpu_args_on(render_node).unwrap_or_else(|e| panic!("--headless: {e}")));
} else {
// The DISCRETE GPU: Chrome defaults WebGPU to the low-power adapter, and on loki
// that is the Raphael iGPU, whose GPU process died with "radv/amdgpu: Not enough
// memory for command submission" (2026-09-28) while the RTX 5080 sat idle.
v.push("--use-webgpu-power-preference=force-high-performance".into());
let ozone = if std::env::var_os("WAYLAND_DISPLAY").is_some() { "wayland" } else { "x11" };
v.extend(facet_wrapped::browser_bench::headful_gpu_args(ozone));
}
v
}
/// The driver page: one loader iframe per job, mounted by message, read back, posted.
const DRIVER: &str = r#"<!doctype html><meta charset="utf-8"><title>gallery probe</title>
<body style="margin:0;background:#111"><script type="module">
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function until(f, ms) { const t = performance.now(); while (performance.now() - t < ms) { try { if (f()) return true; } catch (_) {} await sleep(200); } return false; }
const jobs = await (await fetch('/jobs')).json();
for (const j of jobs) {
const f = document.createElement('iframe');
f.style.cssText = 'width:900px;height:600px;border:0';
f.src = '/index.html';
document.body.appendChild(f);
await Promise.race([new Promise(r => f.onload = r), sleep(20000)]);
const w = f.contentWindow;
// A wasm panic goes to console.error (console_error_panic_hook): keep it.
const errs = [];
try { const orig = w.console.error.bind(w.console); w.console.error = (...a) => { errs.push(a.map(String).join(' ').slice(0, 4000)); orig(...a); }; } catch (_) {}
const ready = await until(() => w.facet_ready === true, 30000);
// The wrong-bundle check: mount `pre` first (it loads ITS bundle), then ask for `name`,
// which another bundle holds — the loader must refuse, naming that bundle.
if (j.pre) {
w.postMessage({ type: 'facet:render', args: { component: j.pre, data: j.pre_data, height: 600 } }, '*');
await until(() => w.facet_mounted() === j.pre || !!w.facet_error(), j.timeout_ms);
}
w.postMessage({ type: 'facet:render', args: { component: j.name, data: j.data, dev: j.dev, nav: 'labels', height: 600 } }, '*');
const mounted = await until(() => w.facet_mounted() === j.name || !!w.facet_error(), j.timeout_ms);
// Let frames run so every lane readout has been through a real paint.
await sleep(3000);
const read = (k) => { try { return typeof w[k] === 'function' ? w[k]() : null; } catch (e) { return 'threw: ' + e; } };
// --dev: read the navgraph back on SEVERAL frames. A pointer move over the canvas makes
// eframe paint a new frame (it is reactive); each sample carries its `frame` number, so
// the verdict can prove it saw distinct frames and asserts the numbers on every one.
const navs = [];
if (j.dev) {
for (let k = 0; k < 6; k++) {
// A synthetic pointer event does not reach eframe (measured headless on the t14s,
// 2026-09-29: frame stuck at 3 on every idle pane), so the nudge is a 1-px RESIZE of
// the iframe: eframe repaints on every canvas resize.
f.style.width = (k % 2 ? 900 : 901) + 'px';
await sleep(250);
navs.push(read('facet_navgraph'));
}
}
const res = { id: j.id, name: j.name, bundle: w.facet_bundle ?? null, ready, mounted, mounted_name: read('facet_mounted'), error: read('facet_error'),
state: read('facet_state'), backend: read('facet_backend'), adapter: read('facet_adapter'),
canvas_api: read('facet_canvas_api'), navgraph: j.dev ? read('facet_navgraph') : null, navgraph_frames: navs,
dev_on: w.facet_dev_on ?? null, console_errors: errs.slice(0, 5) };
await fetch('/result', { method: 'POST', body: JSON.stringify(res) });
f.remove();
}
await fetch('/done', { method: 'POST', body: '{}' });
</script>"#;
struct Shared {
jobs: String,
results: Vec<Value>,
done: bool,
}
fn serve(stream: TcpStream, a: &Args, shared: &Arc<Mutex<Shared>>) -> std::io::Result<()> {
let mut r = BufReader::new(stream.try_clone()?);
let mut line = String::new();
r.read_line(&mut line)?;
let mut parts = line.split_whitespace();
let (method, path) = (parts.next().unwrap_or(""), parts.next().unwrap_or("/").to_string());
let mut len = 0usize;
loop {
let mut h = String::new();
if r.read_line(&mut h)? == 0 || h == "\r\n" {
break;
}
if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; len];
r.read_exact(&mut body)?;
let path = path.split('?').next().unwrap_or("/").to_string();
if a.verbose {
eprintln!("probe: {method} {path} ({len} bytes)");
}
let (status, ctype, bytes): (&str, &str, Vec<u8>) = match (method, path.as_str()) {
("GET", "/") => ("200 OK", "text/html", DRIVER.as_bytes().to_vec()),
("GET", "/jobs") => ("200 OK", "application/json", shared.lock().unwrap().jobs.clone().into_bytes()),
("GET", "/index.html") => ("200 OK", "text/html", std::fs::read(&a.loader)?),
// The routing table the loader reads — the EMITTED one beside the loader page.
("GET", "/bundles.json") => ("200 OK", "application/json", std::fs::read(routing_path(a))?),
("GET", p) if p.ends_with(".js") => ("200 OK", "text/javascript", std::fs::read(bundle_file(a, &p[1..]))?),
("GET", p) if p.ends_with(".wasm") => ("200 OK", "application/wasm", std::fs::read(bundle_file(a, &p[1..]))?),
("GET", p) if p.ends_with(".json") => ("200 OK", "application/json", std::fs::read(bundle_file(a, &p[1..])).unwrap_or_default()),
("POST", "/result") => {
let v: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
shared.lock().unwrap().results.push(v);
("200 OK", "application/json", b"{}".to_vec())
}
("POST", "/done") => {
shared.lock().unwrap().done = true;
("200 OK", "application/json", b"{}".to_vec())
}
_ => ("404 Not Found", "text/plain", b"not found".to_vec()),
};
let mut s = stream;
write!(
s,
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
bytes.len()
)?;
s.write_all(&bytes)?;
s.flush()
}
/// The routing table beside the loader page (facett-build-thing emits it there).
fn routing_path(a: &Args) -> PathBuf {
a.loader.with_file_name("bundles.json")
}
fn routing(a: &Args) -> Value {
let p = routing_path(a);
let text = std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("routing table {}: {e}", p.display()));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("routing table {}: {e}", p.display()))
}
/// The file behind a bundle URL: `<dir>/<file>` of a routed group from its `--group-dist`,
/// anything else from the core `--dist`.
fn bundle_file(a: &Args, rel: &str) -> PathBuf {
if let Some((dir, file)) = rel.split_once('/') {
let r = routing(a);
let group = r["groups"].as_array().and_then(|gs| gs.iter().find(|g| g["dir"] == dir));
if let Some(name) = group.and_then(|g| g["name"].as_str())
&& let Some((_, d)) = a.group_dists.iter().find(|(n, _)| n == name)
{
return d.join(file);
}
}
a.dist.join(rel)
}
fn parse_json_str(v: &Value) -> Value {
match v.as_str() {
Some(s) => serde_json::from_str(s).unwrap_or(Value::String(s.to_string())),
None => v.clone(),
}
}
/// `(ok, why, lanes)` for one result.
fn verdict(r: &Value, dev: bool) -> (bool, String, String) {
let name = r["name"].as_str().unwrap_or("?");
let err = r["error"].as_str().unwrap_or("");
if !err.is_empty() {
return (false, format!("error: {err}"), String::new());
}
if r["mounted_name"].as_str() != Some(name) {
return (false, format!("never mounted (ready={})", r["ready"]), String::new());
}
let state = parse_json_str(&r["state"]);
let adapter = parse_json_str(&r["adapter"]);
let mut lanes = Vec::new();
let mut bad = Vec::new();
let backend = r["backend"].as_str().unwrap_or("");
let api = r["canvas_api"].as_str().unwrap_or("");
if backend != "webgpu" || (!api.is_empty() && api != "webgpu") {
bad.push(format!("backend={backend} canvas={api}"));
}
// A CPU rasteriser is not a GPU: the ONE check, shared with the browser bench — the
// recorded `software` flag and the swiftshader/llvmpipe/lavapipe/softpipe words. Only
// the chosen device's own fields go in: `considered` / `reason` list the OTHER
// candidates, and a Radeon picked over llvmpipe must not read as llvmpipe.
let chosen = json!({ "name": adapter["name"], "driver": adapter["driver"], "arch": adapter["arch"], "software": adapter["software"] });
if !adapter.is_null()
&& let Some(why) = facet_wrapped::browser_bench::software_adapter(&json!({ "gpu_probe": chosen }))
{
bad.push(format!("SOFTWARE adapter {} ({why})", adapter["name"]));
}
// Every lane readout the facett components publish, wherever it sits in the state.
fn walk(v: &Value, path: &str, out: &mut Vec<(String, Value)>) {
match v {
Value::Object(m) => {
for (k, x) in m {
let p = if path.is_empty() { k.clone() } else { format!("{path}.{k}") };
if matches!(
k.as_str(),
"lane_flag" | "gpu_lines" | "gpu_cloud" | "layout_lane" | "layout_fallback" | "gpu_lane_selected"
| "used_gpu" | "backend" | "gpu_render_cpu_fallback" | "lane"
) && !x.is_object()
&& !x.is_array()
{
out.push((p.clone(), x.clone()));
}
walk(x, &p, out);
}
}
Value::Array(a) => {
for x in a.iter().take(4) {
walk(x, path, out);
}
}
_ => {}
}
}
// How many adapter requests the mount needed (`adapter_retry`; > 1 = the browser refused first).
if let Some(n) = state["adapter_attempts"].as_u64() {
lanes.push(format!("adapter_attempts={n}"));
}
let mut found = Vec::new();
walk(&state, "", &mut found);
for (k, v) in &found {
lanes.push(format!("{k}={v}"));
let leaf = k.rsplit('.').next().unwrap_or(k);
let cpu = match leaf {
"lane_flag" => v == "cpu",
"gpu_lines" | "gpu_cloud" | "used_gpu" | "gpu_lane_selected" => v == &json!(false),
"layout_fallback" => !v.is_null() && v != "" && v != &json!(false),
"gpu_render_cpu_fallback" => v == &json!(true),
_ => false,
};
if cpu {
bad.push(format!("{k}={v}"));
}
}
if dev {
let numbers = |nav: &Value| -> BTreeMap<String, String> {
nav["nodes"]
.as_array()
.map(|a| {
a.iter()
.map(|n| (n["id"].as_str().unwrap_or("").to_string(), n["number"].as_str().unwrap_or("").to_string()))
.collect()
})
.unwrap_or_default()
};
let spine_ok = |nodes: &BTreeMap<String, String>| {
nodes.get("facet-wrapped").map(String::as_str) == Some("1")
&& nodes.get(&format!("facet-wrapped.{name}")).map(String::as_str) == Some("1.1")
};
let nav = parse_json_str(&r["navgraph"]);
let nodes = numbers(&nav);
lanes.push(format!("nav={} nodes", nodes.len()));
if !spine_ok(&nodes) {
bad.push(format!("navgraph spine missing (dev_on={})", r["dev_on"]));
}
// PER FRAME: every sample must carry the declared spine, the spine numbers must not
// move between frames, and the samples must come from distinct frames.
let samples: Vec<Value> =
r["navgraph_frames"].as_array().map(|a| a.iter().map(parse_json_str).collect()).unwrap_or_default();
let frames: Vec<u64> = samples.iter().filter_map(|s| s["frame"].as_u64()).collect();
let mut distinct = frames.clone();
distinct.dedup();
let bad_frames: Vec<u64> =
samples.iter().filter(|s| !spine_ok(&numbers(s))).map(|s| s["frame"].as_u64().unwrap_or(0)).collect();
let counts: Vec<u64> = samples.iter().filter_map(|s| s["count"].as_u64()).collect();
let (guessed, undeclared): (u64, u64) = samples
.last()
.map(|s| (s["guessed"].as_u64().unwrap_or(0), s["undeclared"].as_u64().unwrap_or(0)))
.unwrap_or((0, 0));
lanes.push(format!(
"nav_frames={} distinct={} frames={:?} counts={:?} guessed={guessed} undeclared={undeclared}",
samples.len(),
distinct.len(),
frames,
counts
));
if samples.is_empty() || !bad_frames.is_empty() {
bad.push(format!("navgraph spine missing on frames {bad_frames:?} of {}", samples.len()));
}
if distinct.len() < 2 {
bad.push(format!("navgraph read on {} distinct frame(s) only: {frames:?}", distinct.len()));
}
}
// A wasm panic (console_error_panic_hook) is a failure even when a state was read
// first: the canvas stopped at that frame.
if let Some(e) = r["console_errors"].as_array().and_then(|a| a.iter().find(|e| e.as_str().is_some_and(|s| s.contains("panicked")))) {
bad.push(format!("PANIC: {}", e.as_str().unwrap_or("")));
}
let lanes = if lanes.is_empty() { "no lane readout (egui painter on the WebGPU canvas)".to_string() } else { lanes.join(" ") };
(bad.is_empty(), bad.join("; "), format!("{lanes} adapter={}", adapter["name"]))
}
fn main() {
let a = args();
let reg = facet_wrapped::Registry::with_builtins();
let table: Value = serde_json::from_str(&std::fs::read_to_string(&a.payloads).expect("payload table")).expect("json");
let route = routing(&a);
let bundle_of = |n: &str| -> String {
route["groups"]
.as_array()
.and_then(|gs| gs.iter().find(|g| g["entries"].as_array().is_some_and(|e| e.iter().any(|x| x == n))))
.and_then(|g| g["name"].as_str())
.or_else(|| route["default"].as_str())
.unwrap_or("?")
.to_string()
};
// Every bundle's entries: core's from this build's registry, each non-core group's
// from ITS OWN manifest (the one `wrap-wasm` captured into its dist).
let mut entries: Vec<Value> = reg.manifest_json().as_array().unwrap().clone();
for (g, d) in &a.group_dists {
let m: Value = serde_json::from_str(
&std::fs::read_to_string(d.join("manifest.json")).unwrap_or_else(|e| panic!("--group-dist {g}: {e}")),
)
.expect("group manifest json");
entries.extend(m.as_array().cloned().unwrap_or_default());
}
// A routed group nobody supplied cannot be probed: say so rather than skip it quietly.
if a.groups.iter().any(|g| g == "all") {
for g in route["groups"].as_array().into_iter().flatten() {
let name = g["name"].as_str().unwrap_or("");
if name != route["default"].as_str().unwrap_or("") && !a.group_dists.iter().any(|(n, _)| n == name) {
eprintln!("gallery probe: bundle group `{name}` NOT probed — pass --group-dist {name}=<its dist dir>");
}
}
}
let names: Vec<(String, String)> = if a.names.is_empty() {
entries
.iter()
.filter(|e| a.groups.iter().any(|g| g == "all" || e["group"] == g.as_str()))
.map(|e| (e["name"].as_str().unwrap().to_string(), e["group"].as_str().unwrap().to_string()))
.collect()
} else {
a.names
.iter()
.map(|n| {
let e = entries.iter().find(|e| e["name"] == n.as_str()).unwrap_or_else(|| panic!("unknown component {n}"));
(n.clone(), e["group"].as_str().unwrap().to_string())
})
.collect()
};
let payload = |n: &str| -> Value {
// A grouped entry's payload sits under `feature_payloads.<group>` (it is not in the
// core bundle): look there when the core table has none.
table["payloads"]
.get(n)
.cloned()
.or_else(|| table["feature_payloads"].as_object()?.values().find_map(|f| f.get(n).cloned()))
.unwrap_or(Value::Null)
};
let mut jobs: Vec<Value> = names
.iter()
.map(|(n, _)| {
json!({ "id": n, "name": n, "data": payload(n), "expect_bundle": bundle_of(n),
"dev": a.dev, "timeout_ms": a.timeout_s * 1000 })
})
.collect();
// THE WRONG-BUNDLE CHECK, once per probed non-core bundle: a frame that loaded the
// core bundle (by mounting a core entry) is asked for that bundle's first entry and
// must refuse, naming it.
let core_pick = names.iter().map(|(n, _)| n).find(|n| bundle_of(n) == route["default"].as_str().unwrap_or(""));
let mut seen = std::collections::BTreeSet::new();
let mut wrong: Vec<(String, String, String)> = Vec::new();
if let Some(pre) = core_pick {
for (n, _) in &names {
let b = bundle_of(n);
if b != route["default"].as_str().unwrap_or("") && seen.insert(b.clone()) {
let id = format!("wrong_bundle:{n}");
jobs.push(json!({ "id": id, "name": n, "data": payload(n), "pre": pre, "pre_data": payload(pre),
"dev": false, "timeout_ms": a.timeout_s * 1000 }));
wrong.push((id, n.clone(), b));
}
}
}
let shared = Arc::new(Mutex::new(Shared { jobs: Value::Array(jobs).to_string(), results: Vec::new(), done: false }));
let listener = TcpListener::bind(("127.0.0.1", a.port)).expect("bind");
let port = listener.local_addr().unwrap().port();
let a = Arc::new(a);
{
let (a, shared) = (a.clone(), shared.clone());
std::thread::spawn(move || {
for s in listener.incoming().flatten() {
let (a, shared) = (a.clone(), shared.clone());
std::thread::spawn(move || {
let _ = serve(s, &a, &shared);
});
}
});
}
let profile = std::env::temp_dir().join(format!("gallery-probe-{port}"));
let mut cmd = std::process::Command::new(&a.chrome);
let cargs = if a.headless && a.no_gpu_flags {
// The page console (a wasm panic, the refusal) on stderr.
["--no-first-run", "--no-default-browser-check", "--password-store=basic", "--enable-unsafe-webgpu", "--headless=new", "--enable-logging=stderr", "--v=0"]
.map(String::from)
.to_vec()
} else {
chrome_args(a.headless, a.render_node.as_deref())
};
cmd.arg(format!("--user-data-dir={}", profile.display())).args(cargs);
cmd.arg(format!("http://127.0.0.1:{port}/"));
let mut chrome = if a.no_browser { None } else { Some(cmd.spawn().expect("start chrome")) };
println!("gallery probe: {} components on http://127.0.0.1:{port}/ (dev={})", names.len(), a.dev);
let deadline = Instant::now() + Duration::from_secs(a.timeout_s * (names.len() + wrong.len()) as u64 + 120);
while !shared.lock().unwrap().done && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(500));
}
if let Some(c) = chrome.as_mut() {
let _ = c.kill();
}
let _ = std::fs::remove_dir_all(&profile);
let results = shared.lock().unwrap().results.clone();
let mut pass = 0;
let mut rows = Vec::new();
for (n, g) in &names {
let r = results.iter().find(|r| r["id"] == n.as_str()).cloned().unwrap_or(json!({ "name": n }));
let (mut ok, mut why, lanes) = verdict(&r, a.dev);
// The ROUTE: the component came from the bundle the routing table names.
let want = bundle_of(n);
if r["bundle"].as_str() != Some(want.as_str()) {
ok = false;
why = format!("{why}{}loaded from bundle {} — the routing table says `{want}`", if why.is_empty() { "" } else { "; " }, r["bundle"]);
}
pass += usize::from(ok);
println!("{} {:6} {:14} bundle={} {} {}", if ok { "PASS" } else { "FAIL" }, g, n, r["bundle"], lanes, why);
rows.push(json!({ "name": n, "group": g, "bundle": r["bundle"], "ok": ok, "why": why, "lanes": lanes, "raw": r }));
}
let mut wrong_pass = 0;
for (id, n, b) in &wrong {
let r = results.iter().find(|r| r["id"] == id.as_str()).cloned().unwrap_or(json!({ "id": id }));
let err = r["error"].as_str().unwrap_or("");
let ok = err.contains(&format!("`{b}` bundle")) && r["mounted_name"].as_str() != Some(n.as_str());
wrong_pass += usize::from(ok);
println!("{} wrong_bundle {n:14} loaded={} refusal={err:?}", if ok { "PASS" } else { "FAIL" }, r["bundle"]);
rows.push(json!({ "name": id, "group": "wrong_bundle", "bundle": r["bundle"], "ok": ok, "why": err, "raw": r }));
}
println!("\n{pass}/{} passed, wrong-bundle refusals {wrong_pass}/{}", names.len(), wrong.len());
if let Some(out) = &a.out {
std::fs::write(out, serde_json::to_string_pretty(&rows).unwrap()).expect("write --out");
}
std::process::exit(if pass == names.len() && wrong_pass == wrong.len() { 0 } else { 1 });
}