| 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. |
| 18 | import { test } from 'node:test' |
| 19 | import assert from 'node:assert/strict' |
| 20 | import { 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. |
| 25 | function concatUpdates(updates) { |
| 26 | const out = [] |
| 27 | for (const u of updates) out.push(...u) |
| 28 | return new Uint8Array(out) |
| 29 | } |
| 30 | |
| 31 | function 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 | |
| 46 | test('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 | |
| 77 | test('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 | |
| 91 | test('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 | |