aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/search_connect_harness.mjs
blob: 60cea2bae50de6cd72c54c5aa0e1425238123f0f (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
// Run the SHIPPED `connectToGroup` against a fake transport and a fake clock.
//
// Nothing here is a paraphrase of search-page.js: the function and the two
// constants that govern its deadline are lifted out of the file as text and
// executed. What is modelled is the *environment* — a clock the test advances,
// a hub that lists nodes, and a transport that reports progress and resolves
// whenever the scenario says it does.
//
// A real clock would make these scenarios a minute each and would hide the
// thing worth asserting, which is the moment the deadline fires rather than
// roughly how long it took.
//
// Usage: node search_connect_harness.mjs <path to search-page.js> <json config>
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 {
  // When the transport reports progress, in ms from the start of the attempt.
  progressAt = [],
  // When connect() resolves. null: never.
  resolveAt = null,
  // How many nodes the hub lists for the group.
  nodes = 1,
  // How far to run the clock before giving up on the harness itself.
  runUntil = 300000,
} = cfg;

// ── 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);
};

// Promise jobs queued by the code under test have to run between one timer and
// the next, or a `.then` that schedules a timer would be seen after the clock
// has already passed it.
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 ──────────────────────────────────────────────────────────

let closed = 0;

class FakeTransport {
  constructor() {
    this.onConnectProgress = null;
    this.onNeedToken = null;
  }

  connect() {
    const started = now;
    for (const at of progressAt) {
      setTimeout(() => {
        // Exactly what transport.js does: it never lets the caller's callback
        // break the connection it is reporting on.
        if (this.onConnectProgress) {
          try { this.onConnectProgress('test'); } catch { /* caller's problem */ }
        }
      }, at);
    }
    return new Promise((resolve) => {
      if (resolveAt !== null) {
        setTimeout(() => resolve({ ok: true, at: now - started }), resolveAt);
      }
    });
  }

  close() { closed += 1; }
}

globalThis.window = { MeshBayTransport: FakeTransport };

const hubFetch = async () => ({
  nodes: Array.from({ length: nodes }, (_, i) => ({ node_id: `node-${i}` })),
});
const ensureFreshToken = async () => 'fresh-token';

// ── The code under test, as text ─────────────────────────────────────────────

const constant = (name) => {
  const m = new RegExp(`^const ${name} = (\\d+);`, 'm').exec(src);
  if (!m) throw new Error(`${name} is gone from search-page.js`);
  return `const ${name} = ${m[1]};`;
};

const fnStart = src.indexOf('async function connectToGroup(');
if (fnStart < 0) throw new Error('connectToGroup is gone from search-page.js');
const fnEnd = src.indexOf('\n}\n', fnStart) + 3;
const fnText = src.slice(fnStart, fnEnd);

const make = new Function(
  'hubFetch', 'ensureFreshToken', 'window',
  `${constant('SEARCH_STALL_MS')}
   ${constant('SEARCH_MAX_MS')}
   ${fnText}
   return connectToGroup;`,
);
const connectToGroup = make(hubFetch, ensureFreshToken, globalThis.window);

// ── The scenario ─────────────────────────────────────────────────────────────

let outcome = null;
connectToGroup('https://hub.example', 'g1', 'tok', null, 'alice', 'u1')
  .then(() => { outcome = { result: 'connected', at: now }; })
  .catch((e) => { outcome = { result: 'failed', at: now, error: String(e.message) }; });

await run(runUntil);

console.log(JSON.stringify({
  ...(outcome || { result: 'still-waiting', at: now }),
  closed,
}));