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:
Jason Hartman 2024-09-28 23:47:27 -07:00 коммит произвёл GitHub
Родитель d1eade6547
Коммит 6fd547bce1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
13 изменённых файлов: 634 добавлений и 67 удалений

Просмотреть файл

@ -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