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