aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-05 20:26:42 +0100
committertv <tv@krebsco.de>2026-11-05 20:51:21 +0100
commit7d7c83616cc434f11a30b3e7f4577a55206cc76d (patch)
tree24f3a7b1b2a82cb9d43809b681d41832b497de71 /src
parentc9065ee56c6e857f6ceddb78c4663bf9d82f0ac8 (diff)
dashboard: browser checks
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/check.js87
-rw-r--r--src/api/dashboard/mod.rs27
2 files changed, 114 insertions, 0 deletions
diff --git a/src/api/dashboard/check.js b/src/api/dashboard/check.js
new file mode 100644
index 0000000..95f805a
--- /dev/null
+++ b/src/api/dashboard/check.js
@@ -0,0 +1,87 @@
+// Browser checks of the dashboard page (`cargo test --features dashboard
+// dashboard_in_a_browser -- --ignored`, which serves this the page as the node does): the
+// page loads without errors; the peers table has a row per peer, and an update changes
+// cells without making the rows anew (`morphChildren`); the map draws a node per member;
+// and with 300 members, laid out by round trip, the first drawing doesn't hold the page up
+// for long (`LAYOUT_FOR_MS`).
+//
+// Needs Node.js, Playwright's library (`playwright-core`) and its Chromium; `check.nix`
+// gives all three from nixpkgs.
+//
+// Usage: node check.js <page.html>
+
+const { chromium } = require("playwright-core");
+
+const page = process.argv[2];
+const failures = [];
+const check = (ok, what) => {
+ if (!ok) failures.push(what);
+ console.log((ok ? "ok " : "FAIL ") + what);
+};
+
+(async () => {
+ const browser = await chromium.launch();
+ const tab = await browser.newPage({ viewport: { width: 1400, height: 900 } });
+ const errors = [];
+ tab.on("pageerror", error => errors.push(String(error)));
+ await tab.goto("file://" + page);
+ await tab.waitForTimeout(300);
+
+ // The peers table, and an update to it.
+ const peers = await tab.evaluate(() => {
+ const peer = (alias, rtt) => ({ alias, status: "active", lastSeenSecsAgo: 1,
+ destination: { address: "sim:" + alias, paths: [{ rttMs: rtt }] }, clock: null,
+ neighbour: true, sameEpoch: true, epoch: { generation: 1, id: "ab" },
+ reaches: ["a"], hopDelay: null });
+ const members = { a: { self: true, alias: "a" }, b: { alias: "b" }, c: { alias: "c" },
+ d: { alias: "d" } };
+ state = { node: { id: "a", alias: "a" }, stats: {},
+ peers: { localReaches: ["b", "c", "d"],
+ peers: { b: peer("b", 10), c: peer("c", 20), d: peer("d", 30) } },
+ graph: { members, intended: {}, measured: {} } };
+ renderPeers();
+ const before = [...document.querySelectorAll("#peers tr")];
+ state.peers.peers.c.destination.paths[0].rttMs = 25;
+ renderPeers();
+ const after = [...document.querySelectorAll("#peers tr")];
+ return { rows: after.length, same: before.every((row, i) => row === after[i]),
+ rtt: after[2].textContent.includes("25.0 ms") };
+ });
+ check(peers.rows === 4, "the peers table has a header and a row per peer");
+ check(peers.same, "an update keeps the table's rows");
+ check(peers.rtt, "an update changes what changed");
+
+ // The map, small and large.
+ for (const [n, budget] of [[13, 500], [300, 1500]]) {
+ const map = await tab.evaluate(n => {
+ const ids = [...Array(n).keys()].map(i => "n" + String(i).padStart(3, "0"));
+ const members = {};
+ for (const id of ids) members[id] = { alias: id, self: id === "n000" };
+ const intended = {}, measured = {};
+ for (let i = 0; i < n; i++) {
+ const neighbours = [1, 2, 7, 13].map(k => ids[(i + k) % n]).filter(id => id !== ids[i]);
+ intended[ids[i]] = neighbours;
+ measured[ids[i]] = { secsAgo: 1, links: neighbours.map(peer => ({ peer,
+ rttMs: 20 + (i * 37) % 200, dormant: false, down: false, others: [] })) };
+ }
+ state = { node: { id: "n000", alias: "n000" }, stats: {},
+ peers: { localReaches: [], peers: {} }, graph: { members, intended, measured } };
+ byRtt = true; relayout = true; lastShape = null;
+ const started = performance.now();
+ renderGraph();
+ const ms = performance.now() - started;
+ const nodes = [...drawn.keys()].filter(key => key.startsWith("node ")).length;
+ return { ms, nodes };
+ }, n);
+ check(map.nodes === n, `the map draws a node per member (${map.nodes} of ${n})`);
+ check(map.ms < budget, `${n} members drawn in ${map.ms.toFixed(0)} ms (under ${budget})`);
+ }
+
+ await tab.waitForTimeout(500);
+ check(errors.length === 0, "no errors on the page" + (errors.length ? ": " + errors : ""));
+ await browser.close();
+ process.exit(failures.length ? 1 : 0);
+})().catch(error => {
+ console.error(error);
+ process.exit(2);
+});
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 4ba25c7..bea5493 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -760,6 +760,33 @@ fn snapshot_event(published: &Published) -> Event {
mod tests {
use super::*;
+ /// The page in a browser (`check.js`): it loads without errors, the peers table and
+ /// the map draw what the state says, and a large map doesn't hold the page up.
+ /// Needs Node.js with Playwright's library and its Chromium (`check.nix` gives them),
+ /// so only when asked: `cargo test --features dashboard dashboard_in_a_browser --
+ /// --ignored`.
+ #[test]
+ #[ignore]
+ fn dashboard_in_a_browser() {
+ let file =
+ std::env::temp_dir().join(format!("continuum-dashboard-{}.html", std::process::id()));
+ std::fs::write(&file, &page().1).expect("page written");
+ let check =
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/api/dashboard/check.js");
+ let status = std::process::Command::new("node")
+ .arg(&check)
+ .arg(&file)
+ .status()
+ .unwrap_or_else(|error| {
+ panic!(
+ "Node.js doesn't run ({error}): the browser checks need `node` on the \
+ PATH, with Playwright's library and its Chromium (check.nix)"
+ )
+ });
+ let _ = std::fs::remove_file(&file);
+ assert!(status.success(), "the browser checks failed");
+ }
+
#[test]
fn merge_patches() {
let old = json!({ "a": 1, "b": { "c": 2, "d": [1, 2] }, "gone": true });