oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/live-updates.test.mjs — the live-update subscriber's own
2// tests, run under plain Node with no DOM, no EventSource and no real
3// Y.Doc, the same way save-queue.test.mjs and
4// resync-controller.test.mjs exercise their modules.
5//
6// Two of these are regression tests for defects found in review
7// before any of this was written, and both are marked as such: the
8// cursor must not advance over a gap, and a tab that is visible and
9// focused but merely being READ must not go permanently stale.
10import test from 'node:test'
11import assert from 'node:assert/strict'
12import { createLiveUpdates, updatesURL } from './live-updates.mjs'
13
14// A controllable stand-in for the browser's EventSource. Nothing here
15// reconnects on its own — which is the point, since the module under
16// test owns every open and every close.
17function fakeStream() {
18 const streams = []
19 const open = (onEvent, onError) => {
20 const s = { onEvent, onError, closed: false }
21 streams.push(s)
22 return { close: () => { s.closed = true } }
23 }
24 return {
25 open,
26 get all() { return streams },
27 get live() { return streams.filter(s => !s.closed) },
28 get last() { return streams[streams.length - 1] },
29 emit(seq) { streams.filter(s => !s.closed).forEach(s => s.onEvent(seq)) },
30 fail() { streams.filter(s => !s.closed).forEach(s => s.onError(new Error('stream died'))) },
31 }
32}
33
34// A fetch stand-in whose responses are resolved by hand, so a test can
35// hold one open and act while it is in flight. Records the raw flag
36// on each call rather than needing a second fake: a retry after a
37// decode failure is still just a call to fetchSince, distinguished
38// only by { raw: true } in its options.
39function fakeFetches() {
40 const calls = []
41 const fetchSince = (since, { raw = false } = {}) => {
42 let settle
43 const promise = new Promise(resolve => { settle = resolve })
44 calls.push({ since, raw, settle })
45 return promise
46 }
47 return { fetchSince, calls }
48}
49
50// A fake clock: only the timers the module actually schedules.
51function fakeClock() {
52 let timers = []
53 let seq = 0
54 return {
55 setTimeoutFn(fn, ms) {
56 const id = ++seq
57 timers.push({ id, fn, ms })
58 return id
59 },
60 clearTimeoutFn(id) {
61 timers = timers.filter(t => t.id !== id)
62 },
63 // Run everything currently scheduled, as if its delay elapsed.
64 async advance() {
65 const due = timers
66 timers = []
67 for (const t of due) await t.fn()
68 },
69 get pending() { return timers.length },
70 // The delays actually asked for. advance() fires everything
71 // regardless of its ms, which is what makes it a useful fake and
72 // also what makes it blind to the difference between a deadline of
73 // 30 seconds and one of two minutes — the whole subject of the
74 // hidden-tab grace. A test that cares reads this instead.
75 get deadlines() { return timers.map(t => t.ms) },
76 }
77}
78
79function build(overrides = {}) {
80 const stream = fakeStream()
81 const fetches = fakeFetches()
82 const clock = fakeClock()
83 const applied = []
84
85 const live = createLiveUpdates({
86 initialSeq: 0,
87 openStream: stream.open,
88 fetchSince: fetches.fetchSince,
89 onUpdates: (bytes) => applied.push(bytes),
90 setTimeoutFn: clock.setTimeoutFn,
91 clearTimeoutFn: clock.clearTimeoutFn,
92 idleMs: 1000,
93 hiddenMs: 250,
94 ...overrides,
95 })
96
97 return { live, stream, fetches, clock, applied }
98}
99
100test('a tab advances its own cursor only when its POST is the very next row', () => {
101 const { live } = build()
102
103 // cursor 0, own POST lands at 1: contiguous, so adopt it and skip
104 // the round trip.
105 live.noteOwnSeq(1)
106 assert.equal(live.cursor, 1)
107
108 // cursor 1, own POST lands at 5: rows 2-4 came from somewhere else
109 // and this tab has not applied them. Adopting 5 would skip them
110 // permanently, which is the whole reason for this rule.
111 live.noteOwnSeq(5)
112 assert.equal(live.cursor, 1, 'cursor advanced over rows it never applied')
113})
114
115test('the cursor never moves backwards when a response is overtaken by this tab\'s own write', async () => {
116 // Whole-branch review found the first version of this test inert:
117 // it "delivered" the straggler by settling a promise that had
118 // already resolved, which is a no-op, so the assertions passed
119 // whether the module used max() or plain assignment — leaving the
120 // one client-side rule the spec says these tests must pin
121 // effectively untested.
122 //
123 // This is the reachable version of the same race. A fetch goes out
124 // at since=0. While it is in flight this tab's own POST comes back
125 // as row 1, which is contiguous, so the cursor advances to 1
126 // without waiting. The older fetch then resolves naming seq 0.
127 // Plain assignment drags the cursor back to 0 and re-fetches and
128 // re-applies row 1; max() leaves it alone.
129 const { live, stream, fetches } = build()
130 live.start()
131
132 stream.emit(1)
133 assert.equal(fetches.calls.length, 1)
134 assert.equal(fetches.calls[0].since, 0)
135
136 live.noteOwnSeq(1)
137 assert.equal(live.cursor, 1)
138
139 fetches.calls[0].settle({ bytes: new Uint8Array([]), seq: 0 })
140 await Promise.resolve()
141 await Promise.resolve()
142
143 assert.equal(live.cursor, 1, 'an overtaken response dragged the cursor backwards')
144})
145
146test('a later response still carries the cursor forward', async () => {
147 const { live, stream, fetches, applied } = build()
148 live.start()
149
150 stream.emit(3)
151 fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 3 })
152 await Promise.resolve()
153 await Promise.resolve()
154 assert.equal(live.cursor, 3)
155
156 stream.emit(9)
157 fetches.calls[1].settle({ bytes: new Uint8Array([2]), seq: 9 })
158 await Promise.resolve()
159 await Promise.resolve()
160 assert.equal(live.cursor, 9)
161 assert.equal(applied.length, 2)
162})
163
164test('a burst of notifications collapses into one fetch at a time', async () => {
165 const { live, stream, fetches } = build()
166 live.start()
167
168 stream.emit(1)
169 stream.emit(2)
170 stream.emit(3)
171 assert.equal(fetches.calls.length, 1, 'a burst started more than one fetch at once')
172
173 fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 1 })
174 await Promise.resolve()
175 await Promise.resolve()
176
177 // Something arrived while that was in flight, so exactly one
178 // follow-up runs — not three.
179 assert.equal(fetches.calls.length, 2)
180 assert.equal(fetches.calls[1].since, 1)
181})
182
183test('an event naming a seq the tab already has fetches nothing', () => {
184 const { live, stream, fetches } = build()
185 live.start()
186
187 live.noteOwnSeq(1)
188 stream.emit(1) // this tab's own write, echoed back
189
190 assert.equal(fetches.calls.length, 0, 'a tab re-fetched its own write')
191})
192
193test('a stopped subscriber drops a response that arrives late', async () => {
194 const { live, stream, fetches, applied } = build()
195 live.start()
196
197 stream.emit(1)
198 live.stop()
199 fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 1 })
200 await Promise.resolve()
201
202 assert.equal(applied.length, 0, 'a stopped subscriber applied bytes into a document being replaced')
203})
204
205test('stop closes the stream, and start opens exactly one', () => {
206 const { live, stream } = build()
207
208 live.start()
209 assert.equal(stream.live.length, 1)
210
211 live.start() // idempotent: must not open a second
212 assert.equal(stream.live.length, 1)
213
214 live.stop()
215 assert.equal(stream.live.length, 0)
216})
217
218test('a hidden tab closes its stream on a shortened deadline, and activity reopens it', async () => {
219 const { live, stream, clock } = build()
220 live.start()
221 assert.equal(stream.live.length, 1)
222 assert.deepEqual(clock.deadlines, [1000], 'a visible tab should be on the idle deadline')
223
224 // Not on the spot. Closing here is what made every switch back
225 // repaint the document a frame after showing the stale one.
226 live.noteHidden()
227 assert.equal(stream.live.length, 1, 'a hidden tab dropped its stream immediately — the reconnect on return cannot beat the paint, so the old text shows for a frame')
228 assert.deepEqual(clock.deadlines, [250], 'a hidden tab kept the long idle deadline instead of shortening to the hidden grace')
229
230 await clock.advance()
231 assert.equal(stream.live.length, 0, 'a tab left hidden past the grace kept its stream open, pinning the instance awake')
232
233 live.noteActivity()
234 assert.equal(stream.live.length, 1, 'activity did not bring the stream back')
235})
236
237test('switching away and back inside the grace never drops the stream, so nothing has to be caught up', async () => {
238 // This is the property Paul actually reported, stated as a test: the
239 // flash was never a rendering bug, it was a reconnect. A tab that
240 // keeps its stream across the switch has already applied everything
241 // by the time it is painted, so there is nothing left to repaint.
242 //
243 // It asserts on `all`, not `live`: a stream that closed and reopened
244 // leaves both counts at 1 and would pass a liveness check while
245 // being exactly the defect.
246 const { live, stream, clock } = build()
247 live.start()
248 assert.equal(stream.all.length, 1)
249
250 live.noteHidden()
251 live.noteActivity() // back within the grace, before advance()
252
253 assert.equal(stream.all.length, 1, 'the stream was reopened, which means it closed — the tab paints stale and catches up after')
254 assert.deepEqual(clock.deadlines, [1000], 'coming back did not restore the idle deadline')
255
256 // And the grace timer really was cancelled rather than left pending
257 // to close a stream the person has come back to.
258 await clock.advance()
259 assert.equal(stream.live.length, 0, 'sanity: the idle deadline still closes an untouched stream')
260})
261
262test('a visible tab that is only being READ closes when idle and comes back on any activity', async () => {
263 // The review's second finding. Closing on idle and reopening only
264 // on visibility, focus or a local edit leaves the commonest case of
265 // all unhandled: someone reading a long document on a tab that is
266 // already visible and already focused fires none of those three, so
267 // the stream closes and nothing ever reopens it. The watching half
268 // of "type in one tab, watch the other" then fails silently.
269 const { live, stream, clock } = build()
270 live.start()
271 assert.equal(stream.live.length, 1)
272
273 await clock.advance() // the idle timer elapses with nothing happening
274 assert.equal(stream.live.length, 0, 'an idle stream was not closed')
275
276 // Scrolling counts. So does a pointer moving, or a key going down.
277 live.noteActivity()
278 assert.equal(stream.live.length, 1, 'a reader who scrolled was left permanently stale')
279})
280
281test('activity while streaming defers the idle close rather than reopening anything', async () => {
282 const { live, stream, clock } = build()
283 live.start()
284
285 live.noteActivity()
286 live.noteActivity()
287 assert.equal(stream.all.length, 1, 'activity churned the connection instead of deferring the timer')
288
289 await clock.advance()
290 assert.equal(stream.live.length, 0)
291})
292
293test('a stream failure is never reported as a save failure, and heals on activity', () => {
294 const failures = []
295 const { live, stream } = build({ onError: (err) => failures.push(err) })
296 live.start()
297
298 stream.fail()
299
300 // The document is still saving fine — POST is a different path
301 // entirely — so this must not be surfaced as lost work. It is
302 // reported for the console and nothing else.
303 assert.equal(failures.length, 1)
304 assert.equal(stream.live.length, 0)
305
306 live.noteActivity()
307 assert.equal(stream.live.length, 1)
308})
309
310test('an empty response advances the cursor without applying anything', async () => {
311 const { live, stream, fetches, applied } = build()
312 live.start()
313
314 stream.emit(4)
315 fetches.calls[0].settle({ bytes: new Uint8Array([]), seq: 4 })
316 await Promise.resolve()
317
318 assert.equal(live.cursor, 4)
319 assert.equal(applied.length, 0, 'an empty slice was applied as if it were content')
320})
321
322test('a failed fetch leaves the cursor alone so the next notification retries it', async () => {
323 const { live, stream, fetches } = build()
324 live.start()
325
326 stream.emit(2)
327 fetches.calls[0].settle(Promise.reject(new Error('offline')))
328 await Promise.resolve()
329 await Promise.resolve()
330
331 assert.equal(live.cursor, 0, 'a failed fetch advanced the cursor past bytes it never received')
332})
333
334test('a decode failure retries once through the raw log, then gives up', async () => {
335 const { live, stream, fetches } = build({
336 onUpdates: () => { throw new Error('bad update') },
337 })
338 live.start()
339 stream.emit(1)
340
341 fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 1 })
342 await Promise.resolve(); await Promise.resolve()
343
344 assert.equal(fetches.calls.length, 2, 'no retry after a decode failure')
345 assert.equal(fetches.calls[1].raw, true, 'the retry did not ask for the raw log')
346 assert.equal(live.cursor, 0, 'the cursor advanced over bytes that never applied')
347})
348
349// updatesURL is the one place either editor-entry.js call site spells
350// the query string it sends the server, so its exact output is pinned
351// here rather than trusted by inspection of a DOM-bound file this
352// project has no test harness for. handlers.go matches the raw flag
353// as the literal string "1" and nothing else (internal/docs/
354// handlers.go's own r.URL.Query().Get("raw") == "1") -- raw=true,
355// raw=on, and a bare raw all fail silently, with no error and no
356// visible symptom beyond the resync controller refetching the same
357// corrupt snapshot forever. Asserting exact strings, not a substring
358// check, is the point: .includes('raw') would pass against raw=true
359// too.
360test('updatesURL spells the query string exactly, in particular raw as the literal raw=1', () => {
361 assert.equal(updatesURL('/d/abc/updates'), '/d/abc/updates')
362 assert.equal(updatesURL('/d/abc/updates', { since: 4 }), '/d/abc/updates?since=4')
363 assert.equal(updatesURL('/d/abc/updates', { raw: true }), '/d/abc/updates?raw=1')
364 assert.equal(updatesURL('/d/abc/updates', { since: 4, raw: true }), '/d/abc/updates?since=4&raw=1')
365})
366