aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/search_fanout_harness.mjs')
-rw-r--r--packages/meshbay-hub/tests/harness/search_fanout_harness.mjs144
1 files changed, 144 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
new file mode 100644
index 0000000..74b4bb3
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
@@ -0,0 +1,144 @@
+// Run the SHIPPED `fetchAllIndexes` against a fake clock and report *when the
+// reader sees something*.
+//
+// The question this answers is not how long the whole sweep takes. It is how
+// long a group whose node is down makes everybody else wait — which is a
+// property of the fan-out, not of any one connection's deadline.
+//
+// `fetchAllIndexes`, `inFlight` and the concurrency ceiling are lifted out of
+// search-page.js as text
+// and executed; what is modelled is the clock and one group's reachability.
+//
+// Usage: node search_fanout_harness.mjs <path to search-page.js> <json config>
+import { readFileSync } from 'fs';
+
+const src = readFileSync(process.argv[2], 'utf8');
+const cfg = JSON.parse(process.argv[3] || '{}');
+
+const {
+ groups = 12,
+ // Indexes (0-based, in the hub's listing order) of the groups whose node is
+ // down. They take `deadMs`; every other group answers in `liveMs`.
+ dead = [],
+ liveMs = 200,
+ deadMs = 10000,
+ // Group indexes the browser remembers as silent from a previous sweep.
+ knownDown = null,
+ runUntil = 600000,
+} = cfg;
+
+// The browser's own store, modelled: a first visit has nothing, and a private
+// window throws on both halves.
+const refuses = knownDown === 'refuses';
+const stored = (knownDown === null || refuses)
+ ? null
+ : JSON.stringify(knownDown.map((n) => `g-${n}`));
+let written = null;
+globalThis.localStorage = {
+ getItem: () => {
+ if (refuses) throw new Error('storage disabled');
+ return stored;
+ },
+ setItem: (_k, v) => {
+ if (refuses) throw new Error('storage disabled');
+ written = v;
+ },
+};
+
+// ── The clock ────────────────────────────────────────────────────────────────
+
+let now = 0;
+let nextId = 1;
+let timers = [];
+globalThis.setTimeout = (fn, ms) => {
+ const t = { at: now + (ms || 0), fn, id: nextId++ };
+ timers.push(t);
+ return t.id;
+};
+globalThis.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); };
+
+const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); };
+
+async function run(until) {
+ await flush();
+ while (timers.length) {
+ const due = timers.reduce((a, b) => (b.at < a.at ? b : a));
+ if (due.at > until) break;
+ timers = timers.filter((t) => t !== due);
+ now = due.at;
+ due.fn();
+ await flush();
+ }
+ now = until;
+}
+
+// ── The environment ──────────────────────────────────────────────────────────
+
+const shown = []; // [{ at, groups }] — one entry per render the reader gets
+
+const session = { bundleKey: 'k' };
+const _loadBundleKey = async () => 'k';
+const cacheGroupIndex = () => {};
+
+// One group's index, answered on the clock rather than over a network.
+const fetchGroupIndex = (groupId) => new Promise((resolve, reject) => {
+ const n = Number(String(groupId).split('-')[1]);
+ const isDead = dead.includes(n);
+ setTimeout(
+ () => (isDead ? reject(new Error('Connection timeout'))
+ : resolve({ entries: [{ id: `e${n}` }], roots: {} })),
+ isDead ? deadMs : liveMs,
+ );
+});
+
+// ── The code under test, as text ─────────────────────────────────────────────
+
+const ceiling = /^const MAX_IN_FLIGHT = (\d+);/m.exec(src);
+if (!ceiling) throw new Error('MAX_IN_FLIGHT is gone from search-page.js');
+
+// Both functions, because the sweep is the two of them: `inFlight` decides what
+// runs when, and lifting only its caller would leave the harness measuring a
+// concurrency it had supplied itself.
+const lift = (signature) => {
+ const start = src.indexOf(signature);
+ if (start < 0) throw new Error(`${signature} is gone from search-page.js`);
+ return src.slice(start, src.indexOf('\n}\n', start) + 3);
+};
+
+const make = new Function(
+ 'session', '_loadBundleKey', 'cacheGroupIndex', 'fetchGroupIndex', 'localStorage',
+ `const MAX_IN_FLIGHT = ${ceiling[1]};
+ const DOWN_KEY = 'harness';
+ ${lift('function lastKnownDown(')}
+ ${lift('function rememberDown(')}
+ ${lift('async function inFlight(')}
+ ${lift('async function fetchAllIndexes(')}
+ return fetchAllIndexes;`,
+);
+const fetchAllIndexes = make(
+ session, _loadBundleKey, cacheGroupIndex, fetchGroupIndex, globalThis.localStorage);
+
+// ── The scenario ─────────────────────────────────────────────────────────────
+
+const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` }));
+
+let finishedAt = null;
+fetchAllIndexes(
+ list, 'tok', 'alice', 'u1',
+ () => {}, // progress counters only
+ (results) => { shown.push({ at: now, groups: results.size }); },
+).then(() => { finishedAt = now; });
+
+await run(runUntil);
+
+console.log(JSON.stringify({
+ // When the reader first sees any result at all, and how many.
+ firstPaintAt: shown.length ? shown[0].at : null,
+ firstPaintGroups: shown.length ? shown[0].groups : 0,
+ // Every render, so a test can see whether live groups waited on dead ones.
+ renders: shown,
+ finishedAt,
+ inFlight: Number(ceiling[1]),
+ // What the browser will remember for next time.
+ remembered: written === null ? null : JSON.parse(written),
+}));