From 037cc3eb98f942d2deba4d09ff262bca07a80cdf Mon Sep 17 00:00:00 2001 From: Andrei Eres Date: Wed, 6 May 2026 14:03:27 +0200 Subject: [PATCH 1/6] statement-store: Add browser sanity check zombienet test --- .github/workflows/zombienet.yml | 26 +++ .gitignore | 1 + e2e-tests/browser/helpers.js | 111 ++++++++++ e2e-tests/browser/package-lock.json | 78 +++++++ e2e-tests/browser/package.json | 8 + e2e-tests/browser/page/index.html | 11 + e2e-tests/browser/run.mjs | 232 +++++++++++++++++++++ e2e-tests/src/lib.rs | 61 ++++++ e2e-tests/tests/statement_store_browser.rs | 114 ++++++++++ 9 files changed, 642 insertions(+) create mode 100644 e2e-tests/browser/helpers.js create mode 100644 e2e-tests/browser/package-lock.json create mode 100644 e2e-tests/browser/package.json create mode 100644 e2e-tests/browser/page/index.html create mode 100644 e2e-tests/browser/run.mjs create mode 100644 e2e-tests/tests/statement_store_browser.rs diff --git a/.github/workflows/zombienet.yml b/.github/workflows/zombienet.yml index 42aa7d104c..543126e333 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" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -101,6 +104,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 @@ -111,6 +128,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..02c20b96bb --- /dev/null +++ b/e2e-tests/browser/helpers.js @@ -0,0 +1,111 @@ +// 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 sanity test. Mirrors the role of +// e2e-tests/js/helpers.js for the Node-side scripts: shared utilities used +// by run.mjs (and any future browser scenarios). + +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`, mirroring the JS-side helper +/// in e2e-tests/js/helpers.js. 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..7e101a6735 --- /dev/null +++ b/e2e-tests/browser/page/index.html @@ -0,0 +1,11 @@ + + + smoldot browser sanity + + + + diff --git a/e2e-tests/browser/run.mjs b/e2e-tests/browser/run.mjs new file mode 100644 index 0000000000..20f6713049 --- /dev/null +++ b/e2e-tests/browser/run.mjs @@ -0,0 +1,232 @@ +// 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. +// +// Flow (mirrors e2e-tests/js/statement_store_reception.js, just inside a real +// browser via Playwright): +// 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. +// +// `--self-test` skips zombienet and just verifies the page loads and +// `smoldot.start` is callable in Chromium. + +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)); + +const SELF_TEST = process.argv.includes("--self-test"); + +if (!SELF_TEST) { + 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); + + if (SELF_TEST) { + const hasStart = await page.evaluate( + () => typeof window.__smoldot.start === "function", + ); + report("smoldot.start is callable", hasStart); + } else { + 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; + + let pingResult = null; + for (let attempt = 0; attempt < 10; attempt++) { + 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") { + pingResult = resp.result; + break; + } + await new Promise((r) => setTimeout(r, 5_000)); + } + if (pingResult?.status !== "new") { + return { stage: "ping", got: pingResult }; + } + + 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); + + await client.terminate().catch(() => {}); + 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; + } + } +} 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 1f596b27c6..9445324094 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -95,6 +95,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..99f3ccb5b5 --- /dev/null +++ b/e2e-tests/tests/statement_store_browser.rs @@ -0,0 +1,114 @@ +// 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). +#[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/run.mjs"); + let browser_handle = tokio::spawn(async move { + run_browser_test( + "run.mjs", + &[ + ("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?; + + // 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"); + + 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(()) +} From eb65f695832ffece140b6c9d9df11959668003b9 Mon Sep 17 00:00:00 2001 From: Andrei Eres Date: Wed, 6 May 2026 15:22:04 +0200 Subject: [PATCH 2/6] statement-store: Drop unused --self-test mode from browser runner --- e2e-tests/browser/run.mjs | 280 ++++++++++++++++++-------------------- 1 file changed, 133 insertions(+), 147 deletions(-) diff --git a/e2e-tests/browser/run.mjs b/e2e-tests/browser/run.mjs index 20f6713049..bf4ab773d6 100644 --- a/e2e-tests/browser/run.mjs +++ b/e2e-tests/browser/run.mjs @@ -26,9 +26,6 @@ // 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. -// -// `--self-test` skips zombienet and just verifies the page loads and -// `smoldot.start` is callable in Chromium. import { chromium } from "playwright"; import path from "node:path"; @@ -43,18 +40,14 @@ import { const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const SELF_TEST = process.argv.includes("--self-test"); - -if (!SELF_TEST) { - requireEnv([ - "RELAY_CHAIN_SPEC", - "PARA_CHAIN_SPEC", - "STATEMENT_A_HEX", - "STATEMENT_B_HEX", - "TOPIC_B", - "SYNC_PATH", - ]); -} +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( @@ -82,142 +75,135 @@ try { await page.waitForFunction(() => window.__ready === true, { timeout: 30_000 }); report("smoldot browser bundle loaded", true); - if (SELF_TEST) { - const hasStart = await page.evaluate( - () => typeof window.__smoldot.start === "function", - ); - report("smoldot.start is callable", hasStart); - } else { - 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); - } + 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)}`); - } + } + if (Date.now() >= deadline) return reject(new Error("timeout")); + waiters.push(tryMatch); + setTimeout(tryMatch, Math.min(500, deadline - Date.now())); + }; + tryMatch(); + }); - 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; - - let pingResult = null; - for (let attempt = 0; attempt < 10; attempt++) { - 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") { - pingResult = resp.result; - break; - } - await new Promise((r) => setTimeout(r, 5_000)); + 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; + + let pingResult = null; + for (let attempt = 0; attempt < 10; attempt++) { + 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 (pingResult?.status !== "new") { - return { stage: "ping", got: pingResult }; + if (resp.result?.status === "new") { + pingResult = resp.result; + break; } - - 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); - - await client.terminate().catch(() => {}); - 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; - } + await new Promise((r) => setTimeout(r, 5_000)); + } + if (pingResult?.status !== "new") { + return { stage: "ping", got: pingResult }; + } + + 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); + + await client.terminate().catch(() => {}); + 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; } } catch (e) { report("browser test", false, e.stack || e.message || String(e)); From 7244552ab336cfe5a9586b3ef536f935ac5aa7bc Mon Sep 17 00:00:00 2001 From: Andrei Eres Date: Thu, 7 May 2026 08:43:24 +0200 Subject: [PATCH 3/6] statement-store: Rename browser runner to match Rust test name --- e2e-tests/browser/helpers.js | 2 +- e2e-tests/browser/page/index.html | 2 +- e2e-tests/browser/{run.mjs => statement_store_browser.js} | 0 e2e-tests/tests/statement_store_browser.rs | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename e2e-tests/browser/{run.mjs => statement_store_browser.js} (100%) diff --git a/e2e-tests/browser/helpers.js b/e2e-tests/browser/helpers.js index 02c20b96bb..2300644165 100644 --- a/e2e-tests/browser/helpers.js +++ b/e2e-tests/browser/helpers.js @@ -17,7 +17,7 @@ // Node-side helpers for the browser sanity test. Mirrors the role of // e2e-tests/js/helpers.js for the Node-side scripts: shared utilities used -// by run.mjs (and any future browser scenarios). +// by statement_store_browser.js (and any future browser scenarios). import http from "node:http"; import path from "node:path"; diff --git a/e2e-tests/browser/page/index.html b/e2e-tests/browser/page/index.html index 7e101a6735..e59e2bc4f9 100644 --- a/e2e-tests/browser/page/index.html +++ b/e2e-tests/browser/page/index.html @@ -1,6 +1,6 @@ - smoldot browser sanity + smoldot browser tests