From ee703eda315c9f66b9d94cc4f29d48656e12b35c Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Fri, 2 Oct 2026 09:32:55 +1000 Subject: [PATCH 1/3] chore(protocol): require the Document epoch identity pair externalEpoch / epochGeneration were wire-optional so an old host could not brick a new webview. Since PROTOCOL_VERSION 2 such a host is rejected at isProtocolMatch before the pair is read, so the tolerance was unreachable. The pair is now required in the type and the boundary validator, and the webview's pair-absent (legacy host) branches are gone. No wire change: the host already always emits both. --- src/extension/session/document-message.ts | 8 +- src/shared/protocol.ts | 43 +- src/webview/cm/edit-sync.ts | 101 +--- src/webview/editor.ts | 2 +- src/webview/shell.ts | 2 +- src/webview/state.ts | 4 +- test/extension/e2e/types.ts | 7 +- .../apply-edit-settle-rejection.test.ts | 6 +- test/shared/protocol.test.ts | 27 +- test/webview/cm-crlf-line-model.test.ts | 108 +++- test/webview/cm-edit-sync.test.ts | 234 +++------ test/webview/cm-search-replace.test.ts | 9 +- test/webview/cm-widget-containment.test.ts | 48 +- .../cm-decoration-integration.test.ts | 2 + test/webview/editor.test.ts | 481 +++++++++++++----- test/webview/host.test.ts | 2 + .../outline/outline-integration.test.ts | 2 + test/webview/shell.test.ts | 2 + 18 files changed, 642 insertions(+), 446 deletions(-) diff --git a/src/extension/session/document-message.ts b/src/extension/session/document-message.ts index f24e2ae8..8f694e1c 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,7 +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 + * `epochGeneration` — the key-set test pins their * presence. */ export function buildDocumentMessage(input: BuildDocumentMessageInput): DocumentMessage { return { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 24e6e549..6bbe0976 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -253,13 +253,8 @@ 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. * 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 +262,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 +270,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 +701,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 +720,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..980d711c 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 + * 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. Both arguments are required — pass `undefined` - * explicitly for a legacy pair-less host. + * 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,14 +290,10 @@ 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 + * 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 @@ -311,15 +303,14 @@ export type EditSync = { 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 + * 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; + isIdentityTransition: (externalEpoch: number, epochGeneration: number) => boolean; }; export function createEditSync(opts: EditSyncOptions): EditSync { @@ -353,14 +344,14 @@ 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 + // 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 + // 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. @@ -382,17 +373,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,39 +387,18 @@ 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 + // 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 @@ -462,7 +421,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 +472,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 +782,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 +920,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..9b0e99b2 100644 --- a/src/webview/shell.ts +++ b/src/webview/shell.ts @@ -442,7 +442,7 @@ 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 + // 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 diff --git a/src/webview/state.ts b/src/webview/state.ts index 93dd7177..ff4f65d0 100644 --- a/src/webview/state.ts +++ b/src/webview/state.ts @@ -58,13 +58,13 @@ 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) + * 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 (or pure-absent legacy) Documents. */ + * same-generation Documents. */ readonly adopt?: boolean; } | { readonly type: "theme"; readonly themeKind: ThemeKind } 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..45813bd9 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,7 +160,13 @@ 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"); }); }); @@ -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, }; } From 03803b9c6b7485c899fda5c4b84dfd7aaaefc909 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Fri, 2 Oct 2026 09:37:13 +1000 Subject: [PATCH 2/3] style: reflow comments left ragged by the legacy-pair wording removal --- src/extension/session/document-message.ts | 3 +- src/webview/cm/edit-sync.ts | 62 +++++++++++------------ src/webview/shell.ts | 16 +++--- src/webview/state.ts | 13 +++-- 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/src/extension/session/document-message.ts b/src/extension/session/document-message.ts index 8f694e1c..b93f9405 100644 --- a/src/extension/session/document-message.ts +++ b/src/extension/session/document-message.ts @@ -53,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 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/webview/cm/edit-sync.ts b/src/webview/cm/edit-sync.ts index 980d711c..8780f446 100644 --- a/src/webview/cm/edit-sync.ts +++ b/src/webview/cm/edit-sync.ts @@ -269,14 +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. 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. + * 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 @@ -293,10 +293,9 @@ export type EditSync = { 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. - * 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 + * 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`. */ @@ -304,12 +303,12 @@ export type EditSync = { /** 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; 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). */ + * 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; }; @@ -344,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. 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. - // 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. + // 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 @@ -398,11 +396,11 @@ export function createEditSync(opts: EditSyncOptions): EditSync { // `epoch` is compared for magnitude only WITHIN one generation; `generation` // is identity, never ordering (protocol.ts's DocumentMessage doc). // - // 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. + // 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 diff --git a/src/webview/shell.ts b/src/webview/shell.ts index 9b0e99b2..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) 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 ff4f65d0..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) - * 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. */ + * 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 } From cf916368b24339ed3c9264a4e68c22b269cf830d Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Fri, 2 Oct 2026 09:46:12 +1000 Subject: [PATCH 3/3] fix(preview): seed the required epoch pair; align comments --- scripts/preview/README.md | 2 +- scripts/preview/preview.template.html | 3 +++ src/shared/protocol.ts | 5 ++++- test/build/preview-server-theme.test.ts | 4 +++- test/webview/editor.test.ts | 4 ++-- 5 files changed, 13 insertions(+), 5 deletions(-) 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/shared/protocol.ts b/src/shared/protocol.ts index 6bbe0976..12f17b3d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -254,7 +254,10 @@ export function isDocumentEol(value: unknown): value is DocumentEol { * rationale. * * `externalEpoch` + `epochGeneration` are both REQUIRED — a Document missing - * either is a boundary-INVALID message. + * 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` 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/webview/editor.test.ts b/test/webview/editor.test.ts index 45813bd9..7e529c95 100644 --- a/test/webview/editor.test.ts +++ b/test/webview/editor.test.ts @@ -173,8 +173,8 @@ describe("editor — applyDocument seeds the CM doc (a)", () => { // (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();