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