From 6fd547bce10a5e10ca06bbd7f2c614b07b043c18 Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Sat, 28 Sep 2024 23:47:27 -0700 Subject: [PATCH] feat(client-presence): initial telemetry (#22656) Add monitoring context `Presence` Two events: - `PresenceInitiated` logged when runtime instantiates `Presence` feature. - `JoinResponse` logged when a client manages response to another client's Join message. Add basic test infrastructure to check expected events and protocol handling. - Add mock IEphemeralRuntime implementation with signal tracking - Refactor SessionId assignment to allow testing to specify it (mocking underlying functions used generate it is not supported with ESM) - Update/fix Presence test name hierarchy --- packages/framework/presence/package.json | 8 +- .../framework/presence/src/internalTypes.ts | 8 +- .../presence/src/presenceDatastoreManager.ts | 100 ++++++---- .../framework/presence/src/presenceManager.ts | 34 ++-- .../src/test/latestMapValueManager.spec.ts | 12 +- .../src/test/latestValueManager.spec.ts | 12 +- .../presence/src/test/mockEphemeralRuntime.ts | 142 ++++++++++++++ .../src/test/notificationsManager.spec.ts | 12 +- .../src/test/presenceDatastoreManager.spec.ts | 176 ++++++++++++++++++ .../presence/src/test/presenceManager.spec.ts | 47 +++++ .../presence/src/test/presenceStates.spec.ts | 12 +- .../framework/presence/src/test/testUtils.ts | 120 ++++++++++++ pnpm-lock.yaml | 18 ++ 13 files changed, 634 insertions(+), 67 deletions(-) create mode 100644 packages/framework/presence/src/test/mockEphemeralRuntime.ts create mode 100644 packages/framework/presence/src/test/presenceDatastoreManager.spec.ts create mode 100644 packages/framework/presence/src/test/presenceManager.spec.ts create mode 100644 packages/framework/presence/src/test/testUtils.ts diff --git a/packages/framework/presence/package.json b/packages/framework/presence/package.json index 4d95de0cac4..57468c4961f 100644 --- a/packages/framework/presence/package.json +++ b/packages/framework/presence/package.json @@ -126,7 +126,8 @@ "@fluidframework/id-compressor": "workspace:~", "@fluidframework/runtime-definitions": "workspace:~", "@fluidframework/runtime-utils": "workspace:~", - "@fluidframework/shared-object-base": "workspace:~" + "@fluidframework/shared-object-base": "workspace:~", + "@fluidframework/telemetry-utils": "workspace:~" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.2", @@ -134,10 +135,14 @@ "@fluid-tools/build-cli": "^0.46.0", "@fluidframework/build-common": "^2.0.3", "@fluidframework/build-tools": "^0.46.0", + "@fluidframework/driver-definitions": "workspace:~", "@fluidframework/eslint-config-fluid": "^5.4.0", + "@fluidframework/test-runtime-utils": "workspace:~", + "@fluidframework/test-utils": "workspace:~", "@microsoft/api-extractor": "7.47.8", "@types/mocha": "^9.1.1", "@types/node": "^18.19.0", + "@types/sinon": "^17.0.3", "c8": "^8.0.1", "concurrently": "^8.2.1", "copyfiles": "^2.4.1", @@ -148,6 +153,7 @@ "mocha-multi-reporters": "^1.5.1", "prettier": "~3.0.3", "rimraf": "^4.4.0", + "sinon": "^17.0.1", "typescript": "~5.4.5" }, "fluidBuild": { diff --git a/packages/framework/presence/src/internalTypes.ts b/packages/framework/presence/src/internalTypes.ts index 585b4524b60..62c9c9619d7 100644 --- a/packages/framework/presence/src/internalTypes.ts +++ b/packages/framework/presence/src/internalTypes.ts @@ -5,6 +5,7 @@ import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; import type { IFluidDataStoreRuntime } from "@fluidframework/datastore-definitions/internal"; +import type { MonitoringContext } from "@fluidframework/telemetry-utils/internal"; import type { InternalTypes } from "./exposedInternalTypes.js"; import type { ClientSessionId, IPresence, ISessionClient } from "./presence.js"; @@ -44,14 +45,17 @@ export const brandedObjectEntries = Object.entries as ( export type IEphemeralRuntime = Pick< (IContainerRuntime & IRuntimeInternal) | IFluidDataStoreRuntime, "clientId" | "connected" | "getQuorum" | "off" | "on" | "submitSignal" ->; +> & + Partial>; /** * Collection of utilities provided by PresenceManager that are used by presence sub-components. * * @internal */ -export type PresenceManagerInternal = Pick; +export type PresenceManagerInternal = Pick & { + readonly mc: MonitoringContext | undefined; +}; /** * @internal diff --git a/packages/framework/presence/src/presenceDatastoreManager.ts b/packages/framework/presence/src/presenceDatastoreManager.ts index f77e7280ac1..c6494ee5287 100644 --- a/packages/framework/presence/src/presenceDatastoreManager.ts +++ b/packages/framework/presence/src/presenceDatastoreManager.ts @@ -250,39 +250,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { if (message.type === joinMessageType) { assert(this.runtime.connected, "Received presence join signal while not connected"); - const updateProviders = message.content.updateProviders; - this.refreshBroadcastRequested = true; - // We must be connected to receive this message, so clientId should be defined. - // If it isn't then, not really a problem; just won't be in provider or quorum list. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const clientId = this.runtime.clientId!; - if (updateProviders.includes(clientId)) { - // Send all current state to the new client - this.broadcastAllKnownState(); - } else { - // Schedule a broadcast to the new client after a delay only to send if - // another broadcast hasn't been seen in the meantime. The delay is based - // on the position in the quorum list. It doesn't have to be a stable - // list across all clients. We need something to provide suggested order - // to prevent a flood of broadcasts. - const quorumMembers = this.runtime.getQuorum().getMembers(); - const indexOfSelf = - quorumMembers.get(clientId)?.sequenceNumber ?? - // Index past quorum members + arbitrary additional offset up to 10 - quorumMembers.size + Math.random() * 10; - // These numbers have been chosen arbitrarily to start with. - // 20 is minimum wait time, 20 is the additional wait time per provider - // given an chance before us with named providers given more time. - const waitTime = 20 + 20 * (3 * updateProviders.length + indexOfSelf); - setTimeout(() => { - // Make sure a broadcast is still needed and we are currently connected. - // If not connected, nothing we can do. - if (this.refreshBroadcastRequested && this.runtime.connected) { - // TODO: Add telemetry for this attempt to satisfy join - this.broadcastAllKnownState(); - } - }, waitTime); - } + this.prepareJoinResponse(message.content.updateProviders, message.clientId); } else { assert(message.type === datastoreUpdateMessageType, 0xa3b /* Unexpected message type */); if (message.content.isComplete) { @@ -311,4 +279,70 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { } } } + + /** + * Handles responding to another client joining the session. + * + * @param updateProviders - list of client connection id's that requestor selected + * to provide response + * @param requestor - `requestor` is only used in telemetry. While it is the requestor's + * client connection id, that is not most important. It is important that this is a + * unique shared id across all clients that might respond as we want to monitor the + * response patterns. The convenience of being client connection id will allow + * correlation with other telemetry where it is often called just `clientId`. + */ + private prepareJoinResponse( + updateProviders: ClientConnectionId[], + requestor: ClientConnectionId, + ): void { + this.refreshBroadcastRequested = true; + // We must be connected to receive this message, so clientId should be defined. + // If it isn't then, not really a problem; just won't be in provider or quorum list. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const clientId = this.runtime.clientId!; + // const requestor = message.clientId; + if (updateProviders.includes(clientId)) { + // Send all current state to the new client + this.broadcastAllKnownState(); + this.presence.mc?.logger.sendTelemetryEvent({ + eventName: "JoinResponse", + details: { + type: "broadcastAll", + requestor, + role: "primary", + }, + }); + } else { + // Schedule a broadcast to the new client after a delay only to send if + // another broadcast hasn't been seen in the meantime. The delay is based + // on the position in the quorum list. It doesn't have to be a stable + // list across all clients. We need something to provide suggested order + // to prevent a flood of broadcasts. + const quorumMembers = this.runtime.getQuorum().getMembers(); + const indexOfSelf = + quorumMembers.get(clientId)?.sequenceNumber ?? + // Index past quorum members + arbitrary additional offset up to 10 + quorumMembers.size + Math.random() * 10; + // These numbers have been chosen arbitrarily to start with. + // 20 is minimum wait time, 20 is the additional wait time per provider + // given an chance before us with named providers given more time. + const waitTime = 20 + 20 * (3 * updateProviders.length + indexOfSelf); + setTimeout(() => { + // Make sure a broadcast is still needed and we are currently connected. + // If not connected, nothing we can do. + if (this.refreshBroadcastRequested && this.runtime.connected) { + this.broadcastAllKnownState(); + this.presence.mc?.logger.sendTelemetryEvent({ + eventName: "JoinResponse", + details: { + type: "broadcastAll", + requestor, + role: "secondary", + order: indexOfSelf, + }, + }); + } + }, waitTime); + } + } } diff --git a/packages/framework/presence/src/presenceManager.ts b/packages/framework/presence/src/presenceManager.ts index 80ccfda636d..4eddbcb839b 100644 --- a/packages/framework/presence/src/presenceManager.ts +++ b/packages/framework/presence/src/presenceManager.ts @@ -4,6 +4,8 @@ */ import { createSessionId } from "@fluidframework/id-compressor/internal"; +import type { MonitoringContext } from "@fluidframework/telemetry-utils/internal"; +import { createChildMonitoringContext } from "@fluidframework/telemetry-utils/internal"; import type { ClientConnectionId } from "./baseTypes.js"; import type { IEphemeralRuntime, PresenceManagerInternal } from "./internalTypes.js"; @@ -43,17 +45,26 @@ class PresenceManager implements IPresence, PresenceExtensionInterface, PresenceManagerInternal { private readonly datastoreManager: PresenceDatastoreManager; - private readonly selfAttendee: ISessionClient = { - sessionId: createSessionId() as ClientSessionId, - currentConnectionId: () => { - throw new Error("Client has never been connected"); - }, - }; - private readonly attendees = new Map([ - [this.selfAttendee.sessionId, this.selfAttendee], - ]); + private readonly selfAttendee: ISessionClient; + private readonly attendees = new Map(); + + public readonly mc: MonitoringContext | undefined = undefined; + + public constructor(runtime: IEphemeralRuntime, clientSessionId: ClientSessionId) { + this.selfAttendee = { + sessionId: clientSessionId, + currentConnectionId: () => { + throw new Error("Client has never been connected"); + }, + }; + this.attendees.set(clientSessionId, this.selfAttendee); + + const logger = runtime.logger; + if (logger) { + this.mc = createChildMonitoringContext({ logger, namespace: "Presence" }); + this.mc.logger.sendTelemetryEvent({ eventName: "PresenceInstantiated" }); + } - public constructor(runtime: IEphemeralRuntime) { // If already connected (now or in the past), populate self and attendees. const originalClientId = runtime.clientId; if (originalClientId !== undefined) { @@ -137,6 +148,7 @@ class PresenceManager */ export function createPresenceManager( runtime: IEphemeralRuntime, + clientSessionId: ClientSessionId = createSessionId() as ClientSessionId, ): IPresence & PresenceExtensionInterface { - return new PresenceManager(runtime); + return new PresenceManager(runtime, clientSessionId); } diff --git a/packages/framework/presence/src/test/latestMapValueManager.spec.ts b/packages/framework/presence/src/test/latestMapValueManager.spec.ts index 6536a480061..acc54d8f300 100644 --- a/packages/framework/presence/src/test/latestMapValueManager.spec.ts +++ b/packages/framework/presence/src/test/latestMapValueManager.spec.ts @@ -7,11 +7,13 @@ import type { LatestMapItemValueClientData } from "../index.js"; import { LatestMap } from "../index.js"; import type { IPresence } from "../presence.js"; -describe("LatestMapValueManager", () => { - /** - * See {@link checkCompiles} below - */ - it("API use compiles", () => {}); +describe("Presence", () => { + describe("LatestMapValueManager", () => { + /** + * See {@link checkCompiles} below + */ + it("API use compiles", () => {}); + }); }); // ---- test (example) code ---- diff --git a/packages/framework/presence/src/test/latestValueManager.spec.ts b/packages/framework/presence/src/test/latestValueManager.spec.ts index 0b17b17d8ab..815cede4038 100644 --- a/packages/framework/presence/src/test/latestValueManager.spec.ts +++ b/packages/framework/presence/src/test/latestValueManager.spec.ts @@ -7,11 +7,13 @@ import type { LatestValueClientData } from "../index.js"; import { Latest } from "../index.js"; import type { IPresence } from "../presence.js"; -describe("LatestValueManager", () => { - /** - * See {@link checkCompiles} below - */ - it("API use compiles", () => {}); +describe("Presence", () => { + describe("LatestValueManager", () => { + /** + * See {@link checkCompiles} below + */ + it("API use compiles", () => {}); + }); }); // ---- test (example) code ---- diff --git a/packages/framework/presence/src/test/mockEphemeralRuntime.ts b/packages/framework/presence/src/test/mockEphemeralRuntime.ts new file mode 100644 index 00000000000..7cc04445dba --- /dev/null +++ b/packages/framework/presence/src/test/mockEphemeralRuntime.ts @@ -0,0 +1,142 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces"; +import type { IQuorumClients, ISequencedClient } from "@fluidframework/driver-definitions"; +import { MockQuorumClients } from "@fluidframework/test-runtime-utils/internal"; + +import type { ClientConnectionId } from "../baseTypes.js"; +import type { IEphemeralRuntime } from "../internalTypes.js"; + +/** + * Creates a mock {@link @fluidframework/protocol-definitions#IQuorumClients} for testing. + */ +export function makeMockQuorum(clientIds: string[]): IQuorumClients { + const clients = new Map(); + for (const [index, clientId] of clientIds.entries()) { + // eslint-disable-next-line unicorn/prefer-code-point + const stringId = String.fromCharCode(index + 65); + const name = stringId.repeat(10); + const userId = `${name}@microsoft.com`; + const email = userId; + const user = { + id: userId, + name, + email, + }; + clients.set(clientId, { + client: { + mode: "write", + details: { capabilities: { interactive: true } }, + permission: [], + user, + scopes: [], + }, + sequenceNumber: 10 * index, + }); + } + return new MockQuorumClients(...clients.entries()); +} + +/** + * Mock ephemeral runtime for testing + */ +export class MockEphemeralRuntime implements IEphemeralRuntime { + public logger?: ITelemetryBaseLogger; + public readonly quorum: IQuorumClients; + + public readonly listeners: { + connected: ((clientId: ClientConnectionId) => void)[]; + } = { + connected: [], + }; + private isSupportedEvent(event: string): event is keyof typeof this.listeners { + return event in this.listeners; + } + + public constructor( + logger?: ITelemetryBaseLogger, + public readonly signalsExpected: Parameters[] = [], + ) { + if (logger !== undefined) { + this.logger = logger; + } + const quorum = makeMockQuorum([ + "client0", + "client1", + "client2", + "client3", + "client4", + "client5", + ]); + this.quorum = quorum; + this.getQuorum = () => quorum; + this.on = ( + event: string, + listener: (...args: any[]) => void, + // Events style eventing does not lend itself to union that + // IEphemeralRuntime is derived from, so we are using `any` here + // but meet the intent of the interface. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => { + if (!this.isSupportedEvent(event)) { + throw new Error(`Event ${event} is not supported`); + } + // Switch to allowing a single listener as commented when + // implementation uses a single "connected" listener. + // if (this.listeners[event]) { + // throw new Error(`Event ${event} already has a listener`); + // } + // this.listeners[event] = listener; + if (this.listeners[event].length > 1) { + throw new Error(`Event ${event} already has multiple listeners`); + } + this.listeners[event].push(listener); + return this; + }; + } + + public assertAllSignalsSubmitted(): void { + assert.strictEqual( + this.signalsExpected.length, + 0, + `Missing signals [\n${this.signalsExpected + .map( + (a) => + `\t{ type: ${a[0]}, content: ${JSON.stringify(a[1], undefined, "\t")}, targetClientId: ${a[2]} }`, + ) + .join(",\n\t")}\n]`, + ); + } + + // #region IEphemeralRuntime + + public clientId: string | undefined; + public connected: boolean = false; + + public on: IEphemeralRuntime["on"]; + + public off: IEphemeralRuntime["off"] = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => { + throw new Error("IEphemeralRuntime.off method not implemented."); + }; + + public getQuorum: () => ReturnType; + + public submitSignal: IEphemeralRuntime["submitSignal"] = ( + ...args: Parameters + ) => { + if (this.signalsExpected.length === 0) { + throw new Error(`Unexpected signal: ${JSON.stringify(args)}`); + } + const expected = this.signalsExpected.shift(); + assert.deepStrictEqual(args, expected, "Unexpected signal"); + }; + + // #endregion +} diff --git a/packages/framework/presence/src/test/notificationsManager.spec.ts b/packages/framework/presence/src/test/notificationsManager.spec.ts index 21ea140f874..3e1e1a764af 100644 --- a/packages/framework/presence/src/test/notificationsManager.spec.ts +++ b/packages/framework/presence/src/test/notificationsManager.spec.ts @@ -7,11 +7,13 @@ import type { ISessionClient } from "../index.js"; import { Notifications } from "../index.js"; import type { IPresence } from "../presence.js"; -describe("NotificationsManager", () => { - /** - * See {@link checkCompiles} below - */ - it("API use compiles", () => {}); +describe("Presence", () => { + describe("NotificationsManager", () => { + /** + * See {@link checkCompiles} below + */ + it("API use compiles", () => {}); + }); }); // ---- test (example) code ---- diff --git a/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts b/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts new file mode 100644 index 00000000000..8a9db07bd3a --- /dev/null +++ b/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts @@ -0,0 +1,176 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal"; +import type { SinonFakeTimers } from "sinon"; +import { useFakeTimers } from "sinon"; + +import { createPresenceManager } from "../presenceManager.js"; + +import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; +import { assertFinalExpectations, prepareConnectedPresence } from "./testUtils.js"; + +describe("Presence", () => { + describe("protocol handling", () => { + let runtime: MockEphemeralRuntime; + let logger: EventAndErrorTrackingLogger; + const initialTime = 1000; + let clock: SinonFakeTimers; + + before(async () => { + clock = useFakeTimers(); + }); + + beforeEach(() => { + logger = new EventAndErrorTrackingLogger(); + runtime = new MockEphemeralRuntime(logger); + clock.setSystemTime(initialTime); + }); + + afterEach(function (done: Mocha.Done) { + clock.reset(); + + // If the test passed so far, check final expectations. + if (this.currentTest?.state === "passed") { + assertFinalExpectations(runtime, logger); + } + done(); + }); + + after(() => { + clock.restore(); + }); + + it("does not signal when disconnected during initialization", () => { + // Act & Verify + createPresenceManager(runtime); + }); + + it("sends join when connected during initialization", () => { + // Setup, Act (call to createPresenceManager), & Verify (post createPresenceManager call) + prepareConnectedPresence(runtime, "seassionId-2", "client2", clock, logger); + }); + + describe("responds to ClientJoin", () => { + let presence: ReturnType; + + beforeEach(() => { + presence = prepareConnectedPresence(runtime, "seassionId-2", "client2", clock, logger); + + // Pass a little time (to mimic reality) + clock.tick(10); + }); + + it("with broadcast immediately when preferred responder", () => { + // Setup + logger.registerExpectedEvent({ + eventName: "Presence:JoinResponse", + details: JSON.stringify({ + type: "broadcastAll", + requestor: "client4", + role: "primary", + }), + }); + runtime.signalsExpected.push([ + "Pres:DatastoreUpdate", + { + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + "client2": { + "rev": 0, + "timestamp": initialTime, + "value": "seassionId-2", + }, + }, + }, + }, + "isComplete": true, + "sendTimestamp": clock.now, + }, + ]); + + // Act + presence.processSignal( + "", + { + type: "Pres:ClientJoin", + content: { + sendTimestamp: clock.now - 50, + avgLatency: 50, + data: {}, + updateProviders: ["client2"], + }, + clientId: "client4", + }, + false, + ); + + // Verify + assertFinalExpectations(runtime, logger); + }); + + // Delayed secondary response uses quorum sequenceNumber incorrectly. + it.skip("with broadcast after delay when NOT preferred responder", () => { + // #region Part 1 (no response) + // Act + presence.processSignal( + "", + { + type: "Pres:ClientJoin", + content: { + sendTimestamp: clock.now - 20, + avgLatency: 0, + data: {}, + updateProviders: ["client0", "client1"], + }, + clientId: "client4", + }, + false, + ); + // #endregion + + // #region Part 2 (response after delay) + // Setup + logger.registerExpectedEvent({ + eventName: "Presence:JoinResponse", + details: JSON.stringify({ + type: "broadcastAll", + requestor: "client4", + role: "secondary", + order: 2, + }), + }); + runtime.signalsExpected.push([ + "Pres:DatastoreUpdate", + { + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + "client2": { + "rev": 0, + "timestamp": initialTime, + "value": "seassionId-2", + }, + }, + }, + }, + "isComplete": true, + "sendTimestamp": clock.now + 180, + }, + ]); + + // Act + clock.tick(200); + + // Verify + assertFinalExpectations(runtime, logger); + // #endregion + }); + }); + }); +}); diff --git a/packages/framework/presence/src/test/presenceManager.spec.ts b/packages/framework/presence/src/test/presenceManager.spec.ts new file mode 100644 index 00000000000..19dd468a2dc --- /dev/null +++ b/packages/framework/presence/src/test/presenceManager.spec.ts @@ -0,0 +1,47 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal"; + +import { createPresenceManager } from "../presenceManager.js"; + +import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; +import { assertFinalExpectations } from "./testUtils.js"; + +describe("Presence", () => { + describe("PresenceManager", () => { + let runtime: MockEphemeralRuntime; + let logger: EventAndErrorTrackingLogger; + + beforeEach(() => { + logger = new EventAndErrorTrackingLogger(); + runtime = new MockEphemeralRuntime(logger); + }); + + afterEach(function (done: Mocha.Done) { + // If the test passed so far, check final expectations. + if (this.currentTest?.state === "passed") { + assertFinalExpectations(runtime, logger); + } + done(); + }); + + it("can be created", () => { + // Act & Verify (does not throw) + createPresenceManager(runtime); + }); + + it("creation logs initialization event", () => { + // Setup + logger.registerExpectedEvent({ eventName: "Presence:PresenceInstantiated" }); + + // Act + createPresenceManager(runtime); + + // Verify + assertFinalExpectations(runtime, logger); + }); + }); +}); diff --git a/packages/framework/presence/src/test/presenceStates.spec.ts b/packages/framework/presence/src/test/presenceStates.spec.ts index 7bb9404f937..4f43b3115ae 100644 --- a/packages/framework/presence/src/test/presenceStates.spec.ts +++ b/packages/framework/presence/src/test/presenceStates.spec.ts @@ -11,11 +11,13 @@ import type { } from "@fluid-experimental/presence/internal/core-interfaces"; import type { InternalTypes } from "@fluid-experimental/presence/internal/exposedInternalTypes"; -describe("LatestValueManager", () => { - /** - * See {@link checkCompiles} below - */ - it("API use compiles", () => {}); +describe("Presence", () => { + describe("PresenceStates", () => { + /** + * See {@link checkCompiles} below + */ + it("API use compiles", () => {}); + }); }); declare function createValueManager( diff --git a/packages/framework/presence/src/test/testUtils.ts b/packages/framework/presence/src/test/testUtils.ts new file mode 100644 index 00000000000..ab04b431bdb --- /dev/null +++ b/packages/framework/presence/src/test/testUtils.ts @@ -0,0 +1,120 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal"; +import { getUnexpectedLogErrorException } from "@fluidframework/test-utils/internal"; +import type { SinonFakeTimers } from "sinon"; + +import { createPresenceManager } from "../presenceManager.js"; + +import type { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; + +import type { ClientConnectionId, ClientSessionId } from "@fluid-experimental/presence"; + +/** + * Generates expected join signal for a client that was initialized while connected. + */ +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type +export function craftInitializationClientJoin( + fixedTime: number, + clientSessionId: string = "seassionId-2", + clientConnectionId: ClientConnectionId = "client2", + updateProviders: string[] = ["client0", "client1", "client3"], +) { + return { + type: "Pres:ClientJoin", + content: { + "avgLatency": 0, + "data": { + "system:presence": { + "clientToSessionId": { + [clientConnectionId]: { + "rev": 0, + "timestamp": fixedTime, + "value": clientSessionId, + }, + }, + }, + }, + "sendTimestamp": fixedTime, + updateProviders, + }, + }; +} + +/** + * Prepares an instance of presence as it would be if initialized while connected. + * + * @param runtime - the mock runtime + * @param clientSessionId - the client session id given to presence + * @param clientConnectionId - the client connection id + * @param clock - the fake timer. + * @param logger - optional logger to track telemetry events + */ +export function prepareConnectedPresence( + runtime: MockEphemeralRuntime, + clientSessionId: string, + clientConnectionId: ClientConnectionId, + clock: Omit, + logger?: EventAndErrorTrackingLogger, +): ReturnType { + // Set runtime to connected state + runtime.clientId = clientConnectionId; + // TODO: runtime.connected has been hacked in past to lie about true connection. + // This will need to be updated to an alternate status provider. + runtime.connected = true; + + logger?.registerExpectedEvent({ eventName: "Presence:PresenceInstantiated" }); + + // This logic needs to be kept in sync with datastore manager. + const quorumClientIds = [...runtime.quorum.getMembers().keys()].filter( + (quorumClientId) => quorumClientId !== clientConnectionId, + ); + if (quorumClientIds.length > 3) { + quorumClientIds.length = 3; + } + + const expectedClientJoin = craftInitializationClientJoin( + clock.now, + clientSessionId, + clientConnectionId, + quorumClientIds, + ); + runtime.signalsExpected.push([expectedClientJoin.type, expectedClientJoin.content]); + + const presence = createPresenceManager(runtime, clientSessionId as ClientSessionId); + + // Validate expectations post initialization to make sure logger + // and runtime are left in a clean expectation state. + const logErrors = getUnexpectedLogErrorException(logger); + if (logErrors) { + throw logErrors; + } + runtime.assertAllSignalsSubmitted(); + + // Pass a little time (to mimic reality) + clock.tick(10); + + // Return the join signal + presence.processSignal("", { ...expectedClientJoin, clientId: clientConnectionId }, true); + + return presence; +} + +/** + * Asserts that all expected telemetry abd signals were sent. + */ +export function assertFinalExpectations( + runtime: MockEphemeralRuntime, + logger?: EventAndErrorTrackingLogger, +): void { + // Make sure all expected events were logged and there are no unexpected errors. + const logErrors = getUnexpectedLogErrorException(logger); + if (logErrors) { + throw logErrors; + } + // Make sure all expected signals were sent. + runtime.assertAllSignalsSubmitted(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e4b0a8a14e..dc7adaffc0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11360,6 +11360,9 @@ importers: '@fluidframework/shared-object-base': specifier: workspace:~ version: link:../../dds/shared-object-base + '@fluidframework/telemetry-utils': + specifier: workspace:~ + version: link:../../utils/telemetry-utils devDependencies: '@arethetypeswrong/cli': specifier: ^0.15.2 @@ -11376,9 +11379,18 @@ importers: '@fluidframework/build-tools': specifier: ^0.46.0 version: 0.46.0 + '@fluidframework/driver-definitions': + specifier: workspace:~ + version: link:../../common/driver-definitions '@fluidframework/eslint-config-fluid': specifier: ^5.4.0 version: 5.4.0(eslint@8.55.0)(typescript@5.4.5) + '@fluidframework/test-runtime-utils': + specifier: workspace:~ + version: link:../../runtime/test-runtime-utils + '@fluidframework/test-utils': + specifier: workspace:~ + version: link:../../test/test-utils '@microsoft/api-extractor': specifier: 7.47.8 version: 7.47.8(patch_hash=ldzfpsbo3oeejrejk775zxplmi)(@types/node@18.19.1) @@ -11388,6 +11400,9 @@ importers: '@types/node': specifier: ^18.19.0 version: 18.19.1 + '@types/sinon': + specifier: ^17.0.3 + version: 17.0.3 c8: specifier: ^8.0.1 version: 8.0.1 @@ -11418,6 +11433,9 @@ importers: rimraf: specifier: ^4.4.0 version: 4.4.1 + sinon: + specifier: ^17.0.1 + version: 17.0.1 typescript: specifier: ~5.4.5 version: 5.4.5