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
This commit is contained in:
Родитель
d1eade6547
Коммит
6fd547bce1
|
@ -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": {
|
||||
|
|
|
@ -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 <K extends string, T>(
|
|||
export type IEphemeralRuntime = Pick<
|
||||
(IContainerRuntime & IRuntimeInternal) | IFluidDataStoreRuntime,
|
||||
"clientId" | "connected" | "getQuorum" | "off" | "on" | "submitSignal"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<IFluidDataStoreRuntime, "logger">>;
|
||||
|
||||
/**
|
||||
* Collection of utilities provided by PresenceManager that are used by presence sub-components.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type PresenceManagerInternal = Pick<IPresence, "getAttendee">;
|
||||
export type PresenceManagerInternal = Pick<IPresence, "getAttendee"> & {
|
||||
readonly mc: MonitoringContext | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ClientConnectionId | ClientSessionId, ISessionClient>([
|
||||
[this.selfAttendee.sessionId, this.selfAttendee],
|
||||
]);
|
||||
private readonly selfAttendee: ISessionClient;
|
||||
private readonly attendees = new Map<ClientConnectionId | ClientSessionId, ISessionClient>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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 ----
|
||||
|
|
|
@ -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 ----
|
||||
|
|
|
@ -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<string, ISequencedClient>();
|
||||
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<IEphemeralRuntime["submitSignal"]>[] = [],
|
||||
) {
|
||||
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<IEphemeralRuntime["getQuorum"]>;
|
||||
|
||||
public submitSignal: IEphemeralRuntime["submitSignal"] = (
|
||||
...args: Parameters<IEphemeralRuntime["submitSignal"]>
|
||||
) => {
|
||||
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
|
||||
}
|
|
@ -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 ----
|
||||
|
|
|
@ -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<typeof createPresenceManager>;
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<T, Key extends string>(
|
||||
|
|
|
@ -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<SinonFakeTimers, "restore">,
|
||||
logger?: EventAndErrorTrackingLogger,
|
||||
): ReturnType<typeof createPresenceManager> {
|
||||
// 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();
|
||||
}
|
|
@ -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
|
||||
|
|
Загрузка…
Ссылка в новой задаче