// fixtures/live-updates.test.mjs — the live-update subscriber's own // tests, run under plain Node with no DOM, no EventSource and no real // Y.Doc, the same way save-queue.test.mjs and // resync-controller.test.mjs exercise their modules. // // Two of these are regression tests for defects found in review // before any of this was written, and both are marked as such: the // cursor must not advance over a gap, and a tab that is visible and // focused but merely being READ must not go permanently stale. import test from 'node:test' import assert from 'node:assert/strict' import { createLiveUpdates, updatesURL } from './live-updates.mjs' // A controllable stand-in for the browser's EventSource. Nothing here // reconnects on its own — which is the point, since the module under // test owns every open and every close. function fakeStream() { const streams = [] const open = (onEvent, onError) => { const s = { onEvent, onError, closed: false } streams.push(s) return { close: () => { s.closed = true } } } return { open, get all() { return streams }, get live() { return streams.filter(s => !s.closed) }, get last() { return streams[streams.length - 1] }, emit(seq) { streams.filter(s => !s.closed).forEach(s => s.onEvent(seq)) }, fail() { streams.filter(s => !s.closed).forEach(s => s.onError(new Error('stream died'))) }, } } // A fetch stand-in whose responses are resolved by hand, so a test can // hold one open and act while it is in flight. Records the raw flag // on each call rather than needing a second fake: a retry after a // decode failure is still just a call to fetchSince, distinguished // only by { raw: true } in its options. function fakeFetches() { const calls = [] const fetchSince = (since, { raw = false } = {}) => { let settle const promise = new Promise(resolve => { settle = resolve }) calls.push({ since, raw, settle }) return promise } return { fetchSince, calls } } // A fake clock: only the timers the module actually schedules. function fakeClock() { let timers = [] let seq = 0 return { setTimeoutFn(fn, ms) { const id = ++seq timers.push({ id, fn, ms }) return id }, clearTimeoutFn(id) { timers = timers.filter(t => t.id !== id) }, // Run everything currently scheduled, as if its delay elapsed. async advance() { const due = timers timers = [] for (const t of due) await t.fn() }, get pending() { return timers.length }, // The delays actually asked for. advance() fires everything // regardless of its ms, which is what makes it a useful fake and // also what makes it blind to the difference between a deadline of // 30 seconds and one of two minutes — the whole subject of the // hidden-tab grace. A test that cares reads this instead. get deadlines() { return timers.map(t => t.ms) }, } } function build(overrides = {}) { const stream = fakeStream() const fetches = fakeFetches() const clock = fakeClock() const applied = [] const live = createLiveUpdates({ initialSeq: 0, openStream: stream.open, fetchSince: fetches.fetchSince, onUpdates: (bytes) => applied.push(bytes), setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, idleMs: 1000, hiddenMs: 250, ...overrides, }) return { live, stream, fetches, clock, applied } } test('a tab advances its own cursor only when its POST is the very next row', () => { const { live } = build() // cursor 0, own POST lands at 1: contiguous, so adopt it and skip // the round trip. live.noteOwnSeq(1) assert.equal(live.cursor, 1) // cursor 1, own POST lands at 5: rows 2-4 came from somewhere else // and this tab has not applied them. Adopting 5 would skip them // permanently, which is the whole reason for this rule. live.noteOwnSeq(5) assert.equal(live.cursor, 1, 'cursor advanced over rows it never applied') }) test('the cursor never moves backwards when a response is overtaken by this tab\'s own write', async () => { // Whole-branch review found the first version of this test inert: // it "delivered" the straggler by settling a promise that had // already resolved, which is a no-op, so the assertions passed // whether the module used max() or plain assignment — leaving the // one client-side rule the spec says these tests must pin // effectively untested. // // This is the reachable version of the same race. A fetch goes out // at since=0. While it is in flight this tab's own POST comes back // as row 1, which is contiguous, so the cursor advances to 1 // without waiting. The older fetch then resolves naming seq 0. // Plain assignment drags the cursor back to 0 and re-fetches and // re-applies row 1; max() leaves it alone. const { live, stream, fetches } = build() live.start() stream.emit(1) assert.equal(fetches.calls.length, 1) assert.equal(fetches.calls[0].since, 0) live.noteOwnSeq(1) assert.equal(live.cursor, 1) fetches.calls[0].settle({ bytes: new Uint8Array([]), seq: 0 }) await Promise.resolve() await Promise.resolve() assert.equal(live.cursor, 1, 'an overtaken response dragged the cursor backwards') }) test('a later response still carries the cursor forward', async () => { const { live, stream, fetches, applied } = build() live.start() stream.emit(3) fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 3 }) await Promise.resolve() await Promise.resolve() assert.equal(live.cursor, 3) stream.emit(9) fetches.calls[1].settle({ bytes: new Uint8Array([2]), seq: 9 }) await Promise.resolve() await Promise.resolve() assert.equal(live.cursor, 9) assert.equal(applied.length, 2) }) test('a burst of notifications collapses into one fetch at a time', async () => { const { live, stream, fetches } = build() live.start() stream.emit(1) stream.emit(2) stream.emit(3) assert.equal(fetches.calls.length, 1, 'a burst started more than one fetch at once') fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 1 }) await Promise.resolve() await Promise.resolve() // Something arrived while that was in flight, so exactly one // follow-up runs — not three. assert.equal(fetches.calls.length, 2) assert.equal(fetches.calls[1].since, 1) }) test('an event naming a seq the tab already has fetches nothing', () => { const { live, stream, fetches } = build() live.start() live.noteOwnSeq(1) stream.emit(1) // this tab's own write, echoed back assert.equal(fetches.calls.length, 0, 'a tab re-fetched its own write') }) test('a stopped subscriber drops a response that arrives late', async () => { const { live, stream, fetches, applied } = build() live.start() stream.emit(1) live.stop() fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 1 }) await Promise.resolve() assert.equal(applied.length, 0, 'a stopped subscriber applied bytes into a document being replaced') }) test('stop closes the stream, and start opens exactly one', () => { const { live, stream } = build() live.start() assert.equal(stream.live.length, 1) live.start() // idempotent: must not open a second assert.equal(stream.live.length, 1) live.stop() assert.equal(stream.live.length, 0) }) test('a hidden tab closes its stream on a shortened deadline, and activity reopens it', async () => { const { live, stream, clock } = build() live.start() assert.equal(stream.live.length, 1) assert.deepEqual(clock.deadlines, [1000], 'a visible tab should be on the idle deadline') // Not on the spot. Closing here is what made every switch back // repaint the document a frame after showing the stale one. live.noteHidden() 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') assert.deepEqual(clock.deadlines, [250], 'a hidden tab kept the long idle deadline instead of shortening to the hidden grace') await clock.advance() assert.equal(stream.live.length, 0, 'a tab left hidden past the grace kept its stream open, pinning the instance awake') live.noteActivity() assert.equal(stream.live.length, 1, 'activity did not bring the stream back') }) test('switching away and back inside the grace never drops the stream, so nothing has to be caught up', async () => { // This is the property Paul actually reported, stated as a test: the // flash was never a rendering bug, it was a reconnect. A tab that // keeps its stream across the switch has already applied everything // by the time it is painted, so there is nothing left to repaint. // // It asserts on `all`, not `live`: a stream that closed and reopened // leaves both counts at 1 and would pass a liveness check while // being exactly the defect. const { live, stream, clock } = build() live.start() assert.equal(stream.all.length, 1) live.noteHidden() live.noteActivity() // back within the grace, before advance() assert.equal(stream.all.length, 1, 'the stream was reopened, which means it closed — the tab paints stale and catches up after') assert.deepEqual(clock.deadlines, [1000], 'coming back did not restore the idle deadline') // And the grace timer really was cancelled rather than left pending // to close a stream the person has come back to. await clock.advance() assert.equal(stream.live.length, 0, 'sanity: the idle deadline still closes an untouched stream') }) test('a visible tab that is only being READ closes when idle and comes back on any activity', async () => { // The review's second finding. Closing on idle and reopening only // on visibility, focus or a local edit leaves the commonest case of // all unhandled: someone reading a long document on a tab that is // already visible and already focused fires none of those three, so // the stream closes and nothing ever reopens it. The watching half // of "type in one tab, watch the other" then fails silently. const { live, stream, clock } = build() live.start() assert.equal(stream.live.length, 1) await clock.advance() // the idle timer elapses with nothing happening assert.equal(stream.live.length, 0, 'an idle stream was not closed') // Scrolling counts. So does a pointer moving, or a key going down. live.noteActivity() assert.equal(stream.live.length, 1, 'a reader who scrolled was left permanently stale') }) test('activity while streaming defers the idle close rather than reopening anything', async () => { const { live, stream, clock } = build() live.start() live.noteActivity() live.noteActivity() assert.equal(stream.all.length, 1, 'activity churned the connection instead of deferring the timer') await clock.advance() assert.equal(stream.live.length, 0) }) test('a stream failure is never reported as a save failure, and heals on activity', () => { const failures = [] const { live, stream } = build({ onError: (err) => failures.push(err) }) live.start() stream.fail() // The document is still saving fine — POST is a different path // entirely — so this must not be surfaced as lost work. It is // reported for the console and nothing else. assert.equal(failures.length, 1) assert.equal(stream.live.length, 0) live.noteActivity() assert.equal(stream.live.length, 1) }) test('an empty response advances the cursor without applying anything', async () => { const { live, stream, fetches, applied } = build() live.start() stream.emit(4) fetches.calls[0].settle({ bytes: new Uint8Array([]), seq: 4 }) await Promise.resolve() assert.equal(live.cursor, 4) assert.equal(applied.length, 0, 'an empty slice was applied as if it were content') }) test('a failed fetch leaves the cursor alone so the next notification retries it', async () => { const { live, stream, fetches } = build() live.start() stream.emit(2) fetches.calls[0].settle(Promise.reject(new Error('offline'))) await Promise.resolve() await Promise.resolve() assert.equal(live.cursor, 0, 'a failed fetch advanced the cursor past bytes it never received') }) test('a decode failure retries once through the raw log, then gives up', async () => { const { live, stream, fetches } = build({ onUpdates: () => { throw new Error('bad update') }, }) live.start() stream.emit(1) fetches.calls[0].settle({ bytes: new Uint8Array([1]), seq: 1 }) await Promise.resolve(); await Promise.resolve() assert.equal(fetches.calls.length, 2, 'no retry after a decode failure') assert.equal(fetches.calls[1].raw, true, 'the retry did not ask for the raw log') assert.equal(live.cursor, 0, 'the cursor advanced over bytes that never applied') }) // updatesURL is the one place either editor-entry.js call site spells // the query string it sends the server, so its exact output is pinned // here rather than trusted by inspection of a DOM-bound file this // project has no test harness for. handlers.go matches the raw flag // as the literal string "1" and nothing else (internal/docs/ // handlers.go's own r.URL.Query().Get("raw") == "1") -- raw=true, // raw=on, and a bare raw all fail silently, with no error and no // visible symptom beyond the resync controller refetching the same // corrupt snapshot forever. Asserting exact strings, not a substring // check, is the point: .includes('raw') would pass against raw=true // too. test('updatesURL spells the query string exactly, in particular raw as the literal raw=1', () => { assert.equal(updatesURL('/d/abc/updates'), '/d/abc/updates') assert.equal(updatesURL('/d/abc/updates', { since: 4 }), '/d/abc/updates?since=4') assert.equal(updatesURL('/d/abc/updates', { raw: true }), '/d/abc/updates?raw=1') assert.equal(updatesURL('/d/abc/updates', { since: 4, raw: true }), '/d/abc/updates?since=4&raw=1') })