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/save-queue.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/save-queue.test.mjs — regression test for fix round 1's
6// F1: a permanently-rejected batch used to retry forever AND absorb
7// every later edit via Y.mergeUpdates, losing all of it silently.
8// Reproduced against a real running server (a ~1.2MB paste got a
9// real 413, every later flush also 413'd, and the paste plus both
10// subsequent small edits were gone after reload) before the fix in
11// save-queue.mjs went in.
12//
13// Runs under Node's own test runner (node --test), no framework
14// added: `post`/`mergeUpdates`/timing are all injected by
15// createSaveQueue, so this drives the real retry/drop control flow
16// with tiny stand-in byte arrays and a short debounce — no DOM, no
17// ProseMirror, no real Y.Doc, no browser needed.
18import { test } from 'node:test'
19import assert from 'node:assert/strict'
20import { createSaveQueue } from './save-queue.mjs'
21
22// A fake mergeUpdates that behaves like Y.mergeUpdates for this
23// test's purposes: concatenate, in order, so a merged batch's
24// identity is checkable byte-for-byte.
25function concatUpdates(updates) {
26 const out = []
27 for (const u of updates) out.push(...u)
28 return new Uint8Array(out)
29}
30
31function makePostStub(responses) {
32 const calls = []
33 let i = 0
34 return {
35 calls,
36 post: async (bytes) => {
37 calls.push(bytes)
38 const next = responses[Math.min(i, responses.length - 1)]
39 i++
40 if (next.throws) throw new Error('network failure')
41 return { ok: next.status < 300, status: next.status }
42 },
43 }
44}
45
46test('a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit', async () => {
47 const { calls, post } = makePostStub([{ status: 413 }, { status: 204 }])
48 const statuses = []
49 const q = createSaveQueue({
50 post,
51 mergeUpdates: concatUpdates,
52 onStatus: (s) => statuses.push(s),
53 debounceMs: 5,
54 })
55
56 const bigPaste = new Uint8Array([9, 9, 9])
57 q.enqueue(bigPaste)
58 await new Promise((r) => setTimeout(r, 40))
59
60 assert.equal(calls.length, 1, 'the oversized batch should be posted exactly once')
61 assert.deepEqual(calls[0], bigPaste)
62 assert.equal(q.status, 'failed', 'a permanent rejection must be reported, not swallowed')
63 assert.equal(q.pendingBytes(), null, 'a dropped batch must not sit in the queue forever')
64
65 // The crux of F1: an edit typed after the permanent rejection must
66 // reach the server on its own, never merged with the dead batch.
67 const secondEdit = new Uint8Array([1, 2, 3])
68 q.enqueue(secondEdit)
69 await new Promise((r) => setTimeout(r, 40))
70
71 assert.equal(calls.length, 2, 'the later edit must be posted, not swallowed')
72 assert.deepEqual(calls[1], secondEdit, 'the later edit must be posted on its own — merging it with the dead 413 batch would 413 it too')
73 assert.equal(statuses.filter((s) => s === 'failed').length >= 1, true)
74 assert.equal(q.status, 'failed', "the indicator must stay sticky — a later success must not hide that something was lost")
75})
76
77test('a network failure or a 5xx is retried, not dropped', async () => {
78 const { calls, post } = makePostStub([{ throws: true }, { status: 204 }])
79 const q = createSaveQueue({ post, mergeUpdates: concatUpdates, debounceMs: 5 })
80
81 const edit = new Uint8Array([7, 7])
82 q.enqueue(edit)
83 await new Promise((r) => setTimeout(r, 60))
84
85 assert.equal(calls.length, 2, 'a network failure must be retried automatically, not just left queued')
86 assert.deepEqual(calls[0], edit)
87 assert.deepEqual(calls[1], edit, 'the retried batch must be the exact same bytes, not lost or duplicated')
88 assert.equal(q.status, 'ok', 'once the retry lands, nothing was actually lost')
89})
90
91test('a 500 is retried the same way a network failure is, never dropped', async () => {
92 const { calls, post } = makePostStub([{ status: 500 }, { status: 204 }])
93 const q = createSaveQueue({ post, mergeUpdates: concatUpdates, debounceMs: 5 })
94
95 q.enqueue(new Uint8Array([4, 4]))
96 await new Promise((r) => setTimeout(r, 60))
97
98 assert.equal(calls.length, 2, 'a 5xx is our own fault, not the client bytes — it must retry, not drop')
99 assert.equal(q.status, 'ok')
100})
101