oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

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