summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/search_fanout_harness.mjs
blob: c05e23b21e9f9ac49514b4f9061ae52761fa94f7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
// 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';

// 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', '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(
  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),
}));