diff --git a/.github/workflows/zombienet.yml b/.github/workflows/zombienet.yml
index 6bae401b93..d6dc16a9b7 100644
--- a/.github/workflows/zombienet.yml
+++ b/.github/workflows/zombienet.yml
@@ -68,6 +68,9 @@ jobs:
- job-name: "zombienet-smoldot-0003-statement_store_peer_connection"
test: "statement_store_peer_connection"
runner-type: "default"
+ - job-name: "zombienet-smoldot-0004-statement_store_browser"
+ test: "statement_store_browser"
+ runner-type: "default"
- job-name: "zombienet-smoldot-0007-bulletin_fetch"
test: "bulletin_fetch"
runner-type: "default"
@@ -104,6 +107,20 @@ jobs:
path: e2e-tests/js/node_modules
key: e2e-js-nm-${{ hashFiles('e2e-tests/js/package-lock.json') }}
+ - name: Cache e2e-tests browser node_modules
+ if: matrix.test.test == 'statement_store_browser'
+ uses: actions/cache@v4
+ with:
+ path: e2e-tests/browser/node_modules
+ key: e2e-browser-nm-${{ hashFiles('e2e-tests/browser/package-lock.json') }}
+
+ - name: Cache Playwright browsers
+ if: matrix.test.test == 'statement_store_browser'
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ hashFiles('e2e-tests/browser/package-lock.json') }}
+
- name: Install smoldot JS deps
shell: bash
run: npm ci
@@ -114,6 +131,15 @@ jobs:
run: npm ci
working-directory: e2e-tests/js
+ - name: Install browser test deps and Chromium
+ if: matrix.test.test == 'statement_store_browser'
+ shell: bash
+ working-directory: e2e-tests/browser
+ run: |
+ set -euo pipefail
+ npm ci
+ npx playwright install --with-deps chromium
+
# Pull binaries from the polkadot-sdk release
- name: Download polkadot binaries
shell: bash
diff --git a/.gitignore b/.gitignore
index 0e37630a3f..15f8e43e11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
/target
/e2e-tests/target
/e2e-tests/js/node_modules
+/e2e-tests/browser/node_modules
/benchmarks/target
/benchmarks/js/node_modules
diff --git a/e2e-tests/browser/helpers.js b/e2e-tests/browser/helpers.js
new file mode 100644
index 0000000000..be3b1d9bb5
--- /dev/null
+++ b/e2e-tests/browser/helpers.js
@@ -0,0 +1,108 @@
+// Smoldot
+// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// Node-side helpers for the browser tests.
+
+import http from "node:http";
+import path from "node:path";
+import fs from "node:fs/promises";
+
+export function report(name, passed, detail) {
+ const suffix = detail ? `: ${detail}` : "";
+ if (passed) {
+ console.log(`PASS: ${name}${suffix}`);
+ } else {
+ console.log(`FAIL: ${name}${suffix}`);
+ process.exitCode = 1;
+ }
+}
+
+export function requireEnv(names) {
+ const missing = names.filter((n) => !process.env[n]);
+ if (missing.length > 0) {
+ console.error(`Required env vars: ${missing.join(", ")}`);
+ process.exit(1);
+ }
+}
+
+/// Polls `path` until a line equals `expected`. Pair with `SyncFile` on the Rust side.
+export async function waitForSyncMessage(filePath, expected, timeoutMs = 120_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const contents = await fs.readFile(filePath, "utf8").catch(() => "");
+ if (contents.split("\n").some((line) => line.trim() === expected)) {
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ throw new Error(
+ `Timed out waiting for sync message "${expected}" at ${filePath}`,
+ );
+}
+
+/// Starts a tiny HTTP server that serves files from `pageDir` at `/` and from
+/// `smoldotPkgDir` at `/smoldot/`. Returns the server (already listening on a
+/// random local port).
+export function startStaticServer(pageDir, smoldotPkgDir) {
+ const server = http.createServer(async (req, res) => {
+ try {
+ const reqPath = decodeURIComponent(req.url.split("?")[0]);
+ let filePath;
+ if (reqPath === "/" || reqPath === "/index.html") {
+ filePath = path.join(pageDir, "index.html");
+ } else if (reqPath.startsWith("/smoldot/")) {
+ filePath = path.join(smoldotPkgDir, reqPath.slice("/smoldot/".length));
+ } else {
+ res.statusCode = 404;
+ res.end("not found");
+ return;
+ }
+ const resolved = path.resolve(filePath);
+ if (
+ !resolved.startsWith(path.resolve(pageDir)) &&
+ !resolved.startsWith(path.resolve(smoldotPkgDir))
+ ) {
+ res.statusCode = 403;
+ res.end("forbidden");
+ return;
+ }
+ const data = await fs.readFile(resolved);
+ res.setHeader("Content-Type", contentTypeFor(resolved));
+ res.end(data);
+ } catch (e) {
+ res.statusCode = 500;
+ res.end(String(e));
+ }
+ });
+ return new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => resolve(server));
+ });
+}
+
+function contentTypeFor(filePath) {
+ switch (path.extname(filePath)) {
+ case ".html":
+ return "text/html; charset=utf-8";
+ case ".js":
+ case ".mjs":
+ return "application/javascript; charset=utf-8";
+ case ".wasm":
+ return "application/wasm";
+ default:
+ return "application/octet-stream";
+ }
+}
diff --git a/e2e-tests/browser/package-lock.json b/e2e-tests/browser/package-lock.json
new file mode 100644
index 0000000000..2c6480ea95
--- /dev/null
+++ b/e2e-tests/browser/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "browser",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "playwright": "^1.49.0",
+ "smoldot": "file:../../wasm-node/javascript"
+ }
+ },
+ "../../wasm-node/javascript": {
+ "name": "smoldot",
+ "version": "3.1.1",
+ "license": "GPL-3.0-or-later WITH Classpath-exception-2.0",
+ "dependencies": {
+ "ws": "^8.8.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.7.5",
+ "@types/pako": "^2.0.0",
+ "@types/ws": "^8.5.3",
+ "ava": "^6.0.0",
+ "dtslint": "^4.0.6",
+ "typedoc": "^0.25.4",
+ "typescript": "^5.3.2"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/smoldot": {
+ "resolved": "../../wasm-node/javascript",
+ "link": true
+ }
+ }
+}
diff --git a/e2e-tests/browser/package.json b/e2e-tests/browser/package.json
new file mode 100644
index 0000000000..f677190c88
--- /dev/null
+++ b/e2e-tests/browser/package.json
@@ -0,0 +1,8 @@
+{
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "playwright": "^1.49.0",
+ "smoldot": "file:../../wasm-node/javascript"
+ }
+}
diff --git a/e2e-tests/browser/page/index.html b/e2e-tests/browser/page/index.html
new file mode 100644
index 0000000000..e59e2bc4f9
--- /dev/null
+++ b/e2e-tests/browser/page/index.html
@@ -0,0 +1,11 @@
+
+
+
smoldot browser tests
+
+
+
+
diff --git a/e2e-tests/browser/statement_store_browser.js b/e2e-tests/browser/statement_store_browser.js
new file mode 100644
index 0000000000..bf7d5c706d
--- /dev/null
+++ b/e2e-tests/browser/statement_store_browser.js
@@ -0,0 +1,215 @@
+// Smoldot
+// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// Browser sanity check for smoldot statement-store.
+// 1. Page loads, imports `index-browser.js`, exposes `window.__smoldot`.
+// 2. Page starts smoldot, adds the chains, subscribes to topic_B.
+// 3. Node waits for the harness to write READY into SYNC_PATH — that means
+// smoldot has peered and stmt_B is already in both collators' stores so
+// they will push it during initial statement-store sync.
+// 4. PING: page submits stmt_A through smoldot, asserts {status:"new"}.
+// 5. PONG: page waits for stmt_B to arrive on its subscription.
+
+import { chromium } from "playwright";
+import path from "node:path";
+import url from "node:url";
+import fs from "node:fs/promises";
+import {
+ report,
+ requireEnv,
+ waitForSyncMessage,
+ startStaticServer,
+} from "./helpers.js";
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+requireEnv([
+ "RELAY_CHAIN_SPEC",
+ "PARA_CHAIN_SPEC",
+ "STATEMENT_A_HEX",
+ "STATEMENT_B_HEX",
+ "TOPIC_B",
+ "SYNC_PATH",
+]);
+
+const pageDir = path.join(__dirname, "page");
+const smoldotPkgDir = path.resolve(
+ __dirname,
+ "..",
+ "..",
+ "wasm-node",
+ "javascript",
+);
+
+const server = await startStaticServer(pageDir, smoldotPkgDir);
+const port = server.address().port;
+const pageUrl = `http://127.0.0.1:${port}/`;
+
+const browser = await chromium.launch();
+const context = await browser.newContext();
+const page = await context.newPage();
+page.on("console", (m) => console.error(`[browser:${m.type()}] ${m.text()}`));
+page.on("pageerror", (e) => console.error(`[browser:pageerror] ${e.message}`));
+
+let passed = true;
+
+try {
+ await page.goto(pageUrl);
+ await page.waitForFunction(() => window.__ready === true, { timeout: 30_000 });
+ report("smoldot browser bundle loaded", true);
+
+ const relaySpec = await fs.readFile(process.env.RELAY_CHAIN_SPEC, "utf8");
+ const paraSpec = await fs.readFile(process.env.PARA_CHAIN_SPEC, "utf8");
+
+ // Phase 1: start smoldot, addChain, subscribe. Stash the handles on
+ // `window.__t` so subsequent evaluates can reuse the same client.
+ const subscriptionId = await page.evaluate(
+ async ([relaySpec, paraSpec, topicBHex]) => {
+ const log = (s) => console.log(s);
+
+ const client = window.__smoldot.start({
+ maxLogLevel: 3,
+ forbidTcp: true,
+ logCallback: (level, target, message) => {
+ log(`[smoldot L${level}][${target}] ${message}`);
+ },
+ });
+
+ const relay = await client.addChain({
+ chainSpec: relaySpec,
+ disableJsonRpc: true,
+ });
+ const para = await client.addChain({
+ chainSpec: paraSpec,
+ potentialRelayChains: [relay],
+ statementStore: {},
+ });
+
+ const buf = [];
+ const waiters = [];
+ (async () => {
+ try {
+ for await (const raw of para.jsonRpcResponses) {
+ buf.push(JSON.parse(raw));
+ for (const w of waiters.splice(0)) w();
+ }
+ } catch (_) {}
+ })();
+
+ const waitForResponse = (predicate, timeoutMs) =>
+ new Promise((resolve, reject) => {
+ const deadline = Date.now() + timeoutMs;
+ const tryMatch = () => {
+ for (let i = 0; i < buf.length; i++) {
+ if (predicate(buf[i])) {
+ const hit = buf[i];
+ buf.splice(i, 1);
+ return resolve(hit);
+ }
+ }
+ if (Date.now() >= deadline) return reject(new Error("timeout"));
+ waiters.push(tryMatch);
+ setTimeout(tryMatch, Math.min(500, deadline - Date.now()));
+ };
+ tryMatch();
+ });
+
+ let nextId = 1;
+ const send = (method, params) => {
+ const id = String(nextId++);
+ para.sendJsonRpc(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
+ return id;
+ };
+
+ // Subscribe early so we don't miss stmt_B pushed during initial sync.
+ const subReqId = send("statement_subscribeStatement", [
+ { matchAny: [topicBHex] },
+ ]);
+ const subResp = await waitForResponse((m) => m.id === subReqId, 30_000);
+ if (subResp.error) {
+ throw new Error(`subscribe failed: ${JSON.stringify(subResp.error)}`);
+ }
+
+ window.__t = { client, para, send, waitForResponse };
+ return subResp.result;
+ },
+ [relaySpec, paraSpec, process.env.TOPIC_B],
+ );
+ report("subscribe to topic_B", true, `subId=${subscriptionId}`);
+
+ // Phase 2: wait for the harness to confirm smoldot is peered and stmt_B
+ // is in both collators' stores.
+ await waitForSyncMessage(process.env.SYNC_PATH, "READY", 120_000);
+ report("Rust signalled READY", true);
+
+ // Phase 3: ping (submit stmt_A) + pong (await stmt_B notification).
+ const result = await page.evaluate(
+ async ([stmtAHex, stmtBHex, subscriptionId]) => {
+ const { send, waitForResponse, client } = window.__t;
+
+ const id = send("statement_submit", [stmtAHex]);
+ const resp = await waitForResponse((m) => m.id === id, 30_000);
+ if (resp.error) {
+ return { stage: "ping", error: JSON.stringify(resp.error) };
+ }
+ if (resp.result?.status !== "new") {
+ return { stage: "ping", got: resp.result };
+ }
+
+ await waitForResponse((m) => {
+ if (m.method !== "statement_statement") return false;
+ if (m.params?.subscription !== subscriptionId) return false;
+ const r = m.params.result;
+ if (r?.event !== "newStatements") return false;
+ return (r.data?.statements ?? []).includes(stmtBHex);
+ }, 120_000);
+
+ return { stage: "ok" };
+ },
+ [process.env.STATEMENT_A_HEX, process.env.STATEMENT_B_HEX, subscriptionId],
+ );
+
+ if (result.stage === "ping") {
+ report("ping: statement_submit returned status=new", false, JSON.stringify(result));
+ passed = false;
+ } else if (result.stage === "ok") {
+ report("ping: statement_submit returned status=new", true);
+ report("pong: stmt_B received via subscription", true);
+ } else {
+ report("browser ping-pong", false, JSON.stringify(result));
+ passed = false;
+ }
+
+ if (passed) {
+ // Phase 4: keep smoldot alive so its outbound gossip of stmt_A actually
+ // reaches the collators. The harness signals DONE once alice has observed
+ // stmt_A; only then is it safe to tear the client down.
+ await waitForSyncMessage(process.env.SYNC_PATH, "DONE", 240_000);
+ report("Rust signalled DONE", true);
+ await page.evaluate(() => window.__t.client.terminate().catch(() => {}));
+ }
+} catch (e) {
+ report("browser test", false, e.stack || e.message || String(e));
+ passed = false;
+} finally {
+ await browser.close().catch(() => {});
+ server.close();
+}
+
+if (!passed || process.exitCode) {
+ process.exit(1);
+}
diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs
index be8b706128..fcdeb1eb61 100644
--- a/e2e-tests/src/lib.rs
+++ b/e2e-tests/src/lib.rs
@@ -96,6 +96,67 @@ pub fn ensure_js_deps_installed() {
assert!(status.success(), "npm install in e2e-tests/js failed");
}
+/// Ensures browser test dependencies (Playwright + smoldot) are installed and
+/// that Playwright's bundled Chromium is downloaded.
+pub fn ensure_browser_deps_installed() {
+ let browser_dir = project_root().join("e2e-tests/browser");
+ let node_modules = browser_dir.join("node_modules");
+ if !node_modules.exists() {
+ let status = std::process::Command::new("npm")
+ .arg("install")
+ .current_dir(&browser_dir)
+ .status()
+ .expect("failed to run npm install for browser tests");
+ assert!(
+ status.success(),
+ "npm install in e2e-tests/browser failed"
+ );
+ }
+ // `playwright install chromium` is idempotent and a no-op if the browser
+ // is already cached locally.
+ let status = std::process::Command::new("npx")
+ .args(["playwright", "install", "chromium"])
+ .current_dir(&browser_dir)
+ .status()
+ .expect("failed to run playwright install");
+ assert!(status.success(), "playwright install chromium failed");
+}
+
+/// Runs a Node.js script under `e2e-tests/browser` with the given environment.
+/// Mirrors [`run_js_test`] but the working directory is the browser dir so
+/// that `import { chromium } from 'playwright'` resolves.
+pub async fn run_browser_test(
+ script: &str,
+ env_vars: &[(&str, &str)],
+) -> Result<(), String> {
+ let browser_dir = project_root().join("e2e-tests/browser");
+ let script_path = browser_dir.join(script);
+
+ let mut cmd = tokio::process::Command::new("node");
+ cmd.arg(&script_path);
+ cmd.current_dir(&browser_dir);
+ for (key, val) in env_vars {
+ cmd.env(key, val);
+ }
+
+ let output = cmd.output().await.expect("failed to run node");
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ eprintln!("--- browser stdout ---\n{stdout}");
+ eprintln!("--- browser stderr ---\n{stderr}");
+
+ if output.status.success() {
+ Ok(())
+ } else {
+ Err(format!(
+ "browser test exited with {}\nstdout:\n{}\nstderr:\n{}",
+ output.status, stdout, stderr
+ ))
+ }
+}
+
/// Runs a JS test script with the given environment variables.
///
/// Uses `tokio::process::Command` for async compatibility.
diff --git a/e2e-tests/tests/statement_store_browser.rs b/e2e-tests/tests/statement_store_browser.rs
new file mode 100644
index 0000000000..51275adfef
--- /dev/null
+++ b/e2e-tests/tests/statement_store_browser.rs
@@ -0,0 +1,142 @@
+// Smoldot
+// Copyright (C) 2019-2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use log::info;
+use smoldot_e2e_tests::statement::*;
+use smoldot_e2e_tests::*;
+
+/// Browser sanity check: smoldot's browser bundle running inside headless
+/// Chromium can submit a statement (ping) and receive a gossiped statement
+/// (pong) against a real zombienet network.
+///
+/// Flow:
+/// 1. Spawn alice + bob.
+/// 2. Submit stmt_B to alice; wait for it to reach bob via gossip.
+/// 3. Launch the Playwright runner; the page subscribes to topic_B.
+/// 4. Wait for smoldot to peer with both collators.
+/// 5. Signal READY. The page then submits stmt_A (ping) and waits for
+/// stmt_B to arrive on its subscription (pong).
+/// 6. Wait for stmt_A to reach alice via gossip, then signal DONE so the
+/// page tears down smoldot and exits.
+#[tokio::test(flavor = "multi_thread")]
+async fn browser_ping_pong() -> Result<(), anyhow::Error> {
+ let _ = env_logger::try_init_from_env(
+ env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"),
+ );
+
+ let (seed, pubkey) = test_keypair();
+
+ let base_dir = resolve_base_dir()?;
+ let para_spec_path = create_para_chain_spec_with_allowances(&[pubkey], &base_dir)?;
+ info!("Parachain chain spec created at {}", para_spec_path.display());
+
+ let network = spawn_network(&base_dir, ¶_spec_path).await?;
+ info!("Network spawned");
+
+ let (relay_spec_path, para_spec_path) = spawned_chain_spec_paths(&network)?;
+
+ // Two distinct topics. stmt_A is what the browser submits; stmt_B is what
+ // the browser receives via gossip.
+ let topic_a = [0xaau8; 32];
+ let topic_b = [0xbbu8; 32];
+ let stmt_a_hex = create_test_statement(&seed, &topic_a, b"browser-ping");
+ let stmt_b_hex = create_test_statement(&seed, &topic_b, b"browser-pong");
+
+ let alice = network.get_node("alice")?;
+ let bob = network.get_node("bob")?;
+
+ // Pre-populate stmt_B on the network so collators push it to the browser
+ // light client during initial statement-store sync.
+ let bob_rpc = bob.rpc().await?;
+ let mut bob_sub = subscribe_any(&bob_rpc).await?;
+ submit_statement(alice, &stmt_b_hex, "stmt_B").await?;
+ let received = receive_statements(1, &mut bob_sub, 120).await?;
+ assert!(received.contains(&stmt_b_hex), "stmt_B did not reach bob");
+ info!("stmt_B confirmed on bob via gossip");
+
+ info!("Ensuring smoldot JS bundle is built");
+ ensure_smoldot_built();
+ info!("Ensuring browser test dependencies are installed");
+ ensure_browser_deps_installed();
+
+ let sync = SyncFile::new()?;
+ let sync_path_str = sync.path().to_str().unwrap().to_string();
+
+ let topic_b_hex = format!("0x{}", hex::encode(topic_b));
+ let relay_spec_str = relay_spec_path.to_str().unwrap().to_string();
+ let para_spec_str = para_spec_path.to_str().unwrap().to_string();
+ let stmt_a_hex_js = stmt_a_hex.clone();
+ let stmt_b_hex_js = stmt_b_hex.clone();
+
+ info!("Spawning browser test: browser/statement_store_browser.js");
+ let browser_handle = tokio::spawn(async move {
+ run_browser_test(
+ "statement_store_browser.js",
+ &[
+ ("RELAY_CHAIN_SPEC", relay_spec_str.as_str()),
+ ("PARA_CHAIN_SPEC", para_spec_str.as_str()),
+ ("STATEMENT_A_HEX", stmt_a_hex_js.as_str()),
+ ("STATEMENT_B_HEX", stmt_b_hex_js.as_str()),
+ ("TOPIC_B", topic_b_hex.as_str()),
+ ("SYNC_PATH", sync_path_str.as_str()),
+ ],
+ )
+ .await
+ });
+
+ // Wait for smoldot (running inside the browser) to peer with both
+ // collators at the statement-store level.
+ wait_until_peered(alice, 2, 180).await?;
+ wait_until_peered(bob, 2, 180).await?;
+
+ // Subscribe on alice before signalling READY so we don't miss stmt_A
+ // gossiped from the browser.
+ let alice_rpc = alice.rpc().await?;
+ let mut alice_sub = subscribe_any(&alice_rpc).await?;
+
+ // Smoldot is peered and stmt_B is in both collators' stores; signal the
+ // page to perform the ping and start awaiting the pong.
+ sync.send("READY")?;
+ info!("Signalled browser READY");
+
+ // Verify stmt_A submitted by the browser reached alice via gossip *before*
+ // releasing the browser. Outbound gossip from the browser's smoldot
+ // light-client is asynchronous: `statement_submit` returning `status:"new"`
+ // only proves local insertion. If the page calls `client.terminate()`
+ // immediately after the pong arrives, the in-flight gossip of stmt_A is
+ // aborted on slow runners and alice never sees it. So: keep the browser
+ // alive (DONE handshake below) until alice has actually observed stmt_A.
+ //
+ // Read two statements because the subscription replays stmt_B (already in
+ // alice's store when the subscription was opened) before stmt_A arrives.
+ let received = receive_statements(2, &mut alice_sub, 180).await?;
+ assert!(
+ received.contains(&stmt_a_hex),
+ "stmt_A submitted from the browser did not reach alice"
+ );
+ info!("stmt_A confirmed on alice via gossip");
+
+ // Release the browser — it can now terminate the smoldot client and exit.
+ sync.send("DONE")?;
+ info!("Signalled browser DONE");
+
+ let result = browser_handle.await.expect("browser task panicked");
+ result.map_err(|e| anyhow::anyhow!("browser test failed: {e}"))?;
+
+ info!("Browser sanity check passed");
+ Ok(())
+}