diff --git a/packages/dds/cell/src/test/cell.rollback.spec.ts b/packages/dds/cell/src/test/cell.rollback.spec.ts new file mode 100644 index 000000000000..47e3304eea63 --- /dev/null +++ b/packages/dds/cell/src/test/cell.rollback.spec.ts @@ -0,0 +1,173 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { setupRollbackTest, createAdditionalClient } from "@fluid-private/test-dds-utils"; + +import { CellFactory } from "../cellFactory.js"; +import type { ISharedCell } from "../interfaces.js"; + +const cellFactory = new CellFactory(); + +describe("Cell with rollback", () => { + it("should emit valueChanged on set and rollback should re-emit previous value", async () => { + const { dds: cell, containerRuntime } = setupRollbackTest( + "cell-1", + (rt, id): ISharedCell => cellFactory.create(rt, id), + ); + + const events: (string | undefined)[] = []; + + cell.on("valueChanged", (value) => events.push("valueChanged")); + + cell.on("delete", () => events.push("delete")); + + cell.set(10); + assert.equal(cell.get(), 10); + + containerRuntime.rollback?.(); + + assert.equal(cell.get(), undefined); + + assert.deepEqual(events, ["valueChanged", "delete"]); + }); + + it("should emit delete on delete, and rollback should re-emit last valueChanged", async () => { + const { + dds: cell, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "cell-1", + (rt, id): ISharedCell => cellFactory.create(rt, id), + ); + + const events: (string | undefined)[] = []; + + cell.on("valueChanged", (value) => events.push("valueChanged")); + cell.on("delete", () => events.push("delete")); + + cell.set(42); + assert.equal(events.shift(), "valueChanged"); + containerRuntime.flush(); + containerRuntimeFactory.processAllMessages(); + + cell.delete(); + assert(cell.empty()); + + // rollback delete + containerRuntime.rollback?.(); + assert.equal(cell.get(), 42); + + // delete triggers delete event, rollback restores valueChanged + assert.deepEqual(events, ["valueChanged", "delete", "valueChanged"]); + }); +}); + +describe("SharedCell rollback events with multiple clients", () => { + it("should emit valueChanged on set and rollback should re-emit previous value across clients", async () => { + // Setup two clients + const { + dds: cell1, + containerRuntimeFactory, + containerRuntime: runtime1, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedCell => cellFactory.create(rt, id), + ); + const { dds: cell2 } = createAdditionalClient( + containerRuntimeFactory, + "client-2", + (rt, id): ISharedCell => cellFactory.create(rt, `cell-${id}`), + ); + + const events1: string[] = []; + const events2: string[] = []; + + // Listen to valueChanged events on both clients + cell1.on("valueChanged", () => events1.push("valueChanged")); + cell2.on("valueChanged", () => events2.push("valueChanged")); + + // Client 1 sets a value + cell1.set(10); + assert.equal(cell1.get(), 10); + + // Propagate ops to client 2 + runtime1.flush(); + containerRuntimeFactory.processAllMessages(); + + assert.equal(cell2.get(), 10); + + cell1.set(100); + assert.equal(cell1.get(), 100); + assert.equal(cell2.get(), 10); + + // Rollback on client 1 + runtime1.rollback?.(); + + assert.equal(cell1.get(), 10); + assert.equal(cell2.get(), 10); + + // Both clients should have seen the events + assert.deepEqual(events1, ["valueChanged", "valueChanged", "valueChanged"]); + assert.deepEqual(events2, ["valueChanged"]); + }); + + it("should emit delete on delete, and rollback should re-emit last valueChanged across clients", async () => { + // Setup two clients + const { + dds: cell1, + containerRuntimeFactory, + containerRuntime: runtime1, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedCell => cellFactory.create(rt, id), + ); + const { dds: cell2 } = createAdditionalClient( + containerRuntimeFactory, + "client-2", + (rt, id): ISharedCell => cellFactory.create(rt, `cell-${id}`), + ); + + const events1: string[] = []; + const events2: string[] = []; + + // Attach listeners + cell1.on("valueChanged", () => events1.push("valueChanged")); + cell1.on("delete", () => events1.push("delete")); + + cell2.on("valueChanged", () => events2.push("valueChanged")); + cell2.on("delete", () => events2.push("delete")); + + // Set initial value and propagate + cell1.set(42); + runtime1.flush(); + containerRuntimeFactory.processAllMessages(); + + assert.equal(cell2.get(), 42); + + // Delete the value + cell1.delete(); + + assert(cell1.empty()); + assert.equal(cell2.get(), 42); + + // Rollback delete + runtime1.rollback?.(); + + // After rollback, this flush/process should not affect cell2. + runtime1.flush(); + containerRuntimeFactory.processAllMessages(); + + // After rollback, value is restored + assert.equal(cell1.get(), 42); + assert.equal(cell2.get(), 42); + + // Event order + assert.deepEqual(events1, ["valueChanged", "delete", "valueChanged"]); + assert.deepEqual(events2, ["valueChanged"]); + }); +}); diff --git a/packages/dds/map/src/test/mocha/directory.rollback.spec.ts b/packages/dds/map/src/test/mocha/directory.rollback.spec.ts index 4359b972c7c0..a9364f544bac 100644 --- a/packages/dds/map/src/test/mocha/directory.rollback.spec.ts +++ b/packages/dds/map/src/test/mocha/directory.rollback.spec.ts @@ -5,68 +5,20 @@ import { strict as assert } from "node:assert"; -import { AttachState } from "@fluidframework/container-definitions"; -import { - MockContainerRuntimeFactory, - MockFluidDataStoreRuntime, - MockStorage, - type MockContainerRuntime, -} from "@fluidframework/test-runtime-utils/internal"; +import { setupRollbackTest, createAdditionalClient } from "@fluid-private/test-dds-utils"; import { DirectoryFactory } from "../../directoryFactory.js"; import type { ISharedDirectory, IValueChanged } from "../../interfaces.js"; -interface RollbackTestSetup { - sharedDirectory: ISharedDirectory; - dataStoreRuntime: MockFluidDataStoreRuntime; - containerRuntimeFactory: MockContainerRuntimeFactory; - containerRuntime: MockContainerRuntime; -} - const directoryFactory = new DirectoryFactory(); -function setupRollbackTest(): RollbackTestSetup { - const containerRuntimeFactory = new MockContainerRuntimeFactory({ flushMode: 1 }); // TurnBased - const dataStoreRuntime = new MockFluidDataStoreRuntime({ clientId: "1" }); - const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime); - const sharedDirectory = directoryFactory.create(dataStoreRuntime, "shared-directory-1"); - dataStoreRuntime.setAttachState(AttachState.Attached); - sharedDirectory.connect({ - deltaConnection: dataStoreRuntime.createDeltaConnection(), - objectStorage: new MockStorage(), - }); - return { - sharedDirectory, - dataStoreRuntime, - containerRuntimeFactory, - containerRuntime, - }; -} - -// Helper to create another client attached to the same containerRuntimeFactory -function createAdditionalClient( - containerRuntimeFactory: MockContainerRuntimeFactory, - id: string = "client-2", -): { - sharedDirectory: ISharedDirectory; - dataStoreRuntime: MockFluidDataStoreRuntime; - containerRuntime: MockContainerRuntime; -} { - const dataStoreRuntime = new MockFluidDataStoreRuntime({ clientId: id }); - const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime); - const sharedDirectory = directoryFactory.create(dataStoreRuntime, `shared-directory-${id}`); - dataStoreRuntime.setAttachState(AttachState.Attached); - sharedDirectory.connect({ - deltaConnection: dataStoreRuntime.createDeltaConnection(), - objectStorage: new MockStorage(), - }); - return { sharedDirectory, dataStoreRuntime, containerRuntime }; -} - describe("SharedDirectory rollback", () => { describe("Storage operations (root subdirectory)", () => { it("should rollback set operation", () => { - const { sharedDirectory, containerRuntime } = setupRollbackTest(); + const { dds: sharedDirectory, containerRuntime } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); const valueChanges: IValueChanged[] = []; sharedDirectory.on("valueChanged", (event: IValueChanged) => { valueChanges.push(event); @@ -94,8 +46,14 @@ describe("SharedDirectory rollback", () => { }); it("should rollback delete operation", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); sharedDirectory.set("key1", "value1"); containerRuntime.flush(); containerRuntimeFactory.processAllMessages(); @@ -126,8 +84,14 @@ describe("SharedDirectory rollback", () => { }); it("should rollback clear operation", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); sharedDirectory.set("key1", "value1"); sharedDirectory.set("key2", "value2"); containerRuntime.flush(); @@ -182,8 +146,14 @@ describe("SharedDirectory rollback", () => { }); it("should rollback multiple operations in sequence", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); sharedDirectory.set("key1", "value1"); sharedDirectory.set("key2", "value2"); containerRuntime.flush(); @@ -259,11 +229,22 @@ describe("SharedDirectory rollback", () => { }); it("should rollback local changes in presence of remote changes from another client", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); // Create a second client - const { sharedDirectory: sharedDirectory2, containerRuntime: containerRuntime2 } = - createAdditionalClient(containerRuntimeFactory); + + const { dds: sharedDirectory2, containerRuntime: containerRuntime2 } = + createAdditionalClient( + containerRuntimeFactory, + "client-2", + (rt, id): ISharedDirectory => directoryFactory.create(rt, `directory-${id}`), + ); sharedDirectory.set("key1", "value1"); sharedDirectory.set("key2", "value2"); @@ -307,8 +288,14 @@ describe("SharedDirectory rollback", () => { describe("Storage operations (nested subdirectories)", () => { it("should rollback all basic operations (set, delete, clear) in subdirectories and nested subdirectories", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); const subDir = sharedDirectory.createSubDirectory("subdir"); const level1 = sharedDirectory.createSubDirectory("level1"); @@ -383,10 +370,21 @@ describe("SharedDirectory rollback", () => { }); it("should rollback subdirectory operations with concurrent remote changes", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); - const { sharedDirectory: sharedDirectory2, containerRuntime: containerRuntime2 } = - createAdditionalClient(containerRuntimeFactory); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); + + const { dds: sharedDirectory2, containerRuntime: containerRuntime2 } = + createAdditionalClient( + containerRuntimeFactory, + "client-2", + (rt, id): ISharedDirectory => directoryFactory.create(rt, `directory-${id}`), + ); const subDir1 = sharedDirectory.createSubDirectory("shared"); const nestedDir1 = subDir1.createSubDirectory("nested"); @@ -443,8 +441,14 @@ describe("SharedDirectory rollback", () => { }); it("should rollback complex mixed operations across multiple subdirectory levels", () => { - const { sharedDirectory, containerRuntimeFactory, containerRuntime } = - setupRollbackTest(); + const { + dds: sharedDirectory, + containerRuntime, + containerRuntimeFactory, + } = setupRollbackTest( + "client-1", + (rt, id): ISharedDirectory => directoryFactory.create(rt, id), + ); const dirA = sharedDirectory.createSubDirectory("dirA"); const dirB = sharedDirectory.createSubDirectory("dirB"); diff --git a/packages/dds/sequence/src/test/intervalCollection.event.rollback.spec.ts b/packages/dds/sequence/src/test/intervalCollection.event.rollback.spec.ts new file mode 100644 index 000000000000..27bbe733c368 --- /dev/null +++ b/packages/dds/sequence/src/test/intervalCollection.event.rollback.spec.ts @@ -0,0 +1,399 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "assert"; + +import { setupRollbackTest, createAdditionalClient } from "@fluid-private/test-dds-utils"; + +import { IntervalCollection } from "../intervalCollection.js"; +import { SharedStringFactory } from "../sequenceFactory.js"; +import { SharedStringClass } from "../sharedString.js"; + +describe("changeInterval: SharedString IntervalCollection rollback events", () => { + describe("changeInterval: single client", () => { + it("should trigger changeInterval on rollback of local endpoint modification", () => { + const { + dds: sharedString, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection = sharedString.getIntervalCollection("test"); + assert(collection instanceof IntervalCollection); + + sharedString.insertText(0, "abcde"); + const interval = collection.add({ start: 1, end: 3 }); + containerRuntime.flush(); + containerRuntimeFactory.processAllMessages(); + + let eventArgs: any = null; + collection.on("changeInterval", (i, previousInterval, local, op, slide) => { + eventArgs = { event: "changeInterval", i, previousInterval, local, op, slide }; + }); + + // Local change (not flushed) + collection.change(interval.getIntervalId(), { start: 2, end: 4 }); + assert(eventArgs.event === "changeInterval", "changeInterval event fired"); + + containerRuntime.rollback?.(); + + assert(eventArgs.local, "change is local"); + + // even a rollback triggers the event with slide: true. why? + // assert(!eventArgs.slide, "slide should be false"); + assert.equal( + sharedString.localReferencePositionToPosition(eventArgs.i.start), + 1, + "start reverted after rollback", + ); + + assert.equal( + sharedString.localReferencePositionToPosition(eventArgs.i.end), + 3, + "start reverted after rollback", + ); + }); + }); + + describe("multi-client(changeInterval)", () => { + it("should restore interval state after rollback, ignoring op and local flags", () => { + const { + dds: sharedString, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection = sharedString.getIntervalCollection("test"); + assert(collection instanceof IntervalCollection); + + const { dds: sharedString2, containerRuntime: containerRuntime2 } = + createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection2 = sharedString2.getIntervalCollection("test"); + assert( + collection2 instanceof IntervalCollection, + "IntervalCollection instance expected", + ); + + let eventArgs: any = null; + let eventArgs2: any = null; + collection.on("changeInterval", (i, previousInterval) => { + eventArgs = { event: "changeInterval", i, previousInterval }; + }); + + collection2.on("changeInterval", (i, previousInterval) => { + eventArgs2 = { event: "changeInterval", i, previousInterval }; + }); + + sharedString.insertText(0, "abcde"); + containerRuntimeFactory.processAllMessages(); + + const interval = collection.add({ start: 0, end: 3 }); + const intervalId = interval.getIntervalId(); + containerRuntime.flush(); + containerRuntimeFactory.processAllMessages(); + + collection2.change(intervalId, { start: 1, end: 3 }); + assert( + eventArgs2.event === "changeInterval", + "changeInterval event fired by collection2", + ); + containerRuntime2.flush(); + containerRuntimeFactory.processAllMessages(); + + collection.change(intervalId, { start: 2, end: 3 }); + collection2.change(intervalId, { start: 3, end: 4 }); + + // Rollback local change + containerRuntime.rollback?.(); + + assert(eventArgs.event === "changeInterval", "changeInterval event fired"); + // The interval should reflect the remote change after rollback + assert.equal( + sharedString.localReferencePositionToPosition(eventArgs.i.start), + 1, + "start reflects remote change after rollback", + ); + + assert.equal( + sharedString.localReferencePositionToPosition(eventArgs.i.end), + 3, + "end reflects remote change after rollback", + ); + + assert.equal( + sharedString.localReferencePositionToPosition(eventArgs.previousInterval.start), + -1, + "previousInterval reflects local change before rollback", + ); + + assert.equal( + sharedString.localReferencePositionToPosition(eventArgs.previousInterval.end), + -1, + "previousInterval end reflects local change before rollback", + ); + + assert.equal( + sharedString2.localReferencePositionToPosition(eventArgs2.previousInterval.start), + 1, + "previousInterval reflects change by remote", + ); + + assert.equal( + sharedString2.localReferencePositionToPosition(eventArgs2.previousInterval.end), + 3, + "previousInterval end reflects change by remote", + ); + }); + + it("should fire correct events for local rollback and remote delete", () => { + const { + dds: ss1, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection1 = ss1.getIntervalCollection("test"); + ss1.insertText(0, "abcde"); + containerRuntimeFactory.processAllMessages(); + + const interval = collection1.add({ start: 0, end: 3 }); + const intervalId = interval.getIntervalId(); + containerRuntime.flush(); + containerRuntimeFactory.processAllMessages(); + + // Remote client + const { dds: ss2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + const collection2 = ss2.getIntervalCollection("test"); + + // Capture events + const events1: any[] = []; + collection1.on("changeInterval", (i, prev) => events1.push({ type: "change", i, prev })); + + const events2: any[] = []; + collection2.on("changeInterval", (i, prev) => events2.push({ type: "change", i, prev })); + + // Local unflushed change + collection1.change(intervalId, { start: 1, end: 2 }); + + // Rollback local change (interval still exists) + containerRuntime.rollback?.(); + containerRuntimeFactory.processAllMessages(); + + // Local events should capture the rollback + const rollbackEvent = events1.find( + (e) => ss1.localReferencePositionToPosition(e.i.start) === 0, + ); + assert(rollbackEvent, "rollback changeInterval captured"); + assert.equal( + ss1.localReferencePositionToPosition(rollbackEvent.i.end), + 3, + "rollback restores correct end position", + ); + + // Remote client deletes interval + collection2.removeIntervalById(intervalId); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + // Attempting local change now does not fire events + collection1.change(intervalId, { start: 2, end: 3 }); + const newChangeEvents = events1.filter((e) => e.i.start === 2); + assert(newChangeEvents.length === 0, "no changeInterval fired on deleted interval"); + }); + }); +}); + +describe("addInterval/deleteInterval: SharedString IntervalCollection rollback events", () => { + it("should trigger addInterval on rollback of a locally added interval", () => { + const { + dds: sharedString, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection = sharedString.getIntervalCollection("test"); + assert(collection instanceof IntervalCollection); + + sharedString.insertText(0, "abcde"); + containerRuntimeFactory.processAllMessages(); + + let addEventArgs: any = null; + collection.on("addInterval", (interval1, local) => { + addEventArgs = { interval1, local }; + }); + + // Add interval locally + const interval = collection.add({ start: 1, end: 3 }); + + // Rollback the local addition + containerRuntime.rollback?.(); + + assert(addEventArgs, "addInterval event fired on rollback"); + // assert.strictEqual(addEventArgs.interval, interval, "correct interval in event"); + assert.strictEqual(addEventArgs.local, true, "event marked as local"); + // Interval should be removed after rollback + assert.strictEqual(collection.getIntervalById(interval.getIntervalId()), undefined); + assert(interval.disposed, "interval disposed after rollback"); + }); + + it("should trigger deleteInterval on rollback of a locally removed interval", () => { + const { + dds: sharedString, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection = sharedString.getIntervalCollection("test"); + assert(collection instanceof IntervalCollection); + + sharedString.insertText(0, "abcde"); + containerRuntimeFactory.processAllMessages(); + + // Add interval and flush so it's in the collection + const interval = collection.add({ start: 1, end: 3 }); + containerRuntime.flush(); + containerRuntimeFactory.processAllMessages(); + + let deleteEventArgs: any = null; + collection.on("deleteInterval", (deletedInterval, local) => { + deleteEventArgs = { deletedInterval, local }; + }); + + // Remove interval locally (not flushed) + collection.removeIntervalById(interval.getIntervalId()); + + // Rollback the local removal + containerRuntime.rollback?.(); + + assert(deleteEventArgs, "deleteInterval event fired on rollback"); + assert.strictEqual(deleteEventArgs.deletedInterval, interval, "correct interval in event"); + assert.strictEqual(deleteEventArgs.local, true, "event marked as local"); + // Interval should be restored after rollback + const restored = collection.getIntervalById(interval.getIntervalId()); + assert.strictEqual(restored, interval, "interval restored after rollback"); + assert(!interval.disposed, "interval not disposed after rollback"); + }); +}); + +describe("multi-client(addInterval/deleteInterval): SharedString IntervalCollection rollback events", () => { + it("should fire addInterval on rollback of local add with multiple clients", () => { + const { + dds: ss1, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection1 = ss1.getIntervalCollection("test"); + ss1.insertText(0, "abcde"); + containerRuntimeFactory.processAllMessages(); + + let addEventArgs: any = null; + collection1.on("addInterval", (int, local) => { + addEventArgs = { int, local }; + }); + + // Local add + const interval = collection1.add({ start: 1, end: 3 }); + + // Rollback local add + containerRuntime.rollback?.(); + + assert(addEventArgs, "addInterval event fired on rollback"); + assert.strictEqual(addEventArgs.local, true, "event marked as local"); + assert.strictEqual( + collection1.getIntervalById(interval.getIntervalId()), + undefined, + "interval removed", + ); + // assert(interval.disposed, "interval disposed after rollback"); + }); + + it("should fire deleteInterval on rollback of local remove with multiple clients", () => { + const { + dds: ss1, + containerRuntimeFactory, + containerRuntime, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const collection1 = ss1.getIntervalCollection("test"); + ss1.insertText(0, "abcde"); + containerRuntimeFactory.processAllMessages(); + + const interval = collection1.add({ start: 1, end: 3 }); + containerRuntime.flush(); + containerRuntimeFactory.processAllMessages(); + + // Second client modifies collection + const { dds: ss2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + ss2.getIntervalCollection("test"); + + let deleteEventArgs: any = null; + collection1.on("deleteInterval", (deletedInterval, local) => { + deleteEventArgs = { deletedInterval, local }; + }); + + // Local removal + collection1.removeIntervalById(interval.getIntervalId()); + + // Rollback local remove + containerRuntime.rollback?.(); + + assert(deleteEventArgs, "deleteInterval event fired on rollback"); + assert.strictEqual(deleteEventArgs.deletedInterval, interval, "correct interval in event"); + assert.strictEqual(deleteEventArgs.local, true, "event marked as local"); + + // Interval restored + const restored = collection1.getIntervalById(interval.getIntervalId()); + assert.strictEqual(restored, interval, "interval restored after rollback"); + // assert(!interval.disposed, "interval not disposed after rollback"); + }); +}); diff --git a/packages/dds/sequence/src/test/sharedString.rollback.spec.ts b/packages/dds/sequence/src/test/sharedString.rollback.spec.ts new file mode 100644 index 000000000000..e6f343d4a1d6 --- /dev/null +++ b/packages/dds/sequence/src/test/sharedString.rollback.spec.ts @@ -0,0 +1,751 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "assert"; + +import { setupRollbackTest, createAdditionalClient } from "@fluid-private/test-dds-utils"; +import { MergeTreeDeltaType } from "@fluidframework/merge-tree/internal"; +import type { + MockContainerRuntime, + MockContainerRuntimeFactory, +} from "@fluidframework/test-runtime-utils/internal"; + +import { SharedStringFactory, type SharedString } from "../sequenceFactory.js"; +import { SharedStringClass } from "../sharedString.js"; + +describe("SharedString rollback with multiple clients (insert/remove)", () => { + it("Client1 insert + Client2 insert + rollback on Client1", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "hello"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + assert.equal(client1.getText(), "hello"); + assert.equal(client2.getText(), "hello"); + + // Both clients make local edits (pending) + client1.insertText(5, " world"); // pending on Client1 + client2.insertText(5, " there"); // pending on Client2 + + // Before processing, remote should not see each other's pending edits + assert.equal(client1.getText(), "hello world"); + assert.equal(client2.getText(), "hello there"); + + // Process messages to synchronize + cr1.flush(); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + // Both clients see each other's committed edits + assert.equal(client1.getText(), "hello there world"); + assert.equal(client2.getText(), "hello there world"); + + // Rollback pending edits on Client1 (which were already flushed locally) + // To illustrate rollback, add another local insert + client1.insertText(17, "!"); + assert.equal(client1.getText(), "hello there world!"); + + cr1.rollback?.(); + assert.equal( + client1.getText(), + "hello there world", + "rollback discards Client1 pending insert", + ); + assert.equal(client2.getText(), "hello there world", "remote unchanged"); + }); + + it("Client1 remove + Client2 insert + rollback on Client1", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "abcdef"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + assert.equal(client2.getText(), "abcdef"); + + // Client1 removes locally (pending) + client1.removeText(0, 3); // "abc" removed locally + assert.equal(client1.getText(), "def"); + assert.equal(client2.getText(), "abcdef"); + + // Client2 inserts locally (pending) + client2.insertText(3, "XYZ"); // adds at position 3 + assert.equal(client2.getText(), "abcXYZdef"); + assert.equal(client1.getText(), "def"); + + // Flush both and process + cr1.flush(); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + // Texts converge + assert.equal(client1.getText(), "XYZdef"); + assert.equal(client2.getText(), "XYZdef"); + + // Rollback Client1 pending removes (none left) just to confirm no crash + cr1.rollback?.(); + assert.equal(client1.getText(), "XYZdef"); + assert.equal(client2.getText(), "XYZdef"); + }); + + it("Client1 insert + Client2 remove + rollback on Client1", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "123456"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + // Client1 inserts locally (pending) + client1.insertText(6, "ABC"); + assert.equal(client1.getText(), "123456ABC"); + assert.equal(client2.getText(), "123456"); + + // Client2 removes some text locally (pending) + client2.removeText(2, 4); // removes "34" + assert.equal(client2.getText(), "1256"); + assert.equal(client1.getText(), "123456ABC"); + + // Rollback Client1 pending insert (if any) + cr1.rollback?.(); + assert.equal(client1.getText(), "123456", "rollback discards pending insert"); + + // Flush both and process + cr1.flush(); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + assert.equal(client1.getText(), "1256", "rollback removes Client1 insert"); + assert.equal(client2.getText(), "1256", "remote unchanged after rollback"); + }); + + it("Client1 insert + Client2 insert + rollback on Client2", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "hello"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + assert.equal(client1.getText(), "hello"); + assert.equal(client2.getText(), "hello"); + + // Both clients make local edits (pending) + client1.insertText(5, " world"); // pending on Client1 + client2.insertText(5, " there"); // pending on Client2 + + // Before processing, remote should not see each other's pending edits + assert.equal(client1.getText(), "hello world"); + assert.equal(client2.getText(), "hello there"); + + // Process messages to synchronize + cr1.flush(); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + // Both clients see each other's committed edits + assert.equal(client1.getText(), "hello there world"); + assert.equal(client2.getText(), "hello there world"); + + // Rollback pending edits on Client1 (which were already flushed locally) + // To illustrate rollback, add another local insert + client2.insertText(17, "!"); + assert.equal(client2.getText(), "hello there world!"); + + cr2.rollback?.(); + assert.equal( + client2.getText(), + "hello there world", + "rollback discards Client2 pending insert", + ); + assert.equal(client1.getText(), "hello there world", "remote unchanged"); + }); +}); + +describe("SharedString replaceText with rollback and two clients", () => { + it("Client1 replaceText + rollback without remote changes", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "hello world"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + assert.equal(client2.getText(), "hello world"); + + // Client1 replaces text locally (pending) + client1.replaceText(6, 11, "there!"); + assert.equal(client1.getText(), "hello there!"); + assert.equal(client2.getText(), "hello world"); + + // Rollback pending replace + cr1.rollback?.(); + assert.equal(client1.getText(), "hello world", "rollback restores original text"); + assert.equal(client2.getText(), "hello world", "remote unchanged"); + }); + + it("Client1 multiple replaceText + rollback", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + client1.insertText(0, "hello world"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + // Multiple local replacements + client1.replaceText(6, 11, "there!"); + client1.replaceText(0, 5, "hi"); + assert.equal(client1.getText(), "hi there!"); + assert.equal(client2.getText(), "hello world"); + + // Rollback all pending replaces + cr1.rollback?.(); + assert.equal(client1.getText(), "hello world", "rollback restores all replaced text"); + assert.equal(client2.getText(), "hello world", "remote unchanged"); + }); + + it("Client1 replaceText with concurrent remote remove", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "abcdef"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + // Client1 replaces locally (pending) + client1.replaceText(2, 4, "XY"); // "abXYef" + assert.equal(client1.getText(), "abXYef"); + + // Client2 removes text concurrently (pending) + client2.removeText(1, 3); // removes "bX" in their local view + assert.equal(client2.getText(), "adef"); + + // Rollback Client1 before flushing + cr1.rollback?.(); + assert.equal(client1.getText(), "abcdef", "rollback restores original text on Client1"); + + // Flush both and process messages + cr1.flush(); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + // After processing, text converges + assert.equal(client1.getText(), "adef"); + assert.equal(client2.getText(), "adef"); + }); + + it("replaceText: Rollback on both clients", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2, containerRuntime: cr2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + // Baseline text + client1.insertText(0, "abcdef"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + // Client1 replaces locally (pending) + client1.replaceText(2, 4, "XY"); // "abXYef" + assert.equal(client1.getText(), "abXYef"); + + // Client2 removes text concurrently (pending) + client2.removeText(1, 3); // removes "bX" in their local view + assert.equal(client2.getText(), "adef"); + + // Rollback Client1 before flushing + cr1.rollback?.(); + assert.equal(client1.getText(), "abcdef", "rollback restores original text on Client1"); + + // Rollback Client2 before flushing + cr2.rollback?.(); + assert.equal(client2.getText(), "abcdef", "rollback restores original text on Client2"); + + // Flush both and process messages + cr1.flush(); + cr2.flush(); + containerRuntimeFactory.processAllMessages(); + + // After processing, text converges + assert.equal(client1.getText(), "abcdef"); + assert.equal(client2.getText(), "abcdef"); + }); +}); + +describe("SharedString annotate with rollback", () => { + it("can annotate text and rollback without remote changes", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + const text = "hello world"; + const styleProps = { style: "bold" }; + client1.insertText(0, text, styleProps); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + assert.equal(client2.getText(), text); + + // Annotate a range locally (pending) + const colorProps = { color: "green" }; + client1.annotateRange(6, text.length, colorProps); + + for (let i = 6; i < text.length; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + { ...styleProps, ...colorProps }, + "Could not annotate props locally", + ); + } + + // Rollback pending annotation + cr1.rollback?.(); + + for (let i = 0; i < text.length; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + { ...styleProps }, + "Rollback reverted annotations", + ); + } + + // Remote client remains unchanged + for (let i = 0; i < text.length; i++) { + assert.deepEqual( + { ...client2.getPropertiesAtPosition(i) }, + { ...styleProps }, + "Remote client unchanged", + ); + } + }); + + it("can handle null annotations with rollback", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + const text = "hello world"; + const startingProps = { style: "bold", color: null }; + client1.insertText(0, text, startingProps); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + for (let i = 0; i < text.length; i++) { + assert.strictEqual(client1.getPropertiesAtPosition(i)?.color, undefined); + assert.strictEqual(client2.getPropertiesAtPosition(i)?.color, undefined); + } + + // Annotate locally with null values (pending) + const updatedProps = { style: null }; + client1.annotateRange(6, text.length, updatedProps); + + for (let i = 6; i < text.length; i++) { + assert.strictEqual(client1.getPropertiesAtPosition(i)?.style, undefined); + } + + // Rollback pending annotation + cr1.rollback?.(); + + for (let i = 6; i < text.length; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + { style: "bold" }, + "Rollback restores original props", + ); + } + }); + + it("handles multiple annotations with rollback", () => { + const { + dds: client1, + containerRuntimeFactory, + containerRuntime: cr1, + } = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + + const { dds: client2 } = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + const text = "hello world"; + client1.insertText(0, text); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + const styleProps = { style: "italic" }; + const colorProps = { color: "red" }; + + // Annotate ranges with different props + client1.annotateRange(0, 5, styleProps); + client1.annotateRange(6, 11, colorProps); + + // Verify pending annotations locally + for (let i = 0; i < 5; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + { ...styleProps }, + "Could not add styleProps", + ); + } + for (let i = 6; i < 11; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + { ...colorProps }, + "Could not add colorProps", + ); + } + + // Rollback pending annotations + cr1.rollback?.(); + + // Verify annotations reverted + for (let i = 0; i < text.length; i++) { + assert.deepEqual({ ...client1.getPropertiesAtPosition(i) }, {}, "Could not add props"); + } + + // Remote client should remain unchanged (no annotations) + for (let i = 0; i < text.length; i++) { + assert.deepEqual({ ...client2.getPropertiesAtPosition(i) }, {}, "Could not add props"); + } + }); +}); + +interface Event { + op: string; + text: string; +} + +function setupDeltaListener(sharedString: SharedString, events: Event[]) { + sharedString.on("sequenceDelta", ({ deltaOperation, isLocal }) => { + if (!isLocal) return; + switch (deltaOperation) { + case MergeTreeDeltaType.INSERT: + events.push({ op: "insert", text: sharedString.getText() }); + break; + case MergeTreeDeltaType.REMOVE: + events.push({ op: "remove", text: sharedString.getText() }); + break; + case MergeTreeDeltaType.ANNOTATE: + events.push({ op: "annotate", text: sharedString.getText() }); + break; + default: + throw new Error(`Unexpected deltaOperation: ${deltaOperation}`); + } + }); +} + +describe("SharedString rollback triggers correct sequenceDelta events with text", () => { + let client1: SharedStringClass; + let client2: SharedStringClass; + let containerRuntimeFactory: MockContainerRuntimeFactory; + let cr1: MockContainerRuntime; + + beforeEach(() => { + // First client + const setup = setupRollbackTest( + "shared-string-1", + (rt, id) => new SharedStringClass(rt, id, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + client1 = setup.dds; + containerRuntimeFactory = setup.containerRuntimeFactory; + cr1 = setup.containerRuntime; + + // Second client + const additional = createAdditionalClient( + containerRuntimeFactory, + "2", + (rt, id) => + new SharedStringClass(rt, `shared-string-${id}`, SharedStringFactory.Attributes), + { initialize: (dds) => dds.initializeLocal() }, + ); + client2 = additional.dds; + }); + + it("rollback of insert triggers remove", () => { + const events: Event[] = []; + setupDeltaListener(client1, events); + + client1.insertText(0, "hello"); + containerRuntimeFactory.processAllMessages(); + assert.equal(client1.getText(), "hello"); + + cr1.rollback?.(); + + assert( + events.some((e) => e.op === "remove" && e.text === ""), + "Rollback of insert should trigger remove of correct text", + ); + }); + + it("rollback of remove triggers insert", () => { + const events: Event[] = []; + setupDeltaListener(client1, events); + + client1.insertText(0, "world"); + containerRuntimeFactory.processAllMessages(); + client1.removeText(0, 5); + assert.equal(client1.getText(), ""); + + cr1.rollback?.(); + + assert( + events.some((e) => e.op === "insert" && e.text === "world"), + "Rollback of remove should trigger insert of correct text", + ); + }); + + it("rollback of annotate clears properties", () => { + const events: Event[] = []; + setupDeltaListener(client1, events); + + client1.insertText(0, "abc"); + containerRuntimeFactory.processAllMessages(); + + const styleProps = { style: "bold" }; + client1.annotateRange(0, 3, styleProps); + for (let i = 0; i < 3; i++) { + assert.deepEqual({ ...client1.getPropertiesAtPosition(i) }, styleProps); + } + + cr1.rollback?.(); + + for (let i = 0; i < 3; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + {}, + "Rollback of annotate should clear properties", + ); + } + + assert( + events.some((e) => e.op === "annotate" && e.text === "abc"), + "Rollback of annotate should trigger annotate event with correct text", + ); + }); + + it("multi-client: rollback of insert triggers remove", () => { + const eventsClient1: Event[] = []; + setupDeltaListener(client1, eventsClient1); + + client1.insertText(0, "hello"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + assert.equal(client1.getText(), "hello"); + assert.equal(client2.getText(), "hello"); + + client1.insertText(5, "world"); + cr1.rollback?.(); + + assert( + eventsClient1.some((e) => e.op === "remove" && e.text === "hello"), + "Rollback of insert should trigger remove of correct text on client1", + ); + assert.equal(client1.getText(), "hello"); + assert.equal(client2.getText(), "hello"); + }); + + it("multi-client: rollback of remove triggers insert", () => { + const eventsClient1: Event[] = []; + setupDeltaListener(client1, eventsClient1); + + client1.insertText(0, "world"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + client1.removeText(0, 5); + assert.equal(client1.getText(), ""); + assert.equal(client2.getText(), "world"); + + cr1.rollback?.(); + + assert( + eventsClient1.some((e) => e.op === "insert" && e.text === "world"), + "Rollback of remove should trigger insert of correct text on client1", + ); + assert.equal(client1.getText(), "world"); + assert.equal(client2.getText(), "world"); + }); + + it("multi-client: rollback of annotate clears properties", () => { + const eventsClient1: Event[] = []; + setupDeltaListener(client1, eventsClient1); + + client1.insertText(0, "abc"); + cr1.flush(); + containerRuntimeFactory.processAllMessages(); + + const styleProps = { style: "bold" }; + client1.annotateRange(0, 3, styleProps); + + for (let i = 0; i < 3; i++) { + assert.deepEqual({ ...client1.getPropertiesAtPosition(i) }, styleProps); + } + + cr1.rollback?.(); + + for (let i = 0; i < 3; i++) { + assert.deepEqual( + { ...client1.getPropertiesAtPosition(i) }, + {}, + "Rollback of annotate should clear properties", + ); + } + + assert( + eventsClient1.some((e) => e.op === "annotate" && e.text === "abc"), + "Rollback of annotate should trigger annotate event with correct text", + ); + }); +}); diff --git a/packages/dds/test-dds-utils/src/ddsTestUtils.ts b/packages/dds/test-dds-utils/src/ddsTestUtils.ts new file mode 100644 index 000000000000..f883b2fe24d9 --- /dev/null +++ b/packages/dds/test-dds-utils/src/ddsTestUtils.ts @@ -0,0 +1,92 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { AttachState } from "@fluidframework/container-definitions"; +import type { + IChannel, + IChannelServices, +} from "@fluidframework/datastore-definitions/internal"; +import { + MockFluidDataStoreRuntime, + MockStorage, + type MockContainerRuntime, + MockContainerRuntimeFactory, +} from "@fluidframework/test-runtime-utils/internal"; + +/** + * @internal + */ +export type DDSCreator = ( + runtime: MockFluidDataStoreRuntime, + id: string, +) => T; + +/** + * @internal + */ +export interface RollbackTestSetup { + dds: T; + dataStoreRuntime: MockFluidDataStoreRuntime; + containerRuntimeFactory: MockContainerRuntimeFactory; + containerRuntime: MockContainerRuntime; +} + +/** + * Setup rollback tests + * @internal + */ +export function setupRollbackTest( + id: string, + createDDS: DDSCreator, + opts?: { initialize?: (dds: T) => void }, +): RollbackTestSetup { + const containerRuntimeFactory = new MockContainerRuntimeFactory({ flushMode: 1 }); + const dataStoreRuntime = new MockFluidDataStoreRuntime({ clientId: "1" }); + const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime); + + const dds = createDDS(dataStoreRuntime, id); + + dataStoreRuntime.setAttachState(AttachState.Attached); + opts?.initialize?.(dds); + + const services: IChannelServices = { + deltaConnection: dataStoreRuntime.createDeltaConnection(), + objectStorage: new MockStorage(), + }; + dds.connect(services); + + return { dds, dataStoreRuntime, containerRuntimeFactory, containerRuntime }; +} + +/** + * Create a new client + * @internal + */ +export function createAdditionalClient( + containerRuntimeFactory: MockContainerRuntimeFactory, + id: string, + createDDS: DDSCreator, + opts?: { initialize?: (dds: T) => void }, +): { + dds: T; + dataStoreRuntime: MockFluidDataStoreRuntime; + containerRuntime: MockContainerRuntime; +} { + const dataStoreRuntime = new MockFluidDataStoreRuntime({ clientId: id }); + const containerRuntime = containerRuntimeFactory.createContainerRuntime(dataStoreRuntime); + + const dds = createDDS(dataStoreRuntime, id); + + dataStoreRuntime.setAttachState(AttachState.Attached); + opts?.initialize?.(dds); + + const services: IChannelServices = { + deltaConnection: dataStoreRuntime.createDeltaConnection(), + objectStorage: new MockStorage(), + }; + dds.connect(services); + + return { dds, dataStoreRuntime, containerRuntime }; +} diff --git a/packages/dds/test-dds-utils/src/index.ts b/packages/dds/test-dds-utils/src/index.ts index 693f2a229e3a..dc1cab347492 100644 --- a/packages/dds/test-dds-utils/src/index.ts +++ b/packages/dds/test-dds-utils/src/index.ts @@ -34,3 +34,9 @@ export { export type { ISnapshotSuite } from "./ddsSnapshotHarness.js"; export { createSnapshotSuite } from "./ddsSnapshotHarness.js"; export type { Client, FuzzSerializedIdCompressor } from "./clientLoading.js"; +export { + setupRollbackTest, + createAdditionalClient, + type RollbackTestSetup, + type DDSCreator, +} from "./ddsTestUtils.js";