// 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 import { readFileSync } from 'fs'; import { delimiter } from 'path'; // One file or several (search-page.js and what it was split into), joined // with the platform's path delimiter. const src = process.argv[2].split(delimiter).map((p) => readFileSync(p, 'utf8')).join('\n'); 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'; // One group's index, answered on the clock rather than over a network. const fetchGroupIndex = (_pool, 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', '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, fetchGroupIndex, globalThis.localStorage); // ── The scenario ───────────────────────────────────────────────────────────── const list = Array.from({ length: groups }, (_, i) => ({ id: `g-${i}`, name: `G${i}` })); let finishedAt = null; fetchAllIndexes( null, 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), }));