From 7ebbd0ef2c1b0fda54da30a2dea677b5d93bb1d6 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 02:07:20 +0800 Subject: [PATCH 01/10] test(loro.js): add a Rust differential suite for MovableList Drive the Rust WASM build and loro.js with the same random multi-peer edits, crossing engines on every sync, and require equal values, versions, per-element metadata and events. Profiles that diverge on the current MovableList model are recorded as known divergences. Design and findings: context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/loro-js-differential-suite.md | 4 + .github/workflows/loro-js.yml | 53 ++ AGENTS.md | 3 + context/loro-js-movable-list.md | 223 +++++ context/loro-js-performance.md | 5 +- loro-js/package.json | 2 + loro-js/scripts/build-reference.mjs | 27 + loro-js/tests/differential/engine.ts | 113 +++ loro-js/tests/differential/fuzz.ts | 766 ++++++++++++++++++ loro-js/tests/differential/harness.ts | 480 +++++++++++ .../tests/differential/movable-list.test.ts | 166 ++++ loro-js/tests/differential/reference.ts | 32 + loro-js/vite.config.ts | 11 +- 13 files changed, 1883 insertions(+), 2 deletions(-) create mode 100644 .changeset/loro-js-differential-suite.md create mode 100644 .github/workflows/loro-js.yml create mode 100644 context/loro-js-movable-list.md create mode 100644 loro-js/scripts/build-reference.mjs create mode 100644 loro-js/tests/differential/engine.ts create mode 100644 loro-js/tests/differential/fuzz.ts create mode 100644 loro-js/tests/differential/harness.ts create mode 100644 loro-js/tests/differential/movable-list.test.ts create mode 100644 loro-js/tests/differential/reference.ts diff --git a/.changeset/loro-js-differential-suite.md b/.changeset/loro-js-differential-suite.md new file mode 100644 index 000000000..ac05280fe --- /dev/null +++ b/.changeset/loro-js-differential-suite.md @@ -0,0 +1,4 @@ +--- +--- + +Add a differential test suite that compares loro.js with the Rust implementation (`pnpm --dir loro-js test:differential`). Tests and scripts only; the published package does not change. diff --git a/.github/workflows/loro-js.yml b/.github/workflows/loro-js.yml new file mode 100644 index 000000000..55a1c6082 --- /dev/null +++ b/.github/workflows/loro-js.yml @@ -0,0 +1,53 @@ +name: loro.js + +on: + push: + branches: ["main"] + paths: + - "loro-js/**" + - "crates/**" + - "pnpm-lock.yaml" + - ".github/workflows/loro-js.yml" + pull_request: + branches: ["main"] + paths: + - "loro-js/**" + - "crates/**" + - "pnpm-lock.yaml" + - ".github/workflows/loro-js.yml" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: pnpm install --frozen-lockfile + - run: pnpm --dir loro-js check + + differential: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + - run: rustup target add wasm32-unknown-unknown + - uses: jetli/wasm-bindgen-action@v0.2.0 + with: + version: "0.2.100" + - uses: Swatinem/rust-cache@v2 + - run: pnpm install --frozen-lockfile + - run: pnpm --dir loro-js build:reference + - run: pnpm --dir loro-js test:differential diff --git a/AGENTS.md b/AGENTS.md index cb00f0dd4..1e9a77db0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,9 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec. [skills/loro/SKILL.md](skills/loro/SKILL.md). - Pure TypeScript runtime indexes, complexity contracts, benchmarks, and remaining gaps: [context/loro-js-performance.md](context/loro-js-performance.md). +- loro.js MovableList model, Rust differential suite (`pnpm --dir loro-js + test: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..33be8f520 --- /dev/null +++ b/context/loro-js-movable-list.md @@ -0,0 +1,223 @@ +# loro.js MovableList: Rust-Compatible Model + +Verified against code 2026-09-28. + +`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`. + +## 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. + +```sh +pnpm --dir loro-js build:reference # dev WASM build of crates/loro-wasm (nodejs) +pnpm --dir loro-js test:differential # CI-sized seeds +LORO_JS_DIFF_SEEDS=500 LORO_JS_DIFF_STEPS=200 pnpm --dir loro-js test:differential +``` + +`LORO_JS_REFERENCE=/path/to/nodejs/index.js` compares against another Rust +build, for example an unmerged branch. 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. +The suite is excluded from `pnpm test` because it needs the WASM build; CI +runs it in `.github/workflows/loro-js.yml`. + +## 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 leaves loro.js detached; Rust + stays attached. The profiles 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. +- Some shallow-snapshot imports that Rust accepts are rejected by loro.js with + "cannot import updates that depend on an outdated version". +- 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. diff --git a/context/loro-js-performance.md b/context/loro-js-performance.md index 896482166..a5ed421b6 100644 --- a/context/loro-js-performance.md +++ b/context/loro-js-performance.md @@ -325,4 +325,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 +(`pnpm --dir loro-js test: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/package.json b/loro-js/package.json index 2785b414c..4641fe84c 100644 --- a/loro-js/package.json +++ b/loro-js/package.json @@ -50,6 +50,7 @@ "bench:subscribers": "pnpm build && node --expose-gc benchmarks/container-subscriber.mjs", "bench:text-buffer": "pnpm build && node --expose-gc benchmarks/text-buffer.mjs", "build": "vp pack", + "build:reference": "node scripts/build-reference.mjs", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build", "clean": "rm -rf dist", "format": "vp fmt", @@ -57,6 +58,7 @@ "fixtures:rewrite": "pnpm build && node scripts/rewrite-rust-fixture.mjs", "lint": "vp lint", "test": "vp test run", + "test:differential": "LORO_JS_DIFFERENTIAL=1 vp test run", "typecheck": "tsgo -p tsconfig.json --noEmit" }, "devDependencies": { diff --git a/loro-js/scripts/build-reference.mjs b/loro-js/scripts/build-reference.mjs new file mode 100644 index 000000000..cc0742398 --- /dev/null +++ b/loro-js/scripts/build-reference.mjs @@ -0,0 +1,27 @@ +// Builds the Node target of `crates/loro-wasm` (the `loro-crdt` package) in the +// dev profile, for `pnpm test:differential`. This is the subset of +// `pnpm -C crates/loro-wasm build-dev` that the differential suite needs: one +// target, no bundler/browser outputs, and no package tests. +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const wasmDir = fileURLToPath(new URL("../../crates/loro-wasm/", import.meta.url)); + +function run(command, args) { + const result = spawnSync(command, args, { cwd: wasmDir, stdio: "inherit" }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} exited with ${result.status}`); + } +} + +run("deno", ["run", "-A", "./scripts/build.ts", "dev", "nodejs"]); +run("pnpm", ["exec", "rollup", "-c"]); + +// Same rewrite as `crates/loro-wasm/scripts/post-rollup.ts` for the Node target. +const indexPath = new URL("nodejs/index.js", `file://${wasmDir}`); +const index = readFileSync(indexPath, "utf8").replace( + /require\(["']loro-wasm["']\)/g, + 'require("./loro_wasm")', +); +writeFileSync(indexPath, index); diff --git a/loro-js/tests/differential/engine.ts b/loro-js/tests/differential/engine.ts new file mode 100644 index 000000000..382eb87e1 --- /dev/null +++ b/loro-js/tests/differential/engine.ts @@ -0,0 +1,113 @@ +/** + * 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 }; + forkAt(frontiers: FrontiersLike): 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..6796431b5 --- /dev/null +++ b/loro-js/tests/differential/fuzz.ts @@ -0,0 +1,766 @@ +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, + 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; + /** Last action kind per peer, to keep undo/redo unchained (see `generate`). */ + readonly #lastKind = new Map(); + + constructor( + readonly engines: Record, + readonly profile: FuzzProfile, + readonly seed: number, + ) { + this.rng = new Rng(seed); + 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) { + if (from === to) continue; + yield { kind: "sync", from, to, mode: "update", cross: this.profile.crossEngine }; + } + } + } + + generate(): Action { + const action = this.generateAction(); + this.#lastKind.set("peer" in action ? action.peer : action.to, action.kind); + return action; + } + + 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"; + 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 = Math.max(...peers) + 1; + 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": + return { kind, peer, target, from: rng.int(length), to: rng.int(length) }; + 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; + // 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`, + ); + } + 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); + 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) + .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; + } + } + } + + 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); + 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; + } + }); + } + + 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"; + try { + 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; + } + 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": { + const spans = [...vvToMap(doc.oplogVersion())].flatMap(([peer, end]) => { + const start = receiverVersion.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 { + 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; + const doc = source.docs[sourceEngine]; + bytes[engine] = action.shallow + ? doc.export({ mode: "shallow-snapshot", frontiers }) + : doc.export({ mode: "snapshot" }); + } + 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(); + } +} + +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 }; +} + +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..8ed209682 --- /dev/null +++ b/loro-js/tests/differential/harness.ts @@ -0,0 +1,480 @@ +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; + + 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) { + 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 (!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)) + fail("events differ", rustEvents, jsEvents); + } else { + twin.events.rust.length = 0; + twin.events.js.length = 0; + } +} + +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..79440f4e5 --- /dev/null +++ b/loro-js/tests/differential/movable-list.test.ts @@ -0,0 +1,166 @@ +import { readFileSync } 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 engines = { rust: loadReference(), 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", + "insert/delete undo (one peer)", + "move/set", + "move/set with children", + "move/set history", + "move/set undo (one peer)", + "snapshots", + "shallow snapshots", +]); + +describe("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)( + "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); + 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..65e0b9f95 --- /dev/null +++ b/loro-js/tests/differential/reference.ts @@ -0,0 +1,32 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +import * as loroJs from "../../src/index"; + +import type { EngineModule } from "./engine"; + +/** + * Loads the Rust implementation through the workspace `loro-crdt` Node build. + * + * Build it with `pnpm --dir loro-js build:reference` (a dev WASM build of + * `crates/loro-wasm`), or point `LORO_JS_REFERENCE` at another `nodejs/index.js` + * build, for example one made from an unmerged Rust branch. + */ +export function loadReference(): EngineModule { + const path = + process.env["LORO_JS_REFERENCE"] ?? + fileURLToPath(new URL("../../../crates/loro-wasm/nodejs/index.js", import.meta.url)); + if (!existsSync(path)) { + throw new Error( + `Rust reference build not found at ${path}. ` + + "Run `pnpm --dir loro-js build:reference` first.", + ); + } + const require = createRequire(import.meta.url); + return require(path) as EngineModule; +} + +export function loadLoroJs(): EngineModule { + return loroJs as unknown as EngineModule; +} diff --git a/loro-js/vite.config.ts b/loro-js/vite.config.ts index d827ce4be..8151b4e13 100644 --- a/loro-js/vite.config.ts +++ b/loro-js/vite.config.ts @@ -35,6 +35,15 @@ export default defineConfig({ }, test: { environment: "node", - include: ["tests/**/*.test.ts"], + // The differential suite needs a WASM build of the Rust implementation; run it + // with `pnpm test:differential` after `pnpm build:reference`. + include: + process.env["LORO_JS_DIFFERENTIAL"] === "1" + ? ["tests/differential/**/*.test.ts"] + : ["tests/**/*.test.ts"], + exclude: + process.env["LORO_JS_DIFFERENTIAL"] === "1" + ? ["**/node_modules/**"] + : ["**/node_modules/**", "tests/differential/**"], }, }); From dfe945d0f916ffc64f2098ecc0a261907fa82e36 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 02:54:43 +0800 Subject: [PATCH 02/10] test(loro.js): compare event effects and replicate unordered reverts Event deltas that differ only in shape are checked by effect; reverts whose op order Rust leaves to hash order are checked by value and replicated from Rust; peers that both engines reject are retired; peer IDs are never reused; undo stays unchained; the minimizer keeps those rules. See context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- loro-js/tests/differential/engine.ts | 2 + loro-js/tests/differential/fuzz.ts | 122 +++++++++++++++-- loro-js/tests/differential/harness.ts | 128 +++++++++++++++++- .../tests/differential/movable-list.test.ts | 6 +- 4 files changed, 242 insertions(+), 16 deletions(-) diff --git a/loro-js/tests/differential/engine.ts b/loro-js/tests/differential/engine.ts index 382eb87e1..c842bec0e 100644 --- a/loro-js/tests/differential/engine.ts +++ b/loro-js/tests/differential/engine.ts @@ -91,7 +91,9 @@ export interface DocLike { 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 { diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index 6796431b5..0acbbff51 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -134,6 +134,8 @@ export class FuzzRun { 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(); @@ -143,6 +145,7 @@ export class FuzzRun { 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, @@ -229,7 +232,9 @@ export class FuzzRun { generate(): Action { const action = this.generateAction(); - this.#lastKind.set("peer" in action ? action.peer : action.to, action.kind); + if (countsForUndoChain(action)) { + this.#lastKind.set("peer" in action ? action.peer : action.to, action.kind); + } return action; } @@ -282,7 +287,7 @@ export class FuzzRun { }; } case "join": { - const joined = Math.max(...peers) + 1; + const joined = this.#nextPeer++; return { kind: "join", from: peer, @@ -368,8 +373,12 @@ export class FuzzRun { const pos = rng.int(length); return { kind, peer, target, pos, len: 1 + rng.int(Math.min(3, length - pos)) }; } - case "move": - return { kind, peer, target, from: rng.int(length), to: rng.int(length) }; + 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": @@ -462,10 +471,14 @@ export class FuzzRun { case "revert": { const twin = this.twin(action.peer); const frontiers = this.historyAt(twin, action.at); - twin.both("revertTo", (doc) => { - doc.revertTo(frontiers); - doc.commit(); - }); + if (this.#revertOrderIsUnspecified(twin, frontiers)) { + this.#revertThroughRust(twin, frontiers); + } else { + twin.both("revertTo", (doc) => { + doc.revertTo(frontiers); + doc.commit(); + }); + } twin.record(); return; } @@ -493,6 +506,58 @@ export class FuzzRun { } } + /** + * 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. Twins must share + * one history, so such a revert is checked by value and then replicated. + */ + #revertOrderIsUnspecified(twin: Twin, frontiers: FrontiersLike): boolean { + let diff: [string, unknown][]; + try { + twin.docs.rust.commit(); + diff = twin.docs.rust.diff(twin.docs.rust.frontiers(), frontiers, false); + } catch { + return false; + } + const depths = new Map(); + for (const [id] of diff) { + const depth = twin.docs.rust.getPathToContainer(id)?.length ?? 0; + depths.set(depth, (depths.get(depth) ?? 0) + 1); + } + return [...depths.values()].some((count) => count > 1); + } + + #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; + } + twin(peer: number): Twin { return this.twins.get(peer)!; } @@ -575,15 +640,25 @@ export class FuzzRun { js: exported("js"), }; target.batchImport = action.mode === "batch"; + let imported: unknown; try { - target.both(`import from ${source.name} (${action.mode})`, (doc, engine) => - action.mode === "batch" - ? doc.importBatch(blobs[engine]) - : doc.import(blobs[engine][0]!), + 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(); } @@ -614,6 +689,7 @@ export class FuzzRun { } 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", { @@ -700,6 +776,7 @@ export function minimizeTrace( changed = false; for (let index = actions.length - 1; index >= 0; index -= 1) { const candidate = actions.filter((_, i) => i !== index); + if (!keepsUndoUnchained(candidate)) continue; const message = replayTrace(engines, { ...trace, actions: candidate }); if (message !== undefined && !message.startsWith("crash")) { actions = candidate; @@ -710,6 +787,27 @@ export function minimizeTrace( return { ...trace, actions }; } +/** Edits, undo and redo decide whether the next undo would be chained. */ +function countsForUndoChain(action: Action): boolean { + if (action.kind === "move") return action.from !== action.to; + return "target" in action || action.kind === "undo" || action.kind === "redo"; +} + +/** The same rule `FuzzRun.generate` follows: undo after an edit, redo after an undo. */ +function keepsUndoUnchained(actions: readonly Action[]): boolean { + const last = new Map(); + for (const action of actions) { + if (!countsForUndoChain(action)) continue; + const peer = "peer" in action ? action.peer : action.to; + const previous = last.get(peer); + if (action.kind === "undo" && (previous === "undo" || previous === "redo")) + return false; + if (action.kind === "redo" && previous !== "undo") return false; + last.set(peer, action.kind); + } + return true; +} + 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`); diff --git a/loro-js/tests/differential/harness.ts b/loro-js/tests/differential/harness.ts index 8ed209682..8e4bbb808 100644 --- a/loro-js/tests/differential/harness.ts +++ b/loro-js/tests/differential/harness.ts @@ -82,6 +82,8 @@ export class Twin { 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, @@ -441,12 +443,134 @@ export function assertTwinAgrees( 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)) - fail("events differ", rustEvents, jsEvents); + 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. 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, + rust: readonly EventBatchLike[], + js: readonly EventBatchLike[], +): string | undefined { + 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; +} + +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( diff --git a/loro-js/tests/differential/movable-list.test.ts b/loro-js/tests/differential/movable-list.test.ts index 79440f4e5..327241f1d 100644 --- a/loro-js/tests/differential/movable-list.test.ts +++ b/loro-js/tests/differential/movable-list.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; @@ -153,8 +153,10 @@ test.runIf(replayPath !== undefined)( "replays a saved trace", () => { let trace = JSON.parse(readFileSync(replayPath!, "utf8")) as SavedTrace; - if (process.env["LORO_JS_DIFF_MINIMIZE"] === "1") + 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 From 47eb6d7103c068b79b80bb60296290839070f9ed Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 03:17:57 +0800 Subject: [PATCH 03/10] test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained Co-Authored-By: Claude Opus 5.5 (1M context) --- loro-js/tests/differential/fuzz.ts | 94 ++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 32 deletions(-) diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index 0acbbff51..622521642 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -138,6 +138,12 @@ export class FuzzRun { #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, @@ -231,11 +237,7 @@ export class FuzzRun { } generate(): Action { - const action = this.generateAction(); - if (countsForUndoChain(action)) { - this.#lastKind.set("peer" in action ? action.peer : action.to, action.kind); - } - return action; + return this.generateAction(); } generateAction(): Action { @@ -248,7 +250,8 @@ export class FuzzRun { // 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"; + const canUndo = + profile.undo && last !== "undo" && last !== "redo" && this.#changedSinceUndo(peer); const canRedo = profile.undo && last === "undo"; const kind = rng.weighted({ edit: 12, @@ -418,6 +421,15 @@ export class FuzzRun { 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()); @@ -433,6 +445,7 @@ export class FuzzRun { `${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; } @@ -558,6 +571,14 @@ export class FuzzRun { 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)!; } @@ -568,7 +589,7 @@ export class FuzzRun { applyEdit(action: Extract): void { const twin = this.twin(action.peer); - twin.both(action.kind, (doc, engine) => { + const applied = twin.both(action.kind, (doc, engine) => { const engineModule = this.engines[engine]; switch (action.kind) { case "insert": @@ -603,6 +624,10 @@ export class FuzzRun { 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 { @@ -706,10 +731,31 @@ export class FuzzRun { }; for (const engine of ENGINES) { const sourceEngine: EngineName = action.cross ? other(engine) : engine; - const doc = source.docs[sourceEngine]; - bytes[engine] = action.shallow - ? doc.export({ mode: "shallow-snapshot", frontiers }) - : doc.export({ mode: "snapshot" }); + 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, @@ -776,7 +822,6 @@ export function minimizeTrace( changed = false; for (let index = actions.length - 1; index >= 0; index -= 1) { const candidate = actions.filter((_, i) => i !== index); - if (!keepsUndoUnchained(candidate)) continue; const message = replayTrace(engines, { ...trace, actions: candidate }); if (message !== undefined && !message.startsWith("crash")) { actions = candidate; @@ -787,26 +832,11 @@ export function minimizeTrace( return { ...trace, actions }; } -/** Edits, undo and redo decide whether the next undo would be chained. */ -function countsForUndoChain(action: Action): boolean { - if (action.kind === "move") return action.from !== action.to; - return "target" in action || action.kind === "undo" || action.kind === "redo"; -} - -/** The same rule `FuzzRun.generate` follows: undo after an edit, redo after an undo. */ -function keepsUndoUnchained(actions: readonly Action[]): boolean { - const last = new Map(); - for (const action of actions) { - if (!countsForUndoChain(action)) continue; - const peer = "peer" in action ? action.peer : action.to; - const previous = last.get(peer); - if (action.kind === "undo" && (previous === "undo" || previous === "redo")) - return false; - if (action.kind === "redo" && previous !== "undo") return false; - last.set(peer, action.kind); - } - return true; -} +/** + * 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, "-"); From 4ceb7c7bfd5bfd2f241211c533e3baaecdb6e0f8 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 03:34:10 +0800 Subject: [PATCH 04/10] test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs Co-Authored-By: Claude Opus 5.5 (1M context) --- loro-js/tests/differential/fuzz.ts | 61 +++++++++++++++++++++++---- loro-js/tests/differential/harness.ts | 39 +++++++++++++++-- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index 622521642..e3572cce9 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -12,6 +12,7 @@ import { assertTwinAgrees, canonicalDiff, describeError, + eventShapeDifferences, mapContainer, movableList, plain, @@ -484,7 +485,7 @@ export class FuzzRun { case "revert": { const twin = this.twin(action.peer); const frontiers = this.historyAt(twin, action.at); - if (this.#revertOrderIsUnspecified(twin, frontiers)) { + if (this.#revertIsUnspecified(twin, frontiers)) { this.#revertThroughRust(twin, frontiers); } else { twin.both("revertTo", (doc) => { @@ -520,25 +521,42 @@ export class FuzzRun { } /** + * 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. Twins must share - * one history, so such a revert is checked by value and then replicated. + * 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. */ - #revertOrderIsUnspecified(twin: Twin, frontiers: FrontiersLike): boolean { - let diff: [string, unknown][]; + #revertIsUnspecified(twin: Twin, frontiers: FrontiersLike): boolean { + let rustDiff: [string, unknown][]; + let jsDiff: [string, unknown][]; try { - twin.docs.rust.commit(); - diff = twin.docs.rust.diff(twin.docs.rust.frontiers(), frontiers, false); + 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; } const depths = new Map(); - for (const [id] of diff) { + for (const [id] of rustDiff) { const depth = twin.docs.rust.getPathToContainer(id)?.length ?? 0; depths.set(depth, (depths.get(depth) ?? 0) + 1); } - return [...depths.values()].some((count) => count > 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 { @@ -790,6 +808,31 @@ function newChild(engine: EngineModule, kind: ChildKind): ContainerLike { } } +/** + * `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 }; +} + export interface SavedTrace { readonly profile: FuzzProfile; readonly seed: number; diff --git a/loro-js/tests/differential/harness.ts b/loro-js/tests/differential/harness.ts index 8e4bbb808..0caa01927 100644 --- a/loro-js/tests/differential/harness.ts +++ b/loro-js/tests/differential/harness.ts @@ -465,17 +465,22 @@ export function assertTwinAgrees( * 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. After a - * history replay loro.js diffs text before and after instead of using the ops. + * 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, - rust: readonly EventBatchLike[], - js: readonly EventBatchLike[], + 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(); @@ -523,6 +528,32 @@ function equivalentEvents( 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), From 17e8f2c069770c79d9f8854620dfb36374f93580 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 03:46:58 +0800 Subject: [PATCH 05/10] test(loro.js): skip shallow editors and start range exports at the shallow root Both follow Rust issues listed in context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- context/loro-js-movable-list.md | 20 ++++++++++++++++++++ loro-js/tests/differential/fuzz.ts | 10 ++++++---- loro-js/tests/differential/harness.ts | 8 ++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/context/loro-js-movable-list.md b/context/loro-js-movable-list.md index 33be8f520..b3e632ad8 100644 --- a/context/loro-js-movable-list.md +++ b/context/loro-js-movable-list.md @@ -221,3 +221,23 @@ them (see the comments in `harness.ts` and `fuzz.ts`): - 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 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/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index e3572cce9..d9fe1bbb6 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -716,8 +716,12 @@ export class FuzzRun { 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 = receiverVersion.get(peer) ?? 0; + 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 }); @@ -825,9 +829,7 @@ function orderedDiff(diff: unknown): unknown { } 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 }, - ); + output.push("insert" in item ? { insert: plain(item.insert) } : { ...item }); } } return { type: "list", diff: output }; diff --git a/loro-js/tests/differential/harness.ts b/loro-js/tests/differential/harness.ts index 0caa01927..cef02259d 100644 --- a/loro-js/tests/differential/harness.ts +++ b/loro-js/tests/differential/harness.ts @@ -426,12 +426,20 @@ export function assertTwinAgrees( 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); } From 95006a51cb57ae5ba497a432c0f288cb9eff8872 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 28 Sep 2026 04:05:31 +0800 Subject: [PATCH 06/10] test(loro.js): measure revert container depths at the revert target Co-Authored-By: Claude Opus 5.5 (1M context) --- loro-js/tests/differential/fuzz.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index d9fe1bbb6..7a02f3e3a 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -541,9 +541,20 @@ export class FuzzRun { } 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 depth = twin.docs.rust.getPathToContainer(id)?.length ?? 0; + 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; From 17366fd6791531bfcfef54660d202c0fcd28b543 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 30 Sep 2026 01:01:25 +0800 Subject: [PATCH 07/10] test(loro.js): run the MovableList differential suite on the shared Rust reference Load Rust through tests/support/rust-reference.ts like the other differential suites, so it runs in `pnpm test` (and in CI's test-loro-js) and skips without a WASM build. Drop the separate workflow and scripts. insert/delete now converges on main. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/loro-js.yml | 53 ------------------- context/loro-js-movable-list.md | 37 +++++++------ context/loro-js-performance.md | 2 +- loro-js/AGENTS.md | 3 ++ loro-js/package.json | 2 - loro-js/scripts/build-reference.mjs | 27 ---------- .../tests/differential/movable-list.test.ts | 8 +-- loro-js/tests/differential/reference.ts | 28 +++------- loro-js/vite.config.ts | 11 +--- 9 files changed, 38 insertions(+), 133 deletions(-) delete mode 100644 .github/workflows/loro-js.yml delete mode 100644 loro-js/scripts/build-reference.mjs diff --git a/.github/workflows/loro-js.yml b/.github/workflows/loro-js.yml deleted file mode 100644 index 55a1c6082..000000000 --- a/.github/workflows/loro-js.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: loro.js - -on: - push: - branches: ["main"] - paths: - - "loro-js/**" - - "crates/**" - - "pnpm-lock.yaml" - - ".github/workflows/loro-js.yml" - pull_request: - branches: ["main"] - paths: - - "loro-js/**" - - "crates/**" - - "pnpm-lock.yaml" - - ".github/workflows/loro-js.yml" - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 22.x - - run: pnpm install --frozen-lockfile - - run: pnpm --dir loro-js check - - differential: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10 - - uses: actions/setup-node@v4 - with: - node-version: 22.x - - uses: denoland/setup-deno@v1 - with: - deno-version: v2.x - - run: rustup target add wasm32-unknown-unknown - - uses: jetli/wasm-bindgen-action@v0.2.0 - with: - version: "0.2.100" - - uses: Swatinem/rust-cache@v2 - - run: pnpm install --frozen-lockfile - - run: pnpm --dir loro-js build:reference - - run: pnpm --dir loro-js test:differential diff --git a/context/loro-js-movable-list.md b/context/loro-js-movable-list.md index b3e632ad8..a2119d294 100644 --- a/context/loro-js-movable-list.md +++ b/context/loro-js-movable-list.md @@ -1,6 +1,6 @@ # loro.js MovableList: Rust-Compatible Model -Verified against code 2026-09-28. +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 @@ -11,7 +11,9 @@ suite checks the two against each other. Complexity rules still come from 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 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) @@ -177,25 +179,29 @@ identical values, versions, frontiers, reachable container IDs, 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 --dir loro-js build:reference # dev WASM build of crates/loro-wasm (nodejs) -pnpm --dir loro-js test:differential # CI-sized seeds -LORO_JS_DIFF_SEEDS=500 LORO_JS_DIFF_STEPS=200 pnpm --dir loro-js test:differential +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 ``` -`LORO_JS_REFERENCE=/path/to/nodejs/index.js` compares against another Rust -build, for example an unmerged branch. 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. +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. -The suite is excluded from `pnpm test` because it needs the WASM build; CI -runs it in `.github/workflows/loro-js.yml`. ## Remaining known divergences (not MovableList-specific) @@ -209,13 +215,14 @@ them (see the comments in `harness.ts` and `fuzz.ts`): 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 leaves loro.js detached; Rust - stays attached. The profiles skip such checkouts. +- `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. + 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". - Multi-blob `importBatch` events are labeled `by: "checkout"` by Rust and diff --git a/context/loro-js-performance.md b/context/loro-js-performance.md index 6c7725214..474a4a631 100644 --- a/context/loro-js-performance.md +++ b/context/loro-js-performance.md @@ -539,6 +539,6 @@ 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. The randomized Rust differential suite -(`pnpm --dir loro-js test:differential`, see +(`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/package.json b/loro-js/package.json index 4641fe84c..2785b414c 100644 --- a/loro-js/package.json +++ b/loro-js/package.json @@ -50,7 +50,6 @@ "bench:subscribers": "pnpm build && node --expose-gc benchmarks/container-subscriber.mjs", "bench:text-buffer": "pnpm build && node --expose-gc benchmarks/text-buffer.mjs", "build": "vp pack", - "build:reference": "node scripts/build-reference.mjs", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build", "clean": "rm -rf dist", "format": "vp fmt", @@ -58,7 +57,6 @@ "fixtures:rewrite": "pnpm build && node scripts/rewrite-rust-fixture.mjs", "lint": "vp lint", "test": "vp test run", - "test:differential": "LORO_JS_DIFFERENTIAL=1 vp test run", "typecheck": "tsgo -p tsconfig.json --noEmit" }, "devDependencies": { diff --git a/loro-js/scripts/build-reference.mjs b/loro-js/scripts/build-reference.mjs deleted file mode 100644 index cc0742398..000000000 --- a/loro-js/scripts/build-reference.mjs +++ /dev/null @@ -1,27 +0,0 @@ -// Builds the Node target of `crates/loro-wasm` (the `loro-crdt` package) in the -// dev profile, for `pnpm test:differential`. This is the subset of -// `pnpm -C crates/loro-wasm build-dev` that the differential suite needs: one -// target, no bundler/browser outputs, and no package tests. -import { spawnSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -const wasmDir = fileURLToPath(new URL("../../crates/loro-wasm/", import.meta.url)); - -function run(command, args) { - const result = spawnSync(command, args, { cwd: wasmDir, stdio: "inherit" }); - if (result.status !== 0) { - throw new Error(`${command} ${args.join(" ")} exited with ${result.status}`); - } -} - -run("deno", ["run", "-A", "./scripts/build.ts", "dev", "nodejs"]); -run("pnpm", ["exec", "rollup", "-c"]); - -// Same rewrite as `crates/loro-wasm/scripts/post-rollup.ts` for the Node target. -const indexPath = new URL("nodejs/index.js", `file://${wasmDir}`); -const index = readFileSync(indexPath, "utf8").replace( - /require\(["']loro-wasm["']\)/g, - 'require("./loro_wasm")', -); -writeFileSync(indexPath, index); diff --git a/loro-js/tests/differential/movable-list.test.ts b/loro-js/tests/differential/movable-list.test.ts index 327241f1d..2228d17a6 100644 --- a/loro-js/tests/differential/movable-list.test.ts +++ b/loro-js/tests/differential/movable-list.test.ts @@ -22,7 +22,8 @@ import { loadLoroJs, loadReference } from "./reference"; * checkout never targets the current frontiers. */ -const engines = { rust: loadReference(), js: loadLoroJs() }; +const rust = loadReference(); +const engines = { rust: rust!, js: loadLoroJs() }; const none: FuzzProfile = { name: "", @@ -115,7 +116,6 @@ const cases: Case[] = [ * context/loro-js-movable-list.md removes them from this set. */ const KNOWN_DIVERGENT = new Set([ - "insert/delete", "insert/delete undo (one peer)", "move/set", "move/set with children", @@ -125,7 +125,7 @@ const KNOWN_DIVERGENT = new Set([ "shallow snapshots", ]); -describe("MovableList matches the Rust implementation", () => { +describe.skipIf(rust === undefined)("MovableList matches the Rust implementation", () => { test.each(cases)( "$name", ({ name, profile, seeds, steps }) => { @@ -149,7 +149,7 @@ describe("MovableList matches the Rust implementation", () => { // 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)( +test.runIf(replayPath !== undefined && rust !== undefined)( "replays a saved trace", () => { let trace = JSON.parse(readFileSync(replayPath!, "utf8")) as SavedTrace; diff --git a/loro-js/tests/differential/reference.ts b/loro-js/tests/differential/reference.ts index 65e0b9f95..7c33f9adb 100644 --- a/loro-js/tests/differential/reference.ts +++ b/loro-js/tests/differential/reference.ts @@ -1,30 +1,16 @@ -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; - import * as loroJs from "../../src/index"; +import { loadRustReference } from "../support/rust-reference"; import type { EngineModule } from "./engine"; /** - * Loads the Rust implementation through the workspace `loro-crdt` Node build. - * - * Build it with `pnpm --dir loro-js build:reference` (a dev WASM build of - * `crates/loro-wasm`), or point `LORO_JS_REFERENCE` at another `nodejs/index.js` - * build, for example one made from an unmerged Rust branch. + * 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 { - const path = - process.env["LORO_JS_REFERENCE"] ?? - fileURLToPath(new URL("../../../crates/loro-wasm/nodejs/index.js", import.meta.url)); - if (!existsSync(path)) { - throw new Error( - `Rust reference build not found at ${path}. ` + - "Run `pnpm --dir loro-js build:reference` first.", - ); - } - const require = createRequire(import.meta.url); - return require(path) as EngineModule; +export function loadReference(): EngineModule | undefined { + return loadRustReference() as unknown as EngineModule | undefined; } export function loadLoroJs(): EngineModule { diff --git a/loro-js/vite.config.ts b/loro-js/vite.config.ts index 8151b4e13..d827ce4be 100644 --- a/loro-js/vite.config.ts +++ b/loro-js/vite.config.ts @@ -35,15 +35,6 @@ export default defineConfig({ }, test: { environment: "node", - // The differential suite needs a WASM build of the Rust implementation; run it - // with `pnpm test:differential` after `pnpm build:reference`. - include: - process.env["LORO_JS_DIFFERENTIAL"] === "1" - ? ["tests/differential/**/*.test.ts"] - : ["tests/**/*.test.ts"], - exclude: - process.env["LORO_JS_DIFFERENTIAL"] === "1" - ? ["**/node_modules/**"] - : ["**/node_modules/**", "tests/differential/**"], + include: ["tests/**/*.test.ts"], }, }); From f5dadabfc124de66a925e564a1e5a204324fdc15 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 30 Sep 2026 01:40:45 +0800 Subject: [PATCH 08/10] test(loro.js): skip final syncs with a peer that left the run Co-Authored-By: Claude Opus 5.5 --- loro-js/tests/differential/fuzz.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index 7a02f3e3a..828ba9268 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -231,7 +231,8 @@ export class FuzzRun { ); for (const from of peers) { for (const to of peers) { - if (from === to) continue; + // 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 }; } } From 22d811fe331172671e3ef3c43aefe266f1ca4bf0 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 30 Sep 2026 02:07:51 +0800 Subject: [PATCH 09/10] test(loro.js): skip map entries that only delete keys when comparing diff() A child created in the range whose keys were all set and deleted again is listed by Rust and omitted by loro.js, also on main and for any parent; see context/loro-js-movable-list.md. Co-Authored-By: Claude Opus 5.5 --- context/loro-js-movable-list.md | 4 ++++ loro-js/tests/differential/fuzz.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/context/loro-js-movable-list.md b/context/loro-js-movable-list.md index a2119d294..26ff714f3 100644 --- a/context/loro-js-movable-list.md +++ b/context/loro-js-movable-list.md @@ -225,6 +225,10 @@ them (see the comments in `harness.ts` and `fuzz.ts`): 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. diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index 828ba9268..3015cd013 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -508,6 +508,10 @@ export class FuzzRun { .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. + .filter(([, diff]) => !onlyDeletedMapKeys(diff)) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)), ); if (results !== undefined && !isDeepStrictEqual(results.rust, results.js)) { @@ -847,6 +851,14 @@ function orderedDiff(diff: unknown): unknown { return { type: "list", diff: output }; } +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; From 4e99ba64882146a5acf49a0508280c6fccf945e4 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 30 Sep 2026 03:23:54 +0800 Subject: [PATCH 10/10] test(loro.js): fix the suite's changeset and ignore empty diff() deltas The changeset named a test:differential script that does not exist. Rust's diff() can list a sequence with an empty delta where loro.js omits it (context/loro-js-movable-list.md). Co-Authored-By: Claude Opus 5.5 --- .changeset/loro-js-differential-suite.md | 2 +- context/loro-js-movable-list.md | 3 +++ loro-js/tests/differential/fuzz.ts | 11 +++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/loro-js-differential-suite.md b/.changeset/loro-js-differential-suite.md index ac05280fe..4aa735d9e 100644 --- a/.changeset/loro-js-differential-suite.md +++ b/.changeset/loro-js-differential-suite.md @@ -1,4 +1,4 @@ --- --- -Add a differential test suite that compares loro.js with the Rust implementation (`pnpm --dir loro-js test:differential`). Tests and scripts only; the published package does not change. +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/context/loro-js-movable-list.md b/context/loro-js-movable-list.md index 26ff714f3..3ea78a33b 100644 --- a/context/loro-js-movable-list.md +++ b/context/loro-js-movable-list.md @@ -242,6 +242,9 @@ Differences in form, not in effect, which the suite checks by value: - 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: diff --git a/loro-js/tests/differential/fuzz.ts b/loro-js/tests/differential/fuzz.ts index 3015cd013..bff356ae3 100644 --- a/loro-js/tests/differential/fuzz.ts +++ b/loro-js/tests/differential/fuzz.ts @@ -510,8 +510,10 @@ export class FuzzRun { .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. - .filter(([, diff]) => !onlyDeletedMapKeys(diff)) + // 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)) { @@ -851,6 +853,11 @@ function orderedDiff(diff: unknown): unknown { 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 (