| 1 | // Copied verbatim from Docs (oficina/docs/fixtures/resync-controller.test.mjs), the first |
| 2 | // implementation of the live-sync client; Slides is the second, and the |
| 3 | // extraction to a shared module is slice 4. Only the header name differs: |
| 4 | // Docs-Seq is Slides-Seq here. |
| 5 | // fixtures/resync-controller.test.mjs — regression test for fix |
| 6 | // round 2's blocking finding: a resync that fails once used to give |
| 7 | // up forever, silently recreating the 204-but-never-integrated data |
| 8 | // loss for the rest of the session (see resync-controller.mjs's own |
| 9 | // header comment for the full mechanism). Runs under Node's own test |
| 10 | // runner (node --test); no DOM, no ProseMirror, no real Y.Doc — |
| 11 | // attemptResync/timing/the 'online' listener are all injected. |
| 12 | import { test } from 'node:test' |
| 13 | import assert from 'node:assert/strict' |
| 14 | import { createResyncController } from './resync-controller.mjs' |
| 15 | |
| 16 | // A tiny fake clock: setTimeoutFn/clearTimeoutFn below don't run real |
| 17 | // timers at all — advance(ms) runs every callback whose deadline has |
| 18 | // now passed, in the order they were scheduled. Deterministic and |
| 19 | // instant, unlike waiting out a real 30s backoff cap in a test. |
| 20 | function makeClock() { |
| 21 | let now = 0 |
| 22 | let id = 0 |
| 23 | const pending = new Map() |
| 24 | return { |
| 25 | setTimeoutFn: (fn, ms) => { |
| 26 | const t = ++id |
| 27 | pending.set(t, { fn, at: now + ms }) |
| 28 | return t |
| 29 | }, |
| 30 | clearTimeoutFn: (t) => pending.delete(t), |
| 31 | advance: (ms) => { |
| 32 | now += ms |
| 33 | for (const [t, { fn, at }] of [...pending]) { |
| 34 | if (at <= now) { |
| 35 | pending.delete(t) |
| 36 | fn() |
| 37 | } |
| 38 | } |
| 39 | }, |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | test('a resync that fails is retried with backoff until it succeeds, without ever running twice at once', async () => { |
| 44 | const clock = makeClock() |
| 45 | let onlineHandler = null |
| 46 | const calls = [] |
| 47 | let succeedOnCall = 3 |
| 48 | |
| 49 | const attemptResync = () => { |
| 50 | calls.push(clock) |
| 51 | return calls.length >= succeedOnCall |
| 52 | ? Promise.resolve() |
| 53 | : Promise.reject(new Error('network down')) |
| 54 | } |
| 55 | |
| 56 | const divergedEvents = [] |
| 57 | const recoveredEvents = [] |
| 58 | const controller = createResyncController({ |
| 59 | attemptResync, |
| 60 | onDiverged: () => divergedEvents.push(true), |
| 61 | onRecovered: () => recoveredEvents.push(true), |
| 62 | backoffMs: (attempt) => attempt * 1000, // 1s, 2s, 3s — deterministic and small for the test |
| 63 | setTimeoutFn: clock.setTimeoutFn, |
| 64 | clearTimeoutFn: clock.clearTimeoutFn, |
| 65 | addOnlineListener: (fn) => { onlineHandler = fn }, |
| 66 | }) |
| 67 | |
| 68 | assert.equal(controller.diverged, false) |
| 69 | controller.reportDivergence() |
| 70 | // The first attempt runs synchronously off reportDivergence itself — |
| 71 | // "knowingly diverged from the instant a batch is dropped," not |
| 72 | // from whenever the first attempt happens to finish. |
| 73 | await Promise.resolve() |
| 74 | await Promise.resolve() |
| 75 | assert.equal(calls.length, 1, 'the first attempt must run immediately, not wait for a timer') |
| 76 | assert.equal(controller.diverged, true, 'still diverged: without retry, this fix round 2 regression stays true forever') |
| 77 | assert.deepEqual(divergedEvents, [true]) |
| 78 | |
| 79 | // Calling reportDivergence again while already diverged must not |
| 80 | // reset the attempt count or fire onDiverged a second time — it is |
| 81 | // still the same episode of divergence — but it is free to trigger |
| 82 | // another immediate attempt rather than silently waiting out |
| 83 | // whatever backoff is already scheduled; the resyncing guard keeps |
| 84 | // that from ever running two attempts at once. |
| 85 | controller.reportDivergence() |
| 86 | await Promise.resolve() |
| 87 | await Promise.resolve() |
| 88 | assert.equal(calls.length, 2, 'reportDivergence is free to retry eagerly; it must never fire onDiverged twice') |
| 89 | assert.deepEqual(divergedEvents, [true]) |
| 90 | assert.equal(controller.diverged, true, 'still failing (call 2 of 3)') |
| 91 | |
| 92 | // The pending backoff timer from the FIRST failed attempt is still |
| 93 | // live; it must not cause a THIRD concurrent/duplicate call on top |
| 94 | // of the eager one just above. |
| 95 | clock.advance(1000) |
| 96 | await Promise.resolve() |
| 97 | await Promise.resolve() |
| 98 | assert.equal(calls.length, 2, 'a stale timer from a superseded attempt must not double up on the eager retry') |
| 99 | |
| 100 | clock.advance(2000) |
| 101 | await Promise.resolve() |
| 102 | await Promise.resolve() |
| 103 | assert.equal(calls.length, 3, 'the next scheduled retry succeeds') |
| 104 | assert.equal(controller.diverged, false, 'recovered') |
| 105 | assert.deepEqual(recoveredEvents, [true]) |
| 106 | |
| 107 | // A stray, already-scheduled retry firing after recovery must be a |
| 108 | // no-op — re-running resync against an already-healthy document |
| 109 | // would discard everything typed since recovery for nothing. |
| 110 | clock.advance(10000) |
| 111 | assert.equal(calls.length, 3, 'no further attempts once recovered') |
| 112 | }) |
| 113 | |
| 114 | test("the browser's online event retries immediately, not just on the next backoff tick", async () => { |
| 115 | const clock = makeClock() |
| 116 | let onlineHandler = null |
| 117 | let calls = 0 |
| 118 | const attemptResync = () => { |
| 119 | calls++ |
| 120 | return calls >= 2 ? Promise.resolve() : Promise.reject(new Error('offline')) |
| 121 | } |
| 122 | |
| 123 | const controller = createResyncController({ |
| 124 | attemptResync, |
| 125 | backoffMs: () => 60000, // a long backoff the test never advances to, |
| 126 | // so the only thing that can trigger the second attempt here is |
| 127 | // the online listener itself. |
| 128 | setTimeoutFn: clock.setTimeoutFn, |
| 129 | clearTimeoutFn: clock.clearTimeoutFn, |
| 130 | addOnlineListener: (fn) => { onlineHandler = fn }, |
| 131 | }) |
| 132 | |
| 133 | controller.reportDivergence() |
| 134 | await Promise.resolve() |
| 135 | await Promise.resolve() |
| 136 | assert.equal(calls, 1) |
| 137 | assert.equal(controller.diverged, true) |
| 138 | |
| 139 | assert.equal(typeof onlineHandler, 'function', 'an online listener must be registered') |
| 140 | onlineHandler() |
| 141 | await Promise.resolve() |
| 142 | await Promise.resolve() |
| 143 | assert.equal(calls, 2, "the online event must trigger a fresh attempt without waiting for the 60s backoff") |
| 144 | assert.equal(controller.diverged, false) |
| 145 | }) |
| 146 | |
| 147 | test('reportDivergence before any failure runs the first attempt without a caller ever calling online', async () => { |
| 148 | const clock = makeClock() |
| 149 | const controller = createResyncController({ |
| 150 | attemptResync: () => Promise.resolve(), |
| 151 | setTimeoutFn: clock.setTimeoutFn, |
| 152 | clearTimeoutFn: clock.clearTimeoutFn, |
| 153 | addOnlineListener: () => {}, |
| 154 | }) |
| 155 | controller.reportDivergence() |
| 156 | await Promise.resolve() |
| 157 | await Promise.resolve() |
| 158 | assert.equal(controller.diverged, false, 'a resync that succeeds on the first try recovers immediately') |
| 159 | }) |
| 160 | |