// fixtures/resync-controller.test.mjs — regression test for fix // round 2's blocking finding: a resync that fails once used to give // up forever, silently recreating the 204-but-never-integrated data // loss for the rest of the session (see resync-controller.mjs's own // header comment for the full mechanism). Runs under Node's own test // runner (node --test); no DOM, no ProseMirror, no real Y.Doc — // attemptResync/timing/the 'online' listener are all injected. import { test } from 'node:test' import assert from 'node:assert/strict' import { createResyncController } from './resync-controller.mjs' // A tiny fake clock: setTimeoutFn/clearTimeoutFn below don't run real // timers at all — advance(ms) runs every callback whose deadline has // now passed, in the order they were scheduled. Deterministic and // instant, unlike waiting out a real 30s backoff cap in a test. function makeClock() { let now = 0 let id = 0 const pending = new Map() return { setTimeoutFn: (fn, ms) => { const t = ++id pending.set(t, { fn, at: now + ms }) return t }, clearTimeoutFn: (t) => pending.delete(t), advance: (ms) => { now += ms for (const [t, { fn, at }] of [...pending]) { if (at <= now) { pending.delete(t) fn() } } }, } } test('a resync that fails is retried with backoff until it succeeds, without ever running twice at once', async () => { const clock = makeClock() let onlineHandler = null const calls = [] let succeedOnCall = 3 const attemptResync = () => { calls.push(clock) return calls.length >= succeedOnCall ? Promise.resolve() : Promise.reject(new Error('network down')) } const divergedEvents = [] const recoveredEvents = [] const controller = createResyncController({ attemptResync, onDiverged: () => divergedEvents.push(true), onRecovered: () => recoveredEvents.push(true), backoffMs: (attempt) => attempt * 1000, // 1s, 2s, 3s — deterministic and small for the test setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, addOnlineListener: (fn) => { onlineHandler = fn }, }) assert.equal(controller.diverged, false) controller.reportDivergence() // The first attempt runs synchronously off reportDivergence itself — // "knowingly diverged from the instant a batch is dropped," not // from whenever the first attempt happens to finish. await Promise.resolve() await Promise.resolve() assert.equal(calls.length, 1, 'the first attempt must run immediately, not wait for a timer') assert.equal(controller.diverged, true, 'still diverged: without retry, this fix round 2 regression stays true forever') assert.deepEqual(divergedEvents, [true]) // Calling reportDivergence again while already diverged must not // reset the attempt count or fire onDiverged a second time — it is // still the same episode of divergence — but it is free to trigger // another immediate attempt rather than silently waiting out // whatever backoff is already scheduled; the resyncing guard keeps // that from ever running two attempts at once. controller.reportDivergence() await Promise.resolve() await Promise.resolve() assert.equal(calls.length, 2, 'reportDivergence is free to retry eagerly; it must never fire onDiverged twice') assert.deepEqual(divergedEvents, [true]) assert.equal(controller.diverged, true, 'still failing (call 2 of 3)') // The pending backoff timer from the FIRST failed attempt is still // live; it must not cause a THIRD concurrent/duplicate call on top // of the eager one just above. clock.advance(1000) await Promise.resolve() await Promise.resolve() assert.equal(calls.length, 2, 'a stale timer from a superseded attempt must not double up on the eager retry') clock.advance(2000) await Promise.resolve() await Promise.resolve() assert.equal(calls.length, 3, 'the next scheduled retry succeeds') assert.equal(controller.diverged, false, 'recovered') assert.deepEqual(recoveredEvents, [true]) // A stray, already-scheduled retry firing after recovery must be a // no-op — re-running resync against an already-healthy document // would discard everything typed since recovery for nothing. clock.advance(10000) assert.equal(calls.length, 3, 'no further attempts once recovered') }) test("the browser's online event retries immediately, not just on the next backoff tick", async () => { const clock = makeClock() let onlineHandler = null let calls = 0 const attemptResync = () => { calls++ return calls >= 2 ? Promise.resolve() : Promise.reject(new Error('offline')) } const controller = createResyncController({ attemptResync, backoffMs: () => 60000, // a long backoff the test never advances to, // so the only thing that can trigger the second attempt here is // the online listener itself. setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, addOnlineListener: (fn) => { onlineHandler = fn }, }) controller.reportDivergence() await Promise.resolve() await Promise.resolve() assert.equal(calls, 1) assert.equal(controller.diverged, true) assert.equal(typeof onlineHandler, 'function', 'an online listener must be registered') onlineHandler() await Promise.resolve() await Promise.resolve() assert.equal(calls, 2, "the online event must trigger a fresh attempt without waiting for the 60s backoff") assert.equal(controller.diverged, false) }) test('reportDivergence before any failure runs the first attempt without a caller ever calling online', async () => { const clock = makeClock() const controller = createResyncController({ attemptResync: () => Promise.resolve(), setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, addOnlineListener: () => {}, }) controller.reportDivergence() await Promise.resolve() await Promise.resolve() assert.equal(controller.diverged, false, 'a resync that succeeds on the first try recovers immediately') })