diff --git a/.changeset/loro-js-differential-suite.md b/.changeset/loro-js-differential-suite.md new file mode 100644 index 000000000..4aa735d9e --- /dev/null +++ b/.changeset/loro-js-differential-suite.md @@ -0,0 +1,4 @@ +--- +--- + +Add a MovableList differential test suite (`loro-js/tests/differential/`) that compares loro.js with the Rust implementation. It runs in `pnpm test` when the Rust WASM build is present. Tests only; the published package does not change. diff --git a/AGENTS.md b/AGENTS.md index 5beac63ad..708a805f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,9 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec. position rules: [context/loro-js-richtext-anchors.md](context/loro-js-richtext-anchors.md). - loro.js multi-peer differential fuzzing against replay, checkout, event, and Rust/WASM oracles: [context/loro-js-differential-fuzz.md](context/loro-js-differential-fuzz.md). +- loro.js MovableList model, the MovableList Rust differential suite + (`loro-js/tests/differential/`), and known loro.js/Rust divergences: + [context/loro-js-movable-list.md](context/loro-js-movable-list.md). - WASM panic/OOM reporting channels, `__wbindgen_start` glue invariant, and trap-testing recipes: [context/wasm-error-reporting.md](context/wasm-error-reporting.md). diff --git a/context/loro-js-movable-list.md b/context/loro-js-movable-list.md new file mode 100644 index 000000000..3ea78a33b --- /dev/null +++ b/context/loro-js-movable-list.md @@ -0,0 +1,257 @@ +# loro.js MovableList: Rust-Compatible Model + +Verified against code 2026-09-30. + +`loro-js/` must converge with the Rust implementation and read and write its +encoding. This article states the Rust MovableList semantics that loro.js must +reproduce, the loro.js model that implements them, and how the differential +suite checks the two against each other. Complexity rules still come from +[loro-js-performance.md](loro-js-performance.md). + +Status: the differential suite is in the tree. The "loro.js model" section is +the design of the follow-up change; until it lands, `LoroMovableList` still +uses the old model described under "Why the old loro.js model diverged", and +the suite records those profiles in `KNOWN_DIVERGENT`. The insert/delete-only +profile converges on `main` since the Fugue fixes of loro-dev/loro#1136 and +#1131, so it is not in that set. + +## Rust semantics (the contract) + +A MovableList is two CRDTs glued together +([`movable_list_state.rs`](../crates/loro-internal/src/state/movable_list_state.rs)): + +- **List items (positions)** form a Fugue sequence. Every `Insert` op creates + one position per value (ID = op ID + offset). Every `Move` op creates one new + position (ID = the move op ID). Positions are deleted by `Delete` ops and by + the source side of a `Move`. The diff calculator's tracker treats a move as + "delete the item at `from`, then insert at `to` in the view after that + delete" (`Tracker::move_item` in + [`tracker.rs`](../crates/loro-internal/src/container/richtext/tracker.rs)), + and it deletes **by index in the op's causal view**, not by ID + (`CrdtRope::delete`). +- **Elements** are keyed by `IdLp` of their `Insert`. Each element has + - `pos`: the winning position, the LWW maximum by `IdLp` = `(lamport, peer)` + over its insert position and every move position that targets it; + - `value`/`value_id`: the LWW maximum over the insert value and every `Set`. +- A position is **pointed** when its element's winning `pos` is that position. + User-visible items are exactly the positions that are alive in the tracker + and pointed. An alive, unpointed position is a *dead* list item: it still + counts in op indices (`IndexType::ForOp`) but not in user indices + (`IndexType::ForUser`). +- An element is visible iff its winning position is alive. Deleting that + position hides the element. A concurrent move with a greater `IdLp` creates a + new alive position and revives it. This is why concurrent delete + move gives + `["a","c","B"]` in `concurrent_delete_and_move_resurrects_moved_element`. +- Checkout uses the same rule per version + (`MovableListHistoryCache::last_pos` / `last_value` in + [`history_cache.rs`](../crates/loro-internal/src/history_cache.rs)). + On a shallow document, `record_shallow_root_state` seeds each root element's + position and value as a candidate for versions at or after the root. +- Local ops (`MovableListHandler` in + [`handler.rs`](../crates/loro-internal/src/handler.rs)) encode **op + indices**: `insert` at `convert_index(pos, ForUser, ForOp)` (the list end is + the op length, after trailing dead items); `move(from, to)` stores the op + indices of the element and of the current user item at `to`; `delete` + emits one `Delete(position id, op index, 1)` per element, adjusting the op + index for the elements already deleted by the same call. +- `getCreatorAt` = element ID peer, `getLastMoverAt` = winning position peer, + `getLastEditorAt` = `value_id` peer. `getCursor` anchors the **position** ID. +- `apply_delta` for a MovableList + ([`movable_list_apply_delta.rs`](../crates/loro-internal/src/handler/movable_list_apply_delta.rs)) + is used by `revertTo`, `applyDiff` and undo. It inserts before it deletes. + A child container that the delta deletes and reinserts becomes a `move`. + Plain values are reinserted as new elements. +- Snapshot state (normative: [docs/encoding-container-states.md](../docs/encoding-container-states.md) + section 8) lists every alive position in order, dead ones included, plus + element IDs and last-set IDs when they differ from the position/element ID. + +Invalid `Move`/`Set` handling (Rust #1125, open when this was written) is in +[movable-list-op-validation.md](movable-list-op-validation.md) once merged; see +the "Validation" section below for the loro.js side. + +## Why the old loro.js model diverged + +loro.js used to store MovableList elements directly in the Fugue sequence and +physically moved them (`SequenceIndex.moveVisible`). It had no separate +positions, so it could not represent a move of a concurrently deleted element, +a delete that targets a position an element has already left, or a dead list +item. Concretely: + +1. concurrent delete + move lost the element (`["a","c"]` instead of + `["a","c","B"]`); +2. importing Rust's "move, then delete" left the element (`["b","a"]` instead + of `["b"]`), because the delete targeted the old position ID; +3. snapshots wrote every position ID as the element ID and every last-set ID + as the element ID, and ignored dead items on read; +4. `getLastEditorAt`/`getLastMoverAt` returned the creator; +5. checkout/diff/`revertTo` replayed or LIS-reordered elements instead of + selecting winners, and shallow retreat had no root-time fallback; +6. undo ignored `move`/`set`, and `applyDiff` treated a MovableList like a List. + +## loro.js model + +`loro-js/src/runtime/movable-list.ts` owns the state; `LoroMovableList` +(`containers.ts`) delegates to it. `LoroMovableList` still extends `LoroList` +for API compatibility. Its inherited element sequence is unused, and reading +`_sequence` on it throws, so a code path that forgets the MovableList branch +fails loudly. + +- `positions: SequenceIndex`: the Fugue sequence of positions. + `deleted` means "not alive in the tracker"; deletion records (`deletedBy`) + hold the delete or move op that removed it, which gives causal views and + version transitions for free. A position's `utf16`/`utf8` metrics are 1 when + it is pointed and 0 otherwise, so the user-visible count is the sequence's + visible metric and user ↔ op index conversion is O(log n) through the + existing metric queries. `SequenceIndex.refreshMetrics` recomputes one + element's node and its ancestors when pointedness changes. +- `elements: Map>` keyed by element `IdLp`. + Each element keeps its current winner position, value and value writer, plus + history arrays sorted by `IdLp` (positions it may point to, and value + candidates). Winner selection at a version scans candidates from the + greatest `IdLp` down until one is included, which is the same work as Rust's + `BTreeSet` range scan. +- Remote ops are applied in causal order: + - `Insert`: Fugue insertion at the op index in the op's causal view, then new + elements. + - `Delete`: deletes the target position IDs. Rust deletes by index; the two + agree for honest ops. + - `Move`: deletes the position at `from` in the causal view (faithful to Rust, + including a forged `from`), inserts the new position at `to` in the causal + view extended by the move itself, then runs LWW on the element's position. + - `Set`: LWW on the element's value. +- Local ops compute op indices exactly as `MovableListHandler` does, so encoded + ops are interchangeable with Rust's. + +### Versions, events, snapshots + +- Version transitions (checkout, `diff`, `revertTo`) toggle only the positions + created or deleted by the retreated/forwarded ops. They then recompute the + winners of the touched elements at the target version and refresh the + pointedness of their old and new positions. That costs + O((affected ops + affected candidates) · log n), with no replay and no LIS. +- Events come from the same primitive state changes. A position that becomes + visible inserts its value at its new user index. A visible position that + stops being visible deletes at its old user index. A value change on a + visible element is a delete plus an insert at the same index. Transitions + delete in descending before-index order, then insert in ascending after-index + order. +- Snapshot export follows section 8 exactly. Import hydrates alive positions + (visible and dead) and visible elements with their real position, element + and last-set IDs. Hydrated state has no tombstones, origins or candidate + history, so the container is marked incomplete. Version transitions on it + replay history once. An import that is not causally after the hydrated + version (it is concurrent with it) also replays history first: the causal + view of such an op can need tombstones the snapshot dropped. The same rule + fixes List and Text, which had the same gap. +- A shallow root store seeds root-time position and value candidates, the + counterpart of `record_shallow_root_state`, so retreat to the root keeps + root-time winners (the MovableList part of Rust #1124 / loro.js #1127). + +### Undo, `revertTo`, `applyDiff` + +`applyDiff` and `revertTo` port Rust's MovableList `apply_delta`: insert +first, defer deletes, and turn a reinserted child container into a `move`. +Undo of MovableList ops applies the container's inverse diff through the same +function, so undoing a `move`/`set` creates a new element with the old value, +as in Rust. loro.js undo still differs from Rust when remote changes +interleave with the undone change (Rust transforms stored diffs; loro.js +replays ops). That applies to List too and is listed under remaining gaps. + +## Validation and import atomicity + +Import validation follows Rust #1125. A `Move`/`Set` whose element is unknown, +lives in another container, is outside the op's causal history, or (on a +shallow doc) was deleted before the root is rejected with an error. So is a +`from`/`to` outside the op-index range of its causal view. A `Move`/`Set` of a +deleted element is applied with the concurrent-op semantics above. +`import`/`importBatch` stage the document state (history indexes, containers, +pending changes, version, subscribers' events, undo stacks) and restore it +when any blob fails, so a rejected import is invisible +([import-batch-atomicity.md](import-batch-atomicity.md) states the Rust +contract). + +## Differential suite + +`loro-js/tests/differential/` drives the Rust WASM build (`loro-crdt` Node +target) and loro.js with the same random multi-peer operations and requires +identical values, versions, frontiers, reachable container IDs, +`getCreatorAt`/`getLastEditorAt`/`getLastMoverAt`, and canonicalized events +after every step. Sync crosses engines, so each engine also decodes the +other's updates and snapshots. + +It uses the reference that the other loro.js differential suites share +(`loro-js/tests/support/rust-reference.ts`): `crates/loro-wasm/nodejs`, built by +`pnpm -C crates/loro-wasm build-dev`, or the build `LORO_WASM_NODEJS` points +at (for example an unmerged Rust branch). Without a build the suite skips; +`pnpm test-loro-js` sets `LORO_REQUIRE_WASM_REFERENCE=1` and CI runs it after +`release-wasm`, so there it always runs. + +```sh +pnpm -C crates/loro-wasm build-dev +cd loro-js +pnpm vitest run tests/differential/movable-list.test.ts # CI-sized seeds +LORO_JS_DIFF_SEEDS=500 LORO_JS_DIFF_STEPS=200 pnpm vitest run tests/differential/movable-list.test.ts +``` + +The suite folds away a few differences that are unrelated to the data model. +Each is named in `harness.ts` (`canonicalBatch`) and in the test file's profile +notes, so a fix can remove its normalization. + +Files: `engine.ts` (the shared API surface), `harness.ts` (twin peers, +canonical events, comparisons), `fuzz.ts` (action generation, transport, +replay and minimization), `movable-list.test.ts` (profiles). A divergence +saves its trace to the temp directory; replay it with +`LORO_JS_DIFF_REPLAY=` and add `LORO_JS_DIFF_MINIMIZE=1` to shrink it. + +## Remaining known divergences (not MovableList-specific) + +Found by the differential suite on 2026-09-28. The profiles avoid or normalize +them (see the comments in `harness.ts` and `fuzz.ts`): + +- Undo: loro.js replays inverse ops, while Rust applies stored inverse diffs + transformed by later changes. Results differ for List and MovableList when an + undo targets items that an earlier undo recreated, or when remote changes + interleave. A redo also restores deleted items in deletion order instead of + document order. `UndoManager.undo()` picks its item before committing the + pending transaction. Rust commits redo with origin `"undo"`. The profiles run + undo on one peer, never chained, after an explicit commit. +- `checkout(frontiers)` at the latest frontiers left loro.js detached; Rust + stays attached. Fixed by loro-dev/loro#1143; the profiles still skip such + checkouts. +- loro.js emits events and `diff()` entries for child containers whose parent + element is already deleted; Rust does not. Only reachable targets are compared. +- A snapshot-hydrated loro.js document has no tombstones or origins. It + mispositions List/Text/MovableList ops that are concurrent with deletes the + snapshot already contains (loro-dev/loro#1163). +- Some shallow-snapshot imports that Rust accepts are rejected by loro.js with + "cannot import updates that depend on an outdated version". +- `diff()` of a child container created in the range omits a map whose keys + were all set and deleted again in that range; Rust lists those keys as + deleted. It happens under List, Map and MovableList parents alike. The suite + skips such map entries when it compares `diff()`. +- Multi-blob `importBatch` events are labeled `by: "checkout"` by Rust and + `"import"` by loro.js; checkout events carry origin `"checkout"` only in + Rust. Rust's `oplogVersion()` also counts the pending transaction. + +Differences in form, not in effect, which the suite checks by value: + +- `revertTo` applies same-depth containers in Rust's `FxHashMap` order, and + its list ops follow the insert/delete order of `diff(current, target)` at one + index, which Rust's `DeltaRope` composition decides. When either differs, + the suite checks the revert by value and replicates Rust's ops. +- Event deltas can differ in shape, or be a no-op where Rust reports nothing; + the suite then checks that each engine's deltas turn the previous value into + the new one. +- Rust's `diff()` can list a sequence whose delta is empty (two peers + concurrently moving one element), where loro.js omits the container. The + suite ignores such entries. + +Rust issues the suite works around: + +- On a shallow document, `getLastEditorAt` can report the root list item's or + the last mover's peer instead of the setter's (`record_shallow_root_state` + in `history_cache.rs` seeds the value writer with the list item ID). The + suite does not compare editors on shallow documents. +- `updates-in-range` on a shallow document exports nothing for a span that + starts in trimmed history (`ChangeStore::iter_blocks`). The suite starts + such spans at the shallow root. diff --git a/context/loro-js-performance.md b/context/loro-js-performance.md index 830ba979e..474a4a631 100644 --- a/context/loro-js-performance.md +++ b/context/loro-js-performance.md @@ -538,4 +538,7 @@ The remaining differences are representation and JavaScript constant factors: Keep randomized index-invariant coverage in `loro-js/tests/indexes.test.ts` and Rust/TypeScript fixture coverage in `loro-js/tests/rust-interop.test.ts` when -changing these structures. +changing these structures. The randomized Rust differential suite +(`loro-js/tests/differential/`, see +[loro-js-movable-list.md](loro-js-movable-list.md)) checks convergence, events and +encoding interchange against a WASM build of the Rust implementation. diff --git a/loro-js/AGENTS.md b/loro-js/AGENTS.md index 3e46a5bb0..e66274c5b 100644 --- a/loro-js/AGENTS.md +++ b/loro-js/AGENTS.md @@ -12,6 +12,9 @@ the same binary and JSON data as Rust (`loro-crdt`) and give the same results. - Multi-peer fuzzing of all containers against replay, checkout, event, and shallow-snapshot oracles, optionally in lockstep with Rust: [context/loro-js-differential-fuzz.md](../context/loro-js-differential-fuzz.md). +- MovableList model (Rust's positions + LWW elements) and its multi-peer Rust + differential suite (`tests/differential/`): + [context/loro-js-movable-list.md](../context/loro-js-movable-list.md). - Rich text: style anchors live in the Text sequence and op positions count them. Read [context/loro-js-richtext-anchors.md](../context/loro-js-richtext-anchors.md) before changing Text positions, marks, or Text snapshot state. diff --git a/loro-js/tests/differential/engine.ts b/loro-js/tests/differential/engine.ts new file mode 100644 index 000000000..c842bec0e --- /dev/null +++ b/loro-js/tests/differential/engine.ts @@ -0,0 +1,115 @@ +/** + * The subset of the `loro-crdt` API that the differential tests drive. Both the + * Rust WASM build and loro.js implement it, so one driver runs against both. + */ + +export interface OpIdLike { + readonly peer: string; + readonly counter: number; +} + +export type FrontiersLike = OpIdLike[]; + +export interface VersionVectorLike { + toJSON(): Map; +} + +export interface ContainerLike { + readonly id: string; + kind(): string; + isAttached(): boolean; + getShallowValue(): unknown; + toJSON(): unknown; +} + +export interface SequenceLike extends ContainerLike { + readonly length: number; + insert(pos: number, value: unknown): void; + delete(pos: number, len: number): void; + insertContainer(pos: number, child: ContainerLike): ContainerLike; +} + +export interface MovableListLike extends SequenceLike { + move(from: number, to: number): void; + set(pos: number, value: unknown): void; + setContainer(pos: number, child: ContainerLike): ContainerLike; + getCreatorAt(pos: number): string | undefined; + getLastEditorAt(pos: number): string | undefined; + getLastMoverAt(pos: number): string | undefined; + getCursor(pos: number, side?: -1 | 0 | 1): CursorLike | undefined; +} + +export interface MapLike extends ContainerLike { + set(key: string, value: unknown): void; + delete(key: string): void; + setContainer(key: string, child: ContainerLike): ContainerLike; +} + +export interface TextLike extends ContainerLike { + readonly length: number; + insert(pos: number, text: string): void; + delete(pos: number, len: number): void; +} + +export interface CursorLike { + encode(): Uint8Array; +} + +export interface EventLike { + readonly target: string; + readonly path: readonly (string | number)[]; + readonly diff: unknown; +} + +export interface EventBatchLike { + readonly by: string; + readonly origin?: string | undefined; + readonly events: readonly EventLike[]; +} + +export interface DocLike { + setPeerId(peer: number | bigint | string): void; + getMovableList(name: string): MovableListLike; + getMap(name: string): MapLike; + getContainerById(id: string): ContainerLike | undefined; + commit(options?: { readonly origin?: string; readonly message?: string }): void; + getPendingTxnLength(): number; + export(mode: unknown): Uint8Array; + import(bytes: Uint8Array): unknown; + importBatch(bytes: Uint8Array[]): unknown; + toJSON(): unknown; + getShallowValue(): Record; + oplogVersion(): VersionVectorLike; + frontiers(): FrontiersLike; + oplogFrontiers(): FrontiersLike; + checkout(frontiers: FrontiersLike): void; + checkoutToLatest(): void; + isDetached(): boolean; + revertTo(frontiers: FrontiersLike): void; + diff(from: FrontiersLike, to: FrontiersLike, forJson: boolean): [string, unknown][]; + subscribe(listener: (event: EventBatchLike) => void): () => void; + isShallow(): boolean; + shallowSinceVV(): VersionVectorLike; + getCursorPos(cursor: CursorLike): { readonly offset: number }; + getPathToContainer(id: string): (string | number)[] | undefined; + forkAt(frontiers: FrontiersLike): DocLike; + fork(): DocLike; +} + +export interface UndoManagerLike { + undo(): boolean; + redo(): boolean; +} + +export interface EngineModule { + readonly LoroDoc: new () => DocLike; + readonly LoroMap: new () => MapLike; + readonly LoroText: new () => TextLike; + readonly LoroMovableList: new () => MovableListLike; + readonly VersionVector: new (input: Map) => VersionVectorLike; + readonly UndoManager: new ( + doc: DocLike, + config: { readonly mergeInterval?: number; readonly maxUndoSteps?: number }, + ) => UndoManagerLike; + readonly Cursor: { decode(bytes: Uint8Array): CursorLike }; +} diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts new file mode 100644 index 000000000..bff356ae3 --- /dev/null +++ b/loro-js/tests/differential/fuzz.ts @@ -0,0 +1,970 @@ +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +import type { ContainerLike, DocLike, EngineModule, FrontiersLike } from "./engine"; +import { + ENGINES, + Divergence, + Rng, + Twin, + assertTwinAgrees, + canonicalDiff, + describeError, + eventShapeDifferences, + mapContainer, + movableList, + plain, + reachableContainers, + sortedFrontiers, + stringify, + textContainer, + vvToMap, + type EngineName, +} from "./harness"; + +export type ChildKind = "Map" | "Text" | "MovableList"; +export type SyncMode = "update" | "range" | "snapshot" | "batch"; + +export type Action = + | { + readonly kind: "insert"; + readonly peer: number; + readonly target: string; + readonly pos: number; + readonly value: string | number; + } + | { + readonly kind: "insertContainer"; + readonly peer: number; + readonly target: string; + readonly pos: number; + readonly child: ChildKind; + } + | { + readonly kind: "delete"; + readonly peer: number; + readonly target: string; + readonly pos: number; + readonly len: number; + } + | { + readonly kind: "move"; + readonly peer: number; + readonly target: string; + readonly from: number; + readonly to: number; + } + | { + readonly kind: "set"; + readonly peer: number; + readonly target: string; + readonly pos: number; + readonly value: string | number; + } + | { + readonly kind: "setContainer"; + readonly peer: number; + readonly target: string; + readonly pos: number; + readonly child: ChildKind; + } + | { + readonly kind: "mapSet"; + readonly peer: number; + readonly target: string; + readonly key: string; + readonly value: string | number; + } + | { + readonly kind: "textInsert"; + readonly peer: number; + readonly target: string; + readonly pos: number; + readonly text: string; + } + | { readonly kind: "commit"; readonly peer: number } + | { + readonly kind: "sync"; + readonly from: number; + readonly to: number; + readonly mode: SyncMode; + readonly cross: boolean; + readonly via?: number; + } + | { + readonly kind: "join"; + readonly from: number; + readonly peer: number; + readonly shallow: boolean; + readonly at: number; + readonly cross: boolean; + } + | { readonly kind: "checkout"; readonly peer: number; readonly at: number } + | { readonly kind: "revert"; readonly peer: number; readonly at: number } + | { + readonly kind: "diff"; + readonly peer: number; + readonly from: number; + readonly to: number; + } + | { readonly kind: "undo"; readonly peer: number } + | { readonly kind: "redo"; readonly peer: number }; + +export interface FuzzProfile { + readonly name: string; + readonly peers: number; + /** Allow move/set/setContainer on movable lists. */ + readonly moves: boolean; + /** Allow nested child containers. */ + readonly children: boolean; + readonly undo: boolean; + readonly history: boolean; + readonly snapshots: boolean; + readonly shallow: boolean; + readonly crossEngine: boolean; + readonly metadata: boolean; + readonly events: boolean; +} + +export const ROOT_LISTS = ["cid:root-list:MovableList", "cid:root-list2:MovableList"]; + +export class FuzzRun { + readonly rng: Rng; + readonly twins = new Map(); + readonly log: Action[] = []; + #nextValue = 0; + /** Peer IDs are never reused, including those of peers that left the run. */ + #nextPeer: number; + /** Last action kind per peer, to keep undo/redo unchained (see `generate`). */ + readonly #lastKind = new Map(); + /** + * Value after each peer's last undo/redo. Rust skips an undo item that turns + * out to be a no-op and undoes the previous one too, which then chains; so + * undo again only after the value changed. + */ + readonly #valueAtUndo = new Map(); + + constructor( + readonly engines: Record, + readonly profile: FuzzProfile, + readonly seed: number, + ) { + this.rng = new Rng(seed); + this.#nextPeer = profile.peers + 1; + for (let peer = 1; peer <= profile.peers; peer += 1) { + this.twins.set( + peer, + new Twin(engines, `peer${peer}`, peer, undefined, { undo: profile.undo }), + ); + } + } + + run(steps: number): void { + for (let step = 0; step < steps; step += 1) { + const action = this.generate(); + this.log.push(action); + try { + this.apply(action); + this.compareAll(`after step ${step} (${action.kind})`); + } catch (error) { + throw new Divergence( + `seed ${this.seed} profile ${this.profile.name} step ${step}: ${describeError(error)}\n` + + `actions:\n${this.log.map((item) => ` ${stringify(item)}`).join("\n")}`, + ); + } + } + for (const action of this.finalSync()) { + this.log.push(action); + try { + this.apply(action); + this.compareAll(`after final ${action.kind}`); + } catch (error) { + throw new Divergence( + `seed ${this.seed} profile ${this.profile.name} final sync: ${describeError(error)}\n` + + `actions:\n${this.log.map((item) => ` ${stringify(item)}`).join("\n")}`, + ); + } + } + this.assertConverged(); + } + + replay(actions: readonly Action[]): void { + for (const [step, action] of actions.entries()) { + this.log.push(action); + this.apply(action); + this.compareAll(`after replayed step ${step} (${action.kind})`); + } + } + + compareAll(context: string): void { + for (const twin of this.twins.values()) { + assertTwinAgrees(twin, context, { + metadata: this.profile.metadata, + events: this.profile.events, + }); + } + } + + /** Full-history peers that exchanged everything must hold the same state. */ + assertConverged(): void { + const full = [...this.twins.values()].filter((twin) => !twin.docs.rust.isShallow()); + const first = full[0]; + if (first === undefined) return; + const expected = stringify(plain(first.docs.rust.toJSON())); + for (const twin of full.slice(1)) { + const version = vvToMap(twin.docs.rust.oplogVersion()); + if (!isDeepStrictEqual(version, vvToMap(first.docs.rust.oplogVersion()))) continue; + const actual = stringify(plain(twin.docs.rust.toJSON())); + if (actual !== expected) { + throw new Divergence( + `seed ${this.seed}: Rust peers with equal versions diverged (${first.name} vs ${twin.name})`, + ); + } + } + } + + *finalSync(): Generator { + const peers = [...this.twins.keys()].filter( + (peer) => !this.twins.get(peer)!.docs.rust.isShallow(), + ); + for (const from of peers) { + for (const to of peers) { + // A peer can leave the run during these syncs (see applySync). + if (from === to || !this.twins.has(from) || !this.twins.has(to)) continue; + yield { kind: "sync", from, to, mode: "update", cross: this.profile.crossEngine }; + } + } + } + + generate(): Action { + return this.generateAction(); + } + + generateAction(): Action { + const rng = this.rng; + const peers = [...this.twins.keys()]; + const peer = rng.pick(peers); + const twin = this.twins.get(peer)!; + const profile = this.profile; + // loro.js undo replays ops while Rust applies transformed inverse diffs, so + // an undo whose target was recreated by an earlier undo diverges for List + // too. Undo only right after an edit, and redo only right after an undo. + const last = this.#lastKind.get(peer); + const canUndo = + profile.undo && last !== "undo" && last !== "redo" && this.#changedSinceUndo(peer); + const canRedo = profile.undo && last === "undo"; + const kind = rng.weighted({ + edit: 12, + commit: 3, + ...(peers.length > 1 ? { sync: 4 } : {}), + ...(canUndo ? { undo: 2 } : {}), + ...(canRedo ? { redo: 2 } : {}), + ...(profile.history ? { checkout: 0.8, revert: 0.4, diff: 0.6 } : {}), + ...(profile.snapshots && peers.length > 1 && this.twins.size < 5 + ? { join: 0.3 } + : {}), + }); + switch (kind) { + case "edit": + return this.generateEdit(peer, twin); + case "commit": + case "undo": + case "redo": + return { kind, peer }; + case "sync": { + const from = rng.pick(peers.filter((item) => item !== peer)) ?? peer; + const mode = rng.weighted({ + update: 4, + range: 1, + ...(profile.snapshots ? { snapshot: 1 } : {}), + batch: 1, + }); + const others = peers.filter((item) => item !== peer && item !== from); + return { + kind: "sync", + from, + to: peer, + mode, + cross: profile.crossEngine && rng.bool(0.6), + ...(mode === "batch" && others.length > 0 ? { via: rng.pick(others) } : {}), + }; + } + case "join": { + const joined = this.#nextPeer++; + return { + kind: "join", + from: peer, + peer: joined, + shallow: profile.shallow && rng.bool(0.5), + at: twin.history.length === 0 ? -1 : rng.int(twin.history.length + 1) - 1, + cross: profile.crossEngine && rng.bool(0.6), + }; + } + case "checkout": + case "revert": + return { + kind, + peer, + at: twin.history.length === 0 ? -1 : rng.int(twin.history.length), + }; + case "diff": + return { + kind: "diff", + peer, + from: twin.history.length === 0 ? -1 : rng.int(twin.history.length + 1) - 1, + to: twin.history.length === 0 ? -1 : rng.int(twin.history.length + 1) - 1, + }; + default: + throw new Error(`unexpected action ${kind}`); + } + } + + generateEdit(peer: number, twin: Twin): Action { + const rng = this.rng; + const doc = twin.docs.rust; + const containers = reachableContainers(doc).filter( + ({ id }) => !ROOT_LISTS.includes(id), + ); + const lists = [ + ...ROOT_LISTS, + ...containers.filter((c) => c.kind === "MovableList").map((c) => c.id), + ]; + const maps = containers.filter((c) => c.kind === "Map").map((c) => c.id); + const texts = containers.filter((c) => c.kind === "Text").map((c) => c.id); + const targetKind = rng.weighted({ + list: 10, + ...(maps.length > 0 ? { map: 1 } : {}), + ...(texts.length > 0 ? { text: 1 } : {}), + }); + if (targetKind === "map") { + return { + kind: "mapSet", + peer, + target: rng.pick(maps), + key: `k${rng.int(3)}`, + value: this.value(), + }; + } + if (targetKind === "text") { + const target = rng.pick(texts); + const length = textContainer(doc, target).length; + return { + kind: "textInsert", + peer, + target, + pos: rng.int(length + 1), + text: `t${this.#nextValue++}`, + }; + } + const target = rng.pick(lists); + const length = movableList(doc, target).length; + const profile = this.profile; + const kind = rng.weighted({ + insert: 4, + ...(profile.children ? { insertContainer: 0.6 } : {}), + ...(length > 0 ? { delete: 2.5 } : {}), + ...(profile.moves && length > 1 ? { move: 4 } : {}), + ...(profile.moves && length > 0 ? { set: 2.5 } : {}), + ...(profile.moves && profile.children && length > 0 ? { setContainer: 0.3 } : {}), + }); + switch (kind) { + case "insert": + return { kind, peer, target, pos: rng.int(length + 1), value: this.value() }; + case "insertContainer": + return { kind, peer, target, pos: rng.int(length + 1), child: this.child() }; + case "delete": { + const pos = rng.int(length); + return { kind, peer, target, pos, len: 1 + rng.int(Math.min(3, length - pos)) }; + } + case "move": { + // A move onto the same index is a no-op; it would hide a chained undo. + const from = rng.int(length); + const to = (from + 1 + rng.int(length - 1)) % length; + return { kind, peer, target, from, to }; + } + case "set": + return { kind, peer, target, pos: rng.int(length), value: this.value() }; + case "setContainer": + return { kind, peer, target, pos: rng.int(length), child: this.child() }; + default: + throw new Error(`unexpected edit ${kind}`); + } + } + + value(): string | number { + const value = this.#nextValue++; + return this.rng.bool(0.3) ? value : `v${value}`; + } + + child(): ChildKind { + return this.rng.pick(["Map", "Text", "MovableList"]); + } + + apply(action: Action): void { + switch (action.kind) { + case "insert": + case "insertContainer": + case "delete": + case "move": + case "set": + case "setContainer": + case "mapSet": + case "textInsert": + this.applyEdit(action); + return; + case "commit": + this.twin(action.peer).both("commit", (doc) => doc.commit()); + this.twin(action.peer).record(); + return; + case "undo": + case "redo": { + const twin = this.twin(action.peer); + const managers = twin.undo; + if (managers === undefined) return; + const last = this.#lastKind.get(action.peer); + if ( + action.kind === "undo" + ? last === "undo" || last === "redo" || !this.#changedSinceUndo(action.peer) + : last !== "undo" + ) { + throw new ChainedUndo(`${action.kind} after ${last ?? "nothing"}`); + } + this.#lastKind.set(action.peer, action.kind); + // loro.js picks the undo item before committing the pending transaction, + // Rust commits first. That is unrelated to MovableList; commit up front. + twin.both("commit before undo", (doc) => doc.commit()); + assertTwinAgrees(twin, "after commit before undo", { + metadata: this.profile.metadata, + events: this.profile.events, + }); + const results = twin.both(action.kind, (_doc, engine) => + managers[engine][action.kind](), + ); + if (results !== undefined && results.rust !== results.js) { + throw new Divergence( + `${twin.name}: ${action.kind} returned ${results.rust} in Rust but ${results.js} in loro.js`, + ); + } + this.#valueAtUndo.set(action.peer, stringify(plain(twin.docs.rust.toJSON()))); + twin.record(); + return; + } + case "sync": + this.applySync(action); + return; + case "join": + this.applyJoin(action); + return; + case "checkout": { + const twin = this.twin(action.peer); + const frontiers = this.historyAt(twin, action.at); + // loro.js stays detached after checking out the latest frontiers while + // Rust reattaches; that is unrelated to MovableList, so skip it. + twin.both("commit before checkout", (doc) => doc.commit()); + assertTwinAgrees(twin, "after commit before checkout", { + metadata: this.profile.metadata, + events: this.profile.events, + }); + if ( + isDeepStrictEqual( + sortedFrontiers(frontiers), + sortedFrontiers(twin.docs.rust.frontiers()), + ) + ) { + return; + } + twin.both("checkout", (doc) => doc.checkout(frontiers)); + assertTwinAgrees(twin, `at checkout ${stringify(frontiers)}`, { + metadata: this.profile.metadata, + events: this.profile.events, + }); + twin.both("checkoutToLatest", (doc) => doc.checkoutToLatest()); + return; + } + case "revert": { + const twin = this.twin(action.peer); + const frontiers = this.historyAt(twin, action.at); + if (this.#revertIsUnspecified(twin, frontiers)) { + this.#revertThroughRust(twin, frontiers); + } else { + twin.both("revertTo", (doc) => { + doc.revertTo(frontiers); + doc.commit(); + }); + } + twin.record(); + return; + } + case "diff": { + const twin = this.twin(action.peer); + const from = this.historyAt(twin, action.from); + const to = this.historyAt(twin, action.to); + // loro.js also reports containers that are dead at `to`; Rust does not. + const reachable = reachableAt(twin.docs.rust, to); + const results = twin.both("diff", (doc) => + doc + .diff(from, to, false) + .filter(([id]) => reachable === undefined || reachable.has(id)) + .map(([id, diff]) => [id, canonicalDiff(diff)] as const) + // A child created in the range whose map keys were all set and + // deleted again: Rust lists them as deleted, loro.js (List, Map and + // MovableList parents alike, on `main` too) omits the child. Rust + // can also list a sequence whose delta is empty (two concurrent + // moves of one element); loro.js omits it. + .filter(([, diff]) => !onlyDeletedMapKeys(diff) && !emptyDelta(diff)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), + ); + if (results !== undefined && !isDeepStrictEqual(results.rust, results.js)) { + throw new Divergence( + `${twin.name}: diff(${stringify(from)}, ${stringify(to)}) differs\n` + + ` rust: ${stringify(results.rust)}\n js: ${stringify(results.js)}`, + ); + } + return; + } + } + } + + /** + * Whether the ops of a revert are not determined by the value it restores. + * Rust applies a revert's container diffs in `FxHashMap` order within one + * depth, so when several containers at the same depth change, the two + * engines write the same values in a different op order. A revert's list ops + * also follow the shape of `diff(current, target)`: an insert before or after + * a deletion at the same index lands on either side of the deleted positions. + * Both shapes describe the same change (see `eventShapeDifferences`). Twins + * must share one history, so such a revert is checked by value and then + * replicated. + */ + #revertIsUnspecified(twin: Twin, frontiers: FrontiersLike): boolean { + let rustDiff: [string, unknown][]; + let jsDiff: [string, unknown][]; + try { + twin.both("commit before diff", (doc) => doc.commit()); + rustDiff = twin.docs.rust.diff(twin.docs.rust.frontiers(), frontiers, false); + jsDiff = twin.docs.js.diff(twin.docs.js.frontiers(), frontiers, false); + } catch { + return false; + } + // Depths at the target: a container the revert brings back has no path now. + // Rust cannot fork a shallow document; without the target, such a + // container's depth is unknown and the order counts as unspecified. + let atTarget: DocLike | undefined; + try { + atTarget = twin.docs.rust.forkAt(frontiers); + } catch { + atTarget = undefined; + } + const depths = new Map(); + for (const [id] of rustDiff) { + const path = (atTarget ?? twin.docs.rust).getPathToContainer(id); + if (path === undefined && atTarget === undefined) return true; + const depth = path?.length ?? 0; + depths.set(depth, (depths.get(depth) ?? 0) + 1); + } + if ([...depths.values()].some((count) => count > 1)) return true; + const shape = (diff: [string, unknown][]) => + new Map(diff.map(([id, change]) => [id, orderedDiff(change)])); + const rustShape = shape(rustDiff); + if ( + !isDeepStrictEqual(rustShape, shape(jsDiff.filter(([id]) => rustShape.has(id)))) + ) { + eventShapeDifferences.count += 1; + return true; + } + return false; + } + + #revertThroughRust(twin: Twin, frontiers: FrontiersLike): void { + twin.both("commit before revert", (doc) => doc.commit()); + assertTwinAgrees(twin, "after commit before revert", { + metadata: this.profile.metadata, + events: this.profile.events, + }); + const fork = twin.docs.js.fork(); + fork.revertTo(frontiers); + fork.commit(); + const before = vvToMap(twin.docs.js.oplogVersion()); + twin.docs.rust.revertTo(frontiers); + twin.docs.rust.commit(); + const expected = plain(twin.docs.rust.toJSON()); + if (!isDeepStrictEqual(plain(fork.toJSON()), expected)) { + throw new Divergence( + `${twin.name}: revertTo(${stringify(frontiers)}) values differ\n` + + ` rust: ${stringify(expected)}\n js: ${stringify(plain(fork.toJSON()))}`, + ); + } + twin.docs.js.import( + twin.docs.rust.export({ + mode: "update", + from: new this.engines.rust.VersionVector(before), + }), + ); + // The Rust half reports a local change, the loro.js half an import. + twin.events.rust.length = 0; + twin.events.js.length = 0; + } + + #changedSinceUndo(peer: number): boolean { + const previous = this.#valueAtUndo.get(peer); + return ( + previous === undefined || + previous !== stringify(plain(this.twin(peer).docs.rust.toJSON())) + ); + } + + twin(peer: number): Twin { + return this.twins.get(peer)!; + } + + historyAt(twin: Twin, index: number): FrontiersLike { + return index < 0 ? [] : (twin.history[index] ?? []); + } + + applyEdit(action: Extract): void { + const twin = this.twin(action.peer); + const applied = twin.both(action.kind, (doc, engine) => { + const engineModule = this.engines[engine]; + switch (action.kind) { + case "insert": + movableList(doc, action.target).insert(action.pos, action.value); + return; + case "insertContainer": + movableList(doc, action.target).insertContainer( + action.pos, + newChild(engineModule, action.child), + ); + return; + case "delete": + movableList(doc, action.target).delete(action.pos, action.len); + return; + case "move": + movableList(doc, action.target).move(action.from, action.to); + return; + case "set": + movableList(doc, action.target).set(action.pos, action.value); + return; + case "setContainer": + movableList(doc, action.target).setContainer( + action.pos, + newChild(engineModule, action.child), + ); + return; + case "mapSet": + mapContainer(doc, action.target).set(action.key, action.value); + return; + case "textInsert": + textContainer(doc, action.target).insert(action.pos, action.text); + return; + } + }); + // Only a real edit breaks an undo chain (see `generateAction`). + if (applied !== undefined && (action.kind !== "move" || action.from !== action.to)) { + this.#lastKind.set(action.peer, action.kind); + } + } + + applySync(action: Extract): void { + const source = this.twin(action.from); + const target = this.twin(action.to); + const via = action.via === undefined ? undefined : this.twin(action.via); + // Rust's oplog version counts a pending transaction and loro.js's does not; + // commit first so both halves export the same span. + for (const twin of [source, target, via]) { + if (twin === undefined) continue; + twin.both("commit before sync", (doc) => doc.commit()); + assertTwinAgrees(twin, "after commit before sync", { + metadata: this.profile.metadata, + events: this.profile.events, + }); + } + const exported = (engine: EngineName): Uint8Array[] => { + const sourceEngine: EngineName = action.cross ? other(engine) : engine; + const doc = source.docs[sourceEngine]; + const blobs = [this.exportFor(doc, target.docs[engine], sourceEngine, action.mode)]; + if (via !== undefined) { + blobs.push( + this.exportFor( + via.docs[sourceEngine], + target.docs[engine], + sourceEngine, + "update", + ), + ); + } + return blobs; + }; + const blobs: Record = { + rust: exported("rust"), + js: exported("js"), + }; + target.batchImport = action.mode === "batch"; + let imported: unknown; + try { + imported = target.both( + `import from ${source.name} (${action.mode})`, + (doc, engine) => + action.mode === "batch" + ? doc.importBatch(blobs[engine]) + : doc.import(blobs[engine][0]!), + ); + } finally { + target.batchImport = false; + } + if (imported === undefined) { + // Both rejected it, typically a change concurrent with a shallow root. + // Rust keeps the blob's other changes; loro.js imports atomically. The + // halves can now differ by design, so this peer leaves the run. + this.twins.delete(action.to); + return; + } + target.record(); + } + + exportFor( + doc: DocLike, + receiver: DocLike, + sourceEngine: EngineName, + mode: SyncMode, + ): Uint8Array { + const receiverVersion = vvToMap(receiver.oplogVersion()); + switch (mode) { + case "snapshot": + return doc.export({ mode: "snapshot" }); + case "range": { + // Rust exports nothing for a span that starts in history trimmed by a + // shallow snapshot (context/loro-js-movable-list.md), so spans start at + // the shallow root. + const trimmed = vvToMap(doc.shallowSinceVV()); + const spans = [...vvToMap(doc.oplogVersion())].flatMap(([peer, end]) => { + const start = Math.max(receiverVersion.get(peer) ?? 0, trimmed.get(peer) ?? 0); + return end > start ? [{ id: { peer, counter: start }, len: end - start }] : []; + }); + return doc.export({ mode: "updates-in-range", spans }); + } + case "update": + case "batch": + return doc.export({ + mode: "update", + from: new this.engines[sourceEngine].VersionVector(receiverVersion), + }); + } + } + + applyJoin(action: Extract): void { + this.#nextPeer = Math.max(this.#nextPeer, action.peer + 1); + const source = this.twin(action.from); + source.both("commit before join", (doc) => doc.commit()); + assertTwinAgrees(source, "after commit before join", { + metadata: this.profile.metadata, + events: this.profile.events, + }); + const frontiers = + action.at < 0 + ? source.docs.rust.oplogFrontiers() + : this.historyAt(source, action.at); + const bytes: Record = { + rust: new Uint8Array(), + js: new Uint8Array(), + }; + for (const engine of ENGINES) { + const sourceEngine: EngineName = action.cross ? other(engine) : engine; + bytes[engine] = source.docs[sourceEngine].export({ mode: "snapshot" }); + } + if (action.shallow) { + // loro.js roots a shallow snapshot at the meet of the requested heads, + // Rust at the latest single-head critical version (loro-dev/loro#1095), + // so replicas made from each keep different history. Both halves load + // Rust's; loro.js's must still import into both engines with its value. + const rustShallow = source.docs.rust.export({ + mode: "shallow-snapshot", + frontiers, + }); + const jsShallow = source.docs.js.export({ mode: "shallow-snapshot", frontiers }); + const expected = plain(source.docs.rust.toJSON()); + for (const engine of ENGINES) { + const check = new this.engines[engine].LoroDoc(); + check.import(jsShallow); + if (!isDeepStrictEqual(plain(check.toJSON()), expected)) { + throw new Divergence( + `${engine} imported loro.js's shallow snapshot of ${source.name} as ` + + `${stringify(plain(check.toJSON()))}, expected ${stringify(expected)}`, + ); + } + } + bytes.rust = rustShallow; + bytes.js = rustShallow; + } + const twin = new Twin(this.engines, `peer${action.peer}`, action.peer, undefined, { + undo: this.profile.undo, + }); + this.twins.set(action.peer, twin); + twin.both("import joined snapshot", (doc, engine) => doc.import(bytes[engine])); + twin.record(); + } +} + +/** Container IDs reachable at `frontiers`, or undefined when that cannot be forked. */ +function reachableAt(doc: DocLike, frontiers: FrontiersLike): Set | undefined { + try { + return new Set(reachableContainers(doc.forkAt(frontiers)).map(({ id }) => id)); + } catch { + return undefined; + } +} + +function other(engine: EngineName): EngineName { + return engine === "rust" ? "js" : "rust"; +} + +function newChild(engine: EngineModule, kind: ChildKind): ContainerLike { + switch (kind) { + case "Map": + return new engine.LoroMap(); + case "Text": + return new engine.LoroText(); + case "MovableList": + return new engine.LoroMovableList(); + } +} + +/** + * `canonicalDiff`, except that list deltas keep the order of inserts and + * deletes at one index; adjacent items of one kind are still merged. + */ +function orderedDiff(diff: unknown): unknown { + const typed = diff as { type: string; diff?: Record[] }; + if (typed.type !== "list") return canonicalDiff(diff); + const output: Record[] = []; + for (const item of typed.diff!) { + const last = output.at(-1); + if (last !== undefined && "insert" in item && "insert" in last) { + last.insert = [...(last.insert as unknown[]), ...(plain(item.insert) as unknown[])]; + } else if (last !== undefined && "delete" in item && "delete" in last) { + last.delete = (last.delete as number) + (item.delete as number); + } else if (last !== undefined && "retain" in item && "retain" in last) { + last.retain = (last.retain as number) + (item.retain as number); + } else { + output.push("insert" in item ? { insert: plain(item.insert) } : { ...item }); + } + } + return { type: "list", diff: output }; +} + +function emptyDelta(diff: unknown): boolean { + const typed = diff as { type: string; diff?: unknown }; + return Array.isArray(typed.diff) && typed.diff.length === 0; +} + +function onlyDeletedMapKeys(diff: unknown): boolean { + const typed = diff as { type: string; updated?: Record }; + return ( + typed.type === "map" && + Object.values(typed.updated ?? {}).every((value) => value === undefined) + ); +} + +export interface SavedTrace { + readonly profile: FuzzProfile; + readonly seed: number; + readonly actions: readonly Action[]; +} + +/** Replays `trace`, returning the divergence message or undefined when both engines agree. */ +export function replayTrace( + engines: Record, + trace: SavedTrace, +): string | undefined { + try { + new FuzzRun(engines, trace.profile, trace.seed).replay(trace.actions); + return undefined; + } catch (error) { + return error instanceof Divergence + ? describeError(error) + : `crash: ${describeError(error)}`; + } +} + +/** Greedily drops actions while the trace still diverges (not merely crashes). */ +export function minimizeTrace( + engines: Record, + trace: SavedTrace, +): SavedTrace { + let actions = [...trace.actions]; + let changed = true; + while (changed) { + changed = false; + for (let index = actions.length - 1; index >= 0; index -= 1) { + const candidate = actions.filter((_, i) => i !== index); + const message = replayTrace(engines, { ...trace, actions: candidate }); + if (message !== undefined && !message.startsWith("crash")) { + actions = candidate; + changed = true; + } + } + } + return { ...trace, actions }; +} + +/** + * A replayed or minimized trace that chains undo, which the generator never + * does (see `generateAction`); `replayTrace` reports it as a crash. + */ +class ChainedUndo extends Error {} + +function saveTrace(trace: SavedTrace): string { + const safeName = trace.profile.name.replaceAll(/[^a-z0-9]+/giu, "-"); + const path = join(tmpdir(), `loro-js-differential-${safeName}-${trace.seed}.json`); + writeFileSync(path, JSON.stringify(trace)); + return path; +} + +export interface FuzzSummary { + readonly seeds: number; + readonly failures: { readonly seed: number; readonly message: string }[]; +} + +export function fuzz( + engines: Record, + profile: FuzzProfile, + seeds: Iterable, + steps: number, +): FuzzSummary { + const failures: { seed: number; message: string }[] = []; + let count = 0; + for (const seed of seeds) { + count += 1; + const run = new FuzzRun(engines, profile, seed); + try { + run.run(steps); + } catch (error) { + if (!(error instanceof Divergence)) throw error; + const path = saveTrace({ profile, seed, actions: run.log }); + failures.push({ + seed, + message: `${error.message}\nTrace saved to ${path}; replay it with LORO_JS_DIFF_REPLAY=${path}`, + }); + } + } + return { seeds: count, failures }; +} + +export function seedRange(start: number, count: number): number[] { + return Array.from({ length: count }, (_, index) => start + index); +} + +/** Environment overrides for longer local runs. */ +export function fuzzScale( + defaultSeeds: number, + defaultSteps: number, +): { + readonly seeds: number[]; + readonly steps: number; +} { + const start = Number(process.env["LORO_JS_DIFF_SEED"] ?? 1); + const count = Number(process.env["LORO_JS_DIFF_SEEDS"] ?? defaultSeeds); + const steps = Number(process.env["LORO_JS_DIFF_STEPS"] ?? defaultSteps); + return { seeds: seedRange(start, count), steps }; +} diff --git a/loro-js/tests/differential/harness.ts b/loro-js/tests/differential/harness.ts new file mode 100644 index 000000000..cef02259d --- /dev/null +++ b/loro-js/tests/differential/harness.ts @@ -0,0 +1,643 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { + ContainerLike, + DocLike, + EngineModule, + EventBatchLike, + FrontiersLike, + MapLike, + MovableListLike, + TextLike, + UndoManagerLike, + VersionVectorLike, +} from "./engine"; + +/** + * Differential driver for loro.js against the Rust implementation. + * + * Every simulated peer is a pair of documents with the same peer ID, one per + * engine. Local edits are applied to both halves; syncing exports bytes from + * one peer and imports them into another, optionally crossing engines so each + * engine also decodes the other's encoding. After each step both halves must + * agree on the value, version, per-element metadata, and emitted events. + */ + +export type EngineName = "rust" | "js"; +export const ENGINES: readonly EngineName[] = ["rust", "js"]; + +export class Rng { + #state: number; + + constructor(seed: number) { + this.#state = seed >>> 0 || 1; + } + + next(): number { + // mulberry32 + this.#state = (this.#state + 0x6d2b79f5) >>> 0; + let t = this.#state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + } + + int(maxExclusive: number): number { + return Math.floor(this.next() * maxExclusive); + } + + bool(probability = 0.5): boolean { + return this.next() < probability; + } + + pick(items: readonly T[]): T { + return items[this.int(items.length)]!; + } + + weighted(weights: Readonly>>): T { + const entries = Object.entries(weights) as [T, number][]; + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + let roll = this.next() * total; + for (const [key, weight] of entries) { + roll -= weight; + if (roll < 0) return key; + } + return entries.at(-1)![0]; + } +} + +export class Divergence extends Error {} + +type Side = Record; + +export interface TwinOptions { + readonly undo?: boolean; +} + +export class Twin { + readonly docs: Side; + readonly events: Side = { rust: [], js: [] }; + readonly undo: Side | undefined; + /** Frontiers reached by this peer, shared by both halves once they agree. */ + readonly history: FrontiersLike[] = []; + /** Set while `importBatch` runs; see `canonicalBatch`. */ + batchImport = false; + /** List and Text values at the last comparison, to replay event deltas. */ + readonly lists = new Map(); + + constructor( + readonly engines: Side, + readonly name: string, + readonly peer: number, + docs?: Side, + options: TwinOptions = {}, + ) { + this.docs = docs ?? { + rust: new engines.rust.LoroDoc(), + js: new engines.js.LoroDoc(), + }; + for (const engine of ENGINES) { + this.docs[engine].setPeerId(peer); + this.docs[engine].subscribe((event) => { + const batch = canonicalBatch(event, this.batchImport); + if (batch.events.length > 0) this.events[engine].push(batch); + }); + } + this.undo = + options.undo === false + ? undefined + : { + rust: new engines.rust.UndoManager(this.docs.rust, { mergeInterval: 0 }), + js: new engines.js.UndoManager(this.docs.js, { mergeInterval: 0 }), + }; + } + + /** Runs `action` on both halves and requires them to both succeed or both throw. */ + both( + label: string, + action: (doc: DocLike, engine: EngineName) => T, + ): Side | undefined { + const results: Partial> = {}; + const errors: Partial> = {}; + for (const engine of ENGINES) { + try { + results[engine] = action(this.docs[engine], engine); + } catch (error) { + errors[engine] = error; + } + } + if ((errors.rust === undefined) !== (errors.js === undefined)) { + throw new Divergence( + `${this.name}: ${label} ` + + (errors.rust === undefined + ? `succeeded in Rust but threw in loro.js: ${describeError(errors.js)}` + : `threw in Rust but succeeded in loro.js: ${describeError(errors.rust)}`), + ); + } + return errors.rust === undefined ? (results as Side) : undefined; + } + + record(): void { + this.history.push(this.docs.rust.frontiers()); + } +} + +export function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function vvToMap(vv: VersionVectorLike): Map { + return new Map([...vv.toJSON()].sort(([left], [right]) => compareStrings(left, right))); +} + +export function sortedFrontiers(frontiers: FrontiersLike): string[] { + return frontiers.map(({ peer, counter }) => `${counter}@${peer}`).sort(compareStrings); +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function isContainer(value: unknown): value is ContainerLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as { kind?: unknown }).kind === "function" && + typeof (value as { id?: unknown }).id === "string" + ); +} + +/** Replaces container handles with their IDs so values compare structurally. */ +export function plain(value: unknown): unknown { + if (isContainer(value)) return `container:${value.id}`; + if (value instanceof Uint8Array) return { bytes: [...value] }; + if (Array.isArray(value)) return value.map(plain); + if (value instanceof Map) { + return Object.fromEntries( + [...value].map(([key, item]) => [String(key), plain(item)]), + ); + } + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => compareStrings(left, right)) + .map(([key, item]) => [key, plain(item)]), + ); + } + return value; +} + +type DeltaItem = + | { readonly insert: unknown; readonly attributes?: unknown } + | { readonly delete: number } + | { readonly retain: number; readonly attributes?: unknown }; + +/** + * Canonicalizes a list or text delta. Deletes and inserts at the same position + * commute, so each gap between retains is rewritten as one delete followed by + * the concatenated inserts. + */ +export function canonicalDelta(delta: readonly DeltaItem[], text: boolean): DeltaItem[] { + const output: DeltaItem[] = []; + let deleted = 0; + let inserts: { value: unknown; attributes: unknown }[] = []; + const flush = (): void => { + if (deleted > 0) output.push({ delete: deleted }); + for (const insert of inserts) { + const last = output.at(-1); + if ( + last !== undefined && + "insert" in last && + isDeepStrictEqual(plain(last.attributes), plain(insert.attributes)) + ) { + const merged = text + ? (last.insert as string) + (insert.value as string) + : [...(last.insert as unknown[]), ...(insert.value as unknown[])]; + output[output.length - 1] = + insert.attributes === undefined + ? { insert: merged } + : { insert: merged, attributes: insert.attributes }; + } else { + output.push( + insert.attributes === undefined + ? { insert: insert.value } + : { insert: insert.value, attributes: insert.attributes }, + ); + } + } + deleted = 0; + inserts = []; + }; + for (const item of delta) { + if ("insert" in item) { + const attributes = + item.attributes === undefined || + Object.keys(item.attributes as object).length === 0 + ? undefined + : plain(item.attributes); + inserts.push({ value: text ? item.insert : plain(item.insert), attributes }); + } else if ("delete" in item) { + deleted += item.delete; + } else { + flush(); + const attributes = + item.attributes === undefined || + Object.keys(item.attributes as object).length === 0 + ? undefined + : plain(item.attributes); + const last = output.at(-1); + if ( + last !== undefined && + "retain" in last && + isDeepStrictEqual(last.attributes, attributes) + ) { + output[output.length - 1] = + attributes === undefined + ? { retain: last.retain + item.retain } + : { retain: last.retain + item.retain, attributes }; + } else { + output.push( + attributes === undefined + ? { retain: item.retain } + : { retain: item.retain, attributes }, + ); + } + } + } + flush(); + const last = output.at(-1); + if (last !== undefined && "retain" in last && last.attributes === undefined) + output.pop(); + return output; +} + +export function canonicalDiff(diff: unknown): unknown { + const typed = diff as { type: string; diff?: DeltaItem[]; updated?: unknown }; + if (typed.type === "list") { + return { type: "list", diff: canonicalDelta(typed.diff ?? [], false) }; + } + if (typed.type === "text") { + return { type: "text", diff: canonicalDelta(typed.diff ?? [], true) }; + } + return plain(diff); +} + +/** + * Normalizes an event batch for comparison. Two known differences unrelated to + * the data model are folded away: + * + * - Rust labels checkout events with origin `"checkout"`; loro.js leaves the + * origin empty. + * - Rust's multi-blob `importBatch` imports while detached and reattaches with a + * checkout, so its events say `by: "checkout"`; loro.js says `"import"`. + * - Rust commits both undo and redo with origin `"undo"`; loro.js uses + * `"redo"` for redo. + */ +export function canonicalBatch( + batch: EventBatchLike, + batchImport = false, +): EventBatchLike { + const by = batchImport && batch.by === "checkout" ? "import" : batch.by; + const origin = batch.by === "checkout" ? "" : (batch.origin ?? ""); + return { + by, + origin: origin === "redo" ? "undo" : origin, + events: batch.events + .map((event) => ({ + target: event.target, + path: [...event.path], + diff: canonicalDiff(event.diff), + })) + .filter((event) => !isEmptyDiff(event.diff)) + .sort((left, right) => compareStrings(left.target, right.target)), + }; +} + +function isEmptyDiff(diff: unknown): boolean { + const typed = diff as { type: string; diff?: unknown[]; updated?: object }; + if (typed.type === "list" || typed.type === "text") return typed.diff?.length === 0; + if (typed.type === "map") return Object.keys(typed.updated ?? {}).length === 0; + return false; +} + +export interface ContainerInfo { + readonly id: string; + readonly kind: string; + readonly path: readonly (string | number)[]; +} + +/** Lists the reachable containers of `doc`, parents before children. */ +export function reachableContainers(doc: DocLike): ContainerInfo[] { + const output: ContainerInfo[] = []; + const queue: ContainerInfo[] = Object.entries(doc.getShallowValue()) + .sort(([left], [right]) => compareStrings(left, right)) + .map(([name, id]) => ({ id, kind: kindOf(id), path: [name] })); + while (queue.length > 0) { + const info = queue.shift()!; + output.push(info); + const container = doc.getContainerById(info.id); + if (container === undefined) continue; + const shallow = container.getShallowValue(); + const children: [string | number, unknown][] = Array.isArray(shallow) + ? shallow.map((value, index) => [index, value]) + : typeof shallow === "object" && shallow !== null + ? Object.entries(shallow).sort(([left], [right]) => compareStrings(left, right)) + : []; + for (const [key, value] of children) { + if (typeof value === "string" && value.startsWith("cid:")) { + queue.push({ id: value, kind: kindOf(value), path: [...info.path, key] }); + } + } + } + return output; +} + +function kindOf(id: string): string { + return id.slice(id.lastIndexOf(":") + 1); +} + +export interface MovableListMetadata { + readonly creator: (string | undefined)[]; + readonly editor: (string | undefined)[]; + readonly mover: (string | undefined)[]; +} + +export function movableListMetadata(list: MovableListLike): MovableListMetadata { + const creator: (string | undefined)[] = []; + const editor: (string | undefined)[] = []; + const mover: (string | undefined)[] = []; + for (let index = 0; index < list.length; index += 1) { + creator.push(list.getCreatorAt(index)); + editor.push(list.getLastEditorAt(index)); + mover.push(list.getLastMoverAt(index)); + } + return { creator, editor, mover }; +} + +export interface CompareOptions { + /** Compare getCreatorAt/getLastEditorAt/getLastMoverAt. */ + readonly metadata?: boolean; + /** Compare the event batches collected since the last comparison. */ + readonly events?: boolean; +} + +export function assertTwinAgrees( + twin: Twin, + context: string, + options: CompareOptions = {}, +): void { + const fail = (message: string, rust: unknown, js: unknown): never => { + throw new Divergence( + `${twin.name} ${context}: ${message}\n rust: ${stringify(rust)}\n js: ${stringify(js)}`, + ); + }; + const { rust, js } = twin.docs; + const rustValue = plain(rust.toJSON()); + const jsValue = plain(js.toJSON()); + if (!isDeepStrictEqual(rustValue, jsValue)) fail("values differ", rustValue, jsValue); + if (rust.getPendingTxnLength() !== js.getPendingTxnLength()) { + fail( + "pending transaction lengths differ", + rust.getPendingTxnLength(), + js.getPendingTxnLength(), + ); + } + // Rust's oplog version already counts the pending transaction; loro.js's does + // not. Only committed versions are compared. + if (rust.getPendingTxnLength() === 0) { + const rustVersion = vvToMap(rust.oplogVersion()); + const jsVersion = vvToMap(js.oplogVersion()); + if (!isDeepStrictEqual(rustVersion, jsVersion)) { + fail("oplog versions differ", rustVersion, jsVersion); + } + const rustFrontiers = sortedFrontiers(rust.frontiers()); + const jsFrontiers = sortedFrontiers(js.frontiers()); + if (!isDeepStrictEqual(rustFrontiers, jsFrontiers)) { + fail("frontiers differ", rustFrontiers, jsFrontiers); + } + } + if (rust.isDetached() !== js.isDetached()) { + fail("detached state differs", rust.isDetached(), js.isDetached()); + } + const rustContainers = reachableContainers(rust); + const jsContainers = reachableContainers(js); + if (!isDeepStrictEqual(rustContainers, jsContainers)) { + fail("reachable containers differ", rustContainers, jsContainers); + } + if (options.metadata !== false) { + // Rust seeds shallow-root elements' last editor from the wrong op once it + // diffs across the root (see context/loro-js-movable-list.md), so a + // shallow document's editors are not compared. + const shallow = rust.isShallow(); + for (const info of rustContainers) { + if (info.kind !== "MovableList") continue; + const rustMeta = movableListMetadata( + rust.getContainerById(info.id) as MovableListLike, + ); + const jsMeta = movableListMetadata(js.getContainerById(info.id) as MovableListLike); + if (shallow) { + rustMeta.editor.length = 0; + jsMeta.editor.length = 0; + } + if (!isDeepStrictEqual(rustMeta, jsMeta)) { + fail(`movable-list metadata of ${info.id} differs`, rustMeta, jsMeta); + } + } + } + if (options.events !== false) { + // loro.js also reports changes to child containers whose parent element is + // already deleted; Rust does not. Compare only events of reachable targets. + const reachable = new Set(rustContainers.map(({ id }) => id)); + const rustEvents = onlyReachable(twin.events.rust.splice(0), reachable); + const jsEvents = onlyReachable(twin.events.js.splice(0), reachable); + if (!isDeepStrictEqual(rustEvents, jsEvents)) { + const reason = equivalentEvents(twin.lists, rustEvents, jsEvents); + if (reason !== undefined) fail(`events differ (${reason})`, rustEvents, jsEvents); + eventShapeDifferences.count += 1; + } + } else { + twin.events.rust.length = 0; + twin.events.js.length = 0; + } + twin.lists.clear(); + for (const info of rustContainers) { + if (info.kind !== "MovableList" && info.kind !== "List" && info.kind !== "Text") + continue; + const value = rust.getContainerById(info.id)?.getShallowValue(); + if (Array.isArray(value) || typeof value === "string") twin.lists.set(info.id, value); + } +} + +/** + * Event batches whose list or text deltas differ in shape but not in effect. + * Rust derives an import's list event from the net change; loro.js composes the + * change of each op, which can show an element that was hidden and shown again + * within one import as a delete plus an insert of the same value. When several + * imported moves bring an element back to its index, Rust's net change cancels + * and it reports nothing, so sequence events without effect are dropped. After + * a history replay loro.js diffs text before and after instead of using the ops. + */ +export const eventShapeDifferences = { count: 0 }; + +/** Returns why the batches are not equivalent, or undefined when they are. */ +function equivalentEvents( + lists: ReadonlyMap, + rustBatches: readonly EventBatchLike[], + jsBatches: readonly EventBatchLike[], +): string | undefined { + const rust = withoutNoOpSequenceEvents(lists, rustBatches); + const js = withoutNoOpSequenceEvents(lists, jsBatches); + if (rust === undefined || js === undefined) return "a list delta overruns"; + if (rust.length !== js.length) return "batch counts differ"; + const rustLists = new Map(); + const jsLists = new Map(); + for (let index = 0; index < rust.length; index += 1) { + const left = rust[index]!; + const right = js[index]!; + if (left.by !== right.by || left.origin !== right.origin) + return "batch labels differ"; + if (left.events.length !== right.events.length) return "event counts differ"; + for (let eventIndex = 0; eventIndex < left.events.length; eventIndex += 1) { + const rustEvent = left.events[eventIndex]!; + const jsEvent = right.events[eventIndex]!; + if (rustEvent.target !== jsEvent.target) return "event targets differ"; + if (!isDeepStrictEqual(rustEvent.path, jsEvent.path)) return "event paths differ"; + const rustDiff = rustEvent.diff as { type: string; diff?: DeltaItem[] }; + const jsDiff = jsEvent.diff as { type: string; diff?: DeltaItem[] }; + const sequence = + rustDiff.type === jsDiff.type && + (rustDiff.type === "list" || rustDiff.type === "text"); + if (!sequence) { + if (!isDeepStrictEqual(rustDiff, jsDiff)) return `${rustDiff.type} diffs differ`; + continue; + } + // Text attributes are compared structurally; only plain inserts are replayed. + if (rustDiff.type === "text" && hasAttributes(rustDiff.diff!, jsDiff.diff!)) { + return "text diffs with attributes differ"; + } + const start = lists.get(rustEvent.target) ?? (rustDiff.type === "text" ? "" : []); + const rustValue = applySequenceDelta( + rustLists.get(rustEvent.target) ?? start, + rustDiff.diff!, + ); + const jsValue = applySequenceDelta( + jsLists.get(jsEvent.target) ?? start, + jsDiff.diff!, + ); + if (rustValue === undefined || jsValue === undefined) + return "a list delta overruns"; + if (!isDeepStrictEqual(rustValue, jsValue)) + return "list deltas have different effects"; + rustLists.set(rustEvent.target, rustValue); + jsLists.set(jsEvent.target, jsValue); + } + } + return undefined; +} + +/** Drops list and text events that leave their target unchanged, then empty batches. */ +function withoutNoOpSequenceEvents( + lists: ReadonlyMap, + batches: readonly EventBatchLike[], +): EventBatchLike[] | undefined { + const current = new Map(lists); + const output: EventBatchLike[] = []; + for (const batch of batches) { + const events: EventBatchLike["events"][number][] = []; + for (const event of batch.events) { + const diff = event.diff as { type: string; diff?: DeltaItem[] }; + if ((diff.type !== "list" && diff.type !== "text") || hasAttributes(diff.diff!)) { + events.push(event); + continue; + } + const before = current.get(event.target) ?? (diff.type === "text" ? "" : []); + const after = applySequenceDelta(before, diff.diff!); + if (after === undefined) return undefined; + current.set(event.target, after); + if (!isDeepStrictEqual(before, after)) events.push(event); + } + if (events.length > 0) output.push({ ...batch, events }); + } + return output; +} + +function hasAttributes(...deltas: (readonly DeltaItem[])[]): boolean { + return deltas.some((delta) => + delta.some((item) => "attributes" in item && item.attributes !== undefined), + ); +} + +function applySequenceDelta( + value: readonly unknown[] | string, + delta: readonly DeltaItem[], +): unknown[] | string | undefined { + if (typeof value !== "string") return applyListDelta(value, delta); + // Text event indices count UTF-16 code units. + const units = applyListDelta( + value.split(""), + delta.map((item) => + "insert" in item ? { insert: (item.insert as string).split("") } : item, + ), + ); + return units === undefined ? undefined : units.join(""); +} + +function applyListDelta( + value: readonly unknown[], + delta: readonly DeltaItem[], +): unknown[] | undefined { + const output: unknown[] = []; + let index = 0; + for (const item of delta) { + if ("retain" in item) { + if (index + item.retain > value.length) return undefined; + output.push(...value.slice(index, index + item.retain)); + index += item.retain; + } else if ("delete" in item) { + if (index + item.delete > value.length) return undefined; + index += item.delete; + } else { + // Shallow values name child containers by ID; events carry handles. + output.push( + ...(item.insert as unknown[]).map((inserted) => + typeof inserted === "string" && inserted.startsWith("container:") + ? inserted.slice("container:".length) + : inserted, + ), + ); + } + } + output.push(...value.slice(index)); + return output; +} + +function onlyReachable( + batches: readonly EventBatchLike[], + reachable: ReadonlySet, +): EventBatchLike[] { + return batches + .map((batch) => ({ + ...batch, + events: batch.events.filter((event) => reachable.has(event.target)), + })) + .filter((batch) => batch.events.length > 0); +} + +export function stringify(value: unknown): string { + return JSON.stringify(value, (_key, item: unknown) => + item instanceof Map ? Object.fromEntries(item) : item, + ); +} + +export function movableList(doc: DocLike, id: string): MovableListLike { + return doc.getContainerById(id) as MovableListLike; +} + +export function mapContainer(doc: DocLike, id: string): MapLike { + return doc.getContainerById(id) as MapLike; +} + +export function textContainer(doc: DocLike, id: string): TextLike { + return doc.getContainerById(id) as TextLike; +} diff --git a/loro-js/tests/differential/movable-list.test.ts b/loro-js/tests/differential/movable-list.test.ts new file mode 100644 index 000000000..2228d17a6 --- /dev/null +++ b/loro-js/tests/differential/movable-list.test.ts @@ -0,0 +1,168 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +import { describe, expect, test } from "vitest"; + +import { + fuzz, + fuzzScale, + minimizeTrace, + replayTrace, + type FuzzProfile, + type SavedTrace, +} from "./fuzz"; +import { loadLoroJs, loadReference } from "./reference"; + +/** + * Randomized differential tests of MovableList (and its child containers) + * against the Rust implementation. See context/loro-js-movable-list.md. + * + * Profiles leave out behavior that diverges for reasons unrelated to the + * MovableList model; they are listed in that article under "Remaining known + * divergences". In short: undo is only exercised on a single peer, and + * checkout never targets the current frontiers. + */ + +const rust = loadReference(); +const engines = { rust: rust!, js: loadLoroJs() }; + +const none: FuzzProfile = { + name: "", + peers: 3, + moves: false, + children: false, + undo: false, + history: false, + snapshots: false, + shallow: false, + crossEngine: true, + metadata: true, + events: true, +}; + +interface Case { + readonly name: string; + readonly profile: FuzzProfile; + readonly seeds: number; + readonly steps: number; +} + +const cases: Case[] = [ + { + name: "insert/delete", + profile: { ...none, name: "insert/delete", children: true, history: true }, + seeds: 40, + steps: 80, + }, + { + name: "insert/delete undo (one peer)", + profile: { ...none, name: "insert/delete undo (one peer)", peers: 1, undo: true }, + seeds: 40, + steps: 60, + }, + { + name: "move/set", + profile: { ...none, name: "move/set", moves: true }, + seeds: 40, + steps: 80, + }, + { + name: "move/set with children", + profile: { ...none, name: "move/set with children", moves: true, children: true }, + seeds: 30, + steps: 80, + }, + { + name: "move/set history", + profile: { ...none, name: "move/set history", moves: true, history: true }, + seeds: 30, + steps: 80, + }, + { + name: "move/set undo (one peer)", + profile: { + ...none, + name: "move/set undo (one peer)", + peers: 1, + moves: true, + undo: true, + }, + seeds: 30, + steps: 60, + }, + { + name: "snapshots", + profile: { ...none, name: "snapshots", moves: true, children: true, snapshots: true }, + seeds: 30, + steps: 80, + }, + { + name: "shallow snapshots", + profile: { + ...none, + name: "shallow snapshots", + moves: true, + history: true, + snapshots: true, + shallow: true, + }, + seeds: 30, + steps: 80, + }, +]; + +/** + * Profiles that still diverge because loro.js models a MovableList element as + * an item that moves inside the Fugue sequence. The Rust-compatible model in + * context/loro-js-movable-list.md removes them from this set. + */ +const KNOWN_DIVERGENT = new Set([ + "insert/delete undo (one peer)", + "move/set", + "move/set with children", + "move/set history", + "move/set undo (one peer)", + "snapshots", + "shallow snapshots", +]); + +describe.skipIf(rust === undefined)("MovableList matches the Rust implementation", () => { + test.each(cases)( + "$name", + ({ name, profile, seeds, steps }) => { + const scale = fuzzScale(seeds, steps); + const summary = fuzz(engines, profile, scale.seeds, scale.steps); + const expected = KNOWN_DIVERGENT.has(name) ? "diverges" : "converges"; + const first = summary.failures[0]; + expect({ + outcome: first === undefined ? "converges" : "diverges", + // Show the first divergence of a profile that should converge. + detail: + expected === "converges" && first !== undefined + ? `${summary.failures.length}/${summary.seeds} seeds diverged\n${first.message}` + : undefined, + }).toEqual({ outcome: expected, detail: undefined }); + }, + 600_000, + ); +}); + +// Debugging aid: LORO_JS_DIFF_REPLAY= replays one failing trace, +// and LORO_JS_DIFF_MINIMIZE=1 first shrinks it to a minimal failing sequence. +const replayPath = process.env["LORO_JS_DIFF_REPLAY"]; +test.runIf(replayPath !== undefined && rust !== undefined)( + "replays a saved trace", + () => { + let trace = JSON.parse(readFileSync(replayPath!, "utf8")) as SavedTrace; + if (process.env["LORO_JS_DIFF_MINIMIZE"] === "1") { + trace = minimizeTrace(engines, trace); + writeFileSync(`${replayPath!}.min.json`, JSON.stringify(trace)); + } + const message = replayTrace(engines, trace); + expect( + message === undefined + ? [] + : [message, trace.actions.map((action) => JSON.stringify(action)).join("\n")], + ).toEqual([]); + }, + 600_000, +); diff --git a/loro-js/tests/differential/reference.ts b/loro-js/tests/differential/reference.ts new file mode 100644 index 000000000..7c33f9adb --- /dev/null +++ b/loro-js/tests/differential/reference.ts @@ -0,0 +1,18 @@ +import * as loroJs from "../../src/index"; +import { loadRustReference } from "../support/rust-reference"; + +import type { EngineModule } from "./engine"; + +/** + * The Rust implementation from the shared differential reference + * (`tests/support/rust-reference.ts`: `crates/loro-wasm/nodejs`, or + * `LORO_WASM_NODEJS`). Undefined when that build is missing, so the suite + * skips; `LORO_REQUIRE_WASM_REFERENCE=1` makes a missing build an error. + */ +export function loadReference(): EngineModule | undefined { + return loadRustReference() as unknown as EngineModule | undefined; +} + +export function loadLoroJs(): EngineModule { + return loroJs as unknown as EngineModule; +}