diff --git a/packages/dds/map/package.json b/packages/dds/map/package.json index b00f59db6b01..b60d5eb2fd12 100644 --- a/packages/dds/map/package.json +++ b/packages/dds/map/package.json @@ -42,6 +42,18 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } + }, + "./internal/test": { + "allow-ff-test-exports": { + "import": { + "types": "./lib/test/index.d.ts", + "default": "./lib/test/index.js" + }, + "require": { + "types": "./dist/test/index.d.ts", + "default": "./dist/test/index.js" + } + } } }, "main": "lib/index.js", @@ -62,7 +74,7 @@ "build:test": "npm run build:test:esm && npm run build:test:cjs", "build:test:cjs": "fluid-tsc commonjs --project ./src/test/tsconfig.cjs.json", "build:test:esm": "tsc --project ./src/test/tsconfig.json", - "check:are-the-types-wrong": "attw --pack .", + "check:are-the-types-wrong": "attw --pack . --exclude-entrypoints ./internal/test", "check:biome": "biome check .", "check:exports": "concurrently \"npm:check:exports:*\"", "check:exports:bundle-release-tags": "api-extractor run --config api-extractor/api-extractor-lint-bundle.json", diff --git a/packages/dds/map/src/test/index.ts b/packages/dds/map/src/test/index.ts new file mode 100644 index 000000000000..7fee42bd106e --- /dev/null +++ b/packages/dds/map/src/test/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { baseDirModel, baseMapModel } from "./mocha/index.js"; diff --git a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts index 284776cfbb62..23bea880b1da 100644 --- a/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts +++ b/packages/dds/map/src/test/mocha/directoryEquivalenceUtils.ts @@ -6,7 +6,7 @@ import { strict as assert } from "node:assert"; import { isObject } from "@fluidframework/core-utils/internal"; -import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; +import { isFluidHandle, toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; import type { IDirectory } from "../../interfaces.js"; @@ -47,8 +47,12 @@ async function assertEventualConsistencyCore( isObject(secondVal), `Values differ at key ${key}: first is an object, second is not`, ); - const firstHandle = isFluidHandle(firstVal) ? await firstVal.get() : firstVal; - const secondHandle = isFluidHandle(secondVal) ? await secondVal.get() : secondVal; + const firstHandle = isFluidHandle(firstVal) + ? toFluidHandleInternal(firstVal).absolutePath + : firstVal; + const secondHandle = isFluidHandle(secondVal) + ? toFluidHandleInternal(secondVal).absolutePath + : secondVal; assert.equal( firstHandle, secondHandle, @@ -59,8 +63,8 @@ async function assertEventualConsistencyCore( ); } else { assert.strictEqual( - first.get(key), - second.get(key), + firstVal, + secondVal, `Key not found or value not matching ` + `key: ${key}, value in dir first at path ${first.absolutePath}: ${first.get( key, diff --git a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts index e9d632c2740a..0fc738fc9e60 100644 --- a/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts +++ b/packages/dds/map/src/test/mocha/directoryFuzzTests.spec.ts @@ -3,325 +3,27 @@ * Licensed under the MIT License. */ -import { strict as assert } from "node:assert"; import * as dirPath from "node:path"; -import { - type AsyncGenerator, - type AsyncReducer, - combineReducersAsync, - createWeightedAsyncGenerator, - takeAsync, -} from "@fluid-private/stochastic-test-utils"; -import { - type Client, - type DDSFuzzModel, - type DDSFuzzTestState, - createDDSFuzzSuite, -} from "@fluid-private/test-dds-utils"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import type { Serializable } from "@fluidframework/datastore-definitions/internal"; +import { takeAsync } from "@fluid-private/stochastic-test-utils"; +import { type DDSFuzzModel, createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; -import { DirectoryFactory, type IDirectory } from "../../index.js"; +import { DirectoryFactory } from "../../index.js"; import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; import { _dirname } from "./dirname.cjs"; - -type FuzzTestState = DDSFuzzTestState; - -interface SetKey { - type: "set"; - path: string; - key: string; - value: Serializable; -} - -interface ClearKeys { - type: "clear"; - path: string; -} - -interface DeleteKey { - type: "delete"; - path: string; - key: string; -} - -interface CreateSubDirectory { - type: "createSubDirectory"; - path: string; - name: string; -} - -interface DeleteSubDirectory { - type: "deleteSubDirectory"; - path: string; - name: string; -} - -type KeyOperation = SetKey | DeleteKey | ClearKeys; - -type SubDirectoryOperation = CreateSubDirectory | DeleteSubDirectory; - -type Operation = KeyOperation | SubDirectoryOperation; - -interface OperationGenerationConfig { - validateInterval: number; - maxSubDirectoryChild?: number; - subDirectoryNamePool?: string[]; - keyNamePool?: string[]; - setKeyWeight?: number; - deleteKeyWeight?: number; - clearKeysWeight?: number; - createSubDirWeight?: number; - deleteSubDirWeight?: number; -} - -const defaultOptions: Required = { - validateInterval: 10, - maxSubDirectoryChild: 3, - subDirectoryNamePool: ["dir1", "dir2", "dir3"], - keyNamePool: ["prop1", "prop2", "prop3"], - setKeyWeight: 5, - deleteKeyWeight: 2, - clearKeysWeight: 1, - createSubDirWeight: 2, - deleteSubDirWeight: 1, -}; - -function pickAbsolutePathForKeyOps(state: FuzzTestState, shouldHaveKey: boolean): string { - const { random, client } = state; - let parentDir: IDirectory = client.channel; - for (;;) { - assert(parentDir !== undefined, "Directory should be defined"); - const subDirs: IDirectory[] = []; - for (const [_, b] of parentDir.subdirectories()) { - subDirs.push(b); - } - const subDir = random.pick([undefined, ...subDirs]); - if (subDir !== undefined && (!shouldHaveKey || subDir.size > 0)) { - parentDir = subDir; - } else { - break; - } - } - return parentDir.absolutePath; -} - -function makeOperationGenerator( - optionsParam?: OperationGenerationConfig, -): AsyncGenerator { - const options = { ...defaultOptions, ...optionsParam }; - - // All subsequent helper functions are generators; note that they don't actually apply any operations. - function pickAbsolutePathForCreateDirectoryOp(state: FuzzTestState): string { - const { random, client } = state; - let dir: IDirectory = client.channel; - for (;;) { - assert(dir !== undefined, "Directory should be defined"); - const subDirectories: IDirectory[] = []; - for (const [_, b] of dir.subdirectories()) { - subDirectories.push(b); - } - // If this dir already has max number of child, then choose one and continue. - if ( - dir.countSubDirectory !== undefined && - dir.countSubDirectory() === options.maxSubDirectoryChild - ) { - dir = random.pick(subDirectories); - continue; - } - const subDir = random.pick([undefined, ...subDirectories]); - if (subDir === undefined) { - break; - } else { - dir = subDir; - } - } - return dir.absolutePath; - } - - function pickAbsolutePathForDeleteDirectoryOp(state: FuzzTestState): string { - const { random, client } = state; - let parentDir: IDirectory = client.channel; - const subDirectories: IDirectory[] = []; - for (const [_, b] of client.channel.subdirectories()) { - subDirectories.push(b); - } - let dirToDelete = random.pick(subDirectories); - for (;;) { - assert(dirToDelete !== undefined, "Directory should be defined"); - const subDirs: IDirectory[] = []; - for (const [_, b] of dirToDelete.subdirectories()) { - subDirs.push(b); - } - const subDir = random.pick([undefined, ...subDirs]); - if (subDir === undefined) { - break; - } else { - parentDir = dirToDelete; - dirToDelete = subDir; - } - } - return parentDir.absolutePath; - } - - async function createSubDirectory(state: FuzzTestState): Promise { - return { - type: "createSubDirectory", - name: state.random.pick(options.subDirectoryNamePool), - path: pickAbsolutePathForCreateDirectoryOp(state), - }; - } - - async function deleteSubDirectory(state: FuzzTestState): Promise { - const { random, client } = state; - const path = pickAbsolutePathForDeleteDirectoryOp(state); - const parentDir = client.channel.getWorkingDirectory(path); - assert(parentDir !== undefined, "parent dir should be defined"); - assert( - parentDir.countSubDirectory && parentDir.countSubDirectory() > 0, - "Atleast 1 subdir should be there", - ); - const subDirName: string[] = []; - for (const [a, _] of parentDir.subdirectories()) { - subDirName.push(a); - } - return { - type: "deleteSubDirectory", - name: random.pick(subDirName), - path, - }; - } - - async function setKey(state: FuzzTestState): Promise { - const { random } = state; - return { - type: "set", - key: random.pick(options.keyNamePool), - path: pickAbsolutePathForKeyOps(state, false), - value: random.pick([ - (): string => random.string(random.integer(0, 4)), - (): IFluidHandle => random.handle(), - ])(), - }; - } - - async function clearKeys(state: FuzzTestState): Promise { - return { - type: "clear", - path: pickAbsolutePathForKeyOps(state, true), - }; - } - - async function deleteKey(state: FuzzTestState): Promise { - const { random, client } = state; - const path = pickAbsolutePathForKeyOps(state, true); - const dir = client.channel.getWorkingDirectory(path); - assert(dir, "dir should exist"); - return { - type: "delete", - key: random.pick([...dir.keys()]), - path, - }; - } - - return createWeightedAsyncGenerator([ - [createSubDirectory, options.createSubDirWeight], - [ - deleteSubDirectory, - options.deleteSubDirWeight, - (state: FuzzTestState): boolean => (state.client.channel.countSubDirectory?.() ?? 0) > 0, - ], - [setKey, options.setKeyWeight], - [ - deleteKey, - options.deleteKeyWeight, - (state: FuzzTestState): boolean => state.client.channel.size > 0, - ], - [ - clearKeys, - options.clearKeysWeight, - (state: FuzzTestState): boolean => state.client.channel.size > 0, - ], - ]); -} - -interface LoggingInfo { - // Clients to print - clientIds: string[]; - // Set this to true in case you want to debug and print client states and ops. - printConsoleLogs?: boolean; -} - -function logCurrentState(clients: Client[], loggingInfo: LoggingInfo): void { - for (const id of loggingInfo.clientIds) { - const { channel: sharedDirectory } = - clients.find((s) => s.containerRuntime.clientId === id) ?? {}; - if (sharedDirectory !== undefined) { - console.log(`Client ${id}:`); - console.log( - JSON.stringify(sharedDirectory.getAttachSummary(true).summary, undefined, 4), - ); - console.log("\n"); - } - } -} - -function makeReducer(loggingInfo?: LoggingInfo): AsyncReducer { - const withLogging = - (baseReducer: AsyncReducer): AsyncReducer => - async (state, operation) => { - if (loggingInfo !== undefined && loggingInfo.printConsoleLogs) { - logCurrentState(state.clients, loggingInfo); - console.log("-".repeat(20)); - console.log("Next operation:", JSON.stringify(operation, undefined, 4)); - } - try { - await baseReducer(state, operation); - } catch (error) { - if (loggingInfo !== undefined) { - logCurrentState(state.clients, loggingInfo); - } - throw error; - } - return state; - }; - - const reducer: AsyncReducer = combineReducersAsync({ - createSubDirectory: async ({ client }, { path, name }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.createSubDirectory(name); - }, - deleteSubDirectory: async ({ client }, { path, name }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.deleteSubDirectory(name); - }, - set: async ({ client }, { path, key, value }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.set(key, value); - }, - clear: async ({ client }, { path }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.clear(); - }, - delete: async ({ client }, { path, key }) => { - const dir = client.channel.getWorkingDirectory(path); - assert(dir); - dir.delete(key); - }, - }); - - return withLogging(reducer); -} +import { + baseDirModel, + dirDefaultOptions, + makeDirOperationGenerator, + makeDirReducer, + type DirOperation, + type DirOperationGenerationConfig, +} from "./fuzzUtils.js"; describe("SharedDirectory fuzz Create/Delete concentrated", () => { - const options: OperationGenerationConfig = { + const options: DirOperationGenerationConfig = { setKeyWeight: 0, clearKeysWeight: 0, deleteKeyWeight: 0, @@ -329,18 +31,21 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { deleteSubDirWeight: 2, maxSubDirectoryChild: 2, subDirectoryNamePool: ["dir1", "dir2"], - validateInterval: defaultOptions.validateInterval, + validateInterval: dirDefaultOptions.validateInterval, }; - const model: DDSFuzzModel = { + const model: DDSFuzzModel = { workloadName: "default directory 1", - generatorFactory: () => takeAsync(100, makeOperationGenerator(options)), - reducer: makeReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), + generatorFactory: () => takeAsync(100, makeDirOperationGenerator(options)), + reducer: makeDirReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), factory: new DirectoryFactory(), }; createDDSFuzzSuite(model, { - validationStrategy: { type: "fixedInterval", interval: defaultOptions.validateInterval }, + validationStrategy: { + type: "fixedInterval", + interval: dirDefaultOptions.validateInterval, + }, reconnectProbability: 0.15, numberOfClients: 3, // We prevent handles from being generated on the creation/deletion tests since the set operations are disabled. @@ -388,16 +93,11 @@ describe("SharedDirectory fuzz Create/Delete concentrated", () => { }); describe("SharedDirectory fuzz", () => { - const model: DDSFuzzModel = { - workloadName: "default directory 2", - generatorFactory: () => takeAsync(100, makeOperationGenerator()), - reducer: makeReducer({ clientIds: ["A", "B", "C"], printConsoleLogs: false }), - validateConsistency: async (a, b) => assertEquivalentDirectories(a.channel, b.channel), - factory: new DirectoryFactory(), - }; - - createDDSFuzzSuite(model, { - validationStrategy: { type: "fixedInterval", interval: defaultOptions.validateInterval }, + createDDSFuzzSuite(baseDirModel, { + validationStrategy: { + type: "fixedInterval", + interval: dirDefaultOptions.validateInterval, + }, reconnectProbability: 0.15, numberOfClients: 3, clientJoinOptions: { @@ -414,7 +114,7 @@ describe("SharedDirectory fuzz", () => { }); createDDSFuzzSuite( - { ...model, workloadName: "default directory 2 with rebasing" }, + { ...baseDirModel, workloadName: "default directory 2 with rebasing" }, { validationStrategy: { type: "random", diff --git a/packages/dds/map/src/test/mocha/fuzzUtils.ts b/packages/dds/map/src/test/mocha/fuzzUtils.ts new file mode 100644 index 000000000000..883cf5d68e16 --- /dev/null +++ b/packages/dds/map/src/test/mocha/fuzzUtils.ts @@ -0,0 +1,511 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { + type AsyncGenerator, + type AsyncReducer, + type Generator, + combineReducers, + combineReducersAsync, + createWeightedAsyncGenerator, + createWeightedGenerator, + takeAsync, +} from "@fluid-private/stochastic-test-utils"; +import type { Client, DDSFuzzModel, DDSFuzzTestState } from "@fluid-private/test-dds-utils"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; +import { isObject } from "@fluidframework/core-utils/internal"; +import type { Serializable } from "@fluidframework/datastore-definitions/internal"; +import { isFluidHandle, toFluidHandleInternal } from "@fluidframework/runtime-utils/internal"; + +import { + DirectoryFactory, + type IDirectory, + type ISharedMap, + MapFactory, +} from "../../index.js"; + +import { assertEquivalentDirectories } from "./directoryEquivalenceUtils.js"; + +/** + * Represents a map clear operation. + */ +interface MapClear { + type: "clear"; +} + +/** + * Represents a map set key operation. + */ +interface MapSetKey { + type: "setKey"; + key: string; + value: Serializable; +} + +/** + * Represents a map delete key operation. + */ +interface MapDeleteKey { + type: "deleteKey"; + key: string; +} + +type MapOperation = MapSetKey | MapDeleteKey | MapClear; + +// This type gets used a lot as the state object of the suite; shorthand it here. +type MapState = DDSFuzzTestState; + +async function assertMapsAreEquivalent(a: ISharedMap, b: ISharedMap): Promise { + assert.equal(a.size, b.size, `${a.id} and ${b.id} have different number of keys.`); + for (const key of a.keys()) { + const aVal: unknown = a.get(key); + const bVal: unknown = b.get(key); + if (isObject(aVal) === true) { + assert( + isObject(bVal), + `${a.id} and ${b.id} differ at ${key}: a is an object, b is not}`, + ); + const aHandle = isFluidHandle(aVal) ? toFluidHandleInternal(aVal).absolutePath : aVal; + const bHandle = isFluidHandle(bVal) ? toFluidHandleInternal(bVal).absolutePath : bVal; + assert.equal( + aHandle, + bHandle, + `${a.id} and ${b.id} differ at ${key}: ${JSON.stringify(aHandle)} vs ${JSON.stringify( + bHandle, + )}`, + ); + } else { + assert.equal(aVal, bVal, `${a.id} and ${b.id} differ at ${key}: ${aVal} vs ${bVal}`); + } + } +} + +const mapReducer = combineReducers({ + clear: ({ client }) => client.channel.clear(), + setKey: ({ client }, { key, value }) => { + client.channel.set(key, value); + }, + deleteKey: ({ client }, { key }) => { + client.channel.delete(key); + }, +}); + +/** + * Represents the options for the map generator. + */ +interface MapGeneratorOptions { + setWeight: number; + deleteWeight: number; + clearWeight: number; + keyPoolSize: number; +} + +const mapDefaultOptions: MapGeneratorOptions = { + setWeight: 20, + deleteWeight: 20, + clearWeight: 1, + keyPoolSize: 20, +}; + +function mapMakeGenerator( + optionsParam?: Partial, +): AsyncGenerator { + const { setWeight, deleteWeight, clearWeight, keyPoolSize } = { + ...mapDefaultOptions, + ...optionsParam, + }; + // Use numbers as the key names. + const keyNames = Array.from({ length: keyPoolSize }, (_, i) => `${i}`); + + const setKey: Generator = ({ random }) => ({ + type: "setKey", + key: random.pick(keyNames), + value: random.pick([ + (): number => random.integer(1, 50), + (): string => random.string(random.integer(3, 7)), + (): IFluidHandle => random.handle(), + ])(), + }); + const deleteKey: Generator = ({ random }) => ({ + type: "deleteKey", + key: random.pick(keyNames), + }); + + const syncGenerator = createWeightedGenerator([ + [setKey, setWeight], + [deleteKey, deleteWeight], + [{ type: "clear" }, clearWeight], + ]); + + return async (state) => syncGenerator(state); +} + +/** + * the maps fuzz model + */ +export const baseMapModel: DDSFuzzModel = { + workloadName: "default", + factory: new MapFactory(), + generatorFactory: () => takeAsync(100, mapMakeGenerator()), + reducer: async (state, operation) => mapReducer(state, operation), + validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), +}; + +type DirFuzzTestState = DDSFuzzTestState; + +/** + * Represents a directory set key operation. + */ +export interface DirSetKey { + type: "set"; + path: string; + key: string; + value: Serializable; +} + +/** + * Represents a directory clear keys operation. + */ +export interface DirClearKeys { + type: "clear"; + path: string; +} + +/** + * Represents a directory delete key operation. + */ +export interface DirDeleteKey { + type: "delete"; + path: string; + key: string; +} + +/** + * Represents a create subdirectory operation. + */ +export interface CreateSubDirectory { + type: "createSubDirectory"; + path: string; + name: string; +} + +/** + * Represents a delete subdirectory operation. + */ +export interface DeleteSubDirectory { + type: "deleteSubDirectory"; + path: string; + name: string; +} + +/** + * Represents a directory key operation. + */ +export type DirKeyOperation = DirSetKey | DirDeleteKey | DirClearKeys; + +/** + * Represents a subdirectory operation. + */ +export type SubDirectoryOperation = CreateSubDirectory | DeleteSubDirectory; + +/** + * Represents a directory operation. + */ +export type DirOperation = DirKeyOperation | SubDirectoryOperation; + +/** + * Represents the configuration for directory operation generation. + */ +export interface DirOperationGenerationConfig { + validateInterval: number; + maxSubDirectoryChild?: number; + subDirectoryNamePool?: string[]; + keyNamePool?: string[]; + setKeyWeight?: number; + deleteKeyWeight?: number; + clearKeysWeight?: number; + createSubDirWeight?: number; + deleteSubDirWeight?: number; +} + +/** + * The default options for the directory fuzz model + */ +export const dirDefaultOptions: Required = { + validateInterval: 10, + maxSubDirectoryChild: 3, + subDirectoryNamePool: ["dir1", "dir2", "dir3"], + keyNamePool: ["prop1", "prop2", "prop3"], + setKeyWeight: 5, + deleteKeyWeight: 2, + clearKeysWeight: 1, + createSubDirWeight: 2, + deleteSubDirWeight: 1, +}; + +/** + * Picks an absolute path for key operations. + * @param state - The current state of the directory fuzz test. + * @param shouldHaveKey - Whether the directory should have a key. + * @returns The absolute path. + */ +function pickAbsolutePathForKeyOps(state: DirFuzzTestState, shouldHaveKey: boolean): string { + const { random, client } = state; + let parentDir: IDirectory = client.channel; + for (;;) { + assert(parentDir !== undefined, "Directory should be defined"); + const subDirs: IDirectory[] = []; + for (const [_, b] of parentDir.subdirectories()) { + subDirs.push(b); + } + const subDir = random.pick([undefined, ...subDirs]); + if (subDir !== undefined && (!shouldHaveKey || subDir.size > 0)) { + parentDir = subDir; + } else { + break; + } + } + return parentDir.absolutePath; +} + +/** + * Creates a directory operation generator. + * @param optionsParam - The configuration options for the generator. + * @returns An asynchronous generator for directory operations. + */ +export function makeDirOperationGenerator( + optionsParam?: DirOperationGenerationConfig, +): AsyncGenerator { + const options = { ...dirDefaultOptions, ...optionsParam }; + + // All subsequent helper functions are generators; note that they don't actually apply any operations. + function pickAbsolutePathForCreateDirectoryOp(state: DirFuzzTestState): string { + const { random, client } = state; + let dir: IDirectory = client.channel; + for (;;) { + assert(dir !== undefined, "Directory should be defined"); + const subDirectories: IDirectory[] = []; + for (const [_, b] of dir.subdirectories()) { + subDirectories.push(b); + } + // If this dir already has max number of child, then choose one and continue. + if ( + dir.countSubDirectory !== undefined && + dir.countSubDirectory() === options.maxSubDirectoryChild + ) { + dir = random.pick(subDirectories); + continue; + } + const subDir = random.pick([undefined, ...subDirectories]); + if (subDir === undefined) { + break; + } else { + dir = subDir; + } + } + return dir.absolutePath; + } + + function pickAbsolutePathForDeleteDirectoryOp(state: DirFuzzTestState): string { + const { random, client } = state; + let parentDir: IDirectory = client.channel; + const subDirectories: IDirectory[] = []; + for (const [_, b] of client.channel.subdirectories()) { + subDirectories.push(b); + } + let dirToDelete = random.pick(subDirectories); + for (;;) { + assert(dirToDelete !== undefined, "Directory should be defined"); + const subDirs: IDirectory[] = []; + for (const [_, b] of dirToDelete.subdirectories()) { + subDirs.push(b); + } + const subDir = random.pick([undefined, ...subDirs]); + if (subDir === undefined) { + break; + } else { + parentDir = dirToDelete; + dirToDelete = subDir; + } + } + return parentDir.absolutePath; + } + + async function createSubDirectory(state: DirFuzzTestState): Promise { + return { + type: "createSubDirectory", + name: state.random.pick(options.subDirectoryNamePool), + path: pickAbsolutePathForCreateDirectoryOp(state), + }; + } + + async function deleteSubDirectory(state: DirFuzzTestState): Promise { + const { random, client } = state; + const path = pickAbsolutePathForDeleteDirectoryOp(state); + const parentDir = client.channel.getWorkingDirectory(path); + assert(parentDir !== undefined, "parent dir should be defined"); + assert( + parentDir.countSubDirectory && parentDir.countSubDirectory() > 0, + "Atleast 1 subdir should be there", + ); + const subDirName: string[] = []; + for (const [a, _] of parentDir.subdirectories()) { + subDirName.push(a); + } + return { + type: "deleteSubDirectory", + name: random.pick(subDirName), + path, + }; + } + + async function setKey(state: DirFuzzTestState): Promise { + const { random } = state; + return { + type: "set", + key: random.pick(options.keyNamePool), + path: pickAbsolutePathForKeyOps(state, false), + value: random.pick([ + (): string => random.string(random.integer(0, 4)), + (): IFluidHandle => random.handle(), + ])(), + }; + } + + async function clearKeys(state: DirFuzzTestState): Promise { + return { + type: "clear", + path: pickAbsolutePathForKeyOps(state, true), + }; + } + + async function deleteKey(state: DirFuzzTestState): Promise { + const { random, client } = state; + const path = pickAbsolutePathForKeyOps(state, true); + const dir = client.channel.getWorkingDirectory(path); + assert(dir, "dir should exist"); + return { + type: "delete", + key: random.pick([...dir.keys()]), + path, + }; + } + + return createWeightedAsyncGenerator([ + [createSubDirectory, options.createSubDirWeight], + [ + deleteSubDirectory, + options.deleteSubDirWeight, + (state: DirFuzzTestState): boolean => + (state.client.channel.countSubDirectory?.() ?? 0) > 0, + ], + [setKey, options.setKeyWeight], + [ + deleteKey, + options.deleteKeyWeight, + (state: DirFuzzTestState): boolean => state.client.channel.size > 0, + ], + [ + clearKeys, + options.clearKeysWeight, + (state: DirFuzzTestState): boolean => state.client.channel.size > 0, + ], + ]); +} + +/** + * Represents logging information. + */ +interface LoggingInfo { + // Clients to print + clientIds: string[]; + // Set this to true in case you want to debug and print client states and ops. + printConsoleLogs?: boolean; +} + +function logCurrentState(clients: Client[], loggingInfo: LoggingInfo): void { + for (const id of loggingInfo.clientIds) { + const { channel: sharedDirectory } = + clients.find((s) => s.containerRuntime.clientId === id) ?? {}; + if (sharedDirectory !== undefined) { + console.log(`Client ${id}:`); + console.log( + JSON.stringify(sharedDirectory.getAttachSummary(true).summary, undefined, 4), + ); + console.log("\n"); + } + } +} + +/** + * Creates a directory reducer with optional logging. + * @param loggingInfo - The logging information. + * @returns An asynchronous reducer for directory operations. + */ +export function makeDirReducer( + loggingInfo?: LoggingInfo, +): AsyncReducer { + const withLogging = + (baseReducer: AsyncReducer): AsyncReducer => + async (state, operation) => { + if (loggingInfo !== undefined && loggingInfo.printConsoleLogs) { + logCurrentState(state.clients, loggingInfo); + console.log("-".repeat(20)); + console.log("Next operation:", JSON.stringify(operation, undefined, 4)); + } + try { + await baseReducer(state, operation); + } catch (error) { + if (loggingInfo !== undefined) { + logCurrentState(state.clients, loggingInfo); + } + throw error; + } + return state; + }; + + const reducer: AsyncReducer = combineReducersAsync({ + createSubDirectory: async ({ client }, { path, name }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.createSubDirectory(name); + }, + deleteSubDirectory: async ({ client }, { path, name }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.deleteSubDirectory(name); + }, + set: async ({ client }, { path, key, value }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.set(key, value); + }, + clear: async ({ client }, { path }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.clear(); + }, + delete: async ({ client }, { path, key }) => { + const dir = client.channel.getWorkingDirectory(path); + assert(dir); + dir.delete(key); + }, + }); + + return withLogging(reducer); +} + +/** + * The base fuzz model for directory. + */ +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), + factory: new DirectoryFactory(), +}; diff --git a/packages/dds/map/src/test/mocha/index.ts b/packages/dds/map/src/test/mocha/index.ts new file mode 100644 index 000000000000..fa6033d61155 --- /dev/null +++ b/packages/dds/map/src/test/mocha/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { baseDirModel, baseMapModel } from "./fuzzUtils.js"; diff --git a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts index 07236a8bbc18..dad62a8655a7 100644 --- a/packages/dds/map/src/test/mocha/map.fuzz.spec.ts +++ b/packages/dds/map/src/test/mocha/map.fuzz.spec.ts @@ -3,143 +3,16 @@ * Licensed under the MIT License. */ -import { strict as assert } from "node:assert"; import * as path from "node:path"; -import { - type AsyncGenerator, - type Generator, - combineReducers, - createWeightedGenerator, - takeAsync, -} from "@fluid-private/stochastic-test-utils"; -import { - type DDSFuzzModel, - type DDSFuzzTestState, - createDDSFuzzSuite, -} from "@fluid-private/test-dds-utils"; -import type { IFluidHandle } from "@fluidframework/core-interfaces"; -import { isObject } from "@fluidframework/core-utils/internal"; -import type { Serializable } from "@fluidframework/datastore-definitions/internal"; +import { createDDSFuzzSuite } from "@fluid-private/test-dds-utils"; import { FlushMode } from "@fluidframework/runtime-definitions/internal"; -import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; - -import { type ISharedMap, MapFactory } from "../../index.js"; import { _dirname } from "./dirname.cjs"; - -interface Clear { - type: "clear"; -} - -interface SetKey { - type: "setKey"; - key: string; - value: Serializable; -} - -interface DeleteKey { - type: "deleteKey"; - key: string; -} - -type Operation = SetKey | DeleteKey | Clear; - -// This type gets used a lot as the state object of the suite; shorthand it here. -type State = DDSFuzzTestState; - -async function assertMapsAreEquivalent(a: ISharedMap, b: ISharedMap): Promise { - assert.equal(a.size, b.size, `${a.id} and ${b.id} have different number of keys.`); - for (const key of a.keys()) { - const aVal: unknown = a.get(key); - const bVal: unknown = b.get(key); - if (isObject(aVal) === true) { - assert( - isObject(bVal), - `${a.id} and ${b.id} differ at ${key}: a is an object, b is not}`, - ); - const aHandle = isFluidHandle(aVal) ? await aVal.get() : aVal; - const bHandle = isFluidHandle(bVal) ? await bVal.get() : bVal; - assert.equal( - aHandle, - bHandle, - `${a.id} and ${b.id} differ at ${key}: ${JSON.stringify(aHandle)} vs ${JSON.stringify( - bHandle, - )}`, - ); - } else { - assert.equal(aVal, bVal, `${a.id} and ${b.id} differ at ${key}: ${aVal} vs ${bVal}`); - } - } -} - -const reducer = combineReducers({ - clear: ({ client }) => client.channel.clear(), - setKey: ({ client }, { key, value }) => { - client.channel.set(key, value); - }, - deleteKey: ({ client }, { key }) => { - client.channel.delete(key); - }, -}); - -interface GeneratorOptions { - setWeight: number; - deleteWeight: number; - clearWeight: number; - keyPoolSize: number; -} - -const defaultOptions: GeneratorOptions = { - setWeight: 20, - deleteWeight: 20, - clearWeight: 1, - keyPoolSize: 20, -}; - -function makeGenerator( - optionsParam?: Partial, -): AsyncGenerator { - const { setWeight, deleteWeight, clearWeight, keyPoolSize } = { - ...defaultOptions, - ...optionsParam, - }; - // Use numbers as the key names. - const keyNames = Array.from({ length: keyPoolSize }, (_, i) => `${i}`); - - const setKey: Generator = ({ random }) => ({ - type: "setKey", - key: random.pick(keyNames), - value: random.pick([ - (): number => random.integer(1, 50), - (): string => random.string(random.integer(3, 7)), - (): IFluidHandle => random.handle(), - ])(), - }); - const deleteKey: Generator = ({ random }) => ({ - type: "deleteKey", - key: random.pick(keyNames), - }); - - const syncGenerator = createWeightedGenerator([ - [setKey, setWeight], - [deleteKey, deleteWeight], - [{ type: "clear" }, clearWeight], - ]); - - return async (state) => syncGenerator(state); -} +import { baseMapModel } from "./fuzzUtils.js"; describe("Map fuzz tests", () => { - const model: DDSFuzzModel = { - workloadName: "default", - factory: new MapFactory(), - generatorFactory: () => takeAsync(100, makeGenerator()), - reducer: async (state, operation) => reducer(state, operation), - validateConsistency: async (a, b) => assertMapsAreEquivalent(a.channel, b.channel), - }; - - createDDSFuzzSuite(model, { + createDDSFuzzSuite(baseMapModel, { defaultTestCount: 100, numberOfClients: 3, clientJoinOptions: { @@ -154,7 +27,7 @@ describe("Map fuzz tests", () => { }); createDDSFuzzSuite( - { ...model, workloadName: "with reconnect" }, + { ...baseMapModel, workloadName: "with reconnect" }, { defaultTestCount: 100, numberOfClients: 3, @@ -173,7 +46,7 @@ describe("Map fuzz tests", () => { ); createDDSFuzzSuite( - { ...model, workloadName: "with batches and rebasing" }, + { ...baseMapModel, workloadName: "with batches and rebasing" }, { defaultTestCount: 100, numberOfClients: 3, diff --git a/packages/dds/map/src/test/tsconfig.json b/packages/dds/map/src/test/tsconfig.json index ba735163526f..f0b770e0e3bd 100644 --- a/packages/dds/map/src/test/tsconfig.json +++ b/packages/dds/map/src/test/tsconfig.json @@ -7,6 +7,8 @@ "noUnusedLocals": false, // Need it so memory tests can declare local variables just for the sake of keeping things in memory "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, + "declaration": true, + "declarationMap": true, }, "include": ["./**/*"], "references": [