diff --git a/scripts/preview/README.md b/scripts/preview/README.md index 30e2c5ca..0d3ff414 100644 --- a/scripts/preview/README.md +++ b/scripts/preview/README.md @@ -90,7 +90,7 @@ The page stubs the VS Code webview runtime just enough to boot the real bundle: posts `{ protocol, type: "ready" }` through the shim when it mounts (after its `window` message listener is wired). The shim detects that and only then `window.postMessage`es the `document` seed - (`{ protocol, type: "document", content, docVersion: 1, themeKind, canWrite: true, eol }`). + (`{ protocol, type: "document", content, docVersion: 1, themeKind, canWrite: true, eol, externalEpoch: 0, epochGeneration: 1 }`). `protocol` is `serve.mjs`'s `PREVIEW_PROTOCOL_VERSION`, pinned equal to `src/shared/protocol.ts`'s `PROTOCOL_VERSION` by `test/build/preview-server-theme.test.ts` — a stale value makes the shell diff --git a/scripts/preview/preview.template.html b/scripts/preview/preview.template.html index 0fa07968..03e73fff 100644 --- a/scripts/preview/preview.template.html +++ b/scripts/preview/preview.template.html @@ -99,6 +99,9 @@ themeKind: THEME_KIND, canWrite: true, eol: DOC_EOL, + // The identity pair is required; any fixed pair works for a single-session preview. + externalEpoch: 0, + epochGeneration: 1, }, "*" ); diff --git a/src/extension/session/document-message.ts b/src/extension/session/document-message.ts index f24e2ae8..b93f9405 100644 --- a/src/extension/session/document-message.ts +++ b/src/extension/session/document-message.ts @@ -42,10 +42,8 @@ export type BuildDocumentMessageInput = { docVersion: number; themeKind: ThemeKind; canWrite: boolean; - // The Document identity pair (S3a). The host always emits BOTH now (the wire - // optionality is purely reception tolerance for an old host — see the - // `DocumentMessage` doc block + `isValidEpochIdentity` in protocol.ts), so - // both are required at the builder. + // The Document identity pair (S3a) — both required on the wire (see the + // `DocumentMessage` doc block in protocol.ts). externalEpoch: number; epochGeneration: number; }; @@ -55,8 +53,7 @@ export type BuildDocumentMessageInput = { * as a TS error at every call site rather than as a silently-accepted * extra field, and the Object.keys assertion in the unit test catches * it before it reaches the wire. Always emits `externalEpoch` + - * `epochGeneration` (the exclusive pair) — the key-set test pins their - * presence. */ + * `epochGeneration` — the key-set test pins their presence. */ export function buildDocumentMessage(input: BuildDocumentMessageInput): DocumentMessage { return { protocol: PROTOCOL_VERSION, diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 24e6e549..12f17b3d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -253,13 +253,11 @@ export function isDocumentEol(value: unknown): value is DocumentEol { * at the protocol layer — see MAX_CONTENT_LENGTH for the directionality * rationale. * - * `externalEpoch` + `epochGeneration` are an EXCLUSIVE PAIR — both present or - * both absent; a partial pair is a boundary-INVALID message (validator- - * authoritative). They are wire-OPTIONAL for one release so an old host that - * never sends them does not brick a new webview (absence = "no epoch info" = - * today's unconditional-replay behaviour). Since `PROTOCOL_VERSION` 2 an old - * host is rejected at `isProtocolMatch` before the pair is read, so that - * tolerance is unreachable; making the pair required is a follow-up. + * `externalEpoch` + `epochGeneration` are both REQUIRED — a Document missing + * either is a boundary-INVALID message. Requiring them did not bump + * `PROTOCOL_VERSION`, unlike `eol`: `buildDocumentMessage` already emitted + * both before version 2 existed, so every version-2 host sends the pair and + * the stricter validator rejects no message a conforming host produces. * Semantics (S3a plumbs them; S3b consumes them): `externalEpoch` is host-owned and monotonic WITHIN one host * session (starts at 0), advancing whenever document content changed by * anything other than the webview's own acked edit lineage; `epochGeneration` @@ -267,12 +265,7 @@ export function isDocumentEol(value: unknown): value is DocumentEol { * timestamp) that identifies WHICH host session's epoch counter it is, so a * webview surviving a host restart can tell an epoch regression across * generations from a real advance. Identity, not ordering — never compared for - * magnitude. - * - * The fields are typed as INDEPENDENTLY optional, but the EXCLUSIVE-pair - * contract (both present or both absent; a partial pair is invalid) is enforced - * at the boundary by `isValidEpochIdentity` — the validator is the authority, - * not the type. The host's `buildDocumentMessage` always emits BOTH. */ + * magnitude. */ export type DocumentMessage = Envelope & { type: "document"; content: string; @@ -280,8 +273,8 @@ export type DocumentMessage = Envelope & { themeKind: ThemeKind; canWrite: boolean; eol: DocumentEol; - externalEpoch?: number; - epochGeneration?: number; + externalEpoch: number; + epochGeneration: number; }; /** Theme change only — no content, no version. Pushed on @@ -711,26 +704,6 @@ function isEpochComponent(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } -/** The Document's `externalEpoch` + `epochGeneration` pair is EXCLUSIVE: - * both valid, or both absent. A partial pair (exactly one present) is a - * boundary-INVALID message — the webview must never see a half-formed - * identity (its S3b drop-and-adopt logic enumerates only well-formed states: - * absent, or a valid pair). Absence is tolerated (old host → new webview - * skew): the webview falls back to today's unconditional-replay behaviour. - * Since `PROTOCOL_VERSION` 2 an old host fails `isProtocolMatch` first, so - * this tolerance is unreachable; making the pair required is a follow-up. */ -function isValidEpochIdentity(epoch: unknown, generation: unknown): boolean { - const epochAbsent = epoch === undefined; - const generationAbsent = generation === undefined; - if (epochAbsent && generationAbsent) { - return true; // no epoch info — tolerated - } - if (epochAbsent || generationAbsent) { - return false; // partial pair — invalid - } - return isEpochComponent(epoch) && isEpochComponent(generation); -} - function isBoundedContent(value: unknown): value is string { // Webview→host: cap to bound oversized payloads from a user-controlled // surface. The exact boundary is asserted in test/shared/protocol.test.ts. @@ -750,7 +723,8 @@ export function isHostToWebview(value: unknown): value is HostToWebview { isThemeKind(v.themeKind) && typeof v.canWrite === "boolean" && isDocumentEol(v.eol) && - isValidEpochIdentity(v.externalEpoch, v.epochGeneration) + isEpochComponent(v.externalEpoch) && + isEpochComponent(v.epochGeneration) ); case "theme": return isThemeKind(v.themeKind); diff --git a/src/webview/cm/edit-sync.ts b/src/webview/cm/edit-sync.ts index aecca444..8780f446 100644 --- a/src/webview/cm/edit-sync.ts +++ b/src/webview/cm/edit-sync.ts @@ -60,16 +60,15 @@ const IDENTITY_FLAP_THRESHOLD = 3; * bytes actually belonged to: `recorded` is NOT guaranteed to sit still while an * Edit is in flight (onHostSnapshot can accept another Document first), so * reading the live pair instead would judge the wrong lineage. - * `epoch`/`generation` are `null` when captured under a legacy (pair-less) host. + * `epoch`/`generation` are `null` when captured before the first host snapshot. * `content` is `readonly` for the same reason `DocumentIdentity`'s fields are: a * HeldEdit is built WHOLE by `stampHeld`, so the stamp and the content belong to * one capture and neither wing may be replaced on its own. */ type HeldEdit = DocumentIdentity & { readonly content: string }; /** A Document's (externalEpoch, epochGeneration) pair in edit-sync's internal - * form. The wire pair is EXCLUSIVE (both present or both absent — validator- - * authoritative, see protocol.ts); absence is carried as `null` on BOTH fields - * so a single comparison rule can read stamps and incoming Documents alike. + * form. The wire pair is required (see protocol.ts); `null` on BOTH fields + * means only "before the first host snapshot". * Both fields are `readonly`: a pair is REPLACED as a whole, never amended one * wing at a time, so "advance the epoch and leave the generation behind" * cannot be written. (Where the recorded pair's writes live is recorded at its @@ -158,8 +157,8 @@ export type EditSync = { onHostSnapshot: ( docVersion: number, canWrite: boolean, - externalEpoch?: number, - epochGeneration?: number + externalEpoch: number, + epochGeneration: number ) => void; /** The reducer committed — re-evaluate and drain. This is the SINGLE * post-commit drain entry point. The shell fires it from its dispatch @@ -270,17 +269,14 @@ export type EditSync = { * genuine external divergence — whose text never matches our posted * bytes — still reseeds. * 2. The Document's identity pair CONTINUES the lineage we are carrying — - * same generation with the epoch not advanced, OR (legacy tolerance) - * neither side carries a pair at all, which keeps a pair-less host on the - * old unconditional-fold behaviour. Content equality alone does not make a - * Document ours: another writer can produce byte-identical bytes, and the - * host then reports a foreign epoch advance / a new generation. The - * lineage compared against is the held replay buffer's stamp when one is - * held (it is the content whose survival the fold predicts) and the - * recorded pair otherwise. Pass the incoming pair BEFORE `onHostSnapshot` - * records it (applyDocument's order), so the comparison is - * incoming-vs-previous. Both arguments are required — pass `undefined` - * explicitly for a legacy pair-less host. + * same generation with the epoch not advanced. Content equality alone + * does not make a Document ours: another writer can produce + * byte-identical bytes, and the host then reports a foreign epoch advance + * / a new generation. The lineage compared against is the held replay + * buffer's stamp when one is held (it is the content whose survival the + * fold predicts) and the recorded pair otherwise. Pass the incoming pair + * BEFORE `onHostSnapshot` records it (applyDocument's order), so the + * comparison is incoming-vs-previous. * * The reseed path (editor.ts applyDocument) uses this to recognise a host * Document that merely ECHOES our own in-flight edit back. When the live @@ -294,32 +290,26 @@ export type EditSync = { * fold to exactly the Documents whose replay buffer `replayIfNeeded` will * still replay — on a superseded lineage the buffer is DROPPED, so folding * would leave the ahead keystrokes visible but unsavable. */ - acksInFlightEdit: ( - content: string, - externalEpoch: number | undefined, - epochGeneration: number | undefined - ) => boolean; + acksInFlightEdit: (content: string, externalEpoch: number, epochGeneration: number) => boolean; /** The Document identity pair (externalEpoch, epochGeneration) recorded from - * the most recent accepted host snapshot — `null` before the first snapshot - * or when the host omitted the pair (old-host tolerance). TWO consumers read - * it through the shared `supersedesIdentity` rule: the replay side - * (`shouldDropBufferedForEpoch`, which drops a held buffer on a foreign epoch - * advance or an identity transition) and the display side + * the most recent accepted host snapshot — `null` before the first snapshot. + * TWO consumers read it through the shared `supersedesIdentity` rule: the + * replay side (`shouldDropBufferedForEpoch`, which drops a held buffer on a + * foreign epoch advance or an identity transition) and the display side * (`acksInFlightEdit`, which gates the reseed path's ok-ack fold and falls * back to this pair when no buffer is held). They must agree — see * `supersedesIdentity`. */ recordedIdentity: () => DocumentIdentity; /** Pure predicate (no side effects): would an incoming Document's identity * pair be an identity transition against the CURRENTLY recorded pair? True - * on a different generation, absent→present, or present→absent; false for a - * same-generation Document, a pure-absent (legacy) pair, or before the first - * snapshot (the seed is an adoption, not a transition). The shell reads this - * BEFORE `applyDocument` to bypass its whole-Document stale-version drop on a - * transition; `onHostSnapshot` recomputes it internally to bypass its own - * stale guard, count the tripwire, and adopt the pair (both read the same - * unchanged recorded pair, so they agree). Version ordering is meaningful - * only WITHIN one host generation (S3b). */ - isIdentityTransition: (externalEpoch?: number, epochGeneration?: number) => boolean; + * on a different generation; false for a same-generation Document or before + * the first snapshot (the seed is an adoption, not a transition). The shell + * reads this BEFORE `applyDocument` to bypass its whole-Document + * stale-version drop on a transition; `onHostSnapshot` recomputes it + * internally to bypass its own stale guard, count the tripwire, and adopt the + * pair (both read the same unchanged recorded pair, so they agree). Version + * ordering is meaningful only WITHIN one host generation (S3b). */ + isIdentityTransition: (externalEpoch: number, epochGeneration: number) => boolean; }; export function createEditSync(opts: EditSyncOptions): EditSync { @@ -353,17 +343,16 @@ export function createEditSync(opts: EditSyncOptions): EditSync { let timer: ReturnType | null = null; // Document identity pair from the most recent accepted host snapshot (S3a // recorded it; S3b now acts on it). Both fields are `null` before the first - // snapshot / when the host omits the pair. Read in replayIfNeeded's drop - // check, at each buffer capture (via stampHeld), by isIdentityTransition, - // and — via recordedIdentity(), as the no-buffer-held fallback — by - // acksInFlightEdit's lineage conjunct. So BOTH the replay side and the - // display (ok-ack fold) side read it, not the replay side alone. + // snapshot. Read in replayIfNeeded's drop check, at each buffer capture (via + // stampHeld), by isIdentityTransition, and — via recordedIdentity(), as the + // no-buffer-held fallback — by acksInFlightEdit's lineage conjunct. So BOTH + // the replay side and the display (ok-ack fold) side read it, not the replay + // side alone. // ONE variable holding the PAIR, not two independent wings: with two `let`s a - // write could land on one and miss the other, leaving the wings disagreeing - // about presence (which is read off `generation` alone). Here every write - // names the whole pair — the initializer below and the adoption in - // onHostSnapshot are the only two — and `DocumentIdentity`'s `readonly` - // fields stop the pair being amended in place afterwards. + // write could land on one and miss the other, leaving the wings disagreeing. + // Here every write names the whole pair — the initializer below and the + // adoption in onHostSnapshot are the only two — and `DocumentIdentity`'s + // `readonly` fields stop the pair being amended in place afterwards. let recorded: DocumentIdentity = { epoch: null, generation: null }; const now = opts.now ?? (() => Date.now()); // Rolling window of identity-transition timestamps + once-per-session latch @@ -382,17 +371,6 @@ export function createEditSync(opts: EditSyncOptions): EditSync { // caller mutate this module's recorded lineage. const recordedIdentity = (): DocumentIdentity => ({ ...recorded }); - // Wire pair → internal pair. THE constructor for both directions — incoming - // Documents and the pair onHostSnapshot records — so the two sides cannot - // normalize differently. The EXCLUSIVE-pair contract is enforced at the - // boundary validator, so a partial pair cannot arrive from a validated - // message; normalizing one to absent is the conservative read anyway (against - // a present recorded pair it makes presence differ → supersedes). - const incomingIdentity = (epoch?: number, generation?: number): DocumentIdentity => - epoch === undefined || generation === undefined - ? { epoch: null, generation: null } - : { epoch, generation }; - // Stamp a captured buffer with the identity pair CURRENT at capture time. All // four capturing functions route through this — trySend, replayIfNeeded and // flush (each including its failed-post retry arm) plus cancelPendingFlush, @@ -407,43 +385,22 @@ export function createEditSync(opts: EditSyncOptions): EditSync { ...recordedIdentity(), }); - // Do two pairs name DIFFERENT Document identities? The ONE presence/generation - // rule, written once: a pair-less (legacy) session on both sides is the same - // identity, one side carrying a pair while the other does not is a transition, - // and two present pairs differ exactly when their generations differ. - // SYMMETRIC in its arguments — swapping them cannot change the answer — which - // is why it keeps positional parameters while `supersedesIdentity` below, - // whose epoch arm is directional, does not. Two judgements read it: - // `supersedesIdentity` and `isIdentityTransition`. - const identityChanged = (a: DocumentIdentity, b: DocumentIdentity): boolean => { - const aPresent = a.generation !== null; - const bPresent = b.generation !== null; - if (!aPresent && !bPresent) { - return false; - } - if (aPresent !== bPresent) { - return true; - } - return a.generation !== b.generation; - }; - // Has the host's Document lineage moved ON from `from` to `to` — i.e. is // content belonging to `from` no longer ours to carry forward? ONE rule at ONE // choke point (S3b): - // - both pairs absent (legacy throughout) → no (today's behaviour) - // - exactly one side carries a pair → identity transition → yes - // - different generation → identity transition → yes + // - different generation (incl. a pre-seed `null` stamp) → identity + // transition → yes // - same generation, `to` epoch AHEAD → foreign bytes landed → yes // - same generation, `to` epoch equal or BEHIND → no (our own lineage // continues; a within-generation regression is not supersession) // `epoch` is compared for magnitude only WITHIN one generation; `generation` // is identity, never ordering (protocol.ts's DocumentMessage doc). // - // DIRECTIONAL, unlike identityChanged: only the epoch arm asks which side is - // ahead, so a swapped call inverts exactly that arm and nothing else — no type - // error, and no symptom until a same-generation foreign advance arrives. The - // named fields, not argument positions, are what keep the call sites readable - // and typo-proof; the DIRECTION is held by behaviour, not by the naming. + // DIRECTIONAL: only the epoch arm asks which side is ahead, so a swapped call + // inverts exactly that arm and nothing else — no type error, and no symptom + // until a same-generation foreign advance arrives. The named fields, not + // argument positions, are what keep the call sites readable and typo-proof; + // the DIRECTION is held by behaviour, not by the naming. // Measured: swapping `from`/`to` reds 7 tests either way — the acksInFlightEdit // swap reds 4 in cm-edit-sync.test.ts plus 2 in editor.test.ts's (d3) block // and 1 in shell.test.ts; the shouldDropBufferedForEpoch swap reds 5 in @@ -462,7 +419,7 @@ export function createEditSync(opts: EditSyncOptions): EditSync { }: { from: DocumentIdentity; to: DocumentIdentity; - }): boolean => identityChanged(from, to) || (to.epoch ?? 0) > (from.epoch ?? 0); + }): boolean => from.generation !== to.generation || (to.epoch ?? 0) > (from.epoch ?? 0); // Has the host's lineage moved on from these HELD bytes? Its STAMP is the pair // recorded at capture time; the currently recorded pair is where the host has @@ -513,10 +470,9 @@ export function createEditSync(opts: EditSyncOptions): EditSync { // Pure identity-transition predicate — see the EditSync.isIdentityTransition // JSDoc. Reads (never mutates) the recorded pair, so the shell's pre-apply // query and onHostSnapshot's internal call agree. - const isIdentityTransition = (incomingEpoch?: number, incomingGeneration?: number): boolean => + const isIdentityTransition = (_incomingEpoch: number, incomingGeneration: number): boolean => // the first snapshot is an adoption, not a transition - seeded && - identityChanged(recordedIdentity(), incomingIdentity(incomingEpoch, incomingGeneration)); + seeded && recorded.generation !== incomingGeneration; // Record an identity transition for the clustering tripwire and fire the // once-per-session alarm when ≥3 land within the rolling window. @@ -824,22 +780,17 @@ export function createEditSync(opts: EditSyncOptions): EditSync { // replayIfNeeded, which compares its stamp against the new pair. console.info("[quoll] identity transition — adopting new host session", { fromGeneration: recorded.generation, - toGeneration: nextGeneration ?? null, + toGeneration: nextGeneration, fromEpoch: recorded.epoch, - toEpoch: nextEpoch ?? null, + toEpoch: nextEpoch, }); noteIdentityTransition(); } docVersion = nextVersion; canWrite = nextCanWrite; seeded = true; - // Capture the identity pair alongside the version, through the SAME - // constructor the incoming path uses. `undefined` (old host omitted the - // pair) records as `null` — the "no epoch info" fallback that keeps a - // pure-absent (legacy) session on today's replay behaviour — and a partial - // pair normalizes to both-absent instead of being recorded verbatim, which - // would leave `epoch` silently dead (presence is read off `generation`). - recorded = incomingIdentity(nextEpoch, nextGeneration); + // Capture the identity pair alongside the version. + recorded = { epoch: nextEpoch, generation: nextGeneration }; }, // The SINGLE post-commit drain. `committedEditInFlight` is the // reducer's committed `state.editInFlight` — the single source of @@ -967,7 +918,7 @@ export function createEditSync(opts: EditSyncOptions): EditSync { sameTextIgnoringEol(content, inFlight.content) && !supersedesIdentity({ from: buffered ?? recordedIdentity(), - to: incomingIdentity(externalEpoch, epochGeneration), + to: { epoch: externalEpoch, generation: epochGeneration }, }), recordedIdentity, isIdentityTransition, diff --git a/src/webview/editor.ts b/src/webview/editor.ts index 879b6301..f266408b 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -206,7 +206,7 @@ export type EditorHandle = { * the currently recorded pair? (S3b) The shell calls this BEFORE applyDocument * so it can bypass its whole-Document stale-version drop and thread the * `adopt` flag to the reducer. Delegates to edit-sync's pure predicate. */ - isIdentityTransition(externalEpoch?: number, epochGeneration?: number): boolean; + isIdentityTransition(externalEpoch: number, epochGeneration: number): boolean; }; /** Dispatch `post-edit` and ship the Edit message in the same tick. diff --git a/src/webview/shell.ts b/src/webview/shell.ts index 1b8076f5..10cf2508 100644 --- a/src/webview/shell.ts +++ b/src/webview/shell.ts @@ -442,14 +442,14 @@ export function mountShell(root: HTMLElement, opts: ShellOptions): ShellHandle { return; case "document": { // Identity-transition bypass (S3b): a new host session (fresh - // epochGeneration, or a legacy host that dropped the pair) legitimately - // restarts at a LOWER docVersion. Version ordering is meaningful only - // WITHIN one host generation, so on a transition we SKIP the stale drop - // and adopt the Document unconditionally — threading `adopt` so the - // reducer's inlined copy of the same guard also adopts (otherwise the - // webview goes permanently deaf to the live host). editor is non-null - // whenever a stale compare could fire (docVersion only advances past 0 - // after the editor mounted), so the null-guard here is defensive. + // epochGeneration) legitimately restarts at a LOWER docVersion. Version + // ordering is meaningful only WITHIN one host generation, so on a + // transition we SKIP the stale drop and adopt the Document + // unconditionally — threading `adopt` so the reducer's inlined copy of + // the same guard also adopts (otherwise the webview goes permanently + // deaf to the live host). editor is non-null whenever a stale compare + // could fire (docVersion only advances past 0 after the editor + // mounted), so the null-guard here is defensive. const isTransition = editor?.isIdentityTransition(message.externalEpoch, message.epochGeneration) ?? false; if (!isTransition && message.docVersion < state.docVersion) { diff --git a/src/webview/state.ts b/src/webview/state.ts index 93dd7177..35d2578b 100644 --- a/src/webview/state.ts +++ b/src/webview/state.ts @@ -58,13 +58,12 @@ export type Action = readonly themeKind: ThemeKind; /** Identity-transition adoption (S3b): when true, bypass the stale * two-comparison drop and adopt this Document unconditionally. A new host - * session (fresh epochGeneration, or a legacy host that dropped the pair) - * legitimately restarts at a LOWER docVersion; version ordering is - * meaningful only within one generation. The shell computes this (via - * edit-sync's `isIdentityTransition`) and threads it here so the reducer's - * inlined copy of the stale guard cannot re-drop the adoption and strand - * the webview permanently deaf to the live host. Absent/false on ordinary - * same-generation (or pure-absent legacy) Documents. */ + * session (fresh epochGeneration) legitimately restarts at a LOWER + * docVersion; version ordering is meaningful only within one generation. + * The shell computes this (via edit-sync's `isIdentityTransition`) and + * threads it here so the reducer's inlined copy of the stale guard cannot + * re-drop the adoption and strand the webview permanently deaf to the + * live host. Absent/false on ordinary same-generation Documents. */ readonly adopt?: boolean; } | { readonly type: "theme"; readonly themeKind: ThemeKind } diff --git a/test/build/preview-server-theme.test.ts b/test/build/preview-server-theme.test.ts index 35434103..4255e953 100644 --- a/test/build/preview-server-theme.test.ts +++ b/test/build/preview-server-theme.test.ts @@ -129,13 +129,15 @@ describe("preview server — the seed carries the current protocol and a wire eo expect(PREVIEW_PROTOCOL_VERSION).toBe(PROTOCOL_VERSION); }); - it("a served instance seeds that protocol version and an eol", async () => { + it("a served instance seeds that protocol version, an eol and the epoch pair", async () => { // Revert-check: put `protocol: 1` back in preview.template.html → red. const { status, body } = await fetchInstance({ content: "# hi\n" }); expect(status).toBe(200); expect(body).toContain(`protocol: ${PROTOCOL_VERSION},`); expect(body).toContain("eol: DOC_EOL,"); + expect(body).toContain("externalEpoch: 0,"); + expect(body).toContain("epochGeneration: 1,"); // Absent eol: read off the doc's bytes (LF here). expect(body).toContain('var DOC_EOL = "\\n";'); }); diff --git a/test/extension/e2e/types.ts b/test/extension/e2e/types.ts index 8c233c1e..3a2dbe15 100644 --- a/test/extension/e2e/types.ts +++ b/test/extension/e2e/types.ts @@ -41,10 +41,9 @@ export type DocumentMessageShape = EnvelopeShape & { canWrite: boolean; // Mirror of the wire DocumentEol (src/shared/protocol.ts DOCUMENT_EOLS). eol: "\n" | "\r\n"; - // Exclusive pair enforced by the validator (see src/shared/protocol.ts); - // typed as independently optional to mirror the source. - externalEpoch?: number; - epochGeneration?: number; + // Both required — mirrors the source (see src/shared/protocol.ts). + externalEpoch: number; + epochGeneration: number; }; export type ReadyMessageShape = EnvelopeShape & { diff --git a/test/extension/session/apply-edit-settle-rejection.test.ts b/test/extension/session/apply-edit-settle-rejection.test.ts index 713791ad..da8ae241 100644 --- a/test/extension/session/apply-edit-settle-rejection.test.ts +++ b/test/extension/session/apply-edit-settle-rejection.test.ts @@ -685,9 +685,7 @@ describe("applyEdit settlement: a landed write is acked, not toasted", () => { }); const seed = h.identity(); - // VACUITY HAZARD: if the seed snapshot carried no identity pair, edit-sync's - // "both absent -> replay" legacy arm would replay REGARDLESS of any epoch move - // and this test would pass for the wrong reason. Pin that the pair is present. + // Pin that the seed snapshot carries the identity pair the replay is judged on. expect(seed.epochGeneration).toEqual(expect.any(Number)); expect(seed.externalEpoch).toEqual(expect.any(Number)); sync.onHostSnapshot(seed.docVersion, true, seed.externalEpoch, seed.epochGeneration); @@ -717,7 +715,7 @@ describe("applyEdit settlement: a landed write is acked, not toasted", () => { }); it("NEGATIVE pin: the same wiring DOES drop the buffer when the epoch advances", () => { - // Proves the pin above is not passing through edit-sync's pair-less legacy arm: + // Proves the pin above is not passing vacuously: // identical shape, but the ack carries `externalEpoch + 1`. const h = harness(); let webviewDoc = ""; diff --git a/test/shared/protocol.test.ts b/test/shared/protocol.test.ts index 68134d74..456530b7 100644 --- a/test/shared/protocol.test.ts +++ b/test/shared/protocol.test.ts @@ -33,6 +33,8 @@ const validDocument = () => themeKind: "light", canWrite: true, eol: "\n", + externalEpoch: 0, + epochGeneration: 1, }) as const; const validTheme = () => @@ -323,11 +325,6 @@ describe("isHostToWebview — document", () => { // ---------- isHostToWebview / document — externalEpoch + epochGeneration (S3a) ---------- describe("isHostToWebview — document epoch identity pair (S3a)", () => { - // Absence is TOLERATED (old host → new webview skew): both omitted = valid. - it("accepts a document with NEITHER epoch field (pure-absent, today's behaviour)", () => { - expect(isHostToWebview(validDocument())).toBe(true); - }); - it("accepts a document carrying BOTH valid epoch fields", () => { expect(isHostToWebview({ ...validDocument(), externalEpoch: 0, epochGeneration: 12345 })).toBe( true @@ -337,21 +334,19 @@ describe("isHostToWebview — document epoch identity pair (S3a)", () => { ); }); - // Partial pair = boundary-INVALID (the webview never sees a half-formed identity). - it("rejects a PARTIAL pair — externalEpoch present, epochGeneration absent", () => { - expect(isHostToWebview({ ...validDocument(), externalEpoch: 3 })).toBe(false); + it("rejects a document with NEITHER epoch field", () => { + const { externalEpoch: _e, epochGeneration: _g, ...rest } = validDocument(); + expect(isHostToWebview(rest)).toBe(false); }); - it("rejects a PARTIAL pair — epochGeneration present, externalEpoch absent", () => { - expect(isHostToWebview({ ...validDocument(), epochGeneration: 3 })).toBe(false); + it("rejects a document missing externalEpoch", () => { + const { externalEpoch: _omit, ...rest } = validDocument(); + expect(isHostToWebview(rest)).toBe(false); }); - // An explicit `undefined` for one half is treated as absent, so a value + an - // explicit-undefined is still a partial pair → invalid. - it("rejects externalEpoch present with epochGeneration explicitly undefined", () => { - expect( - isHostToWebview({ ...validDocument(), externalEpoch: 2, epochGeneration: undefined }) - ).toBe(false); + it("rejects a document missing epochGeneration", () => { + const { epochGeneration: _omit, ...rest } = validDocument(); + expect(isHostToWebview(rest)).toBe(false); }); const badEpochComponents: Array<[string, unknown]> = [ diff --git a/test/webview/cm-crlf-line-model.test.ts b/test/webview/cm-crlf-line-model.test.ts index 560d3511..1da4b3fa 100644 --- a/test/webview/cm-crlf-line-model.test.ts +++ b/test/webview/cm-crlf-line-model.test.ts @@ -55,6 +55,8 @@ vi.mock("../../src/webview/host.js", () => ({ patchPersistedState: () => {}, })); +// The identity pair for fixtures that stay on one host lineage throughout. +const PAIR = { externalEpoch: 0, epochGeneration: 1 } as const; const mounted: EditorHandle[] = []; let container: HTMLElement | null = null; @@ -166,6 +168,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { eol: "\r\n", canWrite: true, docVersion: 1, + ...PAIR, }); // A reseed posts nothing (editor.test.ts (r4) pins this) — sanity check that // seeding this fixture is not itself an observable Edit before we drive Enter. @@ -179,7 +182,13 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { it("autoCloseFenceOnEnter keeps a clean line model", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "```js\r\nx", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "```js\r\nx", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: EditorSelection.cursor(5) }); // end of "```js" expect(autoCloseFenceOnEnter(view)).toBe(true); expectCleanLineModel(view.state.doc); @@ -187,7 +196,13 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { it("htmlTablePaste keeps a clean line model", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "intro\r\ntext", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "intro\r\ntext", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) }); firePasteAt(view.contentDOM, { html: "
AB
12
", @@ -208,7 +223,13 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { it("richHtmlPaste keeps a clean line model", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "intro\r\ntext", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "intro\r\ntext", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) }); firePasteAt(view.contentDOM, { html: "" }); expectLineModel(view.state.doc, ["intro", "text", "", "- one", "- two", ""]); @@ -226,6 +247,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { eol: "\r\n", canWrite: true, docVersion: 1, + ...PAIR, }); const caret = "- a\n - b\n ".length; // interior is LF-only regardless of source EOL view.dispatch({ selection: { anchor: caret } }); @@ -238,7 +260,13 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { it("imagePaste (resolveImageWrite) keeps a clean line model", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "ab\r\ncd", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "ab\r\ncd", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Anchor mid-line so the insert carries the leading "\n" (image-paste.ts's // standalone-block prefix), exercising the multi-line insert path. view.dispatch({ effects: addPendingAnchor.of({ requestId: "1", anchor: 1 }) }); @@ -262,6 +290,7 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { eol: "\r\n", canWrite: true, docVersion: 1, + ...PAIR, }); handle.runFormatDocument(); // The format LANDED — the seed is already 4 clean lines, so without this the @@ -277,7 +306,13 @@ describe("Quoll's own multi-line insert paths, in a CRLF document", () => { describe("CodeMirror's own multi-line insert paths, in a CRLF document", () => { it("plain paste of LF clipboard text into a CRLF document leaves no stray literal newline", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "ab\r\ncd", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "ab\r\ncd", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: EditorSelection.cursor(1) }); // mid "ab", not a list firePasteAt(view.contentDOM, { text: "x\ny" }); // no html/files: falls to CM's own doPaste expectLineModel(view.state.doc, ["ax", "yb", "cd"]); @@ -286,7 +321,13 @@ describe("CodeMirror's own multi-line insert paths, in a CRLF document", () => { it("a multi-range selection maps every caret through a plain-text paste", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "ab\r\ncd", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "ab\r\ncd", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(4)]), }); @@ -301,7 +342,13 @@ describe("CodeMirror's own multi-line insert paths, in a CRLF document", () => { it("copyLineDown over a multi-line selection keeps a clean line model (commands:1416)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "one\r\ntwo", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "one\r\ntwo", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); expect(copyLineDown(view)).toBe(true); expectCleanLineModel(view.state.doc); @@ -309,7 +356,13 @@ describe("CodeMirror's own multi-line insert paths, in a CRLF document", () => { it("search replace with a \\n escape keeps a clean line model (search:565,929)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: "a", replace: "X\\nY" })), selection: EditorSelection.single(0, 1), // exactly the "a" match, so replaceNext fires @@ -340,6 +393,7 @@ describe("CodeMirror's own multi-line insert paths, in a CRLF document", () => { eol: "\r\n", canWrite: true, docVersion: 1, + ...PAIR, }); view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) }); expect(continueListOnEnter(view)).toBe(false); // deferred: not the marker line @@ -354,7 +408,7 @@ describe("CodeMirror's own multi-line insert paths, in a CRLF document", () => { // no lineSeparator provided, CM's default /\r\n?|\n/ splits the pair and no // stray \r remains. const { handle, view } = mount(); - handle.applyDocument({ content: "ab\ncd", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "ab\ncd", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ selection: EditorSelection.cursor(1) }); firePasteAt(view.contentDOM, { text: "x\r\ny" }); expectLineModel(view.state.doc, ["ax", "yb", "cd"]); @@ -370,6 +424,7 @@ describe("boundary cases", () => { eol: "\r\n", canWrite: true, docVersion: 1, + ...PAIR, }); const line1 = view.state.doc.line(1); expect(line1.text).toBe("- \u{1F600} first"); @@ -390,6 +445,7 @@ describe("boundary cases", () => { eol: "\r\n", canWrite: true, docVersion: 1, + ...PAIR, }); const before = ["- first", "- second"]; expectLineModel(view.state.doc, before); @@ -408,7 +464,13 @@ describe("boundary cases", () => { describe("editor — the document EOL lives in state, not in CodeMirror's splitter", () => { it("the mounted editor never installs a literal-EOL splitter (the root cause)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Auxiliary to expectCleanLineModel: this names the ONE cause the suite // above exists for. It is deliberately not the primary guard — a hand-built // `Text.of(["x\ny"])` or a `ChangeSet.of(spec, len, "\r\n")` corrupts a @@ -426,10 +488,16 @@ describe("editor — the document EOL lives in state, not in CodeMirror's splitt // the view itself — CM's copy handler bails on hasSelection() in an // unfocused happy-dom view.) const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); expect(copyViaEvent(view)).toBe("a\r\nb"); - handle.applyDocument({ content: "a\nb", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "a\nb", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); expect(copyViaEvent(view)).toBe("a\nb"); }); @@ -448,7 +516,13 @@ describe("editor — the document EOL lives in state, not in CodeMirror's splitt // document's EOL reappears. vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); expect(editPosts()).toHaveLength(0); view.dispatch({ changes: { from: view.state.doc.length, insert: "c" } }); vi.advanceTimersByTime(300); @@ -462,9 +536,9 @@ describe("editor — the document EOL lives in state, not in CodeMirror's splitt describe("editor — the document EOL comes from the wire, not from the content", () => { it("installs the wire eol even when the content says otherwise (no inference)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\nb", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "a\nb", eol: "\r\n", canWrite: true, docVersion: 1, ...PAIR }); expect(view.state.facet(quollDocumentEol)).toBe("\r\n"); - handle.applyDocument({ content: "a\r\nb", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "a\r\nb", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.facet(quollDocumentEol)).toBe("\n"); }); @@ -477,8 +551,8 @@ describe("editor — the document EOL comes from the wire, not from the content" // facet stays LF and the post is "a\n". vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "a", eol: "\n", canWrite: true, docVersion: 1 }); - handle.applyDocument({ content: "a", eol: "\r\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "a", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); + handle.applyDocument({ content: "a", eol: "\r\n", canWrite: true, docVersion: 2, ...PAIR }); expect(editPosts()).toHaveLength(0); view.dispatch({ changes: { from: view.state.doc.length, insert: "\n" } }); expectLineModel(view.state.doc, ["a", ""]); diff --git a/test/webview/cm-edit-sync.test.ts b/test/webview/cm-edit-sync.test.ts index 6f32caef..bbb3ec52 100644 --- a/test/webview/cm-edit-sync.test.ts +++ b/test/webview/cm-edit-sync.test.ts @@ -57,7 +57,7 @@ describe("cm edit-sync", () => { // path makes, so a missed-trigger bug cannot hide behind a hand-rolled // drain. const ack = (s: ReturnType, v: number, canWrite = true) => { - s.sync.onHostSnapshot(v, canWrite); + s.sync.onHostSnapshot(v, canWrite, 0, 1); s.sync.onReducerCommit(false); // reducer's document arm cleared editInFlight }; // A consent flip / serialize-error clear: reducer state changed, NOT @@ -66,7 +66,7 @@ describe("cm edit-sync", () => { it("posts the current doc with the base docVersion on a local change", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("hello world"); expect(s.posted).toEqual([{ content: "hello world", baseDocVersion: 1 }]); }); @@ -75,7 +75,7 @@ describe("cm edit-sync", () => { // Review fix #25: the ack-drain is onReducerCommit(false), fired by the // post-commit effect. The test mirrors that snapshot→commit sequence. const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("a"); // posts, editInFlight = true s.type("ab"); // in flight → buffered, not posted expect(s.posted.length).toBe(1); @@ -92,7 +92,7 @@ describe("cm edit-sync", () => { // not move. An earlier docVersion-only trigger missed this and // stranded the buffer. const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("a"); // posts at v1, editInFlight = true s.type("ab"); // buffered while in flight expect(s.posted.length).toBe(1); @@ -113,18 +113,18 @@ describe("cm edit-sync", () => { // fix #30). Mirror that: snapshot(v, false)→commit holds; snapshot(v, // true)→commit drains. const s = setup(); - s.sync.onHostSnapshot(2, true); + s.sync.onHostSnapshot(2, true, 0, 1); s.type("a"); // posts at v2, editInFlight = true s.type("ab"); // buffered while in flight (legitimately, canWrite=true) expect(s.posted.length).toBe(1); // Transient readonly at the SAME docVersion: ack clears in-flight, but // the buffer is HELD (not dropped) because canWrite is now false. - s.sync.onHostSnapshot(2, false); + s.sync.onHostSnapshot(2, false, 0, 1); s.sync.onReducerCommit(false); expect(s.posted.length).toBe(1); // still held — not posted, not dropped // Write re-granted at the SAME docVersion. The canWrite flip in the // next dispatch triggers onReducerCommit; the held buffer drains. - s.sync.onHostSnapshot(2, true); + s.sync.onHostSnapshot(2, true, 0, 1); s.sync.onReducerCommit(false); expect(s.posted.length).toBe(2); expect(s.posted[1]).toEqual({ content: "ab", baseDocVersion: 2 }); @@ -135,7 +135,7 @@ describe("cm edit-sync", () => { // mutated state while an Edit is genuinely in flight) must NOT post a // concurrent second Edit. onReducerCommit(true) returns early. const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("a"); // posts at v1, editInFlight = true s.type("ab"); // buffered while in flight expect(s.posted.length).toBe(1); @@ -152,7 +152,7 @@ describe("cm edit-sync", () => { // Review fix #13: readonly is a HARD DROP, not a buffered hold. A later // write-granting ack must NOT replay content typed while readonly. const s = setup(); - s.sync.onHostSnapshot(1, false); + s.sync.onHostSnapshot(1, false, 0, 1); s.type("x"); // readonly → dropped, buffer NOT retained expect(s.posted).toEqual([]); ack(s, 2, true); // host grants write — must NOT replay "x" @@ -162,7 +162,7 @@ describe("cm edit-sync", () => { it("does not post while the warning/consent gate blocks", () => { let blocked = true; const s = setup({ blockPost: () => blocked }); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("x"); expect(s.posted).toEqual([]); // gate held, buffer retained blocked = false; @@ -180,7 +180,7 @@ describe("cm edit-sync", () => { // releases it. let blocked = true; const s = setup({ blockPost: () => blocked }); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("typed while blocked"); expect(s.posted).toEqual([]); // held by the gate blocked = false; @@ -190,7 +190,7 @@ describe("cm edit-sync", () => { it("retains the buffer when post fails (postMessage threw)", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.setPostOk(false); s.type("x"); // post returns false → buffer retained, not in flight expect(s.posted).toEqual([]); @@ -203,7 +203,8 @@ describe("cm edit-sync", () => { // replayIfNeeded's !seeded guard mirrors trySend. A buffer present // before the first onHostSnapshot must NOT post with the placeholder // docVersion 0. Capture a pre-seed reseed, then a commit before any - // snapshot — nothing posts until the seed arrives. + // snapshot — nothing posts. The seed then drops the buffer: its stamp + // predates any host identity, so the seed's pair supersedes it. let doc = ""; const posted: Posted[] = []; const sync = createEditSync({ @@ -219,9 +220,9 @@ describe("cm edit-sync", () => { sync.cancelPendingFlush(); // captures into buffer pre-seed sync.onReducerCommit(false); // commit before any snapshot → must NOT post expect(posted).toEqual([]); - sync.onHostSnapshot(1, true); // first seed - sync.onReducerCommit(false); // now it may drain - expect(posted).toEqual([{ content: "pre-seed text", baseDocVersion: 1 }]); + sync.onHostSnapshot(1, true, 0, 1); // first seed + sync.onReducerCommit(false); // pre-seed stamp superseded → dropped + expect(posted).toEqual([]); }); it("ignores a stale host snapshot (older docVersion)", () => { @@ -229,8 +230,8 @@ describe("cm edit-sync", () => { // Pins that the stale onHostSnapshot does not clobber the live docVersion // the next Edit echoes as its base. const s = setup(); - s.sync.onHostSnapshot(5, true); - s.sync.onHostSnapshot(3, true); // stale — ignored, docVersion stays 5 + s.sync.onHostSnapshot(5, true, 0, 1); + s.sync.onHostSnapshot(3, true, 0, 1); // stale — ignored, docVersion stays 5 s.type("x"); expect(s.posted).toEqual([{ content: "x", baseDocVersion: 5 }]); }); @@ -251,7 +252,7 @@ describe("cm edit-sync", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "typed"; sync.onLocalChange(); // schedules a real-timer flush (not yet buffered) // Reseed path, in production order: capture-then-cancel happens while @@ -261,7 +262,7 @@ describe("cm edit-sync", () => { expect(posted).toEqual([]); // nothing posted yet (no echo of host bytes) // The reducer commit drains the captured keystroke — "typed" survived the // reseed, and "host-snapshot" was never echoed. - sync.onHostSnapshot(2, true); + sync.onHostSnapshot(2, true, 0, 1); sync.onReducerCommit(false); expect(posted).toEqual([{ content: "typed", baseDocVersion: 2 }]); }); @@ -289,12 +290,12 @@ describe("cm edit-sync", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "seed!"; // user typed one char inside the window sync.onLocalChange(); // debounced, NOT yet buffered, editInFlight false sync.cancelPendingFlush(); // Document interrupts mid-window → captures "seed!" doc = "host snapshot"; // host reseed lands after capture - sync.onHostSnapshot(2, true); // docVersion 1→2, editInFlight still false + sync.onHostSnapshot(2, true, 0, 1); // docVersion 1→2, editInFlight still false sync.onReducerCommit(false); // ONLY trigger is the docVersion change expect(posted).toEqual([{ content: "seed!", baseDocVersion: 2 }]); }); @@ -327,7 +328,7 @@ describe("cm edit-sync", () => { }); // Initial host snapshot at v1. - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); // First edit posts immediately when the timer fires (this verifies // baseline before we exercise the race). doc = "a"; @@ -348,9 +349,9 @@ describe("cm edit-sync", () => { // First Document: snapshot updates to v2 (does NOT clear in-flight // — the host re-sent because the write-lock dropped our previous // Edit; only the reducer's ack via onReducerCommit clears the flag). - sync.onHostSnapshot(2, true); + sync.onHostSnapshot(2, true, 0, 1); // Second Document arrives before any commit: - sync.onHostSnapshot(3, true); + sync.onHostSnapshot(3, true, 0, 1); // Now the reducer commits the ack (editInFlight=false). The // buffered "ab" must replay at v3, NOT v1 or v2. sync.onReducerCommit(false); @@ -365,51 +366,51 @@ describe("cm edit-sync", () => { describe("cm edit-sync — acksInFlightEdit", () => { const ack = (s: ReturnType, v: number, canWrite = true) => { - s.sync.onHostSnapshot(v, canWrite); + s.sync.onHostSnapshot(v, canWrite, 0, 1); s.sync.onReducerCommit(false); }; it("is false before anything is posted", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); - expect(s.sync.acksInFlightEdit("hello", undefined, undefined)).toBe(false); + s.sync.onHostSnapshot(1, true, 0, 1); + expect(s.sync.acksInFlightEdit("hello", 0, 1)).toBe(false); }); it("is true for the exact bytes of the Edit currently in flight", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("hello world"); // posts, editInFlight = true - expect(s.sync.acksInFlightEdit("hello world", undefined, undefined)).toBe(true); + expect(s.sync.acksInFlightEdit("hello world", 0, 1)).toBe(true); // A different string (a genuine external divergence) never matches. - expect(s.sync.acksInFlightEdit("something else", undefined, undefined)).toBe(false); + expect(s.sync.acksInFlightEdit("something else", 0, 1)).toBe(false); }); it("clears when the reducer commit acks the in-flight Edit", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("a"); // posts, editInFlight = true - expect(s.sync.acksInFlightEdit("a", undefined, undefined)).toBe(true); + expect(s.sync.acksInFlightEdit("a", 0, 1)).toBe(true); ack(s, 2); // ack clears editInFlight - expect(s.sync.acksInFlightEdit("a", undefined, undefined)).toBe(false); + expect(s.sync.acksInFlightEdit("a", 0, 1)).toBe(false); }); it("tracks the newest in-flight bytes across a buffered replay", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("a"); // posts "a", editInFlight = true s.type("ab"); // buffered while in flight - expect(s.sync.acksInFlightEdit("a", undefined, undefined)).toBe(true); // still "a" in flight + expect(s.sync.acksInFlightEdit("a", 0, 1)).toBe(true); // still "a" in flight ack(s, 2); // ack "a" → replay drains "ab" → "ab" now in flight - expect(s.sync.acksInFlightEdit("ab", undefined, undefined)).toBe(true); - expect(s.sync.acksInFlightEdit("a", undefined, undefined)).toBe(false); + expect(s.sync.acksInFlightEdit("ab", 0, 1)).toBe(true); + expect(s.sync.acksInFlightEdit("a", 0, 1)).toBe(false); }); it("clears when a post fails (no phantom in-flight echo)", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.setPostOk(false); s.type("x"); // post returns false → not in flight - expect(s.sync.acksInFlightEdit("x", undefined, undefined)).toBe(false); + expect(s.sync.acksInFlightEdit("x", 0, 1)).toBe(false); }); // The identity-lineage conjunct. Content equality alone does not make a @@ -419,14 +420,14 @@ describe("cm edit-sync — acksInFlightEdit", () => { // read the same `supersedesIdentity` rule so the reseed path never folds a // Document whose replay buffer is about to be dropped. // Revert-check: delete the `!supersedesIdentity(...)` conjunct → every - // `toBe(false)` expectation BELOW THIS COMMENT goes red — 6 expectations - // spread across 5 of the `it` blocks that follow (the earlier `toBe(false)` + // `toBe(false)` expectation BELOW THIS COMMENT goes red — 4 expectations + // spread across 4 of the `it` blocks that follow (the earlier `toBe(false)` // cases in this describe test the content conjunct instead, and the // epoch-REGRESSION case below expects `toBe(true)`; both stay green). - // Measured, not derived: within THIS file the mutation reds exactly those 5 + // Measured, not derived: within THIS file the mutation reds exactly those 4 // tests. It also reds the display-side pins that read the same conjunct - // through foldsOkAck — editor.test.ts's (d3) block (4) and shell.test.ts's - // "forwards the Document's externalEpoch VALUE" (1), 10 in total — which is + // through foldsOkAck — editor.test.ts's (d3) block (3) and shell.test.ts's + // "forwards the Document's externalEpoch VALUE" (1), 8 in total — which is // the point: display and replay share one rule. it("is false when a content-equal Document advances the epoch in the same generation", () => { const s = setup(); @@ -459,22 +460,6 @@ describe("cm edit-sync — acksInFlightEdit", () => { expect(s.sync.acksInFlightEdit("hello world", 3, 11)).toBe(true); }); - it("is false on either half of a present/absent pair mismatch", () => { - const withPair = setup(); - withPair.sync.onHostSnapshot(1, true, 0, 11); - withPair.type("hello world"); - // present→absent (a legacy host took over). - expect(withPair.sync.acksInFlightEdit("hello world", undefined, undefined)).toBe(false); - - const legacy = setup(); - legacy.sync.onHostSnapshot(1, true); // no pair recorded - legacy.type("hello world"); - // absent→present (a pair-emitting host took over). - expect(legacy.sync.acksInFlightEdit("hello world", 0, 11)).toBe(false); - // absent→absent stays the legacy unconditional-fold behaviour. - expect(legacy.sync.acksInFlightEdit("hello world", undefined, undefined)).toBe(true); - }); - it("agrees with the replay-buffer drop rule on the same Document", () => { // Non-vacuity for the shared-rule claim: for a same-generation epoch // advance, the fold is refused AND the buffer is dropped; for the @@ -524,13 +509,13 @@ describe("cm edit-sync — acksInFlightEdit", () => { describe("cm edit-sync — discardBuffer", () => { const ack = (s: ReturnType, v: number, canWrite = true) => { - s.sync.onHostSnapshot(v, canWrite); + s.sync.onHostSnapshot(v, canWrite, 0, 1); s.sync.onReducerCommit(false); }; it("clears a buffered pre-reject payload so the next drain does not replay it", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); // seeded + writable + s.sync.onHostSnapshot(1, true, 0, 1); // seeded + writable s.type("a"); // posts at v1, editInFlight = true, buffered = null s.type("ab"); // in-flight → buffered = "ab" expect(s.posted.length).toBe(1); @@ -545,7 +530,7 @@ describe("cm edit-sync — discardBuffer", () => { it("is a no-op when no buffer is held (idempotent)", () => { const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.sync.discardBuffer(); // no buffer at all s.sync.onReducerCommit(false); expect(s.posted.length).toBe(0); @@ -558,7 +543,7 @@ describe("cm edit-sync — discardBuffer", () => { // that after discard, a NEW local change while editInFlight is still // true falls into the buffer arm (re-fills it) rather than posting. const s = setup(); - s.sync.onHostSnapshot(1, true); + s.sync.onHostSnapshot(1, true, 0, 1); s.type("a"); // posts at v1, editInFlight = true expect(s.posted.length).toBe(1); s.sync.discardBuffer(); @@ -585,7 +570,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "seed+edit"; sync.onLocalChange(); // schedules the 300ms timer (pending, not fired) expect(posted.length).toBe(0); @@ -608,7 +593,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); sync.flush(); // nothing typed → nothing pending expect(posted).toEqual([]); } finally { @@ -628,7 +613,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "a"; sync.onLocalChange(); vi.advanceTimersByTime(300); // first Edit posts, editInFlight = true @@ -687,7 +672,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "seed+edit"; sync.onLocalChange(); // timer pending, NO Edit in flight (editInFlight false) sync.flush(); // force-posts once; nothing was in flight → buffer nulled @@ -724,7 +709,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "a"; sync.onLocalChange(); vi.advanceTimersByTime(300); // edit #1 posts at v1, editInFlight = true @@ -743,7 +728,7 @@ describe("cm edit-sync — flush (teardown)", () => { // webview processes it: snapshot advances the version, the commit clears // in-flight and REPLAYS the retained buffer at the fresh v2 — the bytes // the stale force-post could not deliver. - sync.onHostSnapshot(2, true); + sync.onHostSnapshot(2, true, 0, 1); sync.onReducerCommit(false); expect(posted).toContainEqual({ content: "ab", baseDocVersion: 2 }); } finally { @@ -763,7 +748,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "a"; sync.onLocalChange(); vi.advanceTimersByTime(300); // edit #1 posts, editInFlight = true @@ -793,7 +778,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "a"; sync.onLocalChange(); // timer pending, editInFlight still false sync.flush(); // force-posts "a" AND must set editInFlight @@ -828,7 +813,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "a"; sync.onLocalChange(); allow = false; @@ -857,11 +842,11 @@ describe("cm edit-sync — flush (teardown)", () => { getDoc: () => doc, post: () => true, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "seed+edit"; sync.onLocalChange(); // timer pending, idle (no prior in-flight) sync.flush(); // force-posts "seed+edit"; must record it as in-flight - expect(sync.acksInFlightEdit("seed+edit", undefined, undefined)).toBe(true); + expect(sync.acksInFlightEdit("seed+edit", 0, 1)).toBe(true); } finally { vi.useRealTimers(); } @@ -879,7 +864,7 @@ describe("cm edit-sync — flush (teardown)", () => { return true; }, }); - sync.onHostSnapshot(1, false); // readonly + sync.onHostSnapshot(1, false, 0, 1); // readonly doc = "a"; sync.onLocalChange(); sync.flush(); @@ -905,7 +890,7 @@ describe("cm edit-sync — flushIfIdle", () => { // No scheduleFlush override → real setTimeout path so the timer stays // pending until we call flushIfIdle. }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); doc = "seed+typed"; sync.onLocalChange(); // schedules the 300ms timer (pending, not fired) expect(posted.length).toBe(0); // debounce window — not posted yet @@ -928,7 +913,7 @@ describe("cm edit-sync — flushIfIdle", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); // First edit: let the timer fire so the Edit is in flight. doc = "a"; sync.onLocalChange(); @@ -942,7 +927,7 @@ describe("cm edit-sync — flushIfIdle", () => { sync.flushIfIdle(); expect(posted.length).toBe(1); // no second post at the same version // Deliver the ack: the buffered content must replay (no data loss). - sync.onHostSnapshot(2, true); + sync.onHostSnapshot(2, true, 0, 1); sync.onReducerCommit(false); // ack clears in-flight + drains expect(posted.length).toBe(2); expect(posted[1]).toEqual({ content: "ab", baseDocVersion: 2 }); @@ -963,7 +948,7 @@ describe("cm edit-sync — flushIfIdle", () => { return true; }, }); - sync.onHostSnapshot(1, true); + sync.onHostSnapshot(1, true, 0, 1); // No onLocalChange call → no pending timer. sync.flushIfIdle(); // nothing pending → must be a no-op expect(posted).toEqual([]); @@ -989,25 +974,6 @@ describe("cm edit-sync — recordedIdentity", () => { expect(s.sync.recordedIdentity()).toEqual({ epoch: 3, generation: 12345 }); }); - it("records null for an omitted pair (old-host tolerance)", () => { - const s = setup(); - s.sync.onHostSnapshot(1, true); // legacy host: no pair - expect(s.sync.recordedIdentity()).toEqual({ epoch: null, generation: null }); - }); - - it("records a PARTIAL pair as fully absent, like the incoming path normalizes it", () => { - // The wire pair is exclusive (protocol.ts's validator drops a half-pair), so - // this is defence in depth, not a reachable message. It matters because - // presence is read off `generation` alone: recording {epoch: 5, generation: - // null} verbatim would leave the epoch live in comparisons while the pair - // counted as absent — a state no incoming Document can be in, since - // incomingIdentity() normalizes the same shape to both-absent. ONE - // constructor, so the recorded and incoming sides cannot disagree. - const s = setup(); - s.sync.onHostSnapshot(1, true, 5); // epoch without a generation - expect(s.sync.recordedIdentity()).toEqual({ epoch: null, generation: null }); - }); - it("updates the recorded pair on each accepted snapshot", () => { const s = setup(); s.sync.onHostSnapshot(1, true, 0, 999); @@ -1039,8 +1005,8 @@ describe("cm edit-sync — epoch-bounded buffers (S3b)", () => { const ackPair = ( s: ReturnType, v: number, - epoch: number | undefined, - generation: number | undefined, + epoch: number, + generation: number, canWrite = true ) => { s.sync.onHostSnapshot(v, canWrite, epoch, generation); @@ -1109,39 +1075,6 @@ describe("cm edit-sync — epoch-bounded buffers (S3b)", () => { expect(s.posted[1]).toEqual({ content: "fresh", baseDocVersion: 1 }); }); - it("(d2) drops a buffer stamped under an ABSENT pair when the pair appears (absent→present)", () => { - const s = setup(); - s.sync.onHostSnapshot(1, true); // legacy host — no pair - s.type("a"); // posts, in flight - s.type("ab"); // buffered {epoch:null, gen:null} - expect(s.posted.length).toBe(1); - ackPair(s, 2, 0, 42); // pair appears → identity transition → drop - expect(s.posted.length).toBe(1); - expect(s.sync.recordedIdentity()).toEqual({ epoch: 0, generation: 42 }); - }); - - it("(d3) drops a buffer when a legacy Document downgrades the pair (present→absent)", () => { - const s = setup(); - s.sync.onHostSnapshot(1, true, 0, 42); - s.type("a"); // posts, in flight - s.type("ab"); // buffered {epoch:0, gen:42} - expect(s.posted.length).toBe(1); - ackPair(s, 2, undefined, undefined); // host downgraded → present→absent → drop - expect(s.posted.length).toBe(1); - expect(s.sync.recordedIdentity()).toEqual({ epoch: null, generation: null }); - }); - - it("keeps a pure-absent (legacy throughout) buffer replayable — today's behaviour", () => { - const s = setup(); - s.sync.onHostSnapshot(1, true); // legacy - s.type("a"); // posts, in flight - s.type("ab"); // buffered {null, null} - expect(s.posted.length).toBe(1); - ackPair(s, 2, undefined, undefined); // still legacy → no drop → replay - expect(s.posted.length).toBe(2); - expect(s.posted[1]).toEqual({ content: "ab", baseDocVersion: 2 }); - }); - it("(e/f) adopts lower-version different-generation Documents (bypasses the stale guard)", () => { const s = setup(); // Session A leaves the webview at a HIGH version. @@ -1170,22 +1103,6 @@ describe("cm edit-sync — epoch-bounded buffers (S3b)", () => { expect(s.posted[s.posted.length - 1]).toEqual({ content: "survives", baseDocVersion: 6 }); }); - it("(f) B→absent→B: a legacy straggler at a LOWER version is adopted, stream continues", () => { - const s = setup(); - s.sync.onHostSnapshot(10, true, 0, 222); // B at v10 - // Legacy (pair-less) straggler at a LOWER version — present→absent transition. - // A same-or-higher version would pass the stale guard anyway; the lower - // version proves the bypass is exercised. - s.sync.onHostSnapshot(5, true); - expect(s.sync.recordedIdentity()).toEqual({ epoch: null, generation: null }); - // Live host B re-adopts (absent→present). - s.sync.onHostSnapshot(6, true, 1, 222); - expect(s.sync.recordedIdentity()).toEqual({ epoch: 1, generation: 222 }); - s.sync.onReducerCommit(false); - s.type("y"); - expect(s.posted[s.posted.length - 1]).toEqual({ content: "y", baseDocVersion: 6 }); - }); - it("(g) fires the resync-storm notice EXACTLY ONCE at ≥3 transitions in the window", () => { let clock = 1000; const onResyncStorm = vi.fn(); @@ -1278,7 +1195,6 @@ describe("cm edit-sync — epoch-bounded buffers (S3b)", () => { for (let i = 0; i < 5; i++) { expect(s.sync.isIdentityTransition(0, 222)).toBe(true); // new generation expect(s.sync.isIdentityTransition(9, 111)).toBe(false); // same generation - expect(s.sync.isIdentityTransition(undefined, undefined)).toBe(true); // present→absent } expect(onResyncStorm).not.toHaveBeenCalled(); expect(s.sync.recordedIdentity()).toEqual({ epoch: 0, generation: 111 }); @@ -1575,7 +1491,7 @@ describe("cm edit-sync — readonly hard drops are traced", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const s = setup(); - s.sync.onHostSnapshot(1, false); // readonly + s.sync.onHostSnapshot(1, false, 0, 1); // readonly s.type(SECRET); expect(s.posted).toEqual([]); expect(warnArgs(warn)).toEqual([ @@ -1598,7 +1514,7 @@ describe("cm edit-sync — readonly hard drops are traced", () => { try { let doc = "seed"; const sync = createEditSync({ getDoc: () => doc, post: () => true }); - sync.onHostSnapshot(1, false); // readonly + sync.onHostSnapshot(1, false, 0, 1); // readonly doc = SECRET; sync.onLocalChange(); // schedules the flush; still inside the window sync.cancelPendingFlush(); // host Document interrupts → readonly hard drop @@ -1628,7 +1544,7 @@ describe("cm edit-sync — readonly hard drops are traced", () => { return true; }, }); - sync.onHostSnapshot(1, false); // readonly + sync.onHostSnapshot(1, false, 0, 1); // readonly doc = SECRET; sync.onLocalChange(); sync.flush(); @@ -1650,7 +1566,7 @@ describe("cm edit-sync — readonly hard drops are traced", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const s = setup(); - s.sync.onHostSnapshot(1, false); // readonly, nothing typed + s.sync.onHostSnapshot(1, false, 0, 1); // readonly, nothing typed s.sync.flush(); // content === null → genuine no-op s.sync.cancelPendingFlush(); // no live timer → no capture, no drop expect(warnArgs(warn)).toEqual([]); @@ -1663,7 +1579,7 @@ describe("cm edit-sync — readonly hard drops are traced", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const s = setup(); - s.sync.onHostSnapshot(1, true); // writable + s.sync.onHostSnapshot(1, true, 0, 1); // writable s.type(SECRET); expect(s.posted).toEqual([{ content: SECRET, baseDocVersion: 1 }]); expect(warnArgs(warn)).toEqual([]); @@ -1683,11 +1599,11 @@ describe("cm edit-sync — readonly hard drops are traced", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const s = setup(); - s.sync.onHostSnapshot(1, true); // seed, writable + s.sync.onHostSnapshot(1, true, 0, 1); // seed, writable s.type("aa"); // posts "aa" at v1, editInFlight = true s.type("aaa"); // single-flight: stashed into buffered (len 3), not posted expect(s.posted.length).toBe(1); - s.sync.onHostSnapshot(2, false); // readonly ack; canWrite flips false + s.sync.onHostSnapshot(2, false, 0, 1); // readonly ack; canWrite flips false s.sync.onReducerCommit(false); // editInFlight clears; replayIfNeeded holds // the buffer under !canWrite WITHOUT nulling it (buffered is still "aaa") s.type("aaaaa"); // trySend's readonly branch fires: doc is now "aaaaa" (len 5) diff --git a/test/webview/cm-search-replace.test.ts b/test/webview/cm-search-replace.test.ts index f72a5491..eca3c48d 100644 --- a/test/webview/cm-search-replace.test.ts +++ b/test/webview/cm-search-replace.test.ts @@ -45,7 +45,14 @@ function mount(doc: string, canWrite: boolean) { }); mounted.push({ handle, parent }); // Arm edit-sync with a host snapshot at v1 (same as a real seed). - handle.applyDocument({ content: doc, eol: "\n", canWrite: canWrite, docVersion: 1 }); + handle.applyDocument({ + content: doc, + eol: "\n", + canWrite: canWrite, + docVersion: 1, + externalEpoch: 0, + epochGeneration: 1, + }); const view = EditorView.findFromDOM( parent.querySelector(".cm-editor") as HTMLElement ) as EditorView; diff --git a/test/webview/cm-widget-containment.test.ts b/test/webview/cm-widget-containment.test.ts index 41c6b2c2..edd45fff 100644 --- a/test/webview/cm-widget-containment.test.ts +++ b/test/webview/cm-widget-containment.test.ts @@ -32,6 +32,8 @@ vi.mock("../../src/webview/host.js", () => ({ })); let container: HTMLElement | null = null; +// The identity pair for fixtures that stay on one host lineage throughout. +const PAIR = { externalEpoch: 0, epochGeneration: 1 } as const; const mounted: EditorHandle[] = []; function makeState(overrides: Partial = {}): WebviewState { @@ -88,7 +90,7 @@ function mount(opts: { onDispatch?: (action: Action) => void } = {}): { it("a throwing widget render leaves the session fully settled", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "seed\n", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed\n", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); vi.spyOn(console, "error").mockImplementation(() => {}); // ⚠️ Spy `render`, NOT `toDOM`. `toDOM` is the base's contained entry point, // so replacing it would bypass the exact mechanism under test. @@ -99,12 +101,24 @@ it("a throwing widget render leaves the session fully settled", () => { }); expect(() => - handle.applyDocument({ content: "a\n\n---\n\nb\n", eol: "\n", canWrite: true, docVersion: 7 }) + handle.applyDocument({ + content: "a\n\n---\n\nb\n", + eol: "\n", + canWrite: true, + docVersion: 7, + ...PAIR, + }) ).not.toThrow(); // The wedge test: a SECOND snapshot still lands. This is the assertion that // fails without containment even after the widget is healthy again. expect(() => - handle.applyDocument({ content: "a\n\n---\n\nbb\n", eol: "\n", canWrite: true, docVersion: 8 }) + handle.applyDocument({ + content: "a\n\n---\n\nbb\n", + eol: "\n", + canWrite: true, + docVersion: 8, + ...PAIR, + }) ).not.toThrow(); spy.mockRestore(); @@ -124,7 +138,7 @@ it("a StateField block widget: a throwing table render settles the same way", () // implementation to run against, instead of the synthetic stub in // widget-base.test.ts. const { handle, view } = mount(); - handle.applyDocument({ content: "seed\n", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed\n", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); const err = vi.spyOn(console, "error").mockImplementation(() => {}); const spy = vi .spyOn(TableBlockWidget.prototype as unknown as { render: () => HTMLElement }, "render") @@ -138,10 +152,16 @@ it("a StateField block widget: a throwing table render settles the same way", () // vacuous. const table = "intro\n\n| a | b |\n| - | - |\n| 1 | 2 |\n"; expect(() => - handle.applyDocument({ content: table, eol: "\n", canWrite: true, docVersion: 7 }) + handle.applyDocument({ content: table, eol: "\n", canWrite: true, docVersion: 7, ...PAIR }) ).not.toThrow(); expect(() => - handle.applyDocument({ content: `${table}\ntail\n`, eol: "\n", canWrite: true, docVersion: 8 }) + handle.applyDocument({ + content: `${table}\ntail\n`, + eol: "\n", + canWrite: true, + docVersion: 8, + ...PAIR, + }) ).not.toThrow(); // Assert the hook actually ran, rather than inferring it from the placeholder. expect(spy).toHaveBeenCalled(); @@ -167,7 +187,7 @@ it("a throwing table patch tears the old element down and never writes bytes", ( // Same caret caveat as above: prose first, so the table is actually rendered. const v1 = "intro\n\n| a | b |\n| - | - |\n| 1 | 2 |\n"; const v2 = "intro\n\n| a | b |\n| - | - |\n| 9 | 2 |\n"; - handle.applyDocument({ content: v1, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: v1, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Arm the table's document-level drag listeners, so this exercises a real // `dispose` with something to abort rather than a no-op one. const cell = document.querySelector(".quoll-table-block td") as HTMLElement | null; @@ -187,7 +207,7 @@ it("a throwing table patch tears the old element down and never writes bytes", ( }); const before = hostBytes(view); expect(() => - handle.applyDocument({ content: v2, eol: "\n", canWrite: true, docVersion: 2 }) + handle.applyDocument({ content: v2, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }) ).not.toThrow(); expect(spy).toHaveBeenCalled(); spy.mockRestore(); @@ -221,17 +241,23 @@ it("non-vacuity: bypassing the base reproduces the wedge", () => { // CodeMirror started containing widget DOM construction or the containment // moved — and the base class's whole rationale needs re-reading. const { handle } = mount(); - handle.applyDocument({ content: "seed\n", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed\n", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); const spy = vi.spyOn(ThematicBreakWidget.prototype, "toDOM").mockImplementationOnce(() => { throw new Error("forced widget failure"); }); expect(() => - handle.applyDocument({ content: "a\n\n---\n\nb\n", eol: "\n", canWrite: true, docVersion: 7 }) + handle.applyDocument({ + content: "a\n\n---\n\nb\n", + eol: "\n", + canWrite: true, + docVersion: 7, + ...PAIR, + }) ).toThrow(); spy.mockRestore(); // The wedge, pinned: the view cannot apply another snapshot even now that the // widget is healthy. expect(() => - handle.applyDocument({ content: "plain\n", eol: "\n", canWrite: true, docVersion: 8 }) + handle.applyDocument({ content: "plain\n", eol: "\n", canWrite: true, docVersion: 8, ...PAIR }) ).toThrow(); }); diff --git a/test/webview/decorations/cm-decoration-integration.test.ts b/test/webview/decorations/cm-decoration-integration.test.ts index 08f023df..1a0d780e 100644 --- a/test/webview/decorations/cm-decoration-integration.test.ts +++ b/test/webview/decorations/cm-decoration-integration.test.ts @@ -113,6 +113,8 @@ describe("orchestrator integration — providers wired", () => { eol: "\n", canWrite: true, docVersion: 1, + externalEpoch: 0, + epochGeneration: 1, }); const mountEl = parent.querySelector(".quoll-editor") as HTMLElement | null; if (!mountEl) { diff --git a/test/webview/editor.test.ts b/test/webview/editor.test.ts index d27b8a8f..7e529c95 100644 --- a/test/webview/editor.test.ts +++ b/test/webview/editor.test.ts @@ -63,6 +63,8 @@ let container: HTMLElement | null = null; // orphaned timer fires and posts a stray Edit into the next test's freshly // reset postMessage trail — a cross-test leak that made the full parallel // suite non-deterministically red. dispose() cancels the pending flush. +// The identity pair for fixtures that stay on one host lineage throughout. +const PAIR = { externalEpoch: 0, epochGeneration: 1 } as const; const mounted: EditorHandle[] = []; function makeState(overrides: Partial = {}): WebviewState { @@ -158,15 +160,21 @@ function mount( describe("editor — applyDocument seeds the CM doc (a)", () => { it("rawText reaches the CM document (LF seed, so sliceDoc() is byte-identical)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "# hello\n\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "# hello\n\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); expect(view.state.sliceDoc()).toBe("# hello\n\nworld"); }); }); // (S3b) applyDocument threads the (externalEpoch, epochGeneration) pair into // edit-sync, so editor.isIdentityTransition reflects the recorded pair. Pins the -// editor.ts threading: without it the pair would record as absent and the -// same-generation query below would wrongly report a transition. +// editor.ts threading by value: a constant, swapped, or stale pair would make a +// same-generation query below report a transition. describe("editor — applyDocument threads the identity pair (S3b)", () => { it("isIdentityTransition reflects the recorded pair after seeding", () => { const { handle } = mount(); @@ -180,7 +188,6 @@ describe("editor — applyDocument threads the identity pair (S3b)", () => { }); expect(handle.isIdentityTransition(0, 222)).toBe(true); // new generation expect(handle.isIdentityTransition(9, 111)).toBe(false); // same generation - expect(handle.isIdentityTransition(undefined, undefined)).toBe(true); // present→absent // A same-generation advance re-records the pair; the predicate tracks it. handle.applyDocument({ content: "seed2", @@ -204,7 +211,13 @@ describe("editor — applyDocument threads the identity pair (S3b)", () => { describe("editor — nascent-setext token markers wired (production mount)", () => { it("a strong span carries the quoll-tok-strong marker class", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "Foo **bar**", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "Foo **bar**", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); const line = view.contentDOM.querySelector(".cm-line"); const bar = [...(line?.querySelectorAll("span") ?? [])] .reverse() @@ -218,9 +231,9 @@ describe("editor — idempotent reseed posts no Edit (b)", () => { it("identical rawText reseed produces no Edit on the post trail", () => { vi.useFakeTimers(); const { handle } = mount(); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); expect(editPosts()).toHaveLength(0); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); vi.advanceTimersByTime(1000); expect(editPosts()).toHaveLength(0); }); @@ -231,10 +244,10 @@ describe("editor — canWrite param drives BOTH editable + readOnly facets (c)", it("canWrite:false flips readOnly+editable WITHOUT changing state.canWrite, then canWrite:true flips back", () => { const heldState = makeState({ canWrite: true }); const { handle, view } = mount({ state: heldState }); - handle.applyDocument({ content: "body", eol: "\n", canWrite: false, docVersion: 1 }); + handle.applyDocument({ content: "body", eol: "\n", canWrite: false, docVersion: 1, ...PAIR }); expect(view.state.readOnly).toBe(true); expect(view.state.facet(EditorView.editable)).toBe(false); - handle.applyDocument({ content: "body", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "body", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.readOnly).toBe(false); expect(view.state.facet(EditorView.editable)).toBe(true); }); @@ -243,14 +256,14 @@ describe("editor — canWrite param drives BOTH editable + readOnly facets (c)", // (d) Fresh-canWrite seed: applyDocument fires BEFORE getState would see the // next reducer commit, so the Compartment + replay gate must use the param. describe("editor — fresh canWrite from applyDocument drives Compartment + replay (d)", () => { - it("applyDocument({ canWrite: true, ... }) with stale state.canWrite=false leaves view writable AND drains buffered Edit on commit", () => { + it("applyDocument({ canWrite: true, ..., ...PAIR }) with stale state.canWrite=false leaves view writable AND drains buffered Edit on commit", () => { vi.useFakeTimers(); const { handle, view, setState, commit } = mount({ state: makeState({ canWrite: false }), }); // applyDocument with FRESH canWrite=true — drives the Compartment off // the PARAM regardless of the stale state.canWrite reader. - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); expect(view.state.readOnly).toBe(false); expect(view.state.facet(EditorView.editable)).toBe(true); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" } }); @@ -279,7 +292,7 @@ describe("editor — ok-ack while ahead does not reseed backwards (d2)", () => { it("an ok-ack echoing the in-flight edit does NOT visibly rewind newer keystrokes", () => { vi.useFakeTimers(); const { handle, view, commit } = mount(); - handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1 }); // seed at v1 + handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // seed at v1 // User types "2" → the debounced Edit posts (editInFlight in edit-sync). view.dispatch({ changes: { from: view.state.doc.length, insert: "2" } }); vi.advanceTimersByTime(300); @@ -292,7 +305,7 @@ describe("editor — ok-ack while ahead does not reseed backwards (d2)", () => { expect(view.state.sliceDoc()).toBe("D123"); // The ok-ack: host applied "D12", echoes it back at v2. It must NOT reseed // the doc back to "D12" (which would erase the visible "3"). - handle.applyDocument({ content: "D12", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "D12", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe("D123"); // no visible rewind // The shell's post-dispatch commit clears editInFlight and replays "D123". commit(false); @@ -306,13 +319,13 @@ describe("editor — ok-ack while ahead does not reseed backwards (d2)", () => { it("a keystroke typed during the ack round-trip is preserved end-to-end (no fork off a rewound base)", () => { vi.useFakeTimers(); const { handle, view, commit } = mount(); - handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "2" } }); vi.advanceTimersByTime(300); // posts "D12" view.dispatch({ changes: { from: view.state.doc.length, insert: "3" } }); vi.advanceTimersByTime(300); // buffers "D123" // ok-ack #1 echoes "D12" back; the user is ahead at "D123". - handle.applyDocument({ content: "D12", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "D12", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); // Before the commit replays, the user types "4" — it must build on the live // "D123", not a rewound "D12" base (which would fork off "D124", dropping 3). view.dispatch({ changes: { from: view.state.doc.length, insert: "4" } }); @@ -320,7 +333,7 @@ describe("editor — ok-ack while ahead does not reseed backwards (d2)", () => { commit(false); // replays "D123" at v2; "D1234" now buffers behind it vi.advanceTimersByTime(300); // ok-ack #2 echoes "D123" back; the user is ahead at "D1234". - handle.applyDocument({ content: "D123", eol: "\n", canWrite: true, docVersion: 3 }); + handle.applyDocument({ content: "D123", eol: "\n", canWrite: true, docVersion: 3, ...PAIR }); commit(false); // replays "D1234" at v3 expect(view.state.sliceDoc()).toBe("D1234"); const contents = editPosts().map((m) => (m as { content: string }).content); @@ -331,13 +344,19 @@ describe("editor — ok-ack while ahead does not reseed backwards (d2)", () => { it("a genuine external divergence (content not our in-flight edit) still reseeds", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "2" } }); vi.advanceTimersByTime(300); // posts "D12", in flight expect(view.state.sliceDoc()).toBe("D12"); // An EXTERNAL edit changed the file to unrelated content at a newer version. // It does not echo our in-flight "D12", so the reseed must still apply. - handle.applyDocument({ content: "EXTERNAL", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "EXTERNAL", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.sliceDoc()).toBe("EXTERNAL"); }); @@ -354,14 +373,14 @@ describe("editor — ok-ack while ahead does not reseed backwards (d2)", () => { // (foldsOkAck becomes true, the doc keeps the live-ahead "D123"). vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "D1", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "2" } }); vi.advanceTimersByTime(300); // posts "D12", in flight view.dispatch({ changes: { from: view.state.doc.length, insert: "3" } }); vi.advanceTimersByTime(300); // buffers "D123" — editor is now AHEAD of the ack expect(view.state.sliceDoc()).toBe("D123"); // ok-ack echoing "D12" back BUT canWrite=false now → must reseed, not fold. - handle.applyDocument({ content: "D12", eol: "\n", canWrite: false, docVersion: 2 }); + handle.applyDocument({ content: "D12", eol: "\n", canWrite: false, docVersion: 2, ...PAIR }); expect(view.state.readOnly).toBe(true); expect(view.state.sliceDoc()).toBe("D12"); }); @@ -507,17 +526,6 @@ describe("editor — ok-ack fold requires identity-lineage continuity (d3)", () expect(editPosts()).toHaveLength(1); }); - it("a content-equal Document that DROPS the pair (legacy host) reseeds instead of folding", () => { - vi.useFakeTimers(); - const { handle, view, commit } = mount(); - seedAndRunAhead(handle, view, 0, 11); - // present→absent is an identity transition too (edit-sync drops the buffer). - handle.applyDocument({ content: "D12", eol: "\n", canWrite: true, docVersion: 2 }); - expect(view.state.sliceDoc()).toBe("D12"); - commit(false); - expect(editPosts()).toHaveLength(1); - }); - it("a normal SAME-LINEAGE ack still folds and keeps the ahead keystrokes", () => { // The own-edit ack never bumps the epoch (host-session-core), so the fold // path — the whole point of (d2) — must survive the new conjunct. @@ -791,7 +799,13 @@ describe("editor — the outbound serializer pairing (getDoc / liveDoc)", () => it("an ok-ack on a CRLF document folds instead of rewinding (getDoc side)", () => { vi.useFakeTimers(); const { handle, view, commit } = mount(); - handle.applyDocument({ content: "D1\r\n2", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "D1\r\n2", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ changes: { from: view.state.doc.length, insert: "3" } }); vi.advanceTimersByTime(300); expect(editPosts()).toHaveLength(1); @@ -805,7 +819,13 @@ describe("editor — the outbound serializer pairing (getDoc / liveDoc)", () => // editPosts()[0].content would compare the serializer against itself and the // test would pass whatever getDoc does — the one thing this test exists to // catch. - handle.applyDocument({ content: "D1\r\n23", eol: "\r\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "D1\r\n23", + eol: "\r\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); // No visible rewind: the "4" is still on screen. Read the interior here // (sliceDoc() renders LF now), and the wire separately. expect(view.state.doc.toString()).toBe("D1\n234"); @@ -816,7 +836,13 @@ describe("editor — the outbound serializer pairing (getDoc / liveDoc)", () => it("an identical CRLF snapshot does not reseed, so multi-range selection survives (liveDoc side)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb\r\nc", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb\r\nc", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: EditorSelection.create([EditorSelection.range(0, 1), EditorSelection.range(4, 5)]), }); @@ -825,7 +851,13 @@ describe("editor — the outbound serializer pairing (getDoc / liveDoc)", () => // NOT ahead of the host, so applyDocument takes no reseed branch and leaves // the selection alone. An LF-only `liveDoc` would compare "a\nb\nc" against // "a\r\nb\r\nc", enter the reseed branch, and restore only prevMain. - handle.applyDocument({ content: "a\r\nb\r\nc", eol: "\r\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "a\r\nb\r\nc", + eol: "\r\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.selection.ranges).toHaveLength(2); expect(view.state.doc.lines).toBe(3); }); @@ -844,7 +876,13 @@ describe("editor — CRLF/LF round-trip uniform scope (e)", () => { // sliceDoc() answers neither any more — EditorState.lineSeparator is never // provided, so it renders LF like doc.toString(). const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb\r\nc", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb\r\nc", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); expect(hostBytes(view)).toBe("a\r\nb\r\nc"); expect(view.state.doc.lines).toBe(3); expect(view.state.doc.line(1).text).toBe("a"); @@ -855,7 +893,7 @@ describe("editor — CRLF/LF round-trip uniform scope (e)", () => { it("LF seed round-trips byte-identically", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\nb\nc", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "a\nb\nc", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Outbound bytes, not sliceDoc(): EditorState.lineSeparator is never // provided, so sliceDoc() always renders LF regardless of the wire eol // (see the sibling CRLF/CR-only cases below). @@ -866,7 +904,13 @@ describe("editor — CRLF/LF round-trip uniform scope (e)", () => { it("mixed-EOL seed serialises under the WIRE eol regardless of content", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\r\nb\nc", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb\nc", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Interior: three real lines, no separator inside any of them. expect(view.state.doc.lines).toBe(3); expect(view.state.doc.line(1).text).toBe("a"); @@ -877,7 +921,7 @@ describe("editor — CRLF/LF round-trip uniform scope (e)", () => { it("CR-only seed serialises under the WIRE eol regardless of content (host seeds canonical; raw CR-only is unreachable in prod)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\rb\rc", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "a\rb\rc", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // The split on /\r\n?|\n/ drops the lone CRs, so the CM interior is LF. // ⚠️ sliceDoc() cannot observe the EOL: with EditorState.lineSeparator never // provided it renders LF whatever the facet holds, so an assertion on it would @@ -893,7 +937,7 @@ describe("editor — plain typing + paste round-trip posts the exact Edit (f)", it("a programmatic insert round-trips through sliceDoc + posts the exact Edit content", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "typed" } }); expect(view.state.sliceDoc()).toBe("seedtyped"); vi.advanceTimersByTime(300); @@ -911,7 +955,7 @@ describe("editor — plain typing + paste round-trip posts the exact Edit (f)", it("a paste-shaped insert (large block) round-trips and posts the exact pasted content", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); const paste = "Para one.\n\nPara two with `code`.\n\n- list a\n- list b\n"; view.dispatch({ changes: { from: 0, insert: paste } }); expect(view.state.sliceDoc()).toBe(paste); @@ -924,17 +968,24 @@ describe("editor — plain typing + paste round-trip posts the exact Edit (f)", // (g) Parse-failure-shaped seed: identical snapshot-object shape, same flip pattern. describe("editor — parse-failure-shaped seed still tracks canWrite (g)", () => { - it("applyDocument({ content, eol, canWrite: false, docVersion: v }) → readOnly+editable=false; canWrite:true, docVersion:v+1 → readOnly+editable=true", () => { + it("applyDocument({ content, eol, canWrite: false, docVersion: v, ...PAIR }) → readOnly+editable=false; canWrite:true, docVersion:v+1 → readOnly+editable=true", () => { const { handle, view } = mount({ state: makeState({ canWrite: true }) }); handle.applyDocument({ content: "raw with [broken", eol: "\n", canWrite: false, docVersion: 1, + ...PAIR, }); expect(view.state.readOnly).toBe(true); expect(view.state.facet(EditorView.editable)).toBe(false); - handle.applyDocument({ content: "raw with [broken", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "raw with [broken", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.readOnly).toBe(false); expect(view.state.facet(EditorView.editable)).toBe(true); }); @@ -945,7 +996,7 @@ describe("editor — dispose cancels pending flush (k)", () => { it("typing then dispose BEFORE debounce fires posts NO Edit", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" } }); expect(postMessage).not.toHaveBeenCalled(); handle.dispose(); @@ -978,7 +1029,7 @@ describe("editor — postEditMessage debounce-path throw surface (V-M13(a))", () }); const dispatchSpy = vi.fn(); const { handle, view } = mount({ onDispatch: dispatchSpy }); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" } }); vi.advanceTimersByTime(300); // post-edit dispatched (single-flight set) THEN serialize-error @@ -1026,7 +1077,7 @@ describe("editor — postEditMessage survives a throwing serialize-error dispatc } }); const { handle, view } = mount({ onDispatch: dispatchSpy }); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" } }); // Must not throw out of the debounce-driven flush. expect(() => vi.advanceTimersByTime(300)).not.toThrow(); @@ -1063,7 +1114,7 @@ describe("editor — postEditMessage survives a throwing post-edit dispatch (V-M } }); const { handle, view } = mount({ onDispatch: dispatchSpy }); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" } }); // Must not throw out of the debounce-driven flush. expect(() => vi.advanceTimersByTime(300)).not.toThrow(); @@ -1092,7 +1143,7 @@ describe("editor — oversized edit is gated to the serialize-error banner (over vi.useFakeTimers(); const dispatchSpy = vi.fn(); const { handle, view } = mount({ onDispatch: dispatchSpy }); - handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "seed", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Replace the whole doc with an over-limit body (one code unit past the cap). const oversized = "a".repeat(MAX_CONTENT_LENGTH + 1); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: oversized } }); @@ -1117,7 +1168,7 @@ describe("editor — oversized edit is gated to the serialize-error banner (over () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Exactly the cap is BOUNDED (isBoundedContent uses `<=`), so it must post. const atLimit = "a".repeat(MAX_CONTENT_LENGTH); view.dispatch({ changes: { from: 0, insert: atLimit } }); @@ -1134,7 +1185,7 @@ describe("editor — oversized edit is gated to the serialize-error banner (over describe("editor — atomic seed transaction (l)", () => { it("seeding from a readonly prior state lands BOTH the new doc AND editable=true in ONE update", async () => { const { handle, view } = mount(); - handle.applyDocument({ content: "old", eol: "\n", canWrite: false, docVersion: 1 }); + handle.applyDocument({ content: "old", eol: "\n", canWrite: false, docVersion: 1, ...PAIR }); expect(view.state.readOnly).toBe(true); expect(view.state.facet(EditorView.editable)).toBe(false); const observed: Array<{ doc: string; editable: boolean; readOnly: boolean }> = []; @@ -1151,7 +1202,7 @@ describe("editor — atomic seed transaction (l)", () => { ), }); observed.length = 0; - handle.applyDocument({ content: "new", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "new", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(observed.length).toBe(1); expect(observed[0]).toEqual({ doc: "new", editable: true, readOnly: false }); }); @@ -1170,7 +1221,7 @@ describe("editor — GFM tree active (n)", () => { it("strike + table + task fixture produces Strikethrough / Table / TaskMarker nodes", () => { const { handle, view } = mount(); const fixture = "~~s~~\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n- [ ] task"; - handle.applyDocument({ content: fixture, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: fixture, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // fullTree, not a bare ensureSyntaxTree + null check: only the RETURNED tree is // walked here, and fullTree throws with the coverage numbers instead of leaving the // caller to hand-roll the guard. @@ -1192,11 +1243,23 @@ describe("editor — host-seed dispatch excluded from undo history (p)", () => { it("undo returns false after seed; real edits are undoable; reseed does not add to history", async () => { const { undo } = await import("@codemirror/commands"); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); expect(undo(view)).toBe(false); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" } }); expect(undo(view)).toBe(true); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(undo(view)).toBe(false); }); }); @@ -1210,7 +1273,13 @@ describe("editor — host-seed dispatch excluded from undo history (p)", () => { describe("editor — caret preserved across accept-and-reseed (q)", () => { it("caret at mid-doc stays put when reseed shrinks the doc past the user's typing", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Move caret to position 3 (between "hel" and "lo world"). view.dispatch({ selection: { anchor: 3 } }); expect(view.state.selection.main.head).toBe(3); @@ -1220,7 +1289,13 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { view.dispatch({ changes: { from: view.state.doc.length, insert: "X" } }); expect(view.state.sliceDoc()).toBe("hello worldX"); // Host ack arrives without the in-window 'X'. needsReseed = true. - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.sliceDoc()).toBe("hello world"); // The user's caret was at position 3 — well within the new doc // length (11). It must stay at 3 rather than being remapped to the @@ -1231,7 +1306,7 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { it("caret past the new doc end is clamped to the end instead of resetting to 0", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hi", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "hi", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // User types two characters at end; caret follows the typing. view.dispatch({ changes: { from: view.state.doc.length, insert: "ab" }, @@ -1240,7 +1315,7 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { expect(view.state.sliceDoc()).toBe("hiab"); expect(view.state.selection.main.head).toBe(4); // Reseed to the shorter host snapshot — caret was past new end. - handle.applyDocument({ content: "hi", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "hi", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe("hi"); // Clamp to new doc length (2). NOT zero, NOT the original 4. expect(view.state.selection.main.head).toBe(2); @@ -1248,10 +1323,16 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { it("caret is clamped to 0 when reseed empties the doc", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 5 } }); expect(view.state.selection.main.head).toBe(5); - handle.applyDocument({ content: "", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(""); expect(view.state.selection.main.head).toBe(0); expect(view.state.selection.main.anchor).toBe(0); @@ -1259,38 +1340,74 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { it("range selection is preserved when both endpoints fit in the new doc", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 1, head: 5 } }); - handle.applyDocument({ content: "hello worldX", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "hello worldX", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.selection.main.anchor).toBe(1); expect(view.state.selection.main.head).toBe(5); }); it("range selection is clamped when the reseed doc is shorter than head", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 3, head: 8 } }); - handle.applyDocument({ content: "hello", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "hello", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.selection.main.anchor).toBe(3); expect(view.state.selection.main.head).toBe(5); }); it("range selection is clamped when both anchor and head exceed the reseed doc length", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 7, head: 9 } }); - handle.applyDocument({ content: "hi", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: "hi", eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.selection.main.anchor).toBe(2); expect(view.state.selection.main.head).toBe(2); }); it("same-content reseed (needsReseed=false) does NOT disturb the selection", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); view.dispatch({ selection: { anchor: 3, head: 6 } }); expect(view.state.selection.main.anchor).toBe(3); expect(view.state.selection.main.head).toBe(6); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.sliceDoc()).toBe("hello world"); expect(view.state.selection.main.anchor).toBe(3); expect(view.state.selection.main.head).toBe(6); @@ -1298,24 +1415,42 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { it("multi-cursor is collapsed to main-only on reseed (KISS intentional)", async () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); const { EditorSelection } = await import("@codemirror/state"); view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(3), EditorSelection.cursor(7)]), }); expect(view.state.selection.ranges.length).toBe(2); - handle.applyDocument({ content: "hello world!", eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "hello world!", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(view.state.selection.ranges.length).toBe(1); expect(view.state.selection.main.head).toBe(3); }); it("an EOL-only reseed keeps every cursor (no change, so no selection to restore)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "a\nb\nc", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "a\nb\nc", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(3)]), }); - handle.applyDocument({ content: "a\r\nb\r\nc", eol: "\r\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ + content: "a\r\nb\r\nc", + eol: "\r\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }); expect(hostBytes(view)).toBe("a\r\nb\r\nc"); expect(view.state.selection.ranges.map((r) => r.head)).toEqual([1, 3]); expect(view.state.selection.ranges.length).toBe(2); @@ -1324,13 +1459,19 @@ describe("editor — caret preserved across accept-and-reseed (q)", () => { it("seeding guard resets to false even when dispatch throws inside applyDocument", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "initial", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "initial", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); const spy = vi.spyOn(view, "dispatch").mockImplementationOnce(() => { throw new Error("dispatch test throw"); }); try { expect(() => - handle.applyDocument({ content: "other", eol: "\n", canWrite: true, docVersion: 2 }) + handle.applyDocument({ + content: "other", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }) ).toThrow("dispatch test throw"); } finally { spy.mockRestore(); @@ -1356,7 +1497,7 @@ describe("editor — local-edit-attempt + discardBuffer on docChanged", () => { state: stateWithError, onDispatch, }); - handle.applyDocument({ content: "hello\n", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "hello\n", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Programmatic transaction stands in for a keystroke. view.dispatch({ @@ -1376,7 +1517,7 @@ describe("editor — local-edit-attempt + discardBuffer on docChanged", () => { state: makeState(), onDispatch, }); - handle.applyDocument({ content: "hello\n", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: "hello\n", eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ changes: { from: view.state.doc.length, insert: "x" }, @@ -1404,7 +1545,13 @@ describe("editor — local-edit-attempt + discardBuffer on docChanged", () => { const initial = makeState(); const m = mount({ state: initial, onDispatch }); - m.handle.applyDocument({ content: "safe\n", eol: "\n", canWrite: true, docVersion: 1 }); + m.handle.applyDocument({ + content: "safe\n", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // (2) post → in-flight m.view.dispatch({ changes: { from: m.view.state.doc.length, insert: "X" } }); @@ -1484,6 +1631,7 @@ describe("editor — list hang-indent wiring", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); // Wiring: the const ViewPlugin is registered AND exposes its decorations // accessor on the mounted view (proves the `{ decorations }` wiring, not @@ -1521,6 +1669,7 @@ describe("editor — frontmatter block-on-open via applyDocument seed (r)", () = eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); expect(view.state.field(frontmatterBlockField).kind).toBe("collapsed"); expect(view.state.facet(quollSyntaxExclusionZones)).toEqual([{ from: 0, to: 16 }]); @@ -1529,7 +1678,7 @@ describe("editor — frontmatter block-on-open via applyDocument seed (r)", () = it("round-trips byte-identically through applyDocument (no injection)", () => { const { handle, view } = mount(); const fm = "---\ntitle: x\n---\n\nbody\n"; - handle.applyDocument({ content: fm, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: fm, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); expect(view.state.sliceDoc()).toBe(fm); }); }); @@ -1545,6 +1694,7 @@ describe("editor — frontmatter ArrowUp reveal is registered (s)", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); view.dispatch({ selection: { anchor: 17 } }); // line directly below the block (TO+1) const handled = runScopeHandlers( @@ -1572,6 +1722,7 @@ describe("editor — host reseed preserves an active reveal (t)", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); revealFrontmatterAt(view, 6); expect(view.state.field(frontmatterBlockField).kind).toBe("revealed"); @@ -1580,6 +1731,7 @@ describe("editor — host reseed preserves an active reveal (t)", () => { eol: "\n", canWrite: true, docVersion: 2, + ...PAIR, }); expect(view.state.field(frontmatterBlockField).kind).toBe("revealed"); }); @@ -1593,10 +1745,10 @@ describe("editor — host reseed preserves an active reveal (t)", () => { ); const doc = "---\ntitle: x\n---\n\n# Body\n"; const { handle, view } = mount(); - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); revealFrontmatterAt(view, 6); expect(view.state.field(frontmatterBlockField).kind).toBe("revealed"); - handle.applyDocument({ content: doc, eol: "\n", canWrite: false, docVersion: 2 }); // same bytes, write revoked + handle.applyDocument({ content: doc, eol: "\n", canWrite: false, docVersion: 2, ...PAIR }); // same bytes, write revoked expect(view.state.field(frontmatterBlockField).kind).toBe("collapsed"); }); }); @@ -1609,6 +1761,7 @@ describe("editor — block-style wiring", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); // Wiring: both const ViewPlugins are registered AND expose their decorations // accessor (proves `{ decorations }` wiring, not just registration). @@ -1640,7 +1793,7 @@ describe("editor — revealed frontmatter de-markdowns the real task-checkbox pr // Body line `- [ ] x` parses as a Task; the exclusion zone must drop the // checkbox widget the taskCheckboxReveal provider would emit. const doc = "---\n- [ ] x\n---\n\nbody\n"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Anchor 0 is in-span, on the fence line (line 1 `---`), NOT the task line // [4,11]. The provider's caret-on-line reveal-trigger therefore does NOT // suppress the checkbox — only the quollSyntaxExclusionZones contribution @@ -1683,6 +1836,7 @@ describe("editor — context-handoff keymap is registered", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); // Select within lines 1–2 (offset 8 is inside line2). view.dispatch({ selection: EditorSelection.single(0, 8) }); @@ -1729,6 +1883,7 @@ describe("editor — folding is registered", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); // `.cm-foldGutter` is mounted ONLY by quollFolding()'s foldGutter() — absent // without the extension, present with it. The non-vacuous wiring proof. @@ -1744,6 +1899,7 @@ describe("editor — folding is registered", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); view.dispatch({ selection: EditorSelection.cursor(0) }); // caret on "- a" expect(foldCode(view)).toBe(true); @@ -1755,7 +1911,7 @@ describe("editor — folding is registered", () => { const { EditorSelection } = await import("@codemirror/state"); const { view, handle } = mount(); const doc = "- a\n - b\n - c\n- d\n"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); view.dispatch({ selection: EditorSelection.cursor(0) }); expect(foldCode(view)).toBe(true); expect(foldedRanges(view.state).size).toBe(1); @@ -1764,7 +1920,7 @@ describe("editor — folding is registered", () => { // (native clearTouchedFolds does not fire). The fold persists. Pins the // Resolution #1 "folds survive an external reseed" claim through the real // applyDocument path (error-handler review, Confidence 85). - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(foldedRanges(view.state).size).toBe(1); }); }); @@ -1780,6 +1936,7 @@ describe("editor — blockquotes are not foldable through the live editor (wirin eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); settledView(view, 5000); const headingLine = view.state.doc.lineAt(0); @@ -1798,7 +1955,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("applyRemoteCaret moves the selection to the applyCaret offset and posts NO caret-report", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld\n!", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld\n!", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); // drop seed-driven posts (e.g. lint-diagnostics) handle.applyRemoteCaret({ line: 1, character: 2 }); // line 1 ("world") starts at offset 6; character 2 → offset 8. @@ -1813,7 +1976,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("a user selection change posts a caret-report (debounced) with 0-based line/character", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); // Stand in for a user caret move (selection-only dispatch; not seeding, not remote). view.dispatch({ selection: { anchor: 3 } }); @@ -1828,7 +1997,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("coalesces a burst of selection changes into ONE trailing caret-report", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); // A drag-selection / rapid caret walk fires selectionSet many times inside // the debounce window; only the LAST survives as a single post. @@ -1845,7 +2020,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("coalesces a burst of range selections to ONE report carrying the LAST selectedChars", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); // A drag-select that grows: each dispatch changes the selection length; only // the final extent must survive (latest-wins through the debounce). @@ -1862,7 +2043,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("reports the primary-selection char count; a collapsed caret reports 0", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); // A non-empty selection: anchor 1 → head 5 spans 4 code units. view.dispatch({ selection: { anchor: 1, head: 5 } }); @@ -1882,7 +2069,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("flushes the pending caret-report BEFORE switch-to-text on the editor switch", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); view.dispatch({ selection: { anchor: 4 } }); expect(caretReports()).toHaveLength(0); // still debounced @@ -1903,7 +2096,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("flushPending() force-posts the pending caret-report (teardown/hide)", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); view.dispatch({ selection: { anchor: 2 } }); expect(caretReports()).toHaveLength(0); // debounced @@ -1919,7 +2118,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("dispose() cancels a pending caret-report (no stray post through the destroyed view)", () => { vi.useFakeTimers(); const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); postMessage.mockReset(); view.dispatch({ selection: { anchor: 2 } }); handle.dispose(); @@ -1929,7 +2134,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("applyRemoteCaret posts no `edit` (selection-only, document untouched)", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "alpha\nbeta", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "alpha\nbeta", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); const before = view.state.sliceDoc(); postMessage.mockReset(); handle.applyRemoteCaret({ line: 0, character: 4 }); @@ -1945,7 +2156,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { // focus the view or the carried caret is set-but-invisible ("caret not shown"). it("applyRemoteCaret focuses the view so the carried caret is painted", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld\n!", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld\n!", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Precondition (non-vacuity): a freshly seeded view is unfocused — the // webview owns focus, CM's contenteditable does not. expect(view.hasFocus).toBe(false); @@ -1955,7 +2172,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { it("applyRemoteCaret focuses even when the caret is already at the target", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Park the caret at the exact target while unfocused (dispatch does not // focus) so the same-position no-op guard fires below. view.dispatch({ selection: { anchor: applyCaret(view.state.doc, { line: 0, character: 3 }) } }); @@ -1981,7 +2204,13 @@ describe("caret handoff (applyRemoteCaret + caret-report)", () => { const hasFocusSpy = vi.spyOn(document, "hasFocus").mockReturnValue(false); try { const { handle, view } = mount(); - handle.applyDocument({ content: "hello\nworld", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello\nworld", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); // Sanity: the content DOM does not hold focus before the apply. expect(document.activeElement).not.toBe(view.contentDOM); postMessage.mockReset(); @@ -2015,6 +2244,7 @@ describe("editor — Codex context-handoff keymap is registered", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); // happy-dom's CM platform detection is non-deterministic, so fire BOTH // Ctrl-j and Cmd-j; exactly one resolves to Mod-j and posts. @@ -2070,7 +2300,13 @@ describe("editor — native spellcheck toggle", () => { it("toggling spellcheck does not mutate the document", () => { const { handle, view } = mount(); - handle.applyDocument({ content: "hello world", eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "hello world", + eol: "\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); const before = view.state.sliceDoc(); handle.setSpellcheck(false); expect(view.state.sliceDoc()).toBe(before); @@ -2145,14 +2381,14 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { it("(r1) a one-char edit below a folded heading keeps the heading folded", () => { const { handle, view } = mount(); const doc = "# One\n\nalpha\nbravo\n\n# Two\n\ncharlie"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); const foldFrom = doc.indexOf("\n"); // end of "# One" heading line const foldTo = doc.indexOf("\n\n# Two") + 1; // through the blank line view.dispatch({ effects: foldEffect.of({ from: foldFrom, to: foldTo }) }); expect(foldedCount(view)).toBe(1); const next = doc.replace("charlie", "Charlie"); // edit in the SECOND section - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); expect(foldedCount(view)).toBe(1); // fold above the edit survives @@ -2161,7 +2397,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { it("(r2) a fold BELOW the edit survives, remapped to shifted coordinates", () => { const { handle, view } = mount(); const doc = "# One\n\nalpha\n\n# Two\n\nbravo\ncharlie"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Fold the SECOND heading's body. const foldFrom = doc.indexOf("# Two") + "# Two".length; const foldTo = doc.length; @@ -2173,7 +2409,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // which a stale un-remapped range would also pass). const next = doc.replace("alpha", "alpha extended"); const delta = next.length - doc.length; - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); expect(foldRanges(view)).toEqual([{ from: foldFrom + delta, to: foldTo + delta }]); @@ -2188,7 +2424,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // throw. (The coordinate GUARANTEE itself is Task 1's unit test; this test // pins fold-survival + no-throw end-to-end on a CRLF doc.) const doc = "# One\r\n\r\nalpha\r\nbravo\r\n\r\n# Two\r\n\r\ncharlie"; - handle.applyDocument({ content: doc, eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\r\n", canWrite: true, docVersion: 1, ...PAIR }); // Fold the first heading region (positions in LF-internal coords). const internal = view.state.doc; const foldFrom = internal.line(1).to; // end of "# One" @@ -2199,7 +2435,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // External reseed changing an interior line (still CRLF). const next = doc.replace("charlie", "Charlie"); expect(() => - handle.applyDocument({ content: next, eol: "\r\n", canWrite: true, docVersion: 2 }) + handle.applyDocument({ content: next, eol: "\r\n", canWrite: true, docVersion: 2, ...PAIR }) ).not.toThrow(); expect(hostBytes(view)).toBe(next); // byte-identical, no corruption expect(foldedCount(view)).toBe(1); @@ -2212,6 +2448,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { eol: "\n", canWrite: true, docVersion: 1, + ...PAIR, }); postMessage.mockReset(); // ignore the seed's traffic handle.applyDocument({ @@ -2219,6 +2456,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { eol: "\n", canWrite: true, docVersion: 2, + ...PAIR, }); expect(editPosts()).toEqual([]); // reseed is display-only, never posts an edit }); @@ -2228,7 +2466,13 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // Seed as CRLF: the interior is "a\nb\nc" and only the OUTBOUND bytes carry // \r\n. Pin the pre-reseed side too, so the flip below is observed in both // directions rather than passing on a document that was LF all along. - handle.applyDocument({ content: "a\r\nb\r\nc", eol: "\r\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ + content: "a\r\nb\r\nc", + eol: "\r\n", + canWrite: true, + docVersion: 1, + ...PAIR, + }); expect(hostBytes(view)).toBe("a\r\nb\r\nc"); const foldFrom = view.state.doc.line(1).to; view.dispatch({ effects: foldEffect.of({ from: foldFrom, to: view.state.doc.length }) }); @@ -2237,7 +2481,13 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // Reseed the SAME content as LF: raw differs (aheadOfHost true) but the // normalized Text is identical → computeReseedChange yields an empty change. expect(() => - handle.applyDocument({ content: "a\nb\nc", eol: "\n", canWrite: true, docVersion: 2 }) + handle.applyDocument({ + content: "a\nb\nc", + eol: "\n", + canWrite: true, + docVersion: 2, + ...PAIR, + }) ).not.toThrow(); // Read the OUTBOUND bytes: sliceDoc() renders LF for every document, so it // would answer "a\nb\nc" whether or not the EOL facet followed the reseed. @@ -2251,7 +2501,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // Fold "# One"'s body [foldFrom, foldTo]; the caret rests at 0 (outside the // fold) so the selection-head clear does NOT fire. const doc = "# One\n\nalpha\nbravo\n\n# Two\n\ncharlie"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); const foldFrom = doc.indexOf("\n"); // end of "# One" const foldTo = doc.indexOf("\n\n# Two") + 1; view.dispatch({ effects: foldEffect.of({ from: foldFrom, to: foldTo }) }); @@ -2260,7 +2510,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // External edit INSIDE the folded region: "bravo" -> "BR" (shrinks by 3). const next = doc.replace("bravo", "BR"); const delta = next.length - doc.length; // -3 - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); // Fold persists, remapped: from unchanged (before the edit), to shrinks by @@ -2271,7 +2521,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { it("(r7) a reseed that inserts a sibling heading INTO a folded section clamps the stale fold so the new heading is not hidden", () => { const { handle, view } = mount(); const doc = "# One\n\nalpha\nbravo\n\n# Two\n\ncharlie"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Fold "# One" at its CANONICAL range (what foldCode/the gutter would produce), // so the fold matches foldable() exactly BEFORE the reseed remaps it. settledView(view, 5000); @@ -2288,7 +2538,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // overlapping fold THROUGH the insert, so without reconciliation it widens to // swallow "# New" — hiding the new section behind the stale fold. const next = doc.replace("alpha", "alpha\n\n# New\n\ngamma"); - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); // The section is still collapsed (the fold is preserved, not sprung open)... @@ -2305,7 +2555,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { it("(r8) a reseed that removes the heading marker on a folded line fully unfolds it (no clamp target)", () => { const { handle, view } = mount(); const doc = "# One\n\nalpha\nbravo\n\n# Two\n\ncharlie"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); settledView(view, 5000); const line1 = view.state.doc.line(1); const canonical = foldable(view.state, line1.from, line1.to); @@ -2318,7 +2568,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // External reseed strips the ATX marker: "# One" -> "One" (no longer a heading, // so line 1 is no longer foldable at all). const next = doc.replace("# One", "One"); - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); expect(foldedCount(view)).toBe(0); // fully released, not clamped to an empty range @@ -2327,7 +2577,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { it("(r9) clamping a fold that contains a nested child heading keeps the child but reveals an inserted sibling", () => { const { handle, view } = mount(); const doc = "# One\n\n## Sub\n\nfoo\n\n# Two\n\nbar"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); settledView(view, 5000); const line1 = view.state.doc.line(1); const canonical = foldable(view.state, line1.from, line1.to); // includes "## Sub" @@ -2341,7 +2591,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // INTO One's folded body. Reconciliation must clamp so "# New" is revealed, // while the child "## Sub" stays inside the fold. const next = doc.replace("foo", "foo\n\n# New\n\ngamma"); - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); expect(foldedCount(view)).toBe(1); @@ -2362,7 +2612,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // body-delete case the line-only orphan gate missed, which left a phantom fold // pill on a heading that no longer folds. const doc = "intro\n\n# One\n\nalpha\nbravo"; - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); settledView(view, 5000); const line = view.state.doc.line(3); // "# One" const canonical = foldable(view.state, line.from, line.to); @@ -2376,7 +2626,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // minimal-span diff is a pure deletion whose edited span starts after the // heading line, so the line-only gate never sees the touch. const next = "intro\n\n# One\n"; - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); expect(foldedCount(view)).toBe(0); // orphaned fold released, no phantom pill @@ -2400,7 +2650,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { let virtualNow = Date.now(); const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => (virtualNow += 30)); try { - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); } finally { nowSpy.mockRestore(); } @@ -2438,7 +2688,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // concealed behind the stale (remapped, over-wide) fold. postMessage.mockReset(); // ignore the seed traffic; watch the reseed only const next = doc.replace("alpha", "alpha\n\n# New\n\ngamma"); - handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2 }); + handle.applyDocument({ content: next, eol: "\n", canWrite: true, docVersion: 2, ...PAIR }); expect(view.state.sliceDoc()).toBe(next); // The forced parse ran (frontier complete) AND the fold was clamped so the new @@ -2485,7 +2735,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { let virtualNow = Date.now(); const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => (virtualNow += 600)); try { - handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1 }); + handle.applyDocument({ content: doc, eol: "\n", canWrite: true, docVersion: 1, ...PAIR }); // Precondition: the seed left the frontier genuinely incomplete (else // forceParsing would return true instantly and this test would be vacuous). expect(syntaxTreeAvailable(view.state, view.state.doc.length)).toBe(false); @@ -2502,6 +2752,7 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { eol: "\n", canWrite: true, docVersion: 2, + ...PAIR, }); } finally { nowSpy.mockRestore(); diff --git a/test/webview/host.test.ts b/test/webview/host.test.ts index a13083a1..297eb013 100644 --- a/test/webview/host.test.ts +++ b/test/webview/host.test.ts @@ -128,6 +128,8 @@ describe("subscribeToHost — boundary validation + diagnostics", () => { themeKind: "dark", canWrite: true, eol: "\n", + externalEpoch: 0, + epochGeneration: 1, }); let subscribeToHost: HostModule["subscribeToHost"]; diff --git a/test/webview/outline/outline-integration.test.ts b/test/webview/outline/outline-integration.test.ts index 29a99f1d..33f11bbf 100644 --- a/test/webview/outline/outline-integration.test.ts +++ b/test/webview/outline/outline-integration.test.ts @@ -57,6 +57,8 @@ describe("outline navigation integration", () => { eol: "\n", canWrite: true, docVersion: 1, + externalEpoch: 0, + epochGeneration: 1, }); const mountEl = container?.querySelector(".quoll-editor") as HTMLElement; diff --git a/test/webview/shell.test.ts b/test/webview/shell.test.ts index d8899bbd..a0f28aa7 100644 --- a/test/webview/shell.test.ts +++ b/test/webview/shell.test.ts @@ -95,6 +95,8 @@ function buildDocument( themeKind: "light", canWrite: true, eol: "\n", + externalEpoch: 0, + epochGeneration: 1, ...overrides, }; }