chat / client / test / helpers / det-clock.mjs
  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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/**
 * A deterministic clock whose TIMER QUEUE is coupled to its `Date.now`.
 *
 * Why this exists: `mailbox` shipped two defects that took every room down, and
 * the suite stayed green either side of both fixes, because nothing in it can
 * express "and then the retry timer fired 2 seconds later, and here is exactly
 * what happened in between". `withClock` moved `Date.now` and nothing else, so
 * every `setTimeout` in the code under test was a real one that simply never
 * came back inside the test's lifetime. Backoff ladders, the replay throttle and
 * the live-entry debounce were all unobservable.
 *
 * ── the two rules ────────────────────────────────────────────────────────────
 *
 * 1. MACROTASKS ONLY. `setTimeout`/`setInterval`/`requestAnimationFrame` are
 *    virtual; `Promise`, `queueMicrotask` and `await` are NOT touched, ever.
 *    `media`'s `peer.queue`/`peer.mediaQueue` and `log`'s `_updateChain` are
 *    promise chains used as MUTEXES — the interleaving they produce is the
 *    property under test. A fake microtask queue would let the harness change
 *    the very ordering it is supposed to record.
 *
 * 2. THE CLOCK FOLLOWS THE QUEUE. While a timer's callback runs, `Date.now()`
 *    returns that timer's DEADLINE, not the pre-advance time and not the target
 *    of the `advance()` call. Two timers 1s apart see two different `Date.now()`
 *    values, which is what makes `_lastReplay` throttles and backoff ladders
 *    reproducible instead of collapsing onto one instant.
 *
 * ── the real timer is captured and exported ──────────────────────────────────
 *
 * `settle()` in helpers/app-fakes.mjs drains promise chains with a REAL
 * `setTimeout(r, 0)`, and node:test schedules with the global too. Swapping the
 * global without handing the real one back deadlocks the runner with no useful
 * error, so the real four are captured at module load — before any harness has
 * touched them — and re-exported.
 *
 * ── the transcript ───────────────────────────────────────────────────────────
 *
 * Every scheduling call appends to an event sink: `["setTimeout", 1000, 3]`,
 * `["clearTimeout", 3]`, `["fire", 3]`. Pass the SAME sink to helpers/effect-log.mjs
 * and clock ops interleave with method calls in one array, so re-entrancy,
 * side-effect order and backoff are pinned by a single `deepEqual`.
 */

/* The real four, captured at module load. Import this module before anything
   else installs a clock — dom.mjs's `window.setTimeout = setTimeout` alias and
   fake-indexeddb both read the global. */
export const realSetTimeout = globalThis.setTimeout;
export const realClearTimeout = globalThis.clearTimeout;
export const realSetInterval = globalThis.setInterval;
export const realClearInterval = globalThis.clearInterval;
const RealDate = globalThis.Date;

/** One real macrotask turn — necessarily drains the whole microtask queue. */
export const drainMicrotasks = () => new Promise((r) => realSetTimeout(r, 0));

/** Shared event sink for a clock + any number of effect logs. */
export const makeSink = () => [];

/**
 * Install the clock over the globals. Returns the handle; ALWAYS `uninstall()`
 * in a `finally`.
 *
 *   t0        virtual epoch ms (`Date.now()` at install)
 *   timers    virtualize the macrotask timers (default true). `false` leaves the
 *             real ones in place and moves only `Date` — the pre-3b `withClock`
 *             behaviour, which the legacy wrappers still ask for so that every
 *             golden vector recorded under it stays byte-identical.
 *   dateCtor  replace the whole `Date` constructor, not just `Date.now`, so
 *             `new Date()` with no arguments is pinned too (app/rooms's default
 *             room label formats one, and a fixture recording the real one
 *             expires at midnight)
 *   rafMs     the virtual frame interval requestAnimationFrame schedules at
 *   sink      shared event array; defaults to a private one
 */
export function installClock({ t0 = 0, timers = true, dateCtor = false, rafMs = 16, sink = makeSink() } = {}) {
  let now = t0;
  let seq = 0;
  /** id → {id, at, delay, kind, label, fn, args, every} */
  const queue = new Map();
  const ops = [];
  const emit = (e) => { ops.push(e); sink.push(e); return e; };

  const saved = {
    Date: globalThis.Date,
    now: RealDate.now,
    setTimeout: globalThis.setTimeout,
    clearTimeout: globalThis.clearTimeout,
    setInterval: globalThis.setInterval,
    clearInterval: globalThis.clearInterval,
    requestAnimationFrame: globalThis.requestAnimationFrame,
    cancelAnimationFrame: globalThis.cancelAnimationFrame,
    performanceNow: globalThis.performance?.now,
  };

  // ---- Date --------------------------------------------------------------
  if (dateCtor) {
    class FrozenDate extends RealDate {
      constructor(...args) {
        if (args.length === 0) super(now);
        else super(...args);
      }
    }
    FrozenDate.now = () => now;
    FrozenDate.parse = RealDate.parse;
    FrozenDate.UTC = RealDate.UTC;
    globalThis.Date = FrozenDate;
  } else {
    RealDate.now = () => now;
  }
  if (saved.performanceNow) globalThis.performance.now = () => now - t0;

  // ---- the virtual macrotask queue ---------------------------------------
  const schedule = (kind, fn, delay, args, every) => {
    const id = ++seq;
    const d = Number.isFinite(delay) ? Math.max(0, Math.trunc(delay)) : 0;
    queue.set(id, { id, at: now + d, delay: d, kind, label: fn?.name || null, fn, args, every });
    emit(kind === "raf" ? ["requestAnimationFrame", id] : [kind === "interval" ? "setInterval" : "setTimeout", d, id]);
    return id;
  };
  const cancel = (op, id) => {
    // JS tolerates clearTimeout(undefined) and clearing twice; record every
    // call anyway — "the clear that never happened" is a defect this must show
    emit([op, typeof id === "number" ? id : null]);
    if (typeof id === "number") queue.delete(id);
  };

  if (timers) {
    globalThis.setTimeout = (fn, delay, ...args) => schedule("timeout", fn, delay, args);
    globalThis.clearTimeout = (id) => cancel("clearTimeout", id);
    globalThis.setInterval = (fn, delay, ...args) => schedule("interval", fn, delay, args, Math.max(1, Math.trunc(delay) || 1));
    globalThis.clearInterval = (id) => cancel("clearInterval", id);
    globalThis.requestAnimationFrame = (fn) => schedule("raf", fn, rafMs, []);
    globalThis.cancelAnimationFrame = (id) => cancel("cancelAnimationFrame", id);
  }

  // ---- firing -------------------------------------------------------------
  /** Earliest deadline, ties broken by scheduling order (real timer semantics). */
  const earliest = () => {
    let best = null;
    for (const t of queue.values()) if (!best || t.at < best.at || (t.at === best.at && t.id < best.id)) best = t;
    return best;
  };

  const fire = (t) => {
    now = t.at;                       // rule 2: the clock follows the queue
    if (t.every === undefined) queue.delete(t.id);
    else t.at = now + t.every;        // interval: reschedule before the callback
    emit(["fire", t.id]);
    if (t.kind === "raf") t.fn(now - t0);
    else t.fn(...(t.args ?? []));
  };

  const handle = {
    /** the captured real timer, for harness code that must not be virtualized */
    realSetTimeout,
    realClearTimeout,
    sink,
    now: () => now,

    /** Advance the CLOCK ONLY, firing nothing. The pre-3b `withClock` tick. */
    tick(ms) {
      now += ms;
      return now;
    },

    /** Advance the clock AND fire every timer that comes due, in deadline
     *  order, draining real microtasks after each callback. */
    async advance(ms) {
      const target = now + ms;
      for (;;) {
        const next = earliest();
        if (!next || next.at > target) break;
        fire(next);
        await drainMicrotasks();
      }
      now = target;
      await drainMicrotasks();
      return now;
    },

    /** Fire exactly the next timer, jumping the clock to its deadline. */
    async runNext() {
      const next = earliest();
      if (!next) return null;
      const shot = { ...next };
      fire(next);
      await drainMicrotasks();
      return { id: shot.id, at: shot.at, delay: shot.delay, kind: shot.kind, label: shot.label };
    },

    /** Everything still scheduled, in firing order. */
    pending() {
      return [...queue.values()]
        .sort((a, b) => a.at - b.at || a.id - b.id)
        .map(({ id, at, delay, kind, label }) => ({ id, at, delay, kind, label }));
    },

    /** This clock's own ops, in order. `sink` has them interleaved with effects. */
    ops: () => ops.slice(),

    /** Let promise chains settle without moving the clock. */
    drain: drainMicrotasks,

    uninstall() {
      globalThis.Date = saved.Date;
      RealDate.now = saved.now;
      globalThis.setTimeout = saved.setTimeout;
      globalThis.clearTimeout = saved.clearTimeout;
      globalThis.setInterval = saved.setInterval;
      globalThis.clearInterval = saved.clearInterval;
      if (saved.requestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
      else globalThis.requestAnimationFrame = saved.requestAnimationFrame;
      if (saved.cancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
      else globalThis.cancelAnimationFrame = saved.cancelAnimationFrame;
      if (saved.performanceNow) globalThis.performance.now = saved.performanceNow;
    },
  };
  return handle;
}

/** Install, run, uninstall. `fn` receives the clock handle. */
export async function withDetClock(opts, fn) {
  const clock = installClock(opts);
  try {
    return await fn(clock);
  } finally {
    clock.uninstall();
  }
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git