diff --git a/packages/dds/map/src/test/directoryOracle.ts b/packages/dds/map/src/test/directoryOracle.ts new file mode 100644 index 000000000000..201c67357ce6 --- /dev/null +++ b/packages/dds/map/src/test/directoryOracle.ts @@ -0,0 +1,144 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import type { IEventThisPlaceHolder } from "@fluidframework/core-interfaces"; + +import type { + IDirectory, + IDirectoryValueChanged, + ISharedDirectory, + IValueChanged, +} from "../interfaces.js"; + +interface OracleDir { + keys: Map; + subdirs: Map; +} + +/** + * Oracle for directory + * @internal + */ +export class SharedDirectoryOracle { + private readonly model = new Map(); + + public constructor(private readonly sharedDir: ISharedDirectory) { + this.sharedDir.on("valueChanged", this.onValueChanged); + this.sharedDir.on("clear", this.onClear); + this.sharedDir.on("subDirectoryCreated", this.onSubDirCreated); + this.sharedDir.on("subDirectoryDeleted", this.onSubDirDeleted); + this.sharedDir.on("containedValueChanged", this.onContainedValueChanged); + + this.takeSnapshot(); + } + + private takeSnapshot(): void { + for (const [k, v] of this.sharedDir.entries()) { + this.model.set(k, v); + } + } + + private readonly onValueChanged = ( + changed: IDirectoryValueChanged, + local: boolean, + target: IEventThisPlaceHolder, + ): void => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { path, key, previousValue } = changed; + const fullPath = path === "/" ? `/${key}` : `${path}/${key}`; + + if (this.model.has(fullPath)) { + const prevVal = this.model.get(fullPath); + assert.strictEqual( + prevVal, + previousValue, + `previous value mismatch at ${fullPath}: expected: ${prevVal}, actual: ${previousValue}`, + ); + } + + const workingDir = this.sharedDir.getWorkingDirectory(fullPath); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const newVal = workingDir?.get(key); + + if (newVal === undefined) { + // deletion + this.model.delete(fullPath); + } else { + this.model.set(fullPath, newVal); + } + }; + + private readonly onClear = (local: boolean, target: IEventThisPlaceHolder): void => { + this.model.clear(); + }; + + private readonly onSubDirCreated = ( + path: string, + local: boolean, + target: IEventThisPlaceHolder, + ): void => { + this.model.set(path, undefined); + }; + + private readonly onSubDirDeleted = ( + path: string, + local: boolean, + target: IEventThisPlaceHolder, + ): void => { + this.model.delete(path); + }; + + private readonly onContainedValueChanged = ( + changed: IValueChanged, + local: boolean, + target, + ): void => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { key, previousValue } = changed; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const newVal = target.get(key); + if (newVal === undefined) { + this.model.delete(key); + } else { + this.model.set(key, newVal); + } + }; + + public validate(): void { + for (const [pathKey, value] of this.model.entries()) { + const parts = pathKey.split("/").filter((p) => p.length > 0); + assert(parts.length > 0, "Invalid path, cannot extract key"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const leafKey = parts.pop()!; // The actual key + let dir: IDirectory | undefined = this.sharedDir; + + for (const part of parts) { + dir = dir.getWorkingDirectory(part); + if (!dir) break; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = dir?.get(leafKey); + assert.deepStrictEqual( + actual, + value, + `SharedDirectoryOracle mismatch at path="${pathKey}" with actual value = ${actual} and oracle value = ${value}}}`, + ); + } + } + + public dispose(): void { + this.sharedDir.off("valueChanged", this.onValueChanged); + this.sharedDir.off("clear", this.onClear); + this.sharedDir.off("subDirectoryCreated", this.onSubDirCreated); + this.sharedDir.off("subDirectoryDeleted", this.onSubDirDeleted); + this.sharedDir.off("containedValueChanged", this.onContainedValueChanged); + this.model.clear(); + } +} diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index 5305ccb5c444..9b7845c6f558 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -5,11 +5,18 @@ import * as dirPath from "node:path"; +import { TypedEventEmitter } from "@fluid-internal/client-utils"; import { takeAsync } from "@fluid-private/stochastic-test-utils"; -import { type DDSFuzzModel, createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; +import { + type DDSFuzzHarnessEvents, + type DDSFuzzModel, + createDDSFuzzSuite, + registerOracle, +} from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; import { DirectoryFactory } from "../../index.js"; +import { SharedDirectoryOracle } from "../directoryOracle.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; import { _dirname } from "./dirname.cjs"; @@ -21,6 +28,16 @@ import { type DirOperation, type DirOperationGenerationConfig, } from "./fuzzUtils.js"; +import { hasSharedDirectoryOracle, type ISharedDirectoryWithOracle } from "./oracleUtils.js"; + +const oracleEmitter = new TypedEventEmitter(); + +oracleEmitter.on("clientCreate", (client) => { + const channel = client.channel as ISharedDirectoryWithOracle; + const directoryOracle = new SharedDirectoryOracle(channel); + channel.sharedDirectoryOracle = directoryOracle; + registerOracle(directoryOracle); +}); describe("SharedDirectory fuzz Create/Delete concentrated", () => { const options: DirOperationGenerationConfig = { @@ -37,7 +54,16 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { workloadName: "default directory 1", generatorFactory: () => takeAsync(100, makeDirOperationGenerator(options)), reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), - validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), + validateConsistency: async (a, b) => { + if (hasSharedDirectoryOracle(a.channel)) { + a.channel.sharedDirectoryOracle.validate(); + } + + if (hasSharedDirectoryOracle(b.channel)) { + b.channel.sharedDirectoryOracle.validate(); + } + return assertEquivalentDirectories(a.channel, b.channel); + }, factory: new DirectoryFactory(), }; @@ -56,6 +82,7 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { stashableClientProbability: 0.2, }, defaultTestCount: 25, + emitter: oracleEmitter, // Uncomment this line to replay a specific seed from its failure file: // replay: 21, saveFailures: { directory: dirPath.join(_dirname, "../../../src/test/mocha/results/1") }, @@ -83,6 +110,7 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { stashableClientProbability: undefined, }, defaultTestCount: 200, + emitter: oracleEmitter, // Uncomment this line to replay a specific seed from its failure file: // replay: 0, saveFailures: { @@ -108,6 +136,7 @@ describe("SharedDirectory fuzz", () => { stashableClientProbability: 0.2, }, defaultTestCount: 25, + emitter: oracleEmitter, // Uncomment this line to replay a specific seed from its failure file: // replay: 0, saveFailures: { directory: dirPath.join(_dirname, "../../../src/test/mocha/results/2") }, @@ -136,6 +165,7 @@ describe("SharedDirectory fuzz", () => { stashableClientProbability: undefined, }, defaultTestCount: 200, + emitter: oracleEmitter, // Uncomment this line to replay a specific seed from its failure file: // replay: 0, saveFailures: { diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts index 578f5d456a17..ceb5f1ba37f7 100644 --- a/packages/dds/map/src/test/mocha/fuzzUtils.ts +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -29,7 +29,7 @@ import { } from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; -import { hasSharedMapOracle } from "./oracleUtils.js"; +import { hasSharedMapOracle, hasSharedDirectoryOracle } from "./oracleUtils.js"; /** * Represents a map clear operation. @@ -518,7 +518,16 @@ export const baseDirModel: DDSFuzzModel = { workloadName: "default directory 1", generatorFactory: () => takeAsync(100, makeDirOperationGenerator(dirDefaultOptions)), reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), - validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), + validateConsistency: async (a, b) => { + if (hasSharedDirectoryOracle(a.channel)) { + a.channel.sharedDirectoryOracle.validate(); + } + + if (hasSharedDirectoryOracle(b.channel)) { + b.channel.sharedDirectoryOracle.validate(); + } + return assertEquivalentDirectories(a.channel, b.channel); + }, factory: new DirectoryFactory(), minimizationTransforms: [ (op: DirOperation): void => { diff --git a/packages/dds/map/src/test/mocha/oracleUtils.ts b/packages/dds/map/src/test/mocha/oracleUtils.ts index add4038119ec..f40ba6baf62a 100644 --- a/packages/dds/map/src/test/mocha/oracleUtils.ts +++ b/packages/dds/map/src/test/mocha/oracleUtils.ts @@ -3,10 +3,19 @@ * Licensed under the MIT License. */ +import type { SharedDirectory } from "../../directoryFactory.js"; import type { ISharedMap } from "../../interfaces.js"; import type { SharedMap } from "../../mapFactory.js"; +import { SharedDirectoryOracle } from "../directoryOracle.js"; import { SharedMapOracle } from "../mapOracle.js"; +/** + * @internal + */ +export interface ISharedDirectoryWithOracle extends SharedDirectory { + sharedDirectoryOracle: SharedDirectoryOracle; +} + /** * @internal */ @@ -14,6 +23,16 @@ export interface ISharedMapWithOracle extends ISharedMap { sharedMapOracle: SharedMapOracle; } +/** + * Type guard for directory + * @internal + */ +export function hasSharedDirectoryOracle(s: SharedDirectory): s is ISharedDirectoryWithOracle { + return ( + "sharedDirectoryOracle" in s && s.sharedDirectoryOracle instanceof SharedDirectoryOracle + ); +} + /** * Type guard for map * @internal