import { describe, expect, it } from 'vitest'; import { withPageLock } from '../packages/docmost-client/src/lib/page-lock.js'; // A manually-resolvable promise so ordering is fully deterministic without // any timers. `resolve`/`reject` are pulled out of the executor. function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } // A unique pageId per test: the module-level Map is process-global and shared // across every test in a worker, so reusing ids would couple tests together. let pageCounter = 0; function uniquePageId() { return `page-${process.pid}-${Date.now()}-${pageCounter++}`; } // Drain the microtask queue several times. withPageLock chains through // `Promise.resolve().catch().then(fn)`, so `fn` only starts after a few // microtask hops — a single `await Promise.resolve()` is not enough. We never // use timers, so this stays fully deterministic. async function flushMicrotasks() { for (let i = 0; i < 10; i++) await Promise.resolve(); } describe('withPageLock', () => { it('serializes two ops on the same pageId (second waits for the first to settle)', async () => { const pageId = uniquePageId(); const order: string[] = []; const gate1 = deferred(); const op1 = withPageLock(pageId, async () => { order.push('op1-start'); await gate1.promise; order.push('op1-end'); return 'one'; }); const op2 = withPageLock(pageId, async () => { order.push('op2-start'); return 'two'; }); // Let microtasks flush: op1 has started, op2 must not have started yet. await flushMicrotasks(); expect(order).toEqual(['op1-start']); // Release op1; op2 may only begin after op1 fully settles. gate1.resolve(); await Promise.all([op1, op2]); expect(order).toEqual(['op1-start', 'op1-end', 'op2-start']); }); it('does not poison the queue when the first op rejects (second still runs)', async () => { const pageId = uniquePageId(); const order: string[] = []; const gate1 = deferred(); const op1 = withPageLock(pageId, async () => { order.push('op1-start'); await gate1.promise; throw new Error('boom'); }); const op2 = withPageLock(pageId, async () => { order.push('op2-start'); return 'survived'; }); await flushMicrotasks(); expect(order).toEqual(['op1-start']); gate1.resolve(); // op1 rejects, but op2 still runs afterwards. await expect(op1).rejects.toThrow('boom'); await expect(op2).resolves.toBe('survived'); expect(order).toEqual(['op1-start', 'op2-start']); }); it('runs ops on different pageIds concurrently (no cross-page blocking)', async () => { const pageIdA = uniquePageId(); const pageIdB = uniquePageId(); const order: string[] = []; const gateA = deferred(); const opA = withPageLock(pageIdA, async () => { order.push('A-start'); await gateA.promise; order.push('A-end'); return 'a'; }); const opB = withPageLock(pageIdB, async () => { order.push('B-start'); return 'b'; }); // B is on a different page and must start without waiting for A. await flushMicrotasks(); expect(order).toContain('A-start'); expect(order).toContain('B-start'); gateA.resolve(); await Promise.all([opA, opB]); // B finished while A was still gated. expect(order).toEqual(['A-start', 'B-start', 'A-end']); }); it('returns the real resolved value to the caller', async () => { const pageId = uniquePageId(); const value = { ok: true, n: 7 }; await expect(withPageLock(pageId, async () => value)).resolves.toBe(value); }); it('propagates the real rejection to the caller (not swallowed)', async () => { const pageId = uniquePageId(); const err = new Error('real failure'); await expect( withPageLock(pageId, async () => { throw err; }), ).rejects.toBe(err); }); it('forms a FRESH chain after the previous chain has fully drained', async () => { // After op1 fully settles, withPageLock drops the page's map entry (the // chain has drained). A later op on the SAME page must still run and // serialize correctly behind a brand-new chain — observed purely via // behaviour, never by reaching into the private `chains` map. const pageId = uniquePageId(); const order: string[] = []; // First op: run to completion and let its tail drain the chain. await withPageLock(pageId, async () => { order.push('first'); return 'a'; }); // Give the tail's .then(delete) a chance to run. await flushMicrotasks(); // A new op + an immediate second op on the freshly-empty page must still // serialize (second waits for the new chain's head to settle). const gate = deferred(); const second = withPageLock(pageId, async () => { order.push('second-start'); await gate.promise; order.push('second-end'); return 'b'; }); const third = withPageLock(pageId, async () => { order.push('third'); return 'c'; }); await flushMicrotasks(); // The new chain serializes: third has not started while second is gated. expect(order).toEqual(['first', 'second-start']); gate.resolve(); await Promise.all([second, third]); expect(order).toEqual(['first', 'second-start', 'second-end', 'third']); }); it('serializes a third op behind the chain even after a rejection mid-chain', async () => { const pageId = uniquePageId(); const order: string[] = []; const gate1 = deferred(); const op1 = withPageLock(pageId, async () => { order.push('1-start'); await gate1.promise; throw new Error('mid-fail'); }); const op2 = withPageLock(pageId, async () => { order.push('2'); return 2; }); const op3 = withPageLock(pageId, async () => { order.push('3'); return 3; }); await flushMicrotasks(); expect(order).toEqual(['1-start']); gate1.resolve(); await expect(op1).rejects.toThrow('mid-fail'); await expect(op2).resolves.toBe(2); await expect(op3).resolves.toBe(3); expect(order).toEqual(['1-start', '2', '3']); }); });