import { describe, expect, it } from 'vitest'; import { applyTextEdits } from '../packages/docmost-client/src/lib/json-edit.js'; // Helper: build a ProseMirror text node. function text(value: string, extra: Record = {}) { return { type: 'text', text: value, ...extra }; } // Helper: a simple doc with a single paragraph containing one text node. function singleParagraph(value: string) { return { type: 'doc', content: [{ type: 'paragraph', content: [text(value)] }], }; } describe('applyTextEdits', () => { describe('single match', () => { it('replaces an exact single match and reports one replacement', () => { const doc = singleParagraph('hello world'); const { doc: result, results } = applyTextEdits(doc, [ { find: 'world', replace: 'there' }, ]); expect(result.content[0].content[0].text).toBe('hello there'); expect(results).toEqual([{ find: 'world', replacements: 1 }]); }); it('preserves node id/marks around the edited text', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', attrs: { id: 'p1' }, content: [ text('old value', { marks: [{ type: 'bold' }], custom: 42 }), ], }, ], }; const { doc: result } = applyTextEdits(doc, [ { find: 'old', replace: 'new' }, ]); const node = result.content[0].content[0]; expect(node.text).toBe('new value'); expect(node.marks).toEqual([{ type: 'bold' }]); expect(node.custom).toBe(42); expect(result.content[0].attrs).toEqual({ id: 'p1' }); }); }); describe('replaceAll', () => { it('replaces every occurrence inside a single node when replaceAll is set', () => { const doc = singleParagraph('a a a'); const { doc: result, results } = applyTextEdits(doc, [ { find: 'a', replace: 'b', replaceAll: true }, ]); expect(result.content[0].content[0].text).toBe('b b b'); expect(results).toEqual([{ find: 'a', replacements: 3 }]); }); it('replaces occurrences across multiple separate text nodes', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [text('foo and foo')] }, { type: 'paragraph', content: [text('also foo')] }, ], }; const { doc: result, results } = applyTextEdits(doc, [ { find: 'foo', replace: 'bar', replaceAll: true }, ]); expect(result.content[0].content[0].text).toBe('bar and bar'); expect(result.content[1].content[0].text).toBe('also bar'); // 2 + 1 occurrences counted. expect(results).toEqual([{ find: 'foo', replacements: 3 }]); }); }); describe('multi-match without replaceAll', () => { it('throws reporting the match count', () => { const doc = singleParagraph('x x x'); expect(() => applyTextEdits(doc, [{ find: 'x', replace: 'y' }])).toThrow( /matches 3 times/, ); }); it('counts matches across nodes when deciding to throw', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [text('dup')] }, { type: 'paragraph', content: [text('dup')] }, ], }; expect(() => applyTextEdits(doc, [{ find: 'dup', replace: 'z' }])).toThrow( /matches 2 times/, ); }); }); describe('zero match', () => { it('throws "text not found" when text is absent entirely', () => { const doc = singleParagraph('hello world'); expect(() => applyTextEdits(doc, [{ find: 'absent', replace: 'x' }]), ).toThrow(/text not found in the document/); }); }); describe('text split across formatting runs', () => { it('throws a distinct "spans multiple formatting runs" error', () => { // "hello world" exists in the concatenated text but no single text node // contains it: it is split across a bold run and a plain run. const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ text('hello ', { marks: [{ type: 'bold' }] }), text('world'), ], }, ], }; expect(() => applyTextEdits(doc, [{ find: 'hello world', replace: 'x' }]), ).toThrow(/spans multiple formatting runs/); }); it('does not raise the spans error when text is genuinely missing', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [text('hello ', { marks: [{ type: 'bold' }] }), text('world')], }, ], }; // "nope" is in neither the runs nor the concatenated text. expect(() => applyTextEdits(doc, [{ find: 'nope', replace: 'x' }]), ).toThrow(/text not found in the document/); }); }); describe('empty find', () => { it('throws on an empty find string', () => { const doc = singleParagraph('hello'); expect(() => applyTextEdits(doc, [{ find: '', replace: 'x' }])).toThrow( /edit\.find must be a non-empty string/, ); }); }); describe('literal replacement (regex foot-gun)', () => { it('inserts $& literally without regex expansion', () => { const doc = singleParagraph('price NUM here'); const { doc: result } = applyTextEdits(doc, [ { find: 'NUM', replace: '$&100' }, ]); // If String.replace were used, "$&" would expand to the matched "NUM". expect(result.content[0].content[0].text).toBe('price $&100 here'); }); it('inserts $1 literally without regex expansion', () => { const doc = singleParagraph('token TKN end'); const { doc: result } = applyTextEdits(doc, [ { find: 'TKN', replace: 'a$1b$$c' }, ]); expect(result.content[0].content[0].text).toBe('token a$1b$$c end'); }); it('inserts $& literally for replaceAll as well', () => { const doc = singleParagraph('K K'); const { doc: result } = applyTextEdits(doc, [ { find: 'K', replace: '[$&]', replaceAll: true }, ]); expect(result.content[0].content[0].text).toBe('[$&] [$&]'); }); }); describe('pruning emptied nodes', () => { it('removes a text node that becomes empty after the edit', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [text('REMOVE'), text(' keep')], }, ], }; const { doc: result } = applyTextEdits(doc, [ { find: 'REMOVE', replace: '' }, ]); // The emptied node is pruned, leaving only the surviving node. expect(result.content[0].content).toHaveLength(1); expect(result.content[0].content[0].text).toBe(' keep'); }); }); describe('immutability', () => { it('does not mutate the input document', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', attrs: { id: 'p1' }, content: [text('hello world', { marks: [{ type: 'bold' }] })], }, ], }; const snapshot = structuredClone(doc); applyTextEdits(doc, [{ find: 'world', replace: 'there' }]); expect(doc).toEqual(snapshot); }); }); describe('deeply nested recursion', () => { it('finds and replaces text several levels deep', () => { const doc = { type: 'doc', content: [ { type: 'bulletList', content: [ { type: 'listItem', content: [ { type: 'paragraph', content: [ { type: 'blockquote', content: [ { type: 'paragraph', content: [text('deep target value')], }, ], }, ], }, ], }, ], }, ], }; const { doc: result, results } = applyTextEdits(doc, [ { find: 'target', replace: 'goal' }, ]); const leaf = result.content[0].content[0].content[0].content[0].content[0] .content[0]; expect(leaf.text).toBe('deep goal value'); expect(results).toEqual([{ find: 'target', replacements: 1 }]); }); }); describe('multiple edits in sequence', () => { it('applies each edit and returns a result per edit in order', () => { const doc = singleParagraph('alpha beta gamma'); const { doc: result, results } = applyTextEdits(doc, [ { find: 'alpha', replace: 'A' }, { find: 'gamma', replace: 'G' }, ]); expect(result.content[0].content[0].text).toBe('A beta G'); expect(results).toEqual([ { find: 'alpha', replacements: 1 }, { find: 'gamma', replacements: 1 }, ]); }); }); });