// fixtures/save-queue.test.mjs — regression test for fix round 1's // F1: a permanently-rejected batch used to retry forever AND absorb // every later edit via Y.mergeUpdates, losing all of it silently. // Reproduced against a real running server (a ~1.2MB paste got a // real 413, every later flush also 413'd, and the paste plus both // subsequent small edits were gone after reload) before the fix in // save-queue.mjs went in. // // Runs under Node's own test runner (node --test), no framework // added: `post`/`mergeUpdates`/timing are all injected by // createSaveQueue, so this drives the real retry/drop control flow // with tiny stand-in byte arrays and a short debounce — no DOM, no // ProseMirror, no real Y.Doc, no browser needed. import { test } from 'node:test' import assert from 'node:assert/strict' import { createSaveQueue } from './save-queue.mjs' // A fake mergeUpdates that behaves like Y.mergeUpdates for this // test's purposes: concatenate, in order, so a merged batch's // identity is checkable byte-for-byte. function concatUpdates(updates) { const out = [] for (const u of updates) out.push(...u) return new Uint8Array(out) } function makePostStub(responses) { const calls = [] let i = 0 return { calls, post: async (bytes) => { calls.push(bytes) const next = responses[Math.min(i, responses.length - 1)] i++ if (next.throws) throw new Error('network failure') return { ok: next.status < 300, status: next.status } }, } } test('a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit', async () => { const { calls, post } = makePostStub([{ status: 413 }, { status: 204 }]) const statuses = [] const q = createSaveQueue({ post, mergeUpdates: concatUpdates, onStatus: (s) => statuses.push(s), debounceMs: 5, }) const bigPaste = new Uint8Array([9, 9, 9]) q.enqueue(bigPaste) await new Promise((r) => setTimeout(r, 40)) assert.equal(calls.length, 1, 'the oversized batch should be posted exactly once') assert.deepEqual(calls[0], bigPaste) assert.equal(q.status, 'failed', 'a permanent rejection must be reported, not swallowed') assert.equal(q.pendingBytes(), null, 'a dropped batch must not sit in the queue forever') // The crux of F1: an edit typed after the permanent rejection must // reach the server on its own, never merged with the dead batch. const secondEdit = new Uint8Array([1, 2, 3]) q.enqueue(secondEdit) await new Promise((r) => setTimeout(r, 40)) assert.equal(calls.length, 2, 'the later edit must be posted, not swallowed') 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') assert.equal(statuses.filter((s) => s === 'failed').length >= 1, true) assert.equal(q.status, 'failed', "the indicator must stay sticky — a later success must not hide that something was lost") }) test('a network failure or a 5xx is retried, not dropped', async () => { const { calls, post } = makePostStub([{ throws: true }, { status: 204 }]) const q = createSaveQueue({ post, mergeUpdates: concatUpdates, debounceMs: 5 }) const edit = new Uint8Array([7, 7]) q.enqueue(edit) await new Promise((r) => setTimeout(r, 60)) assert.equal(calls.length, 2, 'a network failure must be retried automatically, not just left queued') assert.deepEqual(calls[0], edit) assert.deepEqual(calls[1], edit, 'the retried batch must be the exact same bytes, not lost or duplicated') assert.equal(q.status, 'ok', 'once the retry lands, nothing was actually lost') }) test('a 500 is retried the same way a network failure is, never dropped', async () => { const { calls, post } = makePostStub([{ status: 500 }, { status: 204 }]) const q = createSaveQueue({ post, mergeUpdates: concatUpdates, debounceMs: 5 }) q.enqueue(new Uint8Array([4, 4])) await new Promise((r) => setTimeout(r, 60)) assert.equal(calls.length, 2, 'a 5xx is our own fault, not the client bytes — it must retry, not drop') assert.equal(q.status, 'ok') })