diff --git a/reconcile-js/src/index.test.ts b/reconcile-js/src/index.test.ts index 66d385b..e824724 100644 --- a/reconcile-js/src/index.test.ts +++ b/reconcile-js/src/index.test.ts @@ -1,5 +1,9 @@ import * as webApi from './index'; import * as rnApi from './index.rn'; +import * as webBindings from 'reconcile-text'; +import * as rnBindings from '../pkg-rn/reconcile_text.js'; +import { jest } from '@jest/globals'; +import type { TextWithOptionalCursors } from './core'; import { installWasmLeakDetector, checkForWasmLeaks } from './wasm-leak-detector'; import * as fs from 'fs'; import * as path from 'path'; @@ -23,8 +27,8 @@ afterEach(() => { // same `src/core.ts` wrapper and expose an identical public API, so the behavioural // suite below runs against both to guarantee they stay in lock-step. const backends = [ - { name: 'web/node (WebAssembly)', api: webApi }, - { name: 'React Native (wasm2js)', api: rnApi }, + { name: 'web/node (WebAssembly)', api: webApi, bindings: webBindings }, + { name: 'React Native (wasm2js)', api: rnApi, bindings: rnBindings }, ]; describe.each(backends)('reconcile [$name]', ({ api }) => { @@ -89,6 +93,204 @@ describe.each(backends)('reconcile [$name]', ({ api }) => { }); }); +describe.each(backends)('WASM cleanup after exceptions [$name]', ({ api, bindings }) => { + const mergeOperations = ['reconcile', 'reconcileWithHistory'] as const; + const operations = [...mergeOperations, 'diff'] as const; + const left = { + text: 'Hello world', + cursors: [{ id: 1, position: 2 }], + }; + + function failingString(error: Error): string { + return { + get length() { + throw error; + }, + [Symbol.iterator]() { + return 'Hello world'[Symbol.iterator](); + }, + } as unknown as string; + } + + it.each(mergeOperations)( + '%s frees the left input if reading the right input throws', + (operation) => { + const error = new Error('right input failed'); + const right = { + get text(): string { + throw error; + }, + cursors: [], + }; + + expect(() => api[operation]('Hello', left, right)).toThrow(error); + } + ); + + it.each(operations)( + '%s frees cursors when a later input cursor throws', + (operation) => { + const error = new Error('cursor input failed'); + const input = { + text: 'Hello world', + cursors: [ + { id: 2, position: 1 }, + { + get id(): number { + throw error; + }, + position: 2, + }, + ], + }; + const invoke = () => + operation === 'diff' + ? api.diff('Hello', input) + : api[operation]('Hello', left, input); + + expect(invoke).toThrow(error); + } + ); + + it.each(operations)( + '%s frees cursors if the text constructor throws before consuming them', + (operation) => { + const error = new Error('text input failed'); + const input: TextWithOptionalCursors = { + text: failingString(error), + cursors: [{ id: 2, position: 1 }], + }; + const invoke = () => + operation === 'diff' + ? api.diff('Hello', input) + : api[operation]('Hello', left, input); + + expect(invoke).toThrow(error); + } + ); + + it.each(operations)('%s frees inputs when the backend throws', (operation) => { + const error = new Error('backend failed'); + const original = failingString(error); + const invoke = () => + operation === 'diff' + ? api.diff(original, left) + : api[operation](original, left, 'Hi world'); + + expect(invoke).toThrow(error); + }); + + describe.each(operations)('%s cursor validation', (operation) => { + it.each([-1, 0.5, NaN, Infinity, 4])( + 'rejects position %s before the WASM constructor can trap', + (position) => { + const input = { + text: 'a😀b', + cursors: [ + { id: 2, position: 1 }, + { id: 3, position }, + ], + }; + const invoke = () => + operation === 'diff' + ? api.diff('Hello', input) + : api[operation]('Hello', left, input); + + expect(invoke).toThrow(RangeError); + expect(invoke).toThrow( + `Cursor position ${position} must be an integer between 0 and 3` + ); + } + ); + + it('accepts the start and Unicode scalar end positions', () => { + const input = { + text: 'a😀b', + cursors: [ + { id: 2, position: 0 }, + { id: 3, position: 3 }, + ], + }; + if (operation === 'diff') { + expect(api.undiff('Hello', api.diff('Hello', input))).toBe(input.text); + } else { + expect(api[operation]('Hello', 'Hello', input).cursors).toEqual(input.cursors); + } + }); + }); + + describe.each(mergeOperations)('%s result conversion', (operation) => { + it.each(['id', 'characterIndex'] as const)( + 'frees all result cursors if %s throws', + (method) => { + const error = new Error('cursor conversion failed'); + const spy = jest + .spyOn(bindings.CursorPosition.prototype, method) + .mockImplementation(() => { + throw error; + }); + try { + expect(() => + api[operation]('Hello', left, { + text: 'Hi world', + cursors: [{ id: 2, position: 1 }], + }) + ).toThrow(error); + } finally { + spy.mockRestore(); + } + } + ); + + it.each(['text', 'cursors'] as const)('frees the result if %s throws', (method) => { + const error = new Error('result conversion failed'); + const prototype = + operation === 'reconcile' + ? bindings.TextWithCursors.prototype + : bindings.TextWithCursorsAndHistory.prototype; + const spy = jest.spyOn(prototype, method).mockImplementation(() => { + throw error; + }); + try { + expect(() => api[operation]('Hello', left, 'Hi world')).toThrow(error); + } finally { + spy.mockRestore(); + } + }); + }); + + it.each(['text', 'history'] as const)( + 'frees all history spans if %s throws', + (method) => { + const error = new Error('history conversion failed'); + const spy = jest + .spyOn(bindings.SpanWithHistory.prototype, method) + .mockImplementation(() => { + throw error; + }); + try { + expect(() => api.reconcileWithHistory('Hello', left, 'Hi world')).toThrow(error); + } finally { + spy.mockRestore(); + } + } + ); + + it('frees the result if reading history throws', () => { + const error = new Error('reading history failed'); + const spy = jest + .spyOn(bindings.TextWithCursorsAndHistory.prototype, 'history') + .mockImplementation(() => { + throw error; + }); + try { + expect(() => api.reconcileWithHistory('Hello', left, 'Hi world')).toThrow(error); + } finally { + spy.mockRestore(); + } + }); +}); + describe.each(backends)('diff and undiff are inverse [$name]', ({ api }) => { const { diff, undiff } = api; diff --git a/reconcile-python/tests/test_reconcile.py b/reconcile-python/tests/test_reconcile.py index 196e808..2e7df24 100644 --- a/reconcile-python/tests/test_reconcile.py +++ b/reconcile-python/tests/test_reconcile.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from reconcile_text import diff, reconcile, reconcile_with_history, undiff +from reconcile_text import TextWithCursors, diff, reconcile, reconcile_with_history, undiff EXAMPLES_DIR = Path(__file__).resolve().parent.parent.parent / "examples" RESOURCES_DIR = Path(__file__).resolve().parent.parent.parent / "tests" / "resources" @@ -14,6 +14,41 @@ RESOURCES_DIR = Path(__file__).resolve().parent.parent.parent / "tests" / "resou FILES = ["pride_and_prejudice.txt", "room_with_a_view.txt", "blns.txt"] +class TestCursorPositions: + @pytest.mark.parametrize("text", ["", "a", "é", "😀", "e\u0301"]) + def test_out_of_bounds(self, text: str) -> None: + position = len(text) + 1 + invalid: TextWithCursors = { + "text": text, + "cursors": [{"id": 1, "position": position}], + } + message = f"Cursor position {position} exceeds text length {len(text)}" + + for merge in (reconcile, reconcile_with_history): + with pytest.raises(ValueError, match=message): + merge(text, invalid, text) + with pytest.raises(ValueError, match=message): + merge(text, text, invalid) + + with pytest.raises(ValueError, match=message): + diff(text, invalid) + + @pytest.mark.parametrize("text", ["", "a", "é", "😀", "e\u0301"]) + def test_boundary_positions(self, text: str) -> None: + valid: TextWithCursors = { + "text": text, + "cursors": [{"id": 1, "position": 0}, {"id": 2, "position": len(text)}], + } + + for merge in (reconcile, reconcile_with_history): + for left, right in ((valid, text), (text, valid)): + result = merge(text, left, right) + assert result["text"] == text + assert result["cursors"] == valid["cursors"] + + assert diff(text, valid) == diff(text, text) + + class TestReconcile: def test_basic_merge(self) -> None: result = reconcile("Hello", "Hello world", "Hi world") @@ -122,6 +157,34 @@ class TestUndiff: class TestExamples: + @pytest.mark.parametrize( + "content", + [ + b"line1\r\nline2\r\n", + b"line1\nline2\n", + b"line1\rline2\r", + b"line1\r\nline2\nline3\rline4", + ], + ids=["crlf", "lf", "cr", "mixed-no-final-newline"], + ) + @pytest.mark.parametrize("write_output", [False, True], ids=["stdout", "file"]) + def test_merge_file_preserves_line_endings( + self, tmp_path: Path, content: bytes, write_output: bool + ) -> None: + inputs = [tmp_path / name for name in ("mine.txt", "base.txt", "theirs.txt")] + for path in inputs: + path.write_bytes(content) + + args = [sys.executable, str(EXAMPLES_DIR / "merge_file.py"), *map(str, inputs)] + output = tmp_path / "output.txt" + if write_output: + args.append(str(output)) + + result = subprocess.run(args, capture_output=True, check=True) + + actual = output.read_bytes() if write_output else result.stdout + assert actual == content + def test_merge_file_stdout(self, tmp_path: Path) -> None: (tmp_path / "base.txt").write_text("Hello world") (tmp_path / "mine.txt").write_text("Hello beautiful world")