Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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 });
}