oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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