diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts index 222cbe65be..42934d660f 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import type { Editor } from '../core/Editor.js'; import { v4 as uuidv4 } from 'uuid'; +import { NON_SEMANTIC_BLOCK_ATTRS } from '../extensions/diffing/algorithm/identity-attrs.js'; import type { CreateTableInput, CreateTableResult, @@ -2944,26 +2945,11 @@ function buildSetCellTextParagraph( } /** - * Identity attrs that are auto-generated by appendTransaction hooks (block - * IDs, paragraph IDs, revision counters) or carried in from the source XML - * by the importer (`w:rsid*` revision IDs). They differ between an original - * and a freshly-rewritten paragraph even when the visible content is - * identical, so the NO_OP comparator must ignore them. + * Identity attrs that the cell-content NO_OP comparator must ignore: auto- + * generated session block IDs and OOXML revision IDs. Same list used by + * the diff fingerprint normalization (`NON_SEMANTIC_BLOCK_ATTRS`). */ -const IDENTITY_BLOCK_ATTRS = new Set([ - 'sdBlockId', - 'sdBlockRev', - 'paraId', - 'textId', - // Imported revision IDs (paragraph.js:136-140) - identical visible - // content can have different rsid* values when the source paragraph - // was imported from a real DOCX. - 'rsidR', - 'rsidRDefault', - 'rsidP', - 'rsidRPr', - 'rsidDel', -]); +const IDENTITY_BLOCK_ATTRS = NON_SEMANTIC_BLOCK_ATTRS; /** * Compare two cell-content fragments while ignoring auto-generated block diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.test.js index d9f7a009b4..a2cf65438d 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getAttributesDiff, getMarksDiff } from './attributes-diffing.ts'; +import { applyAttrsDiff, getAttributesDiff, getMarksDiff } from './attributes-diffing.ts'; describe('getAttributesDiff', () => { it('detects nested additions, deletions, and modifications', () => { @@ -165,6 +165,83 @@ describe('getAttributesDiff', () => { }); }); +describe('applyAttrsDiff', () => { + // SD-3279: replay must not overwrite recipient's session-local identity + // attrs. The diff carries only the semantic delta (no sdBlockId path); + // applyAttrsDiff must preserve the recipient's sdBlockId untouched. + it('preserves recipient attrs that the diff does not mention', () => { + const recipientAttrs = { sdBlockId: 'recipient-uuid', sdBlockRev: 7, textAlign: 'left' }; + const diff = { + added: {}, + deleted: {}, + modified: { textAlign: { from: 'left', to: 'right' } }, + }; + + const merged = applyAttrsDiff(recipientAttrs, diff); + + expect(merged).toEqual({ sdBlockId: 'recipient-uuid', sdBlockRev: 7, textAlign: 'right' }); + }); + + it('applies added paths, including nested dotted paths', () => { + const merged = applyAttrsDiff( + { existing: 1 }, + { + added: { 'nested.deep.value': 42, topLevel: 'added' }, + deleted: {}, + modified: {}, + }, + ); + + expect(merged).toEqual({ + existing: 1, + topLevel: 'added', + nested: { deep: { value: 42 } }, + }); + }); + + it('removes deleted paths and keeps siblings', () => { + const merged = applyAttrsDiff( + { keep: 'me', drop: 'this', nested: { keep: 1, drop: 2 } }, + { + added: {}, + deleted: { drop: 'this', 'nested.drop': 2 }, + modified: {}, + }, + ); + + expect(merged).toEqual({ keep: 'me', nested: { keep: 1 } }); + }); + + it('does not mutate the input base attrs', () => { + const base = { sdBlockId: 'recipient', nested: { value: 1 } }; + applyAttrsDiff(base, { + added: { 'nested.added': 2 }, + deleted: {}, + modified: { 'nested.value': { from: 1, to: 9 } }, + }); + + expect(base).toEqual({ sdBlockId: 'recipient', nested: { value: 1 } }); + }); + + it('returns a clone of base when diff is null', () => { + const base = { sdBlockId: 'recipient', textAlign: 'left' }; + const merged = applyAttrsDiff(base, null); + + expect(merged).toEqual(base); + expect(merged).not.toBe(base); + }); + + it('tolerates null base attrs', () => { + const merged = applyAttrsDiff(null, { + added: { textAlign: 'right' }, + deleted: {}, + modified: {}, + }); + + expect(merged).toEqual({ textAlign: 'right' }); + }); +}); + describe('getMarksDiff', () => { it('detects mark additions, deletions, and modifications', () => { const marksA = [ diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts index 1896d83327..bc7b4d55ef 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/attributes-diffing.ts @@ -289,6 +289,99 @@ function joinPath(base: string, key: string): string { return base ? `${base}.${key}` : key; } +/** + * Applies an {@link AttributesDiff} onto a base set of attributes, returning a + * new object where: + * - `added` paths are written + * - `modified` paths overwrite the existing value with the `to` value + * - `deleted` paths are removed + * Any key not referenced by the diff is preserved unchanged. + * + * Used at replay time so cross-editor diff apply does not overwrite the + * recipient editor's session-local identity attrs (sdBlockId, sdBlockRev) with + * the originator's values. See SD-3279. + * + * @param baseAttrs Recipient node's current attrs (preserved when not in diff). + * @param attrsDiff Diff to apply. When null, returns a shallow copy of base. + * @returns New attrs object with the diff layered onto base. + */ +export function applyAttrsDiff( + baseAttrs: Record | null | undefined, + attrsDiff: AttributesDiff | null | undefined, +): Record { + const result = cloneAttrs(baseAttrs ?? {}); + if (!attrsDiff) { + return result; + } + for (const [path, value] of Object.entries(attrsDiff.added ?? {})) { + setAtPath(result, path, value); + } + for (const [path, change] of Object.entries(attrsDiff.modified ?? {})) { + setAtPath(result, path, change.to); + } + for (const path of Object.keys(attrsDiff.deleted ?? {})) { + deleteAtPath(result, path); + } + return result; +} + +function cloneAttrs(value: unknown): Record { + if (!isPlainObject(value)) { + return {}; + } + const clone: Record = {}; + for (const [key, child] of Object.entries(value)) { + clone[key] = cloneDeep(child); + } + return clone; +} + +function cloneDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(cloneDeep); + } + if (isPlainObject(value)) { + const clone: Record = {}; + for (const [key, child] of Object.entries(value)) { + clone[key] = cloneDeep(child); + } + return clone; + } + return value; +} + +function splitPath(path: string): string[] { + return path.split('.'); +} + +function setAtPath(target: Record, path: string, value: unknown): void { + const segments = splitPath(path); + let cursor: Record = target; + for (let i = 0; i < segments.length - 1; i++) { + const key = segments[i]; + const next = cursor[key]; + if (!isPlainObject(next)) { + cursor[key] = {}; + } + cursor = cursor[key] as Record; + } + cursor[segments[segments.length - 1]] = cloneDeep(value); +} + +function deleteAtPath(target: Record, path: string): void { + const segments = splitPath(path); + let cursor: Record = target; + for (let i = 0; i < segments.length - 1; i++) { + const key = segments[i]; + const next = cursor[key]; + if (!isPlainObject(next)) { + return; + } + cursor = next; + } + delete cursor[segments[segments.length - 1]]; +} + /** * Determines if a value is a plain object (no arrays or nulls). * diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts index 2a2d2f73ee..c8849eea5f 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/generic-diffing.ts @@ -13,6 +13,13 @@ import { import { diffSequences, reorderDiffOperations } from './sequence-diffing'; import { getAttributesDiff, type AttributesDiff } from './attributes-diffing'; import { getInsertionPos, type NodePositionInfo } from './diff-utils'; +import { NON_SEMANTIC_BLOCK_ATTRS } from './identity-attrs'; + +// Non-paragraph block-node attr diffing must ignore session-local identity +// attrs. Otherwise a cross-editor diff carries the originator's sdBlockId in +// the `modified` paths and replay overwrites the recipient's ID. Paragraphs +// already strip these upstream via normalizeParagraphAttrs. See SD-3279. +const NON_PARAGRAPH_BLOCK_IGNORED_ATTRS: string[] = Array.from(NON_SEMANTIC_BLOCK_ATTRS); type NodeJSON = ReturnType; @@ -220,7 +227,11 @@ function buildModifiedDiff(oldNodeInfo: NodeInfo, newNodeInfo: NodeInfo): NodeDi return buildModifiedParagraphDiff(oldNodeInfo, newNodeInfo); } - const attrsDiff = getAttributesDiff(oldNodeInfo.node.attrs, newNodeInfo.node.attrs); + const attrsDiff = getAttributesDiff( + oldNodeInfo.node.attrs, + newNodeInfo.node.attrs, + NON_PARAGRAPH_BLOCK_IGNORED_ATTRS, + ); if (!attrsDiff) { return null; } diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts new file mode 100644 index 0000000000..d901e9c881 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/identity-attrs.ts @@ -0,0 +1,25 @@ +/** + * OOXML/import identity and revision fields that should not affect visible + * diff semantics. + * + * `sdBlockId` and `sdBlockRev` are assigned by the block-node plugin and are + * session-local — two editor instances loaded from the same DOCX hold + * different values. `paraId`, `textId`, and the `rsid*` family are import-side + * identity/revision fields whose values can differ across copies of the same + * visible content. + * + * The diff fingerprint must be stable against changes to these attrs, otherwise + * `diff.apply` across two editor instances rejects with `PRECONDITION_FAILED` + * even when both editors hold the same document content. See SD-3279. + */ +export const NON_SEMANTIC_BLOCK_ATTRS = new Set([ + 'sdBlockId', + 'sdBlockRev', + 'paraId', + 'textId', + 'rsidR', + 'rsidRDefault', + 'rsidP', + 'rsidRPr', + 'rsidDel', +]); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts index f76ae06b90..1f9bd823ad 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.test.ts @@ -19,6 +19,8 @@ describe('normalizeParagraphAttrs', () => { rsidP: '00112233', rsidRPr: '00445566', rsidDel: '00778899', + sdBlockId: 'session-local-uuid-1', + sdBlockRev: 7, align: 'center', indent: { left: 720 }, }; @@ -266,6 +268,69 @@ describe('normalizeDocJSON', () => { expect(cellParagraph.attrs).toEqual({}); }); + it('strips identity attrs from non-paragraph block nodes (tables, rows, cells, sections)', () => { + // sdBlockId is assigned per editor session to every block node that + // accepts it (paragraphs, tables, rows, cells, sections). Including it + // in the diff fingerprint makes two editors loaded from the same DOCX + // produce different fingerprints. + const docJSON = { + type: 'doc', + content: [ + { + type: 'table', + attrs: { sdBlockId: 'table-uuid-A', sdBlockRev: 1, tableStyleId: 'Grid' }, + content: [ + { + type: 'tableRow', + attrs: { sdBlockId: 'row-uuid-A', sdBlockRev: 2 }, + content: [ + { + type: 'tableCell', + attrs: { sdBlockId: 'cell-uuid-A', sdBlockRev: 3, cellWidth: 100 }, + content: [ + { + type: 'paragraph', + attrs: { sdBlockId: 'p-uuid-A', sdBlockRev: 4, align: 'left' }, + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = normalizeDocJSON(docJSON) as any; + + expect(result.content[0].attrs).toEqual({ tableStyleId: 'Grid' }); + expect(result.content[0].content[0].attrs).toEqual({}); + expect(result.content[0].content[0].content[0].attrs).toEqual({ cellWidth: 100 }); + expect(result.content[0].content[0].content[0].content[0].attrs).toEqual({ align: 'left' }); + }); + + it('produces the same normalized output for two doc trees that differ only in sdBlockId values', () => { + const makeDoc = (uuidA: string, uuidB: string) => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { sdBlockId: uuidA, align: 'left' }, + content: [{ type: 'run', attrs: {}, content: [{ type: 'text', text: 'Hello' }] }], + }, + { + type: 'table', + attrs: { sdBlockId: uuidB, tableStyleId: 'Grid' }, + content: [], + }, + ], + }); + + const a = normalizeDocJSON(makeDoc('uuid-A1', 'uuid-A2')); + const b = normalizeDocJSON(makeDoc('uuid-B1', 'uuid-B2')); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + it('returns the doc unchanged when there is no content', () => { const docJSON = { type: 'doc' }; expect(normalizeDocJSON(docJSON)).toEqual(docJSON); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts index b9fb447eb2..d61737d494 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/algorithm/semantic-normalization.ts @@ -2,17 +2,13 @@ * Semantic normalization for diff comparisons. * * Strips non-semantic OOXML metadata from node JSON before diffing so that - * volatile attributes (regenerated by Word on every save) do not produce - * false-positive diffs. This module is used exclusively by the diffing - * pipeline — it never mutates live ProseMirror nodes or touches - * importer/exporter code. + * volatile attributes (regenerated by Word on every save, or assigned per + * editor session) do not produce false-positive diffs or fingerprint + * mismatches. This module is used exclusively by the diffing pipeline — it + * never mutates live ProseMirror nodes or touches importer/exporter code. */ -/** - * Paragraph-level attributes that Word regenerates on every save. - * These carry no semantic meaning for diff comparison. - */ -const VOLATILE_PARAGRAPH_ATTRS = new Set(['paraId', 'textId', 'rsidR', 'rsidRDefault', 'rsidP', 'rsidRPr', 'rsidDel']); +import { NON_SEMANTIC_BLOCK_ATTRS } from './identity-attrs'; /** * Keys inside `originalAttributes` on image nodes that Word regenerates @@ -41,7 +37,19 @@ function omitKeys(attrs: Record, keysToOmit: Set): Reco * @returns A shallow copy with non-semantic keys removed. */ export function normalizeParagraphAttrs(attrs: Record): Record { - return omitKeys(attrs, VOLATILE_PARAGRAPH_ATTRS); + return omitKeys(attrs, NON_SEMANTIC_BLOCK_ATTRS); +} + +/** + * Strips identity-only block attributes (`sdBlockId`, `sdBlockRev`, OOXML + * `rsid*` family) from any block node's attrs. Used by `normalizeDocNodeJSON` + * to keep the fingerprint stable across editor instances. + * + * @param attrs Raw node attributes. + * @returns A shallow copy with non-semantic block keys removed. + */ +function normalizeBlockAttrs(attrs: Record): Record { + return omitKeys(attrs, NON_SEMANTIC_BLOCK_ATTRS); } /** @@ -194,6 +202,10 @@ export function normalizeDocJSON(docJSON: Record): Record): Record { const type = nodeJSON.type as string | undefined; @@ -203,13 +215,99 @@ function normalizeDocNodeJSON(nodeJSON: Record): Record | undefined; const content = nodeJSON.content as Record[] | undefined; + const normalizedAttrs = attrs ? normalizeBlockAttrs(attrs) : undefined; + if (content) { return { ...nodeJSON, + ...(normalizedAttrs ? { attrs: normalizedAttrs } : {}), content: content.map(normalizeDocNodeJSON), }; } + if (normalizedAttrs) { + return { ...nodeJSON, attrs: normalizedAttrs }; + } + return nodeJSON; +} + +// --------------------------------------------------------------------------- +// Legacy normalization (SD-3279 backward compatibility) +// +// Reproduces the pre-SD-3279 normalization exactly. Used only by the +// validation fallback path in `diff-service.ts` to accept snapshot and diff +// payloads captured under the old algorithm. The difference from the current +// algorithm: paragraph-only attribute stripping over a smaller set (no +// `sdBlockId` / `sdBlockRev`), no attribute stripping on structural +// containers. Image originalAttributes stripping is unchanged across both +// algorithms and is reused. +// --------------------------------------------------------------------------- + +const LEGACY_VOLATILE_PARAGRAPH_ATTRS = new Set([ + 'paraId', + 'textId', + 'rsidR', + 'rsidRDefault', + 'rsidP', + 'rsidRPr', + 'rsidDel', +]); + +function normalizeParagraphAttrsLegacy(attrs: Record): Record { + return omitKeys(attrs, LEGACY_VOLATILE_PARAGRAPH_ATTRS); +} + +function normalizeParagraphNodeJSONLegacy(nodeJSON: Record): Record { + const attrs = (nodeJSON.attrs as Record) ?? {}; + const content = nodeJSON.content as Record[] | undefined; + + return { + ...nodeJSON, + attrs: normalizeParagraphAttrsLegacy(attrs), + ...(content ? { content: content.map(normalizeContentNodeJSON) } : {}), + }; +} + +function normalizeDocNodeJSONLegacy(nodeJSON: Record): Record { + const type = nodeJSON.type as string | undefined; + + if (type === 'paragraph') { + return normalizeParagraphNodeJSONLegacy(nodeJSON); + } + + // Pre-SD-3279: structural containers passed through with attrs untouched; + // only their content was recursed. + const content = nodeJSON.content as Record[] | undefined; + if (content) { + return { + ...nodeJSON, + content: content.map(normalizeDocNodeJSONLegacy), + }; + } + return nodeJSON; } + +/** + * Reproduces the pre-SD-3279 document normalization for legacy fingerprint + * validation. Used by `diff-service.ts` as a fallback when a snapshot or diff + * payload was captured under the old algorithm and its stored fingerprint + * would not match the current normalizer. + * + * @param docJSON Serialized document (from `doc.toJSON()`). + * @returns Normalized copy matching the pre-SD-3279 algorithm. + */ +export function normalizeDocJSONLegacy(docJSON: Record): Record { + const content = docJSON.content as Record[] | undefined; + if (!content) { + return docJSON; + } + + return { + ...docJSON, + content: content.map(normalizeDocNodeJSONLegacy), + }; +} diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.test.js index b41eec7b94..1f430c2165 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.test.js @@ -154,6 +154,53 @@ const testNonParagraphModify = () => { expect(tr.doc.child(0).textContent).toBe('Contents'); }; +/** + * SD-3279: replay must preserve the recipient editor's session-local + * sdBlockId on a non-paragraph attr update. + * @returns {void} + */ +const testNonParagraphPreservesRecipientSdBlockId = () => { + const schema = createSchema(); + const recipientToc = createTableOfContents(schema, 'Contents', { + sdBlockId: 'recipient-uuid', + }); + const doc = schema.nodes.doc.create(null, [recipientToc]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const tocPos = findNodePos(doc, 'tableOfContents'); + if (tocPos === null) { + throw new Error('Expected to find tableOfContents position for identity-preservation test.'); + } + + const sourceToc = createTableOfContents(schema, 'Contents', { + sdBlockId: 'source-uuid', + instruction: 'updated', + }); + + const diff = { + action: 'modified', + nodeType: 'tableOfContents', + oldNodeJSON: recipientToc.toJSON(), + newNodeJSON: sourceToc.toJSON(), + attrsDiff: { + added: {}, + deleted: {}, + modified: { + instruction: { from: null, to: 'updated' }, + }, + }, + pos: tocPos, + }; + + const result = replayNonParagraphDiff({ tr, diff, schema }); + + expect(result.applied).toBe(1); + const updatedAttrs = tr.doc.child(0).attrs; + expect(updatedAttrs.sdBlockId).toBe('recipient-uuid'); + expect(updatedAttrs.instruction).toBe('updated'); +}; + /** * Runs the non-paragraph diff replay suite. * @returns {void} @@ -162,6 +209,7 @@ const runNonParagraphSuite = () => { it('inserts a non-paragraph node using the diff position', testNonParagraphInsert); it('deletes a non-paragraph node at the diff position', testNonParagraphDelete); it('updates non-paragraph node attributes without replacing content', testNonParagraphModify); + it('preserves recipient sdBlockId on attr replay (SD-3279)', testNonParagraphPreservesRecipientSdBlockId); }; describe('replayNonParagraphDiff', runNonParagraphSuite); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.ts b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.ts index 48d1da168c..a57bdeefa3 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-non-paragraph.ts @@ -4,6 +4,7 @@ import { Fragment, Slice } from 'prosemirror-model'; import { ReplaceStep } from 'prosemirror-transform'; +import { applyAttrsDiff } from '../algorithm/attributes-diffing'; import { ReplayResult } from './replay-types'; /** @@ -107,12 +108,9 @@ export function replayNonParagraphDiff({ skipWithWarning(`Node type mismatch at pos ${pos} for modification.`); return result; } - if (!diff.newNodeJSON?.attrs) { - skipWithWarning(`Missing newNodeJSON.attrs at pos ${pos} for modification.`); - return result; - } try { - tr.setNodeMarkup(pos, undefined, diff.newNodeJSON.attrs, node.marks); + const mergedAttrs = applyAttrsDiff(node.attrs as Record, diff.attrsDiff); + tr.setNodeMarkup(pos, undefined, mergedAttrs, node.marks); result.applied += 1; return result; } catch (error) { diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.test.js b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.test.js index 55a793761b..e8ffa9f06e 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.test.js +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.test.js @@ -191,6 +191,58 @@ const testParagraphAttrsModify = () => { expect(tr.doc.child(0).attrs.paragraphProperties?.styleId).toBe('Heading1'); }; +/** + * SD-3279: replay must not overwrite the recipient editor's session-local + * sdBlockId. The diff carries only the semantic delta; the recipient keeps + * its own block identity. + * + * Before the fix, replay called setNodeMarkup with the source's full attrs + * (including its sdBlockId), which silently replaced the recipient's ID. + * @returns {void} + */ +const testParagraphAttrsPreservesRecipientSdBlockId = () => { + const schema = createSchema(); + const recipientParagraph = schema.nodes.paragraph.create( + { sdBlockId: 'recipient-uuid', sdBlockRev: 7 }, + schema.text('Hello'), + ); + const doc = schema.nodes.doc.create(null, [recipientParagraph]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + + const paragraphPos = findParagraphPos(doc); + const sourceParagraph = schema.nodes.paragraph.create( + { sdBlockId: 'source-uuid', sdBlockRev: 42, paragraphProperties: { styleId: 'Heading1' } }, + schema.text('Hello'), + ); + + const diff = { + action: 'modified', + nodeType: 'paragraph', + pos: paragraphPos, + oldText: 'Hello', + newText: 'Hello', + oldNodeJSON: recipientParagraph.toJSON(), + newNodeJSON: sourceParagraph.toJSON(), + contentDiff: [], + attrsDiff: { + added: {}, + deleted: {}, + modified: { + 'paragraphProperties.styleId': { from: null, to: 'Heading1' }, + }, + }, + }; + + const result = replayParagraphDiff({ tr, diff, schema }); + + expect(result.applied).toBe(1); + const updatedAttrs = tr.doc.child(0).attrs; + expect(updatedAttrs.sdBlockId).toBe('recipient-uuid'); + expect(updatedAttrs.sdBlockRev).toBe(7); + expect(updatedAttrs.paragraphProperties?.styleId).toBe('Heading1'); +}; + /** * Runs the paragraph replay helper suite. * @returns {void} @@ -200,6 +252,7 @@ const runParagraphReplaySuite = () => { it('deletes a paragraph', testParagraphDelete); it('modifies a paragraph with inline diffs', testParagraphInlineModify); it('updates paragraph attributes', testParagraphAttrsModify); + it('preserves recipient sdBlockId on attr replay (SD-3279)', testParagraphAttrsPreservesRecipientSdBlockId); }; describe('replayParagraphDiff', runParagraphReplaySuite); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.ts b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.ts index 8a34d56d79..050adb1cdf 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/replay/replay-paragraph.ts @@ -1,6 +1,7 @@ import { Fragment, Slice } from 'prosemirror-model'; import { ReplaceStep } from 'prosemirror-transform'; +import { applyAttrsDiff } from '../algorithm/attributes-diffing'; import { replayInlineDiff } from './replay-inline'; import { ReplayResult } from './replay-types'; @@ -98,12 +99,9 @@ export function replayParagraphDiff({ } if (diff.attrsDiff) { - if (!diff.newNodeJSON?.attrs) { - skipWithWarning(`Missing newNodeJSON attrs at pos ${pos} for paragraph modification.`); - return result; - } try { - tr.setNodeMarkup(pos, undefined, diff.newNodeJSON.attrs, node.marks); + const mergedAttrs = applyAttrsDiff(node.attrs as Record, diff.attrsDiff); + tr.setNodeMarkup(pos, undefined, mergedAttrs, node.marks); result.applied += 1; } catch (error) { skipWithWarning(`Failed to update paragraph attrs at pos ${pos}.`); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/service/canonicalize.ts b/packages/super-editor/src/editors/v1/extensions/diffing/service/canonicalize.ts index d604884210..d5448f5e44 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/service/canonicalize.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/service/canonicalize.ts @@ -11,7 +11,7 @@ import type { CommentInput } from '../algorithm/comment-diffing'; import type { HeaderFooterState } from '../algorithm/header-footer-diffing'; import type { PartsState } from '../algorithm/parts-diffing'; import { COMMENT_ATTRS_DIFF_IGNORED_KEYS } from '../algorithm/comment-diffing'; -import { normalizeDocJSON } from '../algorithm/semantic-normalization'; +import { normalizeDocJSON, normalizeDocJSONLegacy } from '../algorithm/semantic-normalization'; /** The canonical diffable state of one document. */ export interface CanonicalDiffableState { @@ -79,6 +79,31 @@ export function buildCanonicalDiffableState( }; } +/** + * Builds the canonical state under the pre-SD-3279 normalization. Used only + * by the validation fallback in `diff-service.ts` to accept snapshots and + * diff payloads whose stored fingerprint was computed under the old + * algorithm. Identical shape to {@link buildCanonicalDiffableState}; only + * the body normalizer differs. + */ +export function buildLegacyCanonicalDiffableState( + doc: PMNode, + comments: CommentInput[], + styles: StylesDocumentProperties | null | undefined, + numbering: NumberingProperties | null | undefined, + headerFooters: HeaderFooterState | null | undefined, + partsState: PartsState | null | undefined, +): CanonicalDiffableState { + return { + body: normalizeDocJSONLegacy(doc.toJSON() as Record), + comments: comments.map(canonicalizeComment), + styles: styles ? (styles as unknown as Record) : null, + numbering: numbering ? (numbering as unknown as Record) : null, + headerFooters: headerFooters ? structuredClone(headerFooters) : null, + partsState: partsState ? structuredClone(partsState) : null, + }; +} + /** * Recursively sorts object keys for stable serialization. * Arrays are preserved in order; only object key ordering is normalized. diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.test.ts index 770aff83e3..baf7898db0 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.test.ts @@ -8,7 +8,7 @@ import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers import type { CommentInput } from '../algorithm/comment-diffing.ts'; import { captureHeaderFooterState } from '../algorithm/header-footer-diffing.ts'; import { applyDiffPayload, captureSnapshot, compareToSnapshot } from './index.ts'; -import { buildCanonicalDiffableState } from './canonicalize.ts'; +import { buildCanonicalDiffableState, buildLegacyCanonicalDiffableState } from './canonicalize.ts'; import { computeFingerprint } from './fingerprint.ts'; import { V1_COVERAGE } from './coverage.ts'; @@ -575,3 +575,182 @@ describe('diff-service tracked apply', () => { } }); }); + +// SD-3279: cross-editor diff handoff. A diff produced by one editor instance +// must be applicable to a second editor instance that holds the same document +// content. Previously the fingerprint included session-local block IDs, so +// the apply would throw PRECONDITION_FAILED even for identical content. +describe('diff-service cross-editor handoff (SD-3279)', () => { + it('applies a diff produced by one editor to a second editor with the same content', async () => { + const editorMain = await openBlankDocxWithText('Base document.'); + const editorPreview = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document. Renewal requires written approval.'); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(editorMain, snapshot); + + const { tr } = applyDiffPayload(editorPreview, diff, { changeMode: 'tracked' }); + editorPreview.dispatch(tr); + + expect(editorPreview.state.doc.textContent).toBe(targetEditor.state.doc.textContent); + expect(getTrackChanges(editorPreview.state).length).toBeGreaterThan(0); + } finally { + editorMain.destroy?.(); + editorPreview.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('keeps the same-editor capture/compare/apply path working (regression guard)', async () => { + const editor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document. Renewal.'); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(editor, snapshot); + const { tr } = applyDiffPayload(editor, diff, { changeMode: 'tracked' }); + editor.dispatch(tr); + + expect(editor.state.doc.textContent).toBe(targetEditor.state.doc.textContent); + expect(getTrackChanges(editor.state).length).toBeGreaterThan(0); + } finally { + editor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + // Customer flow: main editor mutates → exports current state → preview loads + // the exported DOCX → applies the diff. This exercises the full converter + // round-trip. Stripping sdBlockId / sdBlockRev from the fingerprint (this + // fix) is necessary but not sufficient: a second canonical-state divergence + // is introduced during export → re-import that still trips the fingerprint + // check. Tracked separately as SD-3282; the cross-editor same-blob path + // above is fixed by this change. + it('exposes the known export → reimport fingerprint divergence (regression marker)', async () => { + const editorMain = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document. Renewal requires written approval.'); + + let editorPreview: Editor | undefined; + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(editorMain, snapshot); + + const exported = await editorMain.exportDocument(); + editorPreview = await reopenExportedDocument(exported); + + // Expected to throw today; flip this assertion to a success expectation + // once the export/reimport canonical-state divergence is resolved. + expect(() => applyDiffPayload(editorPreview!, diff, { changeMode: 'tracked' })).toThrowError( + /fingerprint mismatch/i, + ); + } finally { + editorPreview?.destroy?.(); + editorMain.destroy?.(); + targetEditor.destroy?.(); + } + }); +}); + +// SD-3279 backward compatibility: snapshots and diff payloads captured under +// the pre-fix algorithm carry a fingerprint that included session-local +// sdBlockId / sdBlockRev values. The fix changes the canonical state without +// bumping the snapshot/payload version, which would invalidate already +// persisted artifacts in the customer's revision-history workflow. The +// validation paths in compareToSnapshot and applyDiffPayload accept either +// the new or the legacy fingerprint, so old artifacts remain usable. +describe('diff-service legacy fingerprint fallback (SD-3279 backward compat)', () => { + function legacyFingerprintForEditor(editor: Editor, snapshot: ReturnType): string { + const payload = snapshot.payload as { + doc: Record; + comments: CommentInput[]; + styles: Record | null; + numbering: Record | null; + headerFooters: Record | null; + partsState: Record | null; + }; + const parsedDoc = editor.state.schema.nodeFromJSON(payload.doc); + const legacyCanonical = buildLegacyCanonicalDiffableState( + parsedDoc, + payload.comments, + payload.styles as any, + payload.numbering as any, + payload.headerFooters as any, + payload.partsState as any, + ); + return computeFingerprint(legacyCanonical); + } + + it('accepts a snapshot whose fingerprint was computed under the legacy normalizer', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document. Renewal.'); + + try { + const snapshot = captureSnapshot(targetEditor); + const legacyFingerprint = legacyFingerprintForEditor(targetEditor, snapshot); + + // Confirm the algorithms produce different fingerprints — otherwise the + // test isn't exercising the fallback. + expect(legacyFingerprint).not.toBe(snapshot.fingerprint); + + const legacySnapshot = { ...snapshot, fingerprint: legacyFingerprint }; + const diff = compareToSnapshot(baseEditor, legacySnapshot); + expect(diff.summary.hasChanges).toBe(true); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('accepts a same-editor diff payload whose baseFingerprint was computed under the legacy normalizer', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document. Renewal.'); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + + // Synthesize a legacy-style payload by computing the legacy fingerprint + // of the current base editor and swapping it in for baseFingerprint. + const baseSnapshot = captureSnapshot(baseEditor); + const legacyBaseFingerprint = legacyFingerprintForEditor(baseEditor, baseSnapshot); + expect(legacyBaseFingerprint).not.toBe(diff.baseFingerprint); + + const legacyDiff = { ...diff, baseFingerprint: legacyBaseFingerprint }; + + const { tr } = applyDiffPayload(baseEditor, legacyDiff, { changeMode: 'tracked' }); + baseEditor.dispatch(tr); + + expect(baseEditor.state.doc.textContent).toBe(targetEditor.state.doc.textContent); + expect(getTrackChanges(baseEditor.state).length).toBeGreaterThan(0); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('still rejects a genuinely tampered snapshot (both new and legacy fingerprints mismatch)', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document. Renewal.'); + + try { + const snapshot = captureSnapshot(targetEditor); + // Keep the original fingerprint but replace the body payload with + // unrelated content. Neither the new nor the legacy normalizer will + // produce a hash matching the kept fingerprint. + const tamperedPayload = { + ...(snapshot.payload as Record), + doc: { + type: 'doc', + content: [{ type: 'paragraph', attrs: {}, content: [{ type: 'text', text: 'Unrelated' }] }], + }, + }; + const tamperedSnapshot = { ...snapshot, payload: tamperedPayload }; + + expect(() => compareToSnapshot(baseEditor, tamperedSnapshot)).toThrowError(/may have been tampered/i); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.ts index f46b2efd48..73a3426f7f 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/service/diff-service.ts @@ -19,7 +19,7 @@ import { captureHeaderFooterState } from '../algorithm/header-footer-diffing'; import type { DiffResult } from '../computeDiff'; import { computeDiff } from '../computeDiff'; import { replayDiffs, type ReplayDiffsResult } from '../replayDiffs'; -import { buildCanonicalDiffableState } from './canonicalize'; +import { buildCanonicalDiffableState, buildLegacyCanonicalDiffableState } from './canonicalize'; import { computeFingerprint } from './fingerprint'; import { buildDiffSummary } from './summary'; import { V1_COVERAGE, V2_COVERAGE, coverageEquals } from './coverage'; @@ -136,6 +136,30 @@ function buildCanonicalStateForCoverage( ); } +/** + * Builds the canonical state under the pre-SD-3279 normalization. Used only + * by the validation fallback when a stored fingerprint was computed under + * the old algorithm. See {@link buildLegacyCanonicalDiffableState}. + */ +function buildLegacyCanonicalStateForCoverage( + doc: PMNode, + comments: CommentInput[], + styles: StylesDocumentProperties | null, + numbering: NumberingProperties | null, + headerFooters: HeaderFooterState | null, + partsState: PartsState | null, + coverage: DiffCoverage, +) { + return buildLegacyCanonicalDiffableState( + doc, + comments, + styles, + numbering, + coverage.headerFooters ? headerFooters : null, + coverage.headerFooters ? partsState : null, + ); +} + // --------------------------------------------------------------------------- // Capture // --------------------------------------------------------------------------- @@ -218,6 +242,12 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif // Wrap in try-catch so malformed nested data (e.g. comment body nodes that // pass structural validation but fail during canonicalization) surfaces as // INVALID_INPUT rather than a raw TypeError. + // + // SD-3279: snapshots captured under the pre-fix algorithm stored a + // fingerprint that included session-local `sdBlockId` values. If the + // current normalizer's fingerprint mismatches, retry with the legacy + // normalizer so old persisted snapshots remain accepted. Genuinely tampered + // snapshots fail both checks. let reDerivedFingerprint: string; try { const targetCanonical = buildCanonicalStateForCoverage( @@ -238,10 +268,22 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif ); } if (reDerivedFingerprint !== targetSnapshot.fingerprint) { - throw new DiffServiceError( - 'INVALID_INPUT', - `Target snapshot fingerprint does not match re-derived value. The snapshot may have been tampered with.`, + const legacyCanonical = buildLegacyCanonicalStateForCoverage( + targetDoc, + targetComments, + targetStyles, + targetNumbering, + targetHeaderFooters, + targetSnapshot.version === SNAPSHOT_VERSION_V2 ? targetPartsState : null, + targetCoverage, ); + const legacyFingerprint = computeFingerprint(legacyCanonical); + if (legacyFingerprint !== targetSnapshot.fingerprint) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Target snapshot fingerprint does not match re-derived value. The snapshot may have been tampered with.`, + ); + } } // Compute base fingerprint const baseDoc = editor.state.doc; @@ -360,12 +402,30 @@ export function applyDiffPayload( ); const currentFingerprint = computeFingerprint(baseCanonical); + // SD-3279: diffs produced under the pre-fix algorithm carry a baseFingerprint + // that included session-local sdBlockId values. If the current normalizer's + // fingerprint mismatches, retry with the legacy normalizer so an old payload + // can still be applied against the editor that produced it. Note: this only + // recovers same-editor reapplies — old payloads applied to a freshly + // reloaded editor were already broken by per-session sdBlockId divergence. if (currentFingerprint !== diffPayload.baseFingerprint) { - throw new DiffServiceError( - 'PRECONDITION_FAILED', - `Document fingerprint mismatch. Expected "${diffPayload.baseFingerprint}", got "${currentFingerprint}". ` + - `The document may have changed since the diff was computed. Re-run diff.compare against the current state.`, + const legacyBaseCanonical = buildLegacyCanonicalStateForCoverage( + baseDoc, + baseComments, + baseStyles, + baseNumbering, + baseHeaderFooters, + diffPayload.version === PAYLOAD_VERSION_V2 ? basePartsState : null, + diffPayload.coverage, ); + const legacyFingerprint = computeFingerprint(legacyBaseCanonical); + if (legacyFingerprint !== diffPayload.baseFingerprint) { + throw new DiffServiceError( + 'PRECONDITION_FAILED', + `Document fingerprint mismatch. Expected "${diffPayload.baseFingerprint}", got "${currentFingerprint}". ` + + `The document may have changed since the diff was computed. Re-run diff.compare against the current state.`, + ); + } } // Reconstruct internal DiffResult from opaque payload with structural validation const rawDiff = parseDiffPayloadContents(diffPayload.payload); diff --git a/packages/super-editor/src/editors/v1/extensions/diffing/service/fingerprint.test.ts b/packages/super-editor/src/editors/v1/extensions/diffing/service/fingerprint.test.ts index 9c72a3c252..04986208f8 100644 --- a/packages/super-editor/src/editors/v1/extensions/diffing/service/fingerprint.test.ts +++ b/packages/super-editor/src/editors/v1/extensions/diffing/service/fingerprint.test.ts @@ -61,3 +61,67 @@ describe('computeFingerprint', () => { expect(computeFingerprint(baseState)).not.toBe(computeFingerprint(changedState)); }); }); + +describe('buildCanonicalDiffableState fingerprint stability', () => { + // SD-3279: two SuperDoc editor instances loaded from the same DOCX assign + // different session-local sdBlockId UUIDs. Including those in the body + // fingerprint makes diff.apply across instances throw PRECONDITION_FAILED. + // The fingerprint must be stable against sdBlockId / sdBlockRev divergence. + it('produces the same fingerprint for body trees that differ only in identity attrs (sdBlockId / sdBlockRev)', async () => { + const { buildCanonicalDiffableState } = await import('./canonicalize'); + const { Schema } = await import('prosemirror-model'); + + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'text*', + attrs: { sdBlockId: { default: null }, sdBlockRev: { default: null }, align: { default: 'left' } }, + }, + text: { group: 'inline' }, + }, + }); + + const makeDoc = (uuid: string, rev: number) => + schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create({ sdBlockId: uuid, sdBlockRev: rev, align: 'left' }, schema.text('Hello')), + ]); + + const stateA = buildCanonicalDiffableState(makeDoc('uuid-A', 1), [], null, null, null, null); + const stateB = buildCanonicalDiffableState(makeDoc('uuid-B', 99), [], null, null, null, null); + + expect(computeFingerprint(stateA)).toBe(computeFingerprint(stateB)); + }); + + // SD-3279 backward compatibility: the new and legacy normalizers must + // produce different fingerprints for a doc with sdBlockId on the body. + // The validation fallback relies on this to detect "this snapshot was + // captured under the old algorithm" — if the two were identical, the + // fallback path would be dead code. + it('legacy normalizer produces a different fingerprint than the current normalizer when sdBlockId is present', async () => { + const { buildCanonicalDiffableState, buildLegacyCanonicalDiffableState } = await import('./canonicalize'); + const { Schema } = await import('prosemirror-model'); + + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'text*', + attrs: { sdBlockId: { default: null }, align: { default: 'left' } }, + }, + text: { group: 'inline' }, + }, + }); + + const doc = schema.nodes.doc.create(null, [ + schema.nodes.paragraph.create({ sdBlockId: 'session-uuid', align: 'left' }, schema.text('Hello')), + ]); + + const current = computeFingerprint(buildCanonicalDiffableState(doc, [], null, null, null, null)); + const legacy = computeFingerprint(buildLegacyCanonicalDiffableState(doc, [], null, null, null, null)); + + expect(current).not.toBe(legacy); + }); +}); diff --git a/tests/doc-api-stories/tests/diff/cross-editor-handoff-roundtrip.ts b/tests/doc-api-stories/tests/diff/cross-editor-handoff-roundtrip.ts new file mode 100644 index 0000000000..e93911a08a --- /dev/null +++ b/tests/doc-api-stories/tests/diff/cross-editor-handoff-roundtrip.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const TEST_USER = { name: 'Review Bot', email: 'bot@example.com' }; + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function unwrapNamed(payload: unknown, key?: string): T { + if (key && payload && typeof payload === 'object' && key in payload) { + return (payload as Record)[key] as T; + } + return unwrap(payload); +} + +// SD-3279: the customer's preview-pane pattern (IT-1116). A main editor session +// produces the diff; a *separately-opened* preview session — holding the same +// base document — applies it as tracked changes. Before the fix, the two +// sessions' canonical fingerprints diverged because `sdBlockId` is assigned +// per session, so `diff.apply` threw `PRECONDITION_FAILED`. This story locks +// in the cross-session handoff at the public Document API surface. +describe('document-api story: cross-editor diff handoff (SD-3279)', () => { + const { client } = useStoryHarness('diff/cross-editor-handoff-roundtrip', { + preserveResults: false, + clientOptions: { + user: TEST_USER, + }, + }); + + async function listTrackedChanges(sessionId: string, type?: 'insert' | 'delete' | 'format') { + return unwrap(await client.doc.trackChanges.list(type ? { sessionId, type } : { sessionId })); + } + + it('applies a diff produced in one session to a separate session loaded with the same base content', async () => { + const baseSessionId = sid('cross-base'); + const previewSessionId = sid('cross-preview'); + const targetSessionId = sid('cross-target'); + + const baseText = 'Section 1. Payment is due within thirty days.'; + const targetParagraph = 'Renewal requires written approval.'; + + // Base and preview hold identical content. Target represents the desired + // post-apply state — used only to produce the snapshot the diff is taken + // against, then closed. + await client.doc.open({ + sessionId: baseSessionId, + contentOverride: baseText, + overrideType: 'text', + }); + await client.doc.open({ + sessionId: previewSessionId, + contentOverride: baseText, + overrideType: 'text', + }); + await client.doc.open({ + sessionId: targetSessionId, + contentOverride: `${baseText}\n${targetParagraph}`, + overrideType: 'text', + }); + + const targetSnapshot = unwrapNamed(await client.doc.diff.capture({ sessionId: targetSessionId }), 'snapshot'); + expect(targetSnapshot.engine).toBe('super-editor'); + await client.doc.close({ sessionId: targetSessionId, discard: true }); + + const diff = unwrapNamed( + await client.doc.diff.compare({ + sessionId: baseSessionId, + targetSnapshot, + }), + 'diff', + ); + expect(diff.summary.hasChanges).toBe(true); + expect(diff.summary.body.hasChanges).toBe(true); + + // Apply to PREVIEW, not BASE. This is the cross-editor handoff that was + // broken before SD-3279. + const applyResult = unwrapNamed( + await client.doc.diff.apply({ + sessionId: previewSessionId, + diff, + changeMode: 'tracked', + }), + 'result', + ); + expect(applyResult.appliedOperations).toBeGreaterThan(0); + expect(applyResult.summary.hasChanges).toBe(true); + expect(applyResult.summary.body.hasChanges).toBe(true); + + const tracked = await listTrackedChanges(previewSessionId); + const insertions = await listTrackedChanges(previewSessionId, 'insert'); + expect(tracked.total).toBeGreaterThan(0); + expect(insertions.total).toBeGreaterThan(0); + + // Customer-visible result: the preview now contains the target content. + const previewText = await client.doc.getText({ sessionId: previewSessionId }); + expect(previewText).toContain(targetParagraph); + + await client.doc.close({ sessionId: baseSessionId, discard: true }); + await client.doc.close({ sessionId: previewSessionId, discard: true }); + }); +});