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'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); installWasmLeakDetector(); afterEach(() => { const leaks = checkForWasmLeaks(); if (leaks.length > 0) { throw new Error( `WASM memory leak: ${leaks.length} object(s) not freed:\n ${leaks.join('\n ')}` ); } }); // `./index` is the web/node build (real WebAssembly); `./index.rn` is the React // Native build (the wasm2js pure-JS translation). Both are thin backends over the // 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, bindings: webBindings }, { name: 'React Native (wasm2js)', api: rnApi, bindings: rnBindings }, ]; describe.each(backends)('reconcile [$name]', ({ api }) => { const { reconcile, reconcileWithHistory, diff, undiff } = api; it('call reconcile without cursors', () => { expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world'); }); it('call reconcile with cursors', () => { const result = reconcile( 'Hello', { text: 'Hello world', cursors: [ { id: 3, position: 2, }, ], }, { text: 'Hi world', cursors: [ { id: 4, position: 0, }, { id: 5, position: 3 }, ], } ); expect(result.text).toEqual('Hi world'); expect(result.cursors).toEqual([ { id: 3, position: 0 }, { id: 4, position: 0 }, { id: 5, position: 3 }, ]); }); it('call reconcileWithHistory', () => { const result = reconcileWithHistory('Hello', 'Hello world', 'Hi world'); expect(result.text).toEqual('Hi world'); expect(result.history.length).toBeGreaterThan(0); }); it('undiff accepts bigint entries (per the Array type)', () => { const original = 'Hello world'; const changed = 'Hello cruel world'; // `diff` returns plain numbers; emulate a caller that supplies BigInt, which the // public signature permits. The wasm2js build rejects raw BigInt, so the shared // wrapper must normalise it — running this on both backends asserts the contract. const withBigints = diff(original, changed).map((item) => typeof item === 'number' ? BigInt(item) : item ); expect(withBigints.some((item) => typeof item === 'bigint')).toBe(true); expect(undiff(original, withBigints)).toEqual(changed); }); }); 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, 0.5, 2 ** 32, 2 ** 32 + 1, Number.MAX_SAFE_INTEGER, NaN, Infinity, -Infinity, ])('rejects ID %s before it can be truncated', (id) => { const input = { text: 'Hello world', cursors: [ { id: 0, position: 1 }, { id, position: 2 }, ], }; const invoke = () => operation === 'diff' ? api.diff('Hello', input) : api[operation]('Hello', left, input); expect(invoke).toThrow(RangeError); expect(invoke).toThrow(`Cursor ID ${id} must be an integer between 0 and 2^32-1`); }); it('accepts unsigned 32-bit IDs without changing them', () => { const input = { text: 'Hello world', cursors: [ { id: 0, position: 0 }, { id: 2 ** 31, position: 1 }, { id: 2 ** 32 - 1, position: 2 }, ], }; 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); expect(api[operation]('Hello', input, 'Hello').cursors).toEqual(input.cursors); } }); 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); } }); }); it.each(mergeOperations)('%s rejects an invalid ID in the left input', (operation) => { expect(() => api[operation]( 'Hello', { text: 'Hello world', cursors: [{ id: 2 ** 32, position: 0 }] }, 'Hello' ) ).toThrow(RangeError); }); 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; const resourcesPath = path.join(__dirname, '../../tests/resources'); const readFileSlice = (fileName: string, start: number, end: number): string => { const filePath = path.join(resourcesPath, fileName); const content = fs.readFileSync(filePath, 'utf-8'); const chars = Array.from(content); // Handle unicode properly return chars.slice(start, Math.min(end, chars.length)).join(''); }; const files = ['pride_and_prejudice.txt', 'room_with_a_view.txt', 'blns.txt']; const ranges = [{ start: 0, end: 50000 }]; files.forEach((file1) => { files.forEach((file2) => { ranges.forEach((range1) => { ranges.forEach((range2) => { it(`should diff & undiff ${file1}[${range1.start}..${range1.end}], ${file2}[${range2.start}..${range2.end}] without panic`, () => { const content1 = readFileSlice(file1, range1.start, range1.end); const content2 = readFileSlice(file2, range2.start, range2.end); const changes = diff(content1, content2); const actual = undiff(content1, changes); expect(actual).toEqual(content2); }); }); }); }); }); }); // React-Native-only: Hermes exposes no `WebAssembly` global, which is the whole reason // the RN entry point links a wasm2js build. Only the wasm2js backend can satisfy this. describe('React Native (wasm2js) Hermes parity', () => { const { reconcile, reconcileWithHistory, diff, undiff } = rnApi; it('runs every operation with no WebAssembly global', () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebAssembly'); delete (globalThis as { WebAssembly?: unknown }).WebAssembly; try { expect((globalThis as { WebAssembly?: unknown }).WebAssembly).toBeUndefined(); expect(reconcile('Hello', 'Hello world', 'Hi world').text).toEqual('Hi world'); const changes = diff('Hello world', 'Hello cruel world'); expect(undiff('Hello world', changes)).toEqual('Hello cruel world'); expect( reconcileWithHistory('Hello', 'Hello world', 'Hi world').history.length ).toBeGreaterThan(0); } finally { // Restore the global so the leak check and later suites are unaffected. if (descriptor) { Object.defineProperty(globalThis, 'WebAssembly', descriptor); } } }); });