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.
This commit is contained in:
170
test/page-lock.test.ts
Normal file
170
test/page-lock.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user