Files
docmost-sync/test/page-lock.test.ts
vvzvlad d9d8538846 test(sync): implement test-strategy Phase 1-2 (pure unit/golden/property), +102 tests
Work through test-strategy-report.md, high-ROI no-refactor subset (no regen).

- R-Infra: vitest resolve.alias docmost-client -> packages/docmost-client/src
  (fixes the dist-vs-src coverage artifact: canonicalize 0% -> real)
- R-Cfg-1: export parseArgs + tests
- canonicalize: align family / comment.resolved kept / link non-default +
  fixpoint & docsCanonicallyEqual reflexive/symmetric properties (0 -> 100%)
- markdown-converter golden matrix: columns/embed/audio/pdf, drawio data-align
  rule, inline-mark matrix, textAlign, escaping idempotence, table sanitization
  (61 -> 79%)
- schema parse-closures via generateJSON (TextStyle/comment/mention/Highlight/Column)
- node-ops (immutability, table edge cases, makeFreshId property), transforms
  (setCalloutRange/insertMarkerAfter/commentsToFootnotes + renumber property)
- stabilize normalize-on-write fixpoint (0 -> 100%); diff coarse-fallback;
  client-utils; firstDivergence; corpus fixtures details/columns/mention
- 593 -> 695 green; build clean; corpus STABLE

Deferred (Phase 3-4, refactor-gated): pull/collab/client-REST/git-merge integration.
2026-06-17 01:01:26 +03:00

210 lines
6.3 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('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']);
});
});