Files
docmost-sync/test/page-lock.test.ts
vvzvlad cc13c94f53 test(docmost-client): add unit tests for pure lib modules
Add 230 Vitest unit tests covering the dependency-light, pure modules of
packages/docmost-client/src/lib, imported directly from source:

- node-ops: tree addressing, immutability/clone guarantees, table ops,
  throw-vs-noop contracts (87)
- transforms: commentsToFootnotes reading-order renumbering, insertMarkerAfter
  mark-preserving split, setCalloutRange regex statefulness (43)
- json-edit: applyTextEdits literal $&/$1, error distinction, immutability (17)
- page-lock: async per-page mutex ordering and error isolation (6)
- filters: filterPage/filterComment truthiness traps, filterSearchResult (19)
- markdown-converter: per-node golden matrix + edge cases (41)
- markdown-document envelope: round-trip, CRLF, malformed-JSON throws (17)

No source files changed. The pre-existing test/markdown-document.test.ts is
left intact; new envelope coverage lives in markdown-document-envelope.test.ts.
Full suite: 16 files / 279 tests green.
2026-06-16 22:10:06 +03:00

171 lines
4.8 KiB
TypeScript

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<T = void>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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('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']);
});
});