Files
Agent-System/web/src/lib/editorHydrationGate.test.ts
T
weichenandCursor 1b04d962d1 fix(web): keep block ids and drafts across editor hydration races
Background GET rebuilt markdown AST with Math.random ids and dropped
head_block_map, so save closed the modal without PATCH. Align hydrate
to the ledger map, gate in-flight responses, and fail-closed on stale ids.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 19:54:36 +00:00

121 lines
3.7 KiB
TypeScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
createEditorHydrationGate,
decideHydrationApply,
} from './editorHydrationGate';
describe('decideHydrationApply', () => {
const token = { gen: 2, saveEpoch: 0, docId: 'doc_a' };
it('applies only when gen, doc, save epoch match and editor is closed', () => {
assert.deepEqual(
decideHydrationApply({
token,
currentGen: 2,
currentSaveEpoch: 0,
activeDocId: 'doc_a',
editorOpen: false,
}),
{ apply: true, reason: 'ok' },
);
});
it('rejects a response for another document', () => {
assert.deepEqual(
decideHydrationApply({
token,
currentGen: 2,
currentSaveEpoch: 0,
activeDocId: 'doc_b',
editorOpen: false,
}),
{ apply: false, reason: 'doc_mismatch' },
);
});
it('rejects a superseded generation', () => {
assert.deepEqual(
decideHydrationApply({
token,
currentGen: 3,
currentSaveEpoch: 0,
activeDocId: 'doc_a',
editorOpen: false,
}),
{ apply: false, reason: 'stale_generation' },
);
});
});
describe('createEditorHydrationGate', () => {
it('does not start a new hydrate while the editor is open (UI-06 fill-window GET)', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_proj6140_intake');
const before = gate.beginHydration('doc_proj6140_intake');
assert.ok(before);
gate.setEditorOpen(true);
assert.equal(gate.beginHydration('doc_proj6140_intake'), null);
assert.equal(gate.decide(before).reason, 'editor_open');
assert.equal(gate.decide(before).apply, false);
});
it('deferred hydrate that resolves after the editor opens does not replace the session', async () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_proj6140_intake');
const token = gate.beginHydration('doc_proj6140_intake');
assert.ok(token);
let resolveHydrate!: (ast: { blockId: string }) => void;
const pending = new Promise<{ blockId: string }>((resolve) => {
resolveHydrate = resolve;
});
gate.setEditorOpen(true);
resolveHydrate({ blockId: 'blk_rebuilt_during_fill' });
const rebuilt = await pending;
const decision = gate.decide(token);
assert.equal(rebuilt.blockId, 'blk_rebuilt_during_fill');
assert.equal(decision.apply, false);
assert.equal(decision.reason, 'editor_open');
});
it('covers hydrate resolving between fill and save', async () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_proj6140_intake');
const token = gate.beginHydration('doc_proj6140_intake');
assert.ok(token);
gate.setEditorOpen(true);
let resolveDuringFill!: (value: 'hydrated') => void;
const hydrateDuringFill = new Promise<'hydrated'>((resolve) => {
resolveDuringFill = resolve;
});
resolveDuringFill('hydrated');
await hydrateDuringFill;
assert.equal(gate.decide(token).reason, 'editor_open');
gate.markSaved();
gate.setEditorOpen(false);
assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' });
});
it('late hydrate after a successful PATCH does not roll back the new baseline', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_a');
const stale = gate.beginHydration('doc_a');
assert.ok(stale);
gate.setEditorOpen(true);
gate.markSaved();
gate.setEditorOpen(false);
assert.deepEqual(gate.decide(stale), { apply: false, reason: 'superseded_by_save' });
const fresh = gate.beginHydration('doc_a');
assert.ok(fresh);
assert.deepEqual(gate.decide(fresh), { apply: true, reason: 'ok' });
});
});