diff --git a/.eslintrc.js b/.eslintrc.js index 97e66776c1c..1a2b5f822f2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -69,9 +69,6 @@ module.exports = { // TODO: There are many tests with invalid expects that should be fixed, // https://github.com/matrix-org/matrix-js-sdk/issues/2976 "jest/valid-expect": "off", - // TODO: There are many cases to refactor away, - // https://github.com/matrix-org/matrix-js-sdk/issues/2978 - "jest/no-conditional-expect": "off", // Also treat "oldBackendOnly" as a test function. // Used in some crypto tests. "jest/no-standalone-expect": [ diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 32deb2f2b57..e10937278f0 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -23,7 +23,7 @@ jobs: ) ) steps: - - uses: tibdex/backport@v2 + - uses: tibdex/backport@2e217641d82d02ba0603f46b1aeedefb258890ac # v2 with: labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>" # We can't use GITHUB_TOKEN here or CI won't run on the new PR diff --git a/.github/workflows/docs-pr-netlify.yaml b/.github/workflows/docs-pr-netlify.yaml index 903ad2a4226..bf0ef3ae482 100644 --- a/.github/workflows/docs-pr-netlify.yaml +++ b/.github/workflows/docs-pr-netlify.yaml @@ -14,7 +14,7 @@ jobs: # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - name: 📥 Download artifact - uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2.26.0 + uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2 with: workflow: static_analysis.yml run_id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 88f452faf5a..daa9cb589bc 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -20,11 +20,11 @@ jobs: registry-url: "https://registry.npmjs.org" - name: 🔨 Install dependencies - run: "yarn install --pure-lockfile" + run: "yarn install --frozen-lockfile" - name: 🚀 Publish to npm id: npm-publish - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1 with: token: ${{ secrets.NPM_TOKEN }} access: public diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0326fb469..58dff6e2e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,27 @@ -Changes in [24.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.0.0) (2023-03-28) +Changes in [24.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.1.0) (2023-04-11) ================================================================================================== +## ✨ Features + * Allow via_servers property in findPredecessor (update to MSC3946) ([\#3240](https://github.com/matrix-org/matrix-js-sdk/pull/3240)). Contributed by @andybalaam. + * Fire `closed` event when IndexedDB closes unexpectedly ([\#3218](https://github.com/matrix-org/matrix-js-sdk/pull/3218)). + * Implement MSC3952: intentional mentions ([\#3092](https://github.com/matrix-org/matrix-js-sdk/pull/3092)). Fixes vector-im/element-web#24376. + * Send one time key count and unused fallback keys for rust-crypto ([\#3215](https://github.com/matrix-org/matrix-js-sdk/pull/3215)). Fixes vector-im/element-web#24795. Contributed by @florianduros. + * Improve `processBeaconEvents` hotpath ([\#3200](https://github.com/matrix-org/matrix-js-sdk/pull/3200)). + * Implement MSC3966: a push rule condition to check if an array contains a value ([\#3180](https://github.com/matrix-org/matrix-js-sdk/pull/3180)). + ## 🐛 Bug Fixes - * Changes for matrix-js-sdk v24.0.0 + * indexddb-local-backend - return the current sync to database promise … ([\#3222](https://github.com/matrix-org/matrix-js-sdk/pull/3222)). Contributed by @texuf. + * Revert "Add the call object to Call events" ([\#3236](https://github.com/matrix-org/matrix-js-sdk/pull/3236)). + * Handle group call redaction ([\#3231](https://github.com/matrix-org/matrix-js-sdk/pull/3231)). Fixes vector-im/voip-internal#128. + * Stop doing O(n^2) work to find event's home (`eventShouldLiveIn`) ([\#3227](https://github.com/matrix-org/matrix-js-sdk/pull/3227)). Contributed by @jryans. + * Fix bug where video would not unmute if it started muted ([\#3213](https://github.com/matrix-org/matrix-js-sdk/pull/3213)). Fixes vector-im/element-call#925. + * Fixes to event encryption in the Rust Crypto implementation ([\#3202](https://github.com/matrix-org/matrix-js-sdk/pull/3202)). + +Changes in [24.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v24.0.0) (2023-03-28) +================================================================================================== + +## 🔒 Security + * Fixes for [CVE-2023-28427](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE-2023-28427) / GHSA-mwq8-fjpf-c2gr Changes in [23.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v23.5.0) (2023-03-15) ================================================================================================== diff --git a/package.json b/package.json index 15121a78871..17aa3c2e34e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "24.0.0", + "version": "24.1.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=16.0.0" @@ -55,13 +55,13 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3", + "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.5", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", "loglevel": "^1.7.1", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.0.0", + "matrix-widget-api": "^1.3.1", "p-retry": "4", "sdp-transform": "^2.14.1", "unhomoglyph": "^1.0.6", @@ -101,7 +101,7 @@ "debug": "^4.3.4", "docdash": "^2.0.0", "domexception": "^4.0.0", - "eslint": "8.34.0", + "eslint": "8.35.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^3.5.1", @@ -110,7 +110,7 @@ "eslint-plugin-jsdoc": "^40.0.0", "eslint-plugin-matrix-org": "^1.0.0", "eslint-plugin-tsdoc": "^0.2.17", - "eslint-plugin-unicorn": "^45.0.0", + "eslint-plugin-unicorn": "^46.0.0", "exorcist": "^2.0.0", "fake-indexeddb": "^4.0.0", "fetch-mock-jest": "^1.5.1", diff --git a/release.sh b/release.sh index 6da627294ef..c341ee9f17c 100755 --- a/release.sh +++ b/release.sh @@ -130,7 +130,7 @@ fi # global cache here to ensure we get the right thing. yarn cache clean # Ensure all dependencies are updated -yarn install --ignore-scripts --pure-lockfile +yarn install --ignore-scripts --frozen-lockfile # ignore leading v on release release="${1#v}" @@ -225,7 +225,7 @@ if [ $dodist -eq 0 ]; then pushd "$builddir" git clone "$projdir" . git checkout "$rel_branch" - yarn install --pure-lockfile + yarn install --frozen-lockfile # We haven't tagged yet, so tell the dist script what version # it's building DIST_VERSION="$tag" yarn dist diff --git a/spec/integ/crypto.spec.ts b/spec/integ/crypto.spec.ts index 030fdafb09f..a173d9a7a38 100644 --- a/spec/integ/crypto.spec.ts +++ b/spec/integ/crypto.spec.ts @@ -19,7 +19,7 @@ import anotherjson from "another-json"; import fetchMock from "fetch-mock-jest"; import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; -import { MockResponse } from "fetch-mock"; +import { MockResponse, MockResponseFunction } from "fetch-mock"; import type { IDeviceKeys } from "../../src/@types/crypto"; import * as testUtils from "../test-utils/test-utils"; @@ -47,6 +47,7 @@ import { import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver"; import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder"; +import { escapeRegExp } from "../../src/utils"; const ROOM_ID = "!room:id"; @@ -341,6 +342,11 @@ async function expectSendRoomKey( resolve(onSendRoomKey(content)); return {}; }, + { + // append to the list of intercepts on this path (since we have some tests that call + // this function multiple times) + overwriteRoutes: false, + }, ); }); } @@ -359,12 +365,20 @@ async function expectSendMegolmMessage( inboundGroupSessionPromise: Promise, ): Promise> { const encryptedMessageContent = await new Promise((resolve) => { - fetchMock.putOnce(new RegExp("/send/m.room.encrypted/"), (url: string, opts: RequestInit): MockResponse => { - resolve(JSON.parse(opts.body as string)); - return { - event_id: "$event_id", - }; - }); + fetchMock.putOnce( + new RegExp("/send/m.room.encrypted/"), + (url: string, opts: RequestInit): MockResponse => { + resolve(JSON.parse(opts.body as string)); + return { + event_id: "$event_id", + }; + }, + { + // append to the list of intercepts on this path (since we have some tests that call + // this function multiple times) + overwriteRoutes: false, + }, + ); }); // In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now. @@ -438,8 +452,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, }); return response; } + const rootRegexp = escapeRegExp(new URL("/_matrix/client/", aliceClient.getHomeserverUrl()).toString()); fetchMock.postOnce( - new URL("/_matrix/client/r0/keys/query", aliceClient.getHomeserverUrl()).toString(), + new RegExp(rootRegexp + "(r0|v3)/keys/query"), (url: string, opts: RequestInit) => onQueryRequest(JSON.parse(opts.body as string)), { // append to the list of intercepts on this path @@ -448,6 +463,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, ); } + /** + * Add an expectation for a /keys/claim request for the MatrixClient under test + * + * @param response - the response to return from the request. Normally an {@link IClaimOTKsResult} + * (or a function that returns one). + */ + function expectAliceKeyClaim(response: MockResponse | MockResponseFunction) { + const rootRegexp = escapeRegExp(new URL("/_matrix/client/", aliceClient.getHomeserverUrl()).toString()); + fetchMock.postOnce(new RegExp(rootRegexp + "(r0|v3)/keys/claim"), response); + } + /** * Get the device keys for testOlmAccount in a format suitable for a * response to /keys/query @@ -536,6 +562,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, fetchMock.mockReset(); }); + it("MatrixClient.getCrypto returns a CryptoApi", () => { + expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices"); + }); + it("Alice receives a megolm message", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); @@ -738,7 +768,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(event.getContent().body).toEqual("42"); }); - oldBackendOnly("prepareToEncrypt", async () => { + it("prepareToEncrypt", async () => { expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); aliceClient.setGlobalErrorOnUnknownDevices(false); @@ -751,10 +781,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); // ... and then claim one of his OTKs - fetchMock.postOnce( - new URL("/_matrix/client/r0/keys/claim", aliceClient.getHomeserverUrl()).toString(), - getTestKeysClaimResponse("@bob:xyz"), - ); + expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz")); // fire off the prepare request const room = aliceClient.getRoom(ROOM_ID); @@ -768,7 +795,71 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, await p; }); + it("Alice sends a megolm message with GlobalErrorOnUnknownDevices=false", async () => { + aliceClient.setGlobalErrorOnUnknownDevices(false); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + + // Alice shares a room with Bob + syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); + await syncPromise(aliceClient); + + // Once we send the message, Alice will check Bob's device list (twice, because reasons) ... + expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); + expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); + + // ... and claim one of his OTKs ... + expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz")); + + // ... and send an m.room_key message + const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount); + + // Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt. + await Promise.all([ + aliceClient.sendTextMessage(ROOM_ID, "test"), + expectSendMegolmMessage(inboundGroupSessionPromise), + ]); + }); + + it("We should start a new megolm session after forceDiscardSession", async () => { + aliceClient.setGlobalErrorOnUnknownDevices(false); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + + // Alice shares a room with Bob + syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); + await syncPromise(aliceClient); + + // Once we send the message, Alice will check Bob's device list (twice, because reasons) ... + expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); + expectAliceKeyQuery(getTestKeysQueryResponse("@bob:xyz")); + + // ... and claim one of his OTKs ... + expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz")); + + // ... and send an m.room_key message + const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount); + + // Send the first message, and check we can decrypt it. + await Promise.all([ + aliceClient.sendTextMessage(ROOM_ID, "test"), + expectSendMegolmMessage(inboundGroupSessionPromise), + ]); + + // Finally the interesting part: discard the session. + aliceClient.forceDiscardSession(ROOM_ID); + + // Now when we send the next message, we should get a *new* megolm session. + const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount); + const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2); + await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]); + }); + oldBackendOnly("Alice sends a megolm message", async () => { + // TODO: do something about this for the rust backend. + // Currently it fails because we don't respect the default GlobalErrorOnUnknownDevices and + // send messages to unknown devices. + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); await startClientAndAwaitFirstSync(); const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount); @@ -1033,14 +1124,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, // mark the device as known, and resend. aliceClient.setDeviceKnown(aliceClient.getUserId()!, "DEVICE_ID"); - fetchMock.postOnce( - new URL("/_matrix/client/r0/keys/claim", aliceClient.getHomeserverUrl()).toString(), - (url: string, opts: RequestInit): MockResponse => { - const content = JSON.parse(opts.body as string); - expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519"); - return getTestKeysClaimResponse(aliceClient.getUserId()!); - }, - ); + expectAliceKeyClaim((url: string, opts: RequestInit): MockResponse => { + const content = JSON.parse(opts.body as string); + expect(content.one_time_keys[aliceClient.getUserId()!].DEVICE_ID).toEqual("signed_curve25519"); + return getTestKeysClaimResponse(aliceClient.getUserId()!); + }); const inboundGroupSessionPromise = expectSendRoomKey(aliceClient.getUserId()!, testOlmAccount); @@ -1849,4 +1937,70 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, expect(event.getContent().body).not.toContain("withheld"); }); }); + + describe("key upload request", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function listenToUpload(): Promise { + return new Promise((resolve) => { + const listener = (url: string, options: RequestInit) => { + const content = JSON.parse(options.body as string); + const keysCount = Object.keys(content?.one_time_keys || {}).length; + if (keysCount) resolve(keysCount); + return { + one_time_key_counts: { + signed_curve25519: keysCount ? 60 : keysCount, + }, + }; + }; + + // catch both r0 and v3 variants + fetchMock.post( + new URL("/_matrix/client/r0/keys/upload", aliceClient.getHomeserverUrl()).toString(), + listener, + { + overwriteRoutes: true, + }, + ); + fetchMock.post( + new URL("/_matrix/client/v3/keys/upload", aliceClient.getHomeserverUrl()).toString(), + listener, + { + overwriteRoutes: true, + }, + ); + }); + } + + it("should make key upload request after sync", async () => { + let uploadPromise = listenToUpload(); + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + + syncResponder.sendOrQueueSyncResponse(getSyncResponse([])); + + await syncPromise(aliceClient); + expect(await uploadPromise).toBeGreaterThan(0); + + uploadPromise = listenToUpload(); + syncResponder.sendOrQueueSyncResponse({ + next_batch: 2, + device_one_time_keys_count: { signed_curve25519: 0 }, + }); + + // Advance local date to 2 minutes + // The old crypto only runs the upload every 60 seconds + jest.setSystemTime(Date.now() + 2 * 60 * 1000); + + await syncPromise(aliceClient); + + expect(await uploadPromise).toBeGreaterThan(0); + }); + }); }); diff --git a/spec/integ/matrix-client-opts.spec.ts b/spec/integ/matrix-client-opts.spec.ts index a14912c4a02..5a851ff0237 100644 --- a/spec/integ/matrix-client-opts.spec.ts +++ b/spec/integ/matrix-client-opts.spec.ts @@ -157,14 +157,10 @@ describe("MatrixClient opts", function () { error: "Ruh roh", }), ); - try { - await Promise.all([ - expect(client.sendTextMessage("!foo:bar", "a body", "txn1")).rejects.toThrow(), - httpBackend.flush("/txn1", 1), - ]); - } catch (err) { - expect((err).errcode).toEqual("M_SOMETHING"); - } + + await expect( + Promise.all([client.sendTextMessage("!foo:bar", "a body", "txn1"), httpBackend.flush("/txn1", 1)]), + ).rejects.toThrow("MatrixError: [500] Unknown message"); }); it("shouldn't queue events", async () => { diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 9da48d6cc14..50fe0652e9b 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -891,11 +891,9 @@ describe("SlidingSyncSdk", () => { const evType = ev.getType(); expect(seen[evType]).toBeFalsy(); seen[evType] = true; - if (evType === "m.key.verification.start" || evType === "m.key.verification.request") { - expect(ev.isCancelled()).toEqual(true); - } else { - expect(ev.isCancelled()).toEqual(false); - } + expect(ev.isCancelled()).toEqual( + evType === "m.key.verification.start" || evType === "m.key.verification.request", + ); }); ext.onResponse({ next_batch: "45678", diff --git a/spec/test-utils/webrtc.ts b/spec/test-utils/webrtc.ts index aaac27ff451..e50a8df45d7 100644 --- a/spec/test-utils/webrtc.ts +++ b/spec/test-utils/webrtc.ts @@ -123,6 +123,7 @@ export class MockRTCPeerConnection { public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void; public iceConnectionStateChangeListener?: () => void; public onTrackListener?: (e: RTCTrackEvent) => void; + public onDataChannelListener?: (ev: RTCDataChannelEvent) => void; public needsNegotiation = false; public readyToNegotiate: Promise; private onReadyToNegotiate?: () => void; @@ -168,6 +169,8 @@ export class MockRTCPeerConnection { this.iceConnectionStateChangeListener = listener; } else if (type == "track") { this.onTrackListener = listener; + } else if (type == "datachannel") { + this.onDataChannelListener = listener; } } public createDataChannel(label: string, opts: RTCDataChannelInit) { @@ -232,6 +235,10 @@ export class MockRTCPeerConnection { this.negotiationNeededListener(); } } + + public triggerIncomingDataChannel(): void { + this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent); + } } export class MockRTCRtpSender { @@ -499,18 +506,22 @@ export class MockMatrixCall extends TypedEventEmitter(), + isAudioMuted: jest.fn().mockReturnValue(false), + isVideoMuted: jest.fn().mockReturnValue(false), stream: new MockMediaStream("stream"), - }; + } as unknown as CallFeed; public remoteUsermediaFeed?: CallFeed; public remoteScreensharingFeed?: CallFeed; public reject = jest.fn(); public answerWithCallFeeds = jest.fn(); public hangup = jest.fn(); + public initStats = jest.fn(); public sendMetadataUpdate = jest.fn(); @@ -522,6 +533,14 @@ export class MockMatrixCall extends TypedEventEmitter { }); it("should not permit falsey values for both parts", () => { - try { - new UnstableValue(null!, null!); - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect((e).message).toBe("One of stable or unstable values must be supplied"); - } + expect(() => new UnstableValue(null!, null!)).toThrow("One of stable or unstable values must be supplied"); }); }); @@ -72,12 +66,6 @@ describe("UnstableValue", () => { }); it("should not permit falsey unstable values", () => { - try { - new UnstableValue("stable", null!); - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect((e).message).toBe("Unstable value must be supplied"); - } + expect(() => new UnstableValue("stable", null!)).toThrow("Unstable value must be supplied"); }); }); diff --git a/spec/unit/crypto/CrossSigningInfo.spec.ts b/spec/unit/crypto/CrossSigningInfo.spec.ts index 090a2a10ea9..b343aef1cf8 100644 --- a/spec/unit/crypto/CrossSigningInfo.spec.ts +++ b/spec/unit/crypto/CrossSigningInfo.spec.ts @@ -102,9 +102,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () { const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { getCrossSigningKeyCache }); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); - expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); + expect(getCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0); if (shouldCache) { - expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type); + // eslint-disable-next-line jest/no-conditional-expect + expect(getCrossSigningKeyCache).toHaveBeenLastCalledWith(type, expect.any(String)); } }, ); @@ -115,10 +116,10 @@ describe("CrossSigningInfo.getCrossSigningKey", function () { const info = new CrossSigningInfo(userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); + expect(storeCrossSigningKeyCache).toHaveBeenCalledTimes(shouldCache ? 1 : 0); if (shouldCache) { - expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); - expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); + // eslint-disable-next-line jest/no-conditional-expect + expect(storeCrossSigningKeyCache).toHaveBeenLastCalledWith(type, testKey); } }); diff --git a/spec/unit/crypto/backup.spec.ts b/spec/unit/crypto/backup.spec.ts index 4c1be8672bd..70c8651b450 100644 --- a/spec/unit/crypto/backup.spec.ts +++ b/spec/unit/crypto/backup.spec.ts @@ -456,6 +456,7 @@ describe("MegolmBackup", function () { client.http.authedRequest = function (method, path, queryParams, data, opts): any { ++numCalls; expect(numCalls).toBeLessThanOrEqual(2); + /* eslint-disable jest/no-conditional-expect */ if (numCalls === 1) { expect(method).toBe("POST"); expect(path).toBe("/room_keys/version"); @@ -482,6 +483,7 @@ describe("MegolmBackup", function () { reject(new Error("authedRequest called too many times")); return Promise.resolve({}); } + /* eslint-enable jest/no-conditional-expect */ }; }), client.createKeyBackupVersion({ diff --git a/spec/unit/crypto/secrets.spec.ts b/spec/unit/crypto/secrets.spec.ts index 2a31f856c84..5bd73e9a522 100644 --- a/spec/unit/crypto/secrets.spec.ts +++ b/spec/unit/crypto/secrets.spec.ts @@ -25,10 +25,10 @@ import { encryptAES } from "../../../src/crypto/aes"; import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils"; import { logger } from "../../../src/logger"; import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client"; -import { ISecretStorageKeyInfo } from "../../../src/crypto/api"; import { DeviceInfo } from "../../../src/crypto/deviceinfo"; import { ISignatures } from "../../../src/@types/signed"; import { ICurve25519AuthData } from "../../../src/crypto/keybackup"; +import { SecretStorageKeyDescription } from "../../../src/secret-storage"; async function makeTestClient( userInfo: { userId: string; deviceId: string }, @@ -541,7 +541,9 @@ describe("Secrets", function () { await alice.bootstrapSecretStorage({}); expect(alice.getAccountData("m.secret_storage.default_key")!.getContent()).toEqual({ key: "key_id" }); - const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")!.getContent(); + const keyInfo = alice + .getAccountData("m.secret_storage.key.key_id")! + .getContent(); expect(keyInfo.algorithm).toEqual("m.secret_storage.v1.aes-hmac-sha2"); expect(keyInfo.passphrase).toEqual({ algorithm: "m.pbkdf2", diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index efd4353062f..c8139a71761 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -725,6 +725,7 @@ describe("MatrixClient", function () { getMyMembership: () => "join", currentState: { getStateEvents: (eventType, stateKey) => { + /* eslint-disable jest/no-conditional-expect */ if (eventType === EventType.RoomCreate) { expect(stateKey).toEqual(""); return new MatrixEvent({ @@ -743,6 +744,7 @@ describe("MatrixClient", function () { } else { throw new Error("Unexpected event type or state key"); } + /* eslint-enable jest/no-conditional-expect */ }, } as Room["currentState"], } as unknown as Room; @@ -785,6 +787,7 @@ describe("MatrixClient", function () { getMyMembership: () => "join", currentState: { getStateEvents: (eventType, stateKey) => { + /* eslint-disable jest/no-conditional-expect */ if (eventType === EventType.RoomCreate) { expect(stateKey).toEqual(""); return new MatrixEvent({ @@ -803,6 +806,7 @@ describe("MatrixClient", function () { } else { throw new Error("Unexpected event type or state key"); } + /* eslint-enable jest/no-conditional-expect */ }, } as Room["currentState"], } as unknown as Room; @@ -820,6 +824,7 @@ describe("MatrixClient", function () { getMyMembership: () => "join", currentState: { getStateEvents: (eventType, stateKey) => { + /* eslint-disable jest/no-conditional-expect */ if (eventType === EventType.RoomCreate) { expect(stateKey).toEqual(""); return new MatrixEvent({ @@ -837,6 +842,7 @@ describe("MatrixClient", function () { } else { throw new Error("Unexpected event type or state key"); } + /* eslint-enable jest/no-conditional-expect */ }, } as Room["currentState"], } as unknown as Room; @@ -858,6 +864,7 @@ describe("MatrixClient", function () { const syncPromise = new Promise((resolve, reject) => { client.on(ClientEvent.Sync, function syncListener(state) { if (state === "SYNCING") { + // eslint-disable-next-line jest/no-conditional-expect expect(httpLookups.length).toEqual(0); client.removeListener(ClientEvent.Sync, syncListener); resolve(); @@ -944,6 +951,7 @@ describe("MatrixClient", function () { const wasPreparedPromise = new Promise((resolve) => { client.on(ClientEvent.Sync, function syncListener(state) { + /* eslint-disable jest/no-conditional-expect */ if (state === "ERROR" && httpLookups.length > 0) { expect(httpLookups.length).toEqual(2); expect(client.retryImmediately()).toBe(true); @@ -955,6 +963,7 @@ describe("MatrixClient", function () { // unexpected state transition! expect(state).toEqual(null); } + /* eslint-enable jest/no-conditional-expect */ }); }); await client.startClient(); @@ -976,8 +985,10 @@ describe("MatrixClient", function () { const isSyncingPromise = new Promise((resolve) => { client.on(ClientEvent.Sync, function syncListener(state) { if (state === "ERROR" && httpLookups.length > 0) { + /* eslint-disable jest/no-conditional-expect */ expect(httpLookups.length).toEqual(1); expect(client.retryImmediately()).toBe(true); + /* eslint-enable jest/no-conditional-expect */ jest.advanceTimersByTime(1); } else if (state === "RECONNECTING" && httpLookups.length > 0) { jest.advanceTimersByTime(10000); @@ -1004,6 +1015,7 @@ describe("MatrixClient", function () { const wasPreparedPromise = new Promise((resolve) => { client.on(ClientEvent.Sync, function syncListener(state) { + /* eslint-disable jest/no-conditional-expect */ if (state === "ERROR" && httpLookups.length > 0) { expect(httpLookups.length).toEqual(3); expect(client.retryImmediately()).toBe(true); @@ -1015,6 +1027,7 @@ describe("MatrixClient", function () { // unexpected state transition! expect(state).toEqual(null); } + /* eslint-enable jest/no-conditional-expect */ }); }); await client.startClient(); diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts index 33bc21ddcc8..e9c4877d3a9 100644 --- a/spec/unit/models/MSC3089Branch.spec.ts +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -265,11 +265,13 @@ describe("MSC3089Branch", () => { expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value expect(stateKey).toEqual(stateKeyOrder[stateFn.mock.calls.length - 1]); if (stateKey === fileEventId) { + // eslint-disable-next-line jest/no-conditional-expect expect(content).toMatchObject({ retained: true, // canary for copying state active: false, }); } else if (stateKey === fileEventId2) { + // eslint-disable-next-line jest/no-conditional-expect expect(content).toMatchObject({ active: true, version: 2, diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 9cb82f7870a..31b3c5ecddc 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -130,14 +130,8 @@ describe("MSC3089TreeSpace", () => { return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" })); }); client.invite = fn; - try { - await tree.invite(target, false, false); - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect((e).errcode).toEqual("M_FORBIDDEN"); - } + await expect(tree.invite(target, false, false)).rejects.toThrow("MatrixError: Sample Failure"); expect(fn).toHaveBeenCalledTimes(1); }); @@ -357,13 +351,18 @@ describe("MSC3089TreeSpace", () => { .fn() .mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => { expect([tree.roomId, subspaceId]).toContain(roomId); + + let expectedType: string; + let expectedStateKey: string; if (roomId === subspaceId) { - expect(eventType).toEqual(EventType.SpaceParent); - expect(stateKey).toEqual(tree.roomId); + expectedType = EventType.SpaceParent; + expectedStateKey = tree.roomId; } else { - expect(eventType).toEqual(EventType.SpaceChild); - expect(stateKey).toEqual(subspaceId); + expectedType = EventType.SpaceChild; + expectedStateKey = subspaceId; } + expect(eventType).toEqual(expectedType); + expect(stateKey).toEqual(expectedStateKey); expect(content).toMatchObject({ via: [domain] }); // return value not used @@ -629,15 +628,8 @@ describe("MSC3089TreeSpace", () => { }); it("should throw when setting an order at the top level space", async () => { - try { - // The tree is what we've defined as top level, so it should work - await tree.setOrder(2); - - // noinspection ExceptionCaughtLocallyJS - throw new Error("Failed to fail"); - } catch (e) { - expect((e).message).toEqual("Cannot set order of top level spaces currently"); - } + // The tree is what we've defined as top level, so it should work + await expect(tree.setOrder(2)).rejects.toThrow("Cannot set order of top level spaces currently"); }); it("should return a stable order for unordered children", () => { diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index 27b79f869a4..141e3444593 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -1,6 +1,6 @@ import * as utils from "../test-utils/test-utils"; import { IActionsObject, PushProcessor } from "../../src/pushprocessor"; -import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName } from "../../src"; +import { ConditionKind, EventType, IContent, MatrixClient, MatrixEvent, PushRuleActionName, RuleId } from "../../src"; describe("NotificationService", function () { const testUserId = "@ali:matrix.org"; @@ -48,6 +48,7 @@ describe("NotificationService", function () { credentials: { userId: testUserId, }, + supportsIntentionalMentions: () => true, pushRules: { device: {}, global: { @@ -572,11 +573,76 @@ describe("NotificationService", function () { }); const actions = pushProcessor.actionsForEvent(testEvent); - if (expected) { - expect(actions?.notify).toBeTruthy(); - } else { - expect(actions?.notify).toBeFalsy(); - } + expect(!!actions?.notify).toBe(expected); + }); + }); + + describe("Test event property contains", () => { + it.each([ + // Simple string matching. + { value: "bar", eventValue: ["bar"], expected: true }, + // Matches are case-sensitive. + { value: "bar", eventValue: ["BAR"], expected: false }, + // Values should not be type-coerced. + { value: "bar", eventValue: [true], expected: false }, + { value: "bar", eventValue: [1], expected: false }, + { value: "bar", eventValue: [false], expected: false }, + // Boolean matching. + { value: true, eventValue: [true], expected: true }, + { value: false, eventValue: [false], expected: true }, + // Types should not be coerced. + { value: true, eventValue: ["true"], expected: false }, + { value: true, eventValue: [1], expected: false }, + { value: false, eventValue: [null], expected: false }, + // Null matching. + { value: null, eventValue: [null], expected: true }, + // Types should not be coerced + { value: null, eventValue: [false], expected: false }, + { value: null, eventValue: [0], expected: false }, + { value: null, eventValue: [""], expected: false }, + { value: null, eventValue: [undefined], expected: false }, + // Non-array or empty values should never be matched. + { value: "bar", eventValue: "bar", expected: false }, + { value: "bar", eventValue: { bar: true }, expected: false }, + { value: true, eventValue: { true: true }, expected: false }, + { value: true, eventValue: true, expected: false }, + { value: null, eventValue: [], expected: false }, + { value: null, eventValue: {}, expected: false }, + { value: null, eventValue: null, expected: false }, + { value: null, eventValue: undefined, expected: false }, + ])("test $value against $eventValue", ({ value, eventValue, expected }) => { + matrixClient.pushRules! = { + global: { + override: [ + { + actions: [PushRuleActionName.Notify], + conditions: [ + { + kind: ConditionKind.EventPropertyContains, + key: "content.foo", + value: value, + }, + ], + default: true, + enabled: true, + rule_id: ".m.rule.test", + }, + ], + }, + }; + + testEvent = utils.mkEvent({ + type: "m.room.message", + room: testRoomId, + user: "@alfred:localhost", + event: true, + content: { + foo: eventValue, + }, + }); + + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions?.notify).toBe(expected ? true : undefined); }); }); @@ -647,6 +713,37 @@ describe("NotificationService", function () { }); }); }); + + describe("test intentional mentions behaviour", () => { + it.each([RuleId.ContainsUserName, RuleId.ContainsDisplayName, RuleId.AtRoomNotification])( + "Rule %s matches unless intentional mentions are enabled", + (ruleId) => { + const rule = { + rule_id: ruleId, + actions: [], + conditions: [], + default: false, + enabled: true, + }; + expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(true); + + // Add the mentions property to the event and the rule is now disabled. + testEvent = utils.mkEvent({ + type: "m.room.message", + room: testRoomId, + user: "@alfred:localhost", + event: true, + content: { + "body": "", + "msgtype": "m.text", + "org.matrix.msc3952.mentions": {}, + }, + }); + + expect(pushProcessor.ruleMatchesEvent(rule, testEvent)).toBe(false); + }, + ); + }); }); describe("Test PushProcessor.partsForDottedKey", function () { diff --git a/spec/unit/room-state.spec.ts b/spec/unit/room-state.spec.ts index 0b8a5abf0b3..c8d249870d8 100644 --- a/spec/unit/room-state.spec.ts +++ b/spec/unit/room-state.spec.ts @@ -25,6 +25,8 @@ import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@typ import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { M_BEACON } from "../../src/@types/beacon"; import { MatrixClient } from "../../src/client"; +import { DecryptionError } from "../../src/crypto/algorithms"; +import { defer } from "../../src/utils"; describe("RoomState", function () { const roomId = "!foo:bar"; @@ -886,7 +888,7 @@ describe("RoomState", function () { expect(emitSpy).not.toHaveBeenCalled(); }); - it("adds locations to beacons", () => { + it("adds locations to beacons", async () => { const location1 = makeBeaconEvent(userA, { beaconInfoId: "$beacon1", timestamp: Date.now() + 1, @@ -906,7 +908,7 @@ describe("RoomState", function () { const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beaconInstance, "addLocations"); - state.processBeaconEvents([location1, location2, location3], mockClient); + await state.processBeaconEvents([location1, location2, location3], mockClient); expect(addLocationsSpy).toHaveBeenCalledTimes(2); // only called with locations for beacon1 @@ -978,51 +980,50 @@ describe("RoomState", function () { expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled(); }); - it("decrypts related events if needed", () => { + it("decrypts related events if needed", async () => { const location = makeBeaconEvent(userA, { beaconInfoId: beacon1.getId(), }); state.setStateEvents([beacon1, beacon2]); - state.processBeaconEvents([location, relatedEncryptedEvent], mockClient); + await state.processBeaconEvents([location, relatedEncryptedEvent], mockClient); // discard unrelated events early expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2); }); - it("listens for decryption on events that are being decrypted", () => { + it("awaits for decryption on events that are being decrypted", async () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); - // spy on event.once - const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once"); state.setStateEvents([beacon1, beacon2]); - state.processBeaconEvents([decryptingRelatedEvent], mockClient); + await state.processBeaconEvents([decryptingRelatedEvent], mockClient); // listener was added - expect(eventOnceSpy).toHaveBeenCalled(); + expect(mockClient.decryptEventIfNeeded).toHaveBeenCalled(); }); - it("listens for decryption on events that have decryption failure", () => { + it("listens for decryption on events that have decryption failure", async () => { const failedDecryptionRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, content: beacon1RelationContent, }); jest.spyOn(failedDecryptionRelatedEvent, "isDecryptionFailure").mockReturnValue(true); + mockClient.decryptEventIfNeeded.mockRejectedValue(new DecryptionError("ERR", "msg")); // spy on event.once - const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, "once"); + const eventOnceSpy = jest.spyOn(failedDecryptionRelatedEvent, "once"); state.setStateEvents([beacon1, beacon2]); - state.processBeaconEvents([decryptingRelatedEvent], mockClient); + await state.processBeaconEvents([failedDecryptionRelatedEvent], mockClient); // listener was added expect(eventOnceSpy).toHaveBeenCalled(); }); - it("discard events that are not m.beacon type after decryption", () => { + it("discard events that are not m.beacon type after decryption", async () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, @@ -1032,7 +1033,7 @@ describe("RoomState", function () { state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); - state.processBeaconEvents([decryptingRelatedEvent], mockClient); + await state.processBeaconEvents([decryptingRelatedEvent], mockClient); // this event is a message after decryption decryptingRelatedEvent.event.type = EventType.RoomMessage; @@ -1041,7 +1042,7 @@ describe("RoomState", function () { expect(addLocationsSpy).not.toHaveBeenCalled(); }); - it("adds locations to beacons after decryption", () => { + it("adds locations to beacons after decryption", async () => { const decryptingRelatedEvent = new MatrixEvent({ sender: userA, type: EventType.RoomMessageEncrypted, @@ -1051,16 +1052,20 @@ describe("RoomState", function () { beaconInfoId: "$beacon1", timestamp: Date.now() + 1, }); - jest.spyOn(decryptingRelatedEvent, "isBeingDecrypted").mockReturnValue(true); + + const deferred = defer(); + mockClient.decryptEventIfNeeded.mockReturnValue(deferred.promise); + state.setStateEvents([beacon1, beacon2]); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon; const addLocationsSpy = jest.spyOn(beacon, "addLocations").mockClear(); - state.processBeaconEvents([decryptingRelatedEvent], mockClient); + const prom = state.processBeaconEvents([decryptingRelatedEvent], mockClient); // update type after '''decryption''' decryptingRelatedEvent.event.type = M_BEACON.name; decryptingRelatedEvent.event.content = locationEvent.event.content; - decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent); + deferred.resolve(); + await prom; expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]); }); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 37dc11b6601..d256bf758b4 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -390,36 +390,30 @@ describe("Room", function () { remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; const remoteEventId = remoteEvent.getId(); - let callCount = 0; - room.on(RoomEvent.LocalEchoUpdated, (event, emitRoom, oldEventId, oldStatus) => { - switch (callCount) { - case 0: - expect(event.getId()).toEqual(localEventId); - expect(event.status).toEqual(EventStatus.SENDING); - expect(emitRoom).toEqual(room); - expect(oldEventId).toBeUndefined(); - expect(oldStatus).toBeUndefined(); - break; - case 1: - expect(event.getId()).toEqual(remoteEventId); - expect(event.status).toBeNull(); - expect(emitRoom).toEqual(room); - expect(oldEventId).toEqual(localEventId); - expect(oldStatus).toBe(EventStatus.SENDING); - break; - } - callCount += 1; - }); + const stub = jest.fn(); + room.on(RoomEvent.LocalEchoUpdated, stub); // first add the local echo room.addPendingEvent(localEvent, "TXN_ID"); expect(room.timeline.length).toEqual(1); + expect(stub.mock.calls[0][0].getId()).toEqual(localEventId); + expect(stub.mock.calls[0][0].status).toEqual(EventStatus.SENDING); + expect(stub.mock.calls[0][1]).toEqual(room); + expect(stub.mock.calls[0][2]).toBeUndefined(); + expect(stub.mock.calls[0][3]).toBeUndefined(); + // then the remoteEvent room.addLiveEvents([remoteEvent]); expect(room.timeline.length).toEqual(1); - expect(callCount).toEqual(2); + expect(stub).toHaveBeenCalledTimes(2); + + expect(stub.mock.calls[1][0].getId()).toEqual(remoteEventId); + expect(stub.mock.calls[1][0].status).toBeNull(); + expect(stub.mock.calls[1][1]).toEqual(room); + expect(stub.mock.calls[1][2]).toEqual(localEventId); + expect(stub.mock.calls[1][3]).toBe(EventStatus.SENDING); }); it("should be able to update local echo without a txn ID (/send then /sync)", function () { @@ -3349,11 +3343,16 @@ describe("Room", function () { newRoomId: string, predecessorRoomId: string, tombstoneEventId: string | null = null, + viaServers: string[] = [], ): MatrixEvent { const content = tombstoneEventId === null - ? { predecessor_room_id: predecessorRoomId } - : { predecessor_room_id: predecessorRoomId, last_known_event_id: tombstoneEventId }; + ? { predecessor_room_id: predecessorRoomId, via_servers: viaServers } + : { + predecessor_room_id: predecessorRoomId, + last_known_event_id: tombstoneEventId, + via_servers: viaServers, + }; return new MatrixEvent({ content, @@ -3393,6 +3392,7 @@ describe("Room", function () { expect(room.findPredecessor(useMsc3946)).toEqual({ roomId: "otherreplacedroomid", eventId: undefined, // m.predecessor did not include an event_id + viaServers: [], }); }); @@ -3400,12 +3400,13 @@ describe("Room", function () { const room = new Room("roomid", client!, "@u:example.com"); room.addLiveEvents([ roomCreateEvent("roomid", "replacedroomid"), - predecessorEvent("roomid", "otherreplacedroomid", "lstevtid"), + predecessorEvent("roomid", "otherreplacedroomid", "lstevtid", ["one.example.com", "two.example.com"]), ]); const useMsc3946 = true; expect(room.findPredecessor(useMsc3946)).toEqual({ roomId: "otherreplacedroomid", eventId: "lstevtid", + viaServers: ["one.example.com", "two.example.com"], }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 1a00acddfae..ce290b693eb 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -53,7 +53,7 @@ describe("RustCrypto", () => { }); }); - describe("to-device messages", () => { + describe("call preprocess methods", () => { let rustCrypto: RustCrypto; beforeEach(async () => { @@ -92,6 +92,16 @@ describe("RustCrypto", () => { const res = await rustCrypto.preprocessToDeviceMessages(inputs); expect(res).toEqual(inputs); }); + + it("should pass through one time key counts", async () => { + const oneTimeKeyCounts = new Map([["signed_curve25519", 50]]); + await expect(rustCrypto.preprocessOneTimeKeyCounts(oneTimeKeyCounts)).resolves.not.toBeDefined(); + }); + + it("should pass through unused fallback keys", async () => { + const unusedFallbackKeys = new Set(["signed_curve25519"]); + await expect(rustCrypto.preprocessUnusedFallbackKeys(unusedFallbackKeys)).resolves.not.toBeDefined(); + }); }); describe("outgoing requests", () => { diff --git a/spec/unit/scheduler.spec.ts b/spec/unit/scheduler.spec.ts index 6975e475553..35d057081ac 100644 --- a/spec/unit/scheduler.spec.ts +++ b/spec/unit/scheduler.spec.ts @@ -58,10 +58,12 @@ describe("MatrixScheduler", function () { let yieldedA = false; scheduler.setProcessFunction(function (event) { if (yieldedA) { + // eslint-disable-next-line jest/no-conditional-expect expect(event).toEqual(eventB); return deferB.promise; } else { yieldedA = true; + // eslint-disable-next-line jest/no-conditional-expect expect(event).toEqual(eventA); return deferA.promise; } @@ -89,6 +91,7 @@ describe("MatrixScheduler", function () { scheduler.setProcessFunction(function (ev) { procCount += 1; if (procCount === 1) { + // eslint-disable-next-line jest/no-conditional-expect expect(ev).toEqual(eventA); return deferred.promise; } else if (procCount === 2) { @@ -129,9 +132,11 @@ describe("MatrixScheduler", function () { scheduler.setProcessFunction(function (ev) { procCount += 1; if (procCount === 1) { + // eslint-disable-next-line jest/no-conditional-expect expect(ev).toEqual(eventA); return deferA.promise; } else if (procCount === 2) { + // eslint-disable-next-line jest/no-conditional-expect expect(ev).toEqual(eventB); return deferB.promise; } diff --git a/spec/unit/stores/indexeddb-store-worker.spec.ts b/spec/unit/stores/indexeddb-store-worker.spec.ts new file mode 100644 index 00000000000..536327d2e38 --- /dev/null +++ b/spec/unit/stores/indexeddb-store-worker.spec.ts @@ -0,0 +1,47 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "fake-indexeddb/auto"; + +import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend"; +import { IndexedDBStoreWorker } from "../../../src/store/indexeddb-store-worker"; +import { defer } from "../../../src/utils"; + +function setupWorker(worker: IndexedDBStoreWorker): void { + worker.onMessage({ data: { command: "setupWorker", args: [] } } as any); + worker.onMessage({ data: { command: "connect", seq: 1 } } as any); +} + +describe("IndexedDBStore Worker", () => { + it("should pass 'closed' event via postMessage", async () => { + const deferred = defer(); + const postMessage = jest.fn().mockImplementation(({ seq, command }) => { + if (seq === 1 && command === "cmd_success") { + deferred.resolve(); + } + }); + const worker = new IndexedDBStoreWorker(postMessage); + setupWorker(worker); + + await deferred.promise; + + // @ts-ignore - private field access + (worker.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event); + expect(postMessage).toHaveBeenCalledWith({ + command: "closed", + }); + }); +}); diff --git a/spec/unit/stores/indexeddb.spec.ts b/spec/unit/stores/indexeddb.spec.ts index f2ce29e47e6..544a3d75c5b 100644 --- a/spec/unit/stores/indexeddb.spec.ts +++ b/spec/unit/stores/indexeddb.spec.ts @@ -166,4 +166,92 @@ describe("IndexedDBStore", () => { await expect(store.isNewlyCreated()).resolves.toBeFalsy(); }); + + it("should emit 'closed' if database is unexpectedly closed", async () => { + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + }); + await store.startup(); + + const deferred = defer(); + store.on("closed", deferred.resolve); + + // @ts-ignore - private field access + (store.backend as LocalIndexedDBStoreBackend).db!.onclose!({} as Event); + await deferred.promise; + }); + + it("should use remote backend if workerFactory passed", async () => { + const deferred = defer(); + class MockWorker { + postMessage(data: any) { + if (data.command === "setupWorker") { + deferred.resolve(); + } + } + } + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + workerFactory: () => new MockWorker() as Worker, + }); + store.startup(); + await deferred.promise; + }); + + it("remote worker should pass closed event", async () => { + const worker = new (class MockWorker { + postMessage(data: any) {} + })() as Worker; + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + workerFactory: () => worker, + }); + store.startup(); + + const deferred = defer(); + store.on("closed", deferred.resolve); + (worker as any).onmessage({ data: { command: "closed" } }); + await deferred.promise; + }); + + it("remote worker should pass command failures", async () => { + const worker = new (class MockWorker { + private onmessage!: (data: any) => void; + postMessage(data: any) { + if (data.command === "setupWorker" || data.command === "connect") { + this.onmessage({ + data: { + command: "cmd_success", + seq: data.seq, + }, + }); + return; + } + + this.onmessage({ + data: { + command: "cmd_fail", + seq: data.seq, + error: new Error("Test"), + }, + }); + } + })() as unknown as Worker; + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + workerFactory: () => worker, + }); + await expect(store.startup()).rejects.toThrow("Test"); + }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 6ec2dcb5d4d..db84ee54004 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -431,6 +431,58 @@ describe("Call", function () { expect(transceivers.get("m.usermedia:video")!.sender.track!.id).toBe("usermedia_video_track"); }); + it("should handle error on call upgrade", async () => { + const onError = jest.fn(); + call.on(CallEvent.Error, onError); + + await startVoiceCall(client, call); + + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + [SDPStreamMetadataKey]: {}, + }), + ); + + const mockGetUserMediaStream = jest.fn().mockRejectedValue(new Error("Test error")); + client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream; + + // then unmute which should cause an upgrade + await call.setLocalVideoMuted(false); + + expect(onError).toHaveBeenCalled(); + }); + + it("should unmute video after upgrading to video call", async () => { + // Regression test for https://github.com/vector-im/element-call/issues/925 + await startVoiceCall(client, call); + // start off with video muted + await call.setLocalVideoMuted(true); + + await call.onAnswerReceived( + makeMockEvent("@test:foo", { + version: 1, + call_id: call.callId, + party_id: "party_id", + answer: { + sdp: DUMMY_SDP, + }, + [SDPStreamMetadataKey]: {}, + }), + ); + + // then unmute which should cause an upgrade + await call.setLocalVideoMuted(false); + + // video should now be unmuted + expect(call.isLocalVideoMuted()).toBe(false); + }); + it("should handle SDPStreamMetadata changes", async () => { await startVoiceCall(client, call); @@ -712,11 +764,22 @@ describe("Call", function () { const dataChannel = call.createDataChannel("data_channel_label", { id: 123 }); - expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel); + expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel, call); expect(dataChannel.label).toBe("data_channel_label"); expect(dataChannel.id).toBe(123); }); + it("should emit a data channel event when the other side adds a data channel", async () => { + await startVoiceCall(client, call); + + const dataChannelCallback = jest.fn(); + call.on(CallEvent.DataChannel, dataChannelCallback); + + (call.peerConn as unknown as MockRTCPeerConnection).triggerIncomingDataChannel(); + + expect(dataChannelCallback).toHaveBeenCalled(); + }); + describe("supportsMatrixCall", () => { it("should return true when the environment is right", () => { expect(supportsMatrixCall()).toBe(true); @@ -1579,7 +1642,7 @@ describe("Call", function () { hasAdvancedBy += advanceBy; expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy); - expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy); + expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy, call); } }); @@ -1609,4 +1672,24 @@ describe("Call", function () { expect(call.hangup).not.toHaveBeenCalled(); }); }); + + describe("Call replace", () => { + it("Fires event when call replaced", async () => { + const onReplace = jest.fn(); + call.on(CallEvent.Replaced, onReplace); + + await call.placeVoiceCall(); + + const call2 = new MatrixCall({ + client: client.client, + roomId: FAKE_ROOM_ID, + }); + call2.on(CallEvent.Error, errorListener); + await fakeIncomingCall(client, call2); + + call.replacedBy(call2); + + expect(onReplace).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/unit/webrtc/callFeed.spec.ts b/spec/unit/webrtc/callFeed.spec.ts index 803e4648ed6..ea80d5267f2 100644 --- a/spec/unit/webrtc/callFeed.spec.ts +++ b/spec/unit/webrtc/callFeed.spec.ts @@ -102,7 +102,7 @@ describe("CallFeed", () => { [CallState.Connected, true], [CallState.Connecting, false], ])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => { - call.emit(CallEvent.State, state); + call.emit(CallEvent.State, state, CallState.InviteSent, call.typed()); expect(feed.connected).toBe(expected); }); diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index 914a1246a3a..04f67c862a9 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { mocked } from "jest-mock"; + import { EventType, GroupCallIntent, GroupCallType, MatrixCall, MatrixEvent, Room, RoomMember } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { GroupCall, GroupCallEvent, GroupCallState } from "../../../src/webrtc/groupCall"; @@ -95,16 +97,16 @@ const FAKE_STATE_EVENTS = [ }, ]; -const mockGetStateEvents = (type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => { - if (type === EventType.GroupCallMemberPrefix) { - return userId === undefined - ? (FAKE_STATE_EVENTS as MatrixEvent[]) - : (FAKE_STATE_EVENTS.find((e) => e.getStateKey() === userId) as MatrixEvent); - } else { - const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent; - return userId === undefined ? [fakeEvent] : fakeEvent; - } -}; +const mockGetStateEvents = + (events: MatrixEvent[] = FAKE_STATE_EVENTS as MatrixEvent[]) => + (type: EventType, userId?: string): MatrixEvent[] | MatrixEvent | null => { + if (type === EventType.GroupCallMemberPrefix) { + return userId === undefined ? events : events.find((e) => e.getStateKey() === userId) ?? null; + } else { + const fakeEvent = { getContent: () => ({}), getTs: () => 0 } as MatrixEvent; + return userId === undefined ? [fakeEvent] : fakeEvent; + } + }; const ONE_HOUR = 1000 * 60 * 60; @@ -142,6 +144,10 @@ describe("Group Call", function () { } as unknown as RoomMember; }); + afterEach(() => { + groupCall.leave(); + }); + it.each(Object.values(GroupCallState).filter((v) => v !== GroupCallState.LocalCallFeedUninitialized))( "throws when initializing local call feed in %s state", async (state: GroupCallState) => { @@ -567,7 +573,7 @@ describe("Group Call", function () { // the call starts muted, so unmute to get in the right state to test await groupCall.setMicrophoneMuted(false); - mockCall.localUsermediaFeed.setAudioVideoMuted.mockReset(); + mocked(mockCall.localUsermediaFeed.setAudioVideoMuted).mockReset(); let metadataUpdateResolve: () => void; const metadataUpdatePromise = new Promise((resolve) => { @@ -792,7 +798,7 @@ describe("Group Call", function () { call.isLocalVideoMuted = jest.fn().mockReturnValue(true); call.setLocalVideoMuted = jest.fn(); - call.emit(CallEvent.State, CallState.Connected); + call.emit(CallEvent.State, CallState.Connected, CallState.InviteSent, call); expect(call.setMicrophoneMuted).toHaveBeenCalledWith(false); expect(call.setLocalVideoMuted).toHaveBeenCalledWith(false); @@ -811,7 +817,7 @@ describe("Group Call", function () { mockClient = typedMockClient as unknown as MatrixClient; room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1); - room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents); + room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents()); room.currentState.members[FAKE_USER_ID_1] = { userId: FAKE_USER_ID_1, membership: "join", @@ -987,7 +993,14 @@ describe("Group Call", function () { mockClient = typedMockClient as unknown as MatrixClient; room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_2); - room.getMember = jest.fn().mockImplementation((userId) => ({ userId })); + room.currentState.members[FAKE_USER_ID_1] = { + userId: FAKE_USER_ID_1, + membership: "join", + } as unknown as RoomMember; + room.currentState.members[FAKE_USER_ID_2] = { + userId: FAKE_USER_ID_2, + membership: "join", + } as unknown as RoomMember; groupCall = await createAndEnterGroupCall(mockClient, room); }); @@ -1060,6 +1073,71 @@ describe("Group Call", function () { expect(call.answerWithCallFeeds).toHaveBeenCalled(); }); + const aliceEnters = () => { + room.currentState.getStateEvents = jest.fn().mockImplementation( + mockGetStateEvents([ + { + getContent: () => ({ + "m.calls": [ + { + "m.call_id": groupCall.groupCallId, + "m.devices": [ + { + device_id: FAKE_DEVICE_ID_1, + session_id: FAKE_SESSION_ID_1, + expires_ts: Date.now() + ONE_HOUR, + feeds: [], + }, + ], + }, + ], + }), + getStateKey: () => FAKE_USER_ID_1, + getRoomId: () => FAKE_ROOM_ID, + getTs: () => 0, + }, + ] as unknown as MatrixEvent[]), + ); + room.currentState.emit(RoomStateEvent.Update, room.currentState); + }; + + const aliceLeaves = () => { + room.currentState.getStateEvents = jest + .fn() + .mockImplementation(mockGetStateEvents([] as unknown as MatrixEvent[])); + room.currentState.emit(RoomStateEvent.Update, room.currentState); + }; + + it("enables tracks on expected calls, then disables them when the participant leaves", async () => { + aliceEnters(); + + const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); + mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed)); + mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); + + // Tracks should be enabled + expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true); + + aliceLeaves(); + + // Tracks should be disabled + expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true); + }); + + it("disables tracks on unexpected calls, then enables them when the participant joins", async () => { + const mockCall = new MockMatrixCall(room.roomId, groupCall.groupCallId); + mockCall.answerWithCallFeeds.mockImplementation(([feed]) => (mockCall.localUsermediaFeed = feed)); + mockClient.emit(CallEventHandlerEvent.Incoming, mockCall as unknown as MatrixCall); + + // Tracks should be disabled + expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => !t.enabled)).toBe(true); + + aliceEnters(); + + // Tracks should be enabled + expect(mockCall.localUsermediaFeed.stream.getTracks().every((t) => t.enabled)).toBe(true); + }); + describe("handles call being replaced", () => { let callChangedListener: jest.Mock; let oldMockCall: MockMatrixCall; @@ -1080,7 +1158,7 @@ describe("Group Call", function () { }); it("handles regular case", () => { - oldMockCall.emit(CallEvent.Replaced, newMockCall.typed()); + oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed()); expect(oldMockCall.hangup).toHaveBeenCalled(); expect(callChangedListener).toHaveBeenCalledWith(newCallsMap); @@ -1091,7 +1169,7 @@ describe("Group Call", function () { it("handles case where call is missing from the calls map", () => { // @ts-ignore groupCall.calls = new Map(); - oldMockCall.emit(CallEvent.Replaced, newMockCall.typed()); + oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed()); expect(oldMockCall.hangup).toHaveBeenCalled(); expect(callChangedListener).toHaveBeenCalledWith(newCallsMap); @@ -1157,7 +1235,7 @@ describe("Group Call", function () { userId: FAKE_USER_ID_2, membership: "join", } as unknown as RoomMember; - room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents); + room.currentState.getStateEvents = jest.fn().mockImplementation(mockGetStateEvents()); groupCall = await createAndEnterGroupCall(mockClient, room); }); diff --git a/spec/unit/webrtc/groupCallEventHandler.spec.ts b/spec/unit/webrtc/groupCallEventHandler.spec.ts index 6c97988bf46..c8d65538c60 100644 --- a/spec/unit/webrtc/groupCallEventHandler.spec.ts +++ b/spec/unit/webrtc/groupCallEventHandler.spec.ts @@ -98,6 +98,23 @@ describe("Group Call Event Handler", function () { expect(groupCall.state).toBe(GroupCallState.Ended); }); + + it("terminates call when redacted", async () => { + await groupCallEventHandler.start(); + mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID), { + roomId: FAKE_ROOM_ID, + } as unknown as RoomState); + + const groupCall = groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)!; + + expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized); + + mockClient.emitRoomState(makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, undefined, true), { + roomId: FAKE_ROOM_ID, + } as unknown as RoomState); + + expect(groupCall.state).toBe(GroupCallState.Ended); + }); }); it("waits until client starts syncing", async () => { @@ -222,9 +239,9 @@ describe("Group Call Event Handler", function () { jest.clearAllMocks(); }); - const setupCallAndStart = async (content?: IContent) => { + const setupCallAndStart = async (content?: IContent, redacted?: boolean) => { mocked(mockRoom.currentState.getStateEvents).mockReturnValue([ - makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content), + makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, content, redacted), ] as unknown as MatrixEvent); mockClient.getRooms.mockReturnValue([mockRoom]); await groupCallEventHandler.start(); @@ -285,5 +302,24 @@ describe("Group Call Event Handler", function () { }), ); }); + + it("ignores redacted calls", async () => { + await setupCallAndStart( + { + // Real event contents to make sure that it's specifically the + // event being redacted that causes it to be ignored + "m.type": GroupCallType.Video, + "m.intent": GroupCallIntent.Prompt, + }, + true, + ); + + expect(mockClientEmit).not.toHaveBeenCalledWith( + GroupCallEventHandlerEvent.Incoming, + expect.objectContaining({ + groupCallId: FAKE_GROUP_CALL_ID, + }), + ); + }); }); }); diff --git a/spec/unit/webrtc/stats/connectionStatsReporter.spec.ts b/spec/unit/webrtc/stats/connectionStatsReporter.spec.ts new file mode 100644 index 00000000000..1c9b2123319 --- /dev/null +++ b/spec/unit/webrtc/stats/connectionStatsReporter.spec.ts @@ -0,0 +1,46 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter"; + +describe("ConnectionStatsReporter", () => { + describe("should on bandwidth stats", () => { + it("build bandwidth report if chromium starts attributes available", () => { + const stats = { + availableIncomingBitrate: 1000, + availableOutgoingBitrate: 2000, + } as RTCIceCandidatePairStats; + expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 }); + }); + it("build empty bandwidth report if chromium starts attributes not available", () => { + const stats = {} as RTCIceCandidatePairStats; + expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 }); + }); + }); + + describe("should on connection stats", () => { + it("build bandwidth report if chromium starts attributes available", () => { + const stats = { + availableIncomingBitrate: 1000, + availableOutgoingBitrate: 2000, + } as RTCIceCandidatePairStats; + expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 }); + }); + it("build empty bandwidth report if chromium starts attributes not available", () => { + const stats = {} as RTCIceCandidatePairStats; + expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 }); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/groupCallStats.spec.ts b/spec/unit/webrtc/stats/groupCallStats.spec.ts new file mode 100644 index 00000000000..47e94902c2f --- /dev/null +++ b/spec/unit/webrtc/stats/groupCallStats.spec.ts @@ -0,0 +1,136 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats"; + +const GROUP_CALL_ID = "GROUP_ID"; +const LOCAL_USER_ID = "LOCAL_USER_ID"; +const TIME_INTERVAL = 10000; + +describe("GroupCallStats", () => { + let stats: GroupCallStats; + beforeEach(() => { + stats = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, TIME_INTERVAL); + }); + + describe("should on adding a stats collector", () => { + it("creating a new one if not existing.", async () => { + expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy(); + }); + + it("creating only one when trying add the same collector multiple times.", async () => { + expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy(); + expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeFalsy(); + // The User ID is not relevant! Because for stats the call is needed and the user id is for monitoring + expect(stats.addStatsReportGatherer("CALL_ID", "SOME_OTHER_USER_ID", mockRTCPeerConnection())).toBeFalsy(); + }); + }); + + describe("should on removing a stats collector", () => { + it("returning `true` if the collector exists", async () => { + expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy(); + expect(stats.removeStatsReportGatherer("CALL_ID")).toBeTruthy(); + }); + it("returning false if the collector not exists", async () => { + expect(stats.removeStatsReportGatherer("CALL_ID_NOT_EXIST")).toBeFalsy(); + }); + }); + + describe("should on get stats collector", () => { + it("returning `undefined` if collector not existing", async () => { + expect(stats.getStatsReportGatherer("CALL_ID")).toBeUndefined(); + }); + + it("returning Collector if collector existing", async () => { + expect(stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection())).toBeTruthy(); + expect(stats.getStatsReportGatherer("CALL_ID")).toBeDefined(); + }); + }); + + describe("should on start", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it("starting processing as well without stats collectors", async () => { + // @ts-ignore + stats.processStats = jest.fn(); + stats.start(); + jest.advanceTimersByTime(TIME_INTERVAL); + // @ts-ignore + expect(stats.processStats).toHaveBeenCalled(); + }); + + it("starting processing and calling the collectors", async () => { + stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection()); + const collector = stats.getStatsReportGatherer("CALL_ID"); + let processStatsSpy; + if (collector) { + processStatsSpy = jest.spyOn(collector, "processStats"); + stats.start(); + jest.advanceTimersByTime(TIME_INTERVAL); + } else { + throw new Error("Test failed, because no Collector found!"); + } + expect(processStatsSpy).toHaveBeenCalledWith(GROUP_CALL_ID, LOCAL_USER_ID); + }); + + it("doing nothing if process already running", async () => { + // @ts-ignore + jest.spyOn(global, "setInterval").mockReturnValue(22); + stats.start(); + expect(setInterval).toHaveBeenCalledTimes(1); + stats.start(); + stats.start(); + stats.start(); + stats.start(); + expect(setInterval).toHaveBeenCalledTimes(1); + }); + }); + + describe("should on stop", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it("finish stats process if was started", async () => { + // @ts-ignore + jest.spyOn(global, "setInterval").mockReturnValue(22); + jest.spyOn(global, "clearInterval"); + stats.start(); + expect(setInterval).toHaveBeenCalledTimes(1); + stats.stop(); + expect(clearInterval).toHaveBeenCalledWith(22); + }); + + it("do nothing if stats process was not started", async () => { + jest.spyOn(global, "clearInterval"); + stats.stop(); + expect(clearInterval).not.toHaveBeenCalled(); + }); + }); +}); + +const mockRTCPeerConnection = (): RTCPeerConnection => { + const pc = {} as RTCPeerConnection; + pc.addEventListener = jest.fn(); + pc.getStats = jest.fn().mockResolvedValue(null); + return pc; +}; diff --git a/spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts b/spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts new file mode 100644 index 00000000000..4b6e93179a4 --- /dev/null +++ b/spec/unit/webrtc/stats/media/mediaSsrcHandler.spec.ts @@ -0,0 +1,41 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { Mid, Ssrc, MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler"; +import { REMOTE_SFU_DESCRIPTION } from "../../../../test-utils/webrtc"; + +describe("MediaSsrcHandler", () => { + const remoteMap = new Map([ + ["0", ["2963372119"]], + ["1", ["1212931603"]], + ]); + let handler: MediaSsrcHandler; + beforeEach(() => { + handler = new MediaSsrcHandler(); + }); + describe("should parse description", () => { + it("and build mid ssrc map", () => { + handler.parse(REMOTE_SFU_DESCRIPTION, "remote"); + expect(handler.getSsrcToMidMap("remote")).toEqual(remoteMap); + }); + }); + + describe("should on find mid by ssrc", () => { + it("and return mid if mapping exists.", () => { + handler.parse(REMOTE_SFU_DESCRIPTION, "remote"); + expect(handler.findMidBySsrc("2963372119", "remote")).toEqual("0"); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts b/spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts new file mode 100644 index 00000000000..66dcc5ebf03 --- /dev/null +++ b/spec/unit/webrtc/stats/media/mediaTrackHandler.spec.ts @@ -0,0 +1,113 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler"; + +describe("TrackHandler", () => { + let pc: RTCPeerConnection; + let handler: MediaTrackHandler; + beforeEach(() => { + pc = { + getTransceivers: (): RTCRtpTransceiver[] => [mockTransceiver("1", "audio"), mockTransceiver("2", "video")], + } as RTCPeerConnection; + handler = new MediaTrackHandler(pc); + }); + describe("should get local tracks", () => { + it("returns video track", () => { + expect(handler.getLocalTracks("video")).toEqual([ + { + id: `sender-track-2`, + kind: "video", + } as MediaStreamTrack, + ]); + }); + + it("returns audio track", () => { + expect(handler.getLocalTracks("audio")).toEqual([ + { + id: `sender-track-1`, + kind: "audio", + } as MediaStreamTrack, + ]); + }); + }); + + describe("should get local track by mid", () => { + it("returns video track", () => { + expect(handler.getLocalTrackIdByMid("2")).toEqual("sender-track-2"); + }); + + it("returns audio track", () => { + expect(handler.getLocalTrackIdByMid("1")).toEqual("sender-track-1"); + }); + + it("returns undefined if not exists", () => { + expect(handler.getLocalTrackIdByMid("3")).toBeUndefined(); + }); + }); + + describe("should get remote track by mid", () => { + it("returns video track", () => { + expect(handler.getRemoteTrackIdByMid("2")).toEqual("receiver-track-2"); + }); + + it("returns audio track", () => { + expect(handler.getRemoteTrackIdByMid("1")).toEqual("receiver-track-1"); + }); + + it("returns undefined if not exists", () => { + expect(handler.getRemoteTrackIdByMid("3")).toBeUndefined(); + }); + }); + + describe("should get track by id", () => { + it("returns remote track", () => { + expect(handler.getTackById("receiver-track-2")).toEqual({ + id: `receiver-track-2`, + kind: "video", + } as MediaStreamTrack); + }); + + it("returns local track", () => { + expect(handler.getTackById("sender-track-1")).toEqual({ + id: `sender-track-1`, + kind: "audio", + } as MediaStreamTrack); + }); + + it("returns undefined if not exists", () => { + expect(handler.getTackById("sender-track-3")).toBeUndefined(); + }); + }); + + describe("should get simulcast track count", () => { + it("returns 2", () => { + expect(handler.getActiveSimulcastStreams()).toEqual(3); + }); + }); +}); + +const mockTransceiver = (mid: string, kind: "video" | "audio"): RTCRtpTransceiver => { + return { + mid, + currentDirection: "sendrecv", + sender: { + track: { id: `sender-track-${mid}`, kind } as MediaStreamTrack, + } as RTCRtpSender, + receiver: { + track: { id: `receiver-track-${mid}`, kind } as MediaStreamTrack, + } as RTCRtpReceiver, + } as RTCRtpTransceiver; +}; diff --git a/spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts b/spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts new file mode 100644 index 00000000000..d263786fda2 --- /dev/null +++ b/spec/unit/webrtc/stats/media/mediaTrackStatsHandler.spec.ts @@ -0,0 +1,83 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { MediaTrackHandler } from "../../../../../src/webrtc/stats/media/mediaTrackHandler"; +import { MediaTrackStatsHandler } from "../../../../../src/webrtc/stats/media/mediaTrackStatsHandler"; +import { MediaSsrcHandler } from "../../../../../src/webrtc/stats/media/mediaSsrcHandler"; + +describe("MediaTrackStatsHandler", () => { + let statsHandler: MediaTrackStatsHandler; + let ssrcHandler: MediaSsrcHandler; + let trackHandler: MediaTrackHandler; + beforeEach(() => { + ssrcHandler = {} as MediaSsrcHandler; + trackHandler = {} as MediaTrackHandler; + trackHandler.getLocalTrackIdByMid = jest.fn().mockReturnValue("2222"); + trackHandler.getRemoteTrackIdByMid = jest.fn().mockReturnValue("5555"); + trackHandler.getLocalTracks = jest.fn().mockReturnValue([{ id: "2222" } as MediaStreamTrack]); + trackHandler.getTackById = jest.fn().mockReturnValue([{ id: "2222", kind: "audio" } as MediaStreamTrack]); + statsHandler = new MediaTrackStatsHandler(ssrcHandler, trackHandler); + }); + describe("should find track stats", () => { + it("and returns stats if `trackIdentifier` exists in report", () => { + const report = { trackIdentifier: "123" }; + expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("123"); + }); + it("and returns stats if `mid` exists in report", () => { + const reportIn = { mid: "1", type: "inbound-rtp" }; + expect(statsHandler.findTrack2Stats(reportIn, "remote")?.trackId).toEqual("5555"); + const reportOut = { mid: "1", type: "outbound-rtp" }; + expect(statsHandler.findTrack2Stats(reportOut, "local")?.trackId).toEqual("2222"); + }); + it("and returns undefined if `ssrc` exists in report but not on connection", () => { + const report = { ssrc: "142443", type: "inbound-rtp" }; + ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue(undefined); + expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toBeUndefined(); + }); + it("and returns undefined if `ssrc` exists in inbound-rtp report", () => { + const report = { ssrc: "142443", type: "inbound-rtp" }; + ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2"); + expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toEqual("5555"); + }); + it("and returns undefined if `ssrc` exists in outbound-rtp report", () => { + const report = { ssrc: "142443", type: "outbound-rtp" }; + ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2"); + expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222"); + }); + it("and returns undefined if needed property not existing", () => { + const report = {}; + expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined(); + }); + }); + describe("should find local video track stats", () => { + it("and returns stats if `trackIdentifier` exists in report", () => { + const report = { trackIdentifier: "2222" }; + expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222"); + }); + it("and returns stats if `mid` exists in report", () => { + const report = { mid: "1" }; + expect(statsHandler.findLocalVideoTrackStats(report)?.trackId).toEqual("2222"); + }); + it("and returns undefined if `ssrc` exists", () => { + const report = { ssrc: "142443", type: "outbound-rtp" }; + ssrcHandler.findMidBySsrc = jest.fn().mockReturnValue("2"); + expect(statsHandler.findTrack2Stats(report, "local")?.trackId).toEqual("2222"); + }); + it("and returns undefined if needed property not existing", () => { + const report = {}; + expect(statsHandler.findTrack2Stats(report, "remote")?.trackId).toBeUndefined(); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/statsReportBuilder.spec.ts b/spec/unit/webrtc/stats/statsReportBuilder.spec.ts new file mode 100644 index 00000000000..b1843bbfc9a --- /dev/null +++ b/spec/unit/webrtc/stats/statsReportBuilder.spec.ts @@ -0,0 +1,115 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TrackID } from "../../../../src/webrtc/stats/statsReport"; +import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats"; +import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder"; + +describe("StatsReportBuilder", () => { + const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID"; + const LOCAL_AUDIO_TRACK_ID = "LOCAL_AUDIO_TRACK_ID"; + const REMOTE_AUDIO_TRACK_ID = "REMOTE_AUDIO_TRACK_ID"; + const REMOTE_VIDEO_TRACK_ID = "REMOTE_VIDEO_TRACK_ID"; + const localAudioTrack = new MediaTrackStats(LOCAL_AUDIO_TRACK_ID, "local", "audio"); + const localVideoTrack = new MediaTrackStats(LOCAL_VIDEO_TRACK_ID, "local", "video"); + const remoteAudioTrack = new MediaTrackStats(REMOTE_AUDIO_TRACK_ID, "remote", "audio"); + const remoteVideoTrack = new MediaTrackStats(REMOTE_VIDEO_TRACK_ID, "remote", "video"); + const stats = new Map([ + [LOCAL_AUDIO_TRACK_ID, localAudioTrack], + [LOCAL_VIDEO_TRACK_ID, localVideoTrack], + [REMOTE_AUDIO_TRACK_ID, remoteAudioTrack], + [REMOTE_VIDEO_TRACK_ID, remoteVideoTrack], + ]); + beforeEach(() => { + buildData(); + }); + + describe("should build stats", () => { + it("by media track stats.", async () => { + expect(StatsReportBuilder.build(stats)).toEqual({ + bitrate: { + audio: { + download: 4000, + upload: 5000, + }, + download: 5004000, + upload: 3005000, + video: { + download: 5000000, + upload: 3000000, + }, + }, + codec: { + local: new Map([ + ["LOCAL_AUDIO_TRACK_ID", "opus"], + ["LOCAL_VIDEO_TRACK_ID", "v8"], + ]), + remote: new Map([ + ["REMOTE_AUDIO_TRACK_ID", "opus"], + ["REMOTE_VIDEO_TRACK_ID", "v9"], + ]), + }, + framerate: { + local: new Map([ + ["LOCAL_AUDIO_TRACK_ID", 0], + ["LOCAL_VIDEO_TRACK_ID", 30], + ]), + remote: new Map([ + ["REMOTE_AUDIO_TRACK_ID", 0], + ["REMOTE_VIDEO_TRACK_ID", 60], + ]), + }, + packetLoss: { + download: 7, + total: 15, + upload: 28, + }, + resolution: { + local: new Map([ + ["LOCAL_AUDIO_TRACK_ID", { height: -1, width: -1 }], + ["LOCAL_VIDEO_TRACK_ID", { height: 460, width: 780 }], + ]), + remote: new Map([ + ["REMOTE_AUDIO_TRACK_ID", { height: -1, width: -1 }], + ["REMOTE_VIDEO_TRACK_ID", { height: 960, width: 1080 }], + ]), + }, + }); + }); + }); + + const buildData = (): void => { + localAudioTrack.setCodec("opus"); + localAudioTrack.setLoss({ packetsTotal: 10, packetsLost: 5, isDownloadStream: false }); + localAudioTrack.setBitrate({ download: 0, upload: 5000 }); + + remoteAudioTrack.setCodec("opus"); + remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true }); + remoteAudioTrack.setBitrate({ download: 4000, upload: 0 }); + + localVideoTrack.setCodec("v8"); + localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false }); + localVideoTrack.setBitrate({ download: 0, upload: 3000000 }); + localVideoTrack.setFramerate(30); + localVideoTrack.setResolution({ width: 780, height: 460 }); + + remoteVideoTrack.setCodec("v9"); + remoteVideoTrack.setLoss({ packetsTotal: 40, packetsLost: 4, isDownloadStream: true }); + remoteVideoTrack.setBitrate({ download: 5000000, upload: 0 }); + remoteVideoTrack.setFramerate(60); + remoteVideoTrack.setResolution({ width: 1080, height: 960 }); + }; +}); diff --git a/spec/unit/webrtc/stats/statsReportEmitter.spec.ts b/spec/unit/webrtc/stats/statsReportEmitter.spec.ts new file mode 100644 index 00000000000..de75d44746d --- /dev/null +++ b/spec/unit/webrtc/stats/statsReportEmitter.spec.ts @@ -0,0 +1,48 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter"; +import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "../../../../src/webrtc/stats/statsReport"; + +describe("StatsReportEmitter", () => { + let emitter: StatsReportEmitter; + beforeEach(() => { + emitter = new StatsReportEmitter(); + }); + + it("should emit and receive ByteSendStatsReport", async () => { + const report = {} as ByteSentStatsReport; + return new Promise((resolve, _) => { + emitter.on(StatsReport.BYTE_SENT_STATS, (r) => { + expect(r).toBe(report); + resolve(null); + return; + }); + emitter.emitByteSendReport(report); + }); + }); + + it("should emit and receive ConnectionStatsReport", async () => { + const report = {} as ConnectionStatsReport; + return new Promise((resolve, _) => { + emitter.on(StatsReport.CONNECTION_STATS, (r) => { + expect(r).toBe(report); + resolve(null); + return; + }); + emitter.emitConnectionStatsReport(report); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/statsReportGatherer.spec.ts b/spec/unit/webrtc/stats/statsReportGatherer.spec.ts new file mode 100644 index 00000000000..cd10ebfb21c --- /dev/null +++ b/spec/unit/webrtc/stats/statsReportGatherer.spec.ts @@ -0,0 +1,68 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer"; +import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter"; + +const CALL_ID = "CALL_ID"; +const USER_ID = "USER_ID"; + +describe("StatsReportGatherer", () => { + let collector: StatsReportGatherer; + let rtcSpy: RTCPeerConnection; + let emitter: StatsReportEmitter; + beforeEach(() => { + rtcSpy = { getStats: () => new Promise(() => null) } as RTCPeerConnection; + rtcSpy.addEventListener = jest.fn(); + emitter = new StatsReportEmitter(); + collector = new StatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter); + }); + + describe("on process stats", () => { + it("if active calculate stats reports", async () => { + const getStats = jest.spyOn(rtcSpy, "getStats"); + getStats.mockResolvedValue({} as RTCStatsReport); + await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID"); + expect(getStats).toHaveBeenCalled(); + }); + + it("if not active do not calculate stats reports", async () => { + collector.setActive(false); + const getStats = jest.spyOn(rtcSpy, "getStats"); + await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID"); + expect(getStats).not.toHaveBeenCalled(); + }); + + it("if get reports fails, the collector becomes inactive", async () => { + expect(collector.getActive()).toBeTruthy(); + const getStats = jest.spyOn(rtcSpy, "getStats"); + getStats.mockRejectedValue(new Error("unknown")); + await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID"); + expect(getStats).toHaveBeenCalled(); + expect(collector.getActive()).toBeFalsy(); + }); + + it("if active an RTCStatsReport not a promise the collector becomes inactive", async () => { + const getStats = jest.spyOn(rtcSpy, "getStats"); + // @ts-ignore + getStats.mockReturnValue({}); + const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID"); + expect(actual).toBeFalsy(); + expect(getStats).toHaveBeenCalled(); + expect(collector.getActive()).toBeFalsy(); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/statsValueFormatter.spec.ts b/spec/unit/webrtc/stats/statsValueFormatter.spec.ts new file mode 100644 index 00000000000..1ce563e91d6 --- /dev/null +++ b/spec/unit/webrtc/stats/statsValueFormatter.spec.ts @@ -0,0 +1,28 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter"; + +describe("StatsValueFormatter", () => { + describe("on get non negative values", () => { + it("formatter shod return number", async () => { + expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2); + expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0); + expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0); + expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0); + expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/trackStatsReporter.spec.ts b/spec/unit/webrtc/stats/trackStatsReporter.spec.ts new file mode 100644 index 00000000000..6a1bb5bf21a --- /dev/null +++ b/spec/unit/webrtc/stats/trackStatsReporter.spec.ts @@ -0,0 +1,132 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter"; +import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats"; + +describe("TrackStatsReporter", () => { + describe("should on frame and resolution stats", () => { + it("creating empty frame and resolution report, if no data available.", async () => { + const trackStats = new MediaTrackStats("1", "local", "video"); + TrackStatsReporter.buildFramerateResolution(trackStats, {}); + expect(trackStats.getFramerate()).toEqual(0); + expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 }); + }); + it("creating empty frame and resolution report.", async () => { + const trackStats = new MediaTrackStats("1", "remote", "video"); + TrackStatsReporter.buildFramerateResolution(trackStats, { + framesPerSecond: 22.2, + frameHeight: 180, + frameWidth: 360, + }); + expect(trackStats.getFramerate()).toEqual(22); + expect(trackStats.getResolution()).toEqual({ width: 360, height: 180 }); + }); + }); + + describe("should on simulcast", () => { + it("creating simulcast framerate.", async () => { + const trackStats = new MediaTrackStats("1", "local", "video"); + TrackStatsReporter.calculateSimulcastFramerate( + trackStats, + { + framesSent: 100, + timestamp: 1678957001000, + }, + { + framesSent: 10, + timestamp: 1678957000000, + }, + 3, + ); + expect(trackStats.getFramerate()).toEqual(30); + }); + }); + + describe("should on bytes received stats", () => { + it("creating build bitrate received report.", async () => { + const trackStats = new MediaTrackStats("1", "remote", "video"); + TrackStatsReporter.buildBitrateReceived( + trackStats, + { + bytesReceived: 2001000, + timestamp: 1678957010, + }, + { bytesReceived: 2000000, timestamp: 1678957000 }, + ); + expect(trackStats.getBitrate()).toEqual({ download: 800, upload: 0 }); + }); + }); + + describe("should on bytes send stats", () => { + it("creating build bitrate send report.", async () => { + const trackStats = new MediaTrackStats("1", "local", "video"); + TrackStatsReporter.buildBitrateSend( + trackStats, + { + bytesSent: 2001000, + timestamp: 1678957010, + }, + { bytesSent: 2000000, timestamp: 1678957000 }, + ); + expect(trackStats.getBitrate()).toEqual({ download: 0, upload: 800 }); + }); + }); + + describe("should on codec stats", () => { + it("creating build bitrate send report.", async () => { + const trackStats = new MediaTrackStats("1", "remote", "video"); + const remote = {} as RTCStatsReport; + remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" }); + TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" }); + expect(trackStats.getCodec()).toEqual("v8"); + }); + }); + + describe("should on package lost stats", () => { + it("creating build package lost on send report.", async () => { + const trackStats = new MediaTrackStats("1", "local", "video"); + TrackStatsReporter.buildPacketsLost( + trackStats, + { + type: "outbound-rtp", + packetsSent: 200, + packetsLost: 120, + }, + { + packetsSent: 100, + packetsLost: 30, + }, + ); + expect(trackStats.getLoss()).toEqual({ packetsTotal: 190, packetsLost: 90, isDownloadStream: false }); + }); + it("creating build package lost on received report.", async () => { + const trackStats = new MediaTrackStats("1", "remote", "video"); + TrackStatsReporter.buildPacketsLost( + trackStats, + { + type: "inbound-rtp", + packetsReceived: 300, + packetsLost: 100, + }, + { + packetsReceived: 100, + packetsLost: 20, + }, + ); + expect(trackStats.getLoss()).toEqual({ packetsTotal: 280, packetsLost: 80, isDownloadStream: true }); + }); + }); +}); diff --git a/spec/unit/webrtc/stats/transportStatsReporter.spec.ts b/spec/unit/webrtc/stats/transportStatsReporter.spec.ts new file mode 100644 index 00000000000..bd3288b15ae --- /dev/null +++ b/spec/unit/webrtc/stats/transportStatsReporter.spec.ts @@ -0,0 +1,126 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter"; +import { TransportStats } from "../../../../src/webrtc/stats/transportStats"; + +describe("TransportStatsReporter", () => { + describe("should on build report", () => { + const REMOTE_CANDIDATE_ID = "REMOTE_CANDIDATE_ID"; + const LOCAL_CANDIDATE_ID = "LOCAL_CANDIDATE_ID"; + const localIC = { ip: "88.88.99.1", port: 56670, protocol: "tcp", candidateType: "local", networkType: "lan" }; + const remoteIC = { + ip: "123.88.99.1", + port: 46670, + protocol: "udp", + candidateType: "srfx", + networkType: "wifi", + }; + const isFocus = false; + const rtt = 200000; + + it("build new transport stats if all properties there", () => { + const { report, stats } = mockStatsReport(isFocus, 0); + const conferenceStatsTransport: TransportStats[] = []; + const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus); + expect(transportStats).toEqual([ + { + ip: `${remoteIC.ip + 0}:${remoteIC.port}`, + type: remoteIC.protocol, + localIp: `${localIC.ip + 0}:${localIC.port}`, + isFocus, + localCandidateType: localIC.candidateType, + remoteCandidateType: remoteIC.candidateType, + networkType: localIC.networkType, + rtt, + }, + ]); + }); + + it("build next transport stats if candidates different", () => { + const mock1 = mockStatsReport(isFocus, 0); + const mock2 = mockStatsReport(isFocus, 1); + let transportStats: TransportStats[] = []; + transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus); + transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus); + expect(transportStats).toEqual([ + { + ip: `${remoteIC.ip + 0}:${remoteIC.port}`, + type: remoteIC.protocol, + localIp: `${localIC.ip + 0}:${localIC.port}`, + isFocus, + localCandidateType: localIC.candidateType, + remoteCandidateType: remoteIC.candidateType, + networkType: localIC.networkType, + rtt, + }, + { + ip: `${remoteIC.ip + 1}:${remoteIC.port}`, + type: remoteIC.protocol, + localIp: `${localIC.ip + 1}:${localIC.port}`, + isFocus, + localCandidateType: localIC.candidateType, + remoteCandidateType: remoteIC.candidateType, + networkType: localIC.networkType, + rtt, + }, + ]); + }); + + it("build not a second transport stats if candidates the same", () => { + const mock1 = mockStatsReport(isFocus, 0); + const mock2 = mockStatsReport(isFocus, 0); + let transportStats: TransportStats[] = []; + transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus); + transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus); + expect(transportStats).toEqual([ + { + ip: `${remoteIC.ip + 0}:${remoteIC.port}`, + type: remoteIC.protocol, + localIp: `${localIC.ip + 0}:${localIC.port}`, + isFocus, + localCandidateType: localIC.candidateType, + remoteCandidateType: remoteIC.candidateType, + networkType: localIC.networkType, + rtt, + }, + ]); + }); + + const mockStatsReport = ( + isFocus: boolean, + prifix: number, + ): { report: RTCStatsReport; stats: RTCIceCandidatePairStats } => { + const report = {} as RTCStatsReport; + report.get = (key: string) => { + if (key === LOCAL_CANDIDATE_ID) { + return { ...localIC, ip: localIC.ip + prifix }; + } + if (key === REMOTE_CANDIDATE_ID) { + return { ...remoteIC, ip: remoteIC.ip + prifix }; + } + // remote + return {}; + }; + const stats = { + remoteCandidateId: REMOTE_CANDIDATE_ID, + localCandidateId: LOCAL_CANDIDATE_ID, + currentRoundTripTime: 200, + } as RTCIceCandidatePairStats; + return { report, stats }; + }; + }); +}); diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 8f6d24a5b8c..da3b01b6d17 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -63,6 +63,7 @@ export function isDmMemberCountCondition(condition: AnyMemberCountCondition): bo export enum ConditionKind { EventMatch = "event_match", EventPropertyIs = "event_property_is", + EventPropertyContains = "event_property_contains", ContainsDisplayName = "contains_display_name", RoomMemberCount = "room_member_count", SenderNotificationPermission = "sender_notification_permission", @@ -88,6 +89,11 @@ export interface IEventPropertyIsCondition extends IPushRuleCondition { + key: string; + value: string | boolean | null | number; +} + export interface IContainsDisplayNameCondition extends IPushRuleCondition { // no additional fields } @@ -114,6 +120,7 @@ export interface ICallStartedPrefixCondition extends IPushRuleCondition } = {}; public identityServer?: IIdentityServerProvider; public http: MatrixHttpApi; // XXX: Intended private, used in code. - public crypto?: Crypto; // libolm crypto implementation. XXX: Intended private, used in code. Being replaced by cryptoBackend + + /** + * The libolm crypto implementation, if it is in use. + * + * @deprecated This should not be used. Instead, use the methods exposed directly on this class or + * (where they are available) via {@link getCrypto}. + */ + public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend + private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code. @@ -2203,9 +2217,20 @@ export class MatrixClient extends TypedEventEmitter { + public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2451,6 +2476,12 @@ export class MatrixClient extends TypedEventEmitter { if (!this.cryptoBackend) { @@ -2810,7 +2863,7 @@ export class MatrixClient extends TypedEventEmitter { + ): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2876,7 +2929,7 @@ export class MatrixClient extends TypedEventEmitter | null> { + public isSecretStored(name: string): Promise | null> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -3077,13 +3130,14 @@ export class MatrixClient extends TypedEventEmitter { if (!this.cryptoBackend) { @@ -3247,7 +3306,7 @@ export class MatrixClient extends TypedEventEmitter | null> { + public isKeyBackupKeyStored(): Promise | null> { return Promise.resolve(this.isSecretStored("m.megolm_backup.v1")); } @@ -8527,7 +8586,7 @@ export class MatrixClient extends TypedEventEmitter { + ): Promise { const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", { $roomId: roomId, }); diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts index d8e61a8b4ac..a0b4621ecf8 100644 --- a/src/common-crypto/CryptoBackend.ts +++ b/src/common-crypto/CryptoBackend.ts @@ -14,31 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto"; import type { IToDeviceEvent } from "../sync-accumulator"; -import type { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning"; import { MatrixEvent } from "../models/event"; import { Room } from "../models/room"; +import { CryptoApi } from "../crypto-api"; +import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning"; import { IEncryptedEventInfo } from "../crypto/api"; +import { IEventDecryptionResult } from "../@types/crypto"; /** * Common interface for the crypto implementations */ -export interface CryptoBackend extends SyncCryptoCallbacks { - /** - * Global override for whether the client should ever send encrypted - * messages to unverified devices. This provides the default for rooms which - * do not specify a value. - * - * If true, all unverified devices will be blacklisted by default - */ - globalBlacklistUnverifiedDevices: boolean; - +export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi { /** * Whether sendMessage in a room with unknown and unverified devices * should throw an error and not send the message. This has 'Global' for * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently * no room-level equivalent for this setting. + * + * @remarks this is here, rather than in `CryptoApi`, because I don't think we're + * going to support it in the rust crypto implementation. */ globalErrorOnUnknownDevices: boolean; @@ -47,16 +42,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks { */ stop(): void; - /** - * Checks if the user has previously published cross-signing keys - * - * This means downloading the devicelist for the user and checking if the list includes - * the cross-signing pseudo-device. - - * @returns true if the user has previously published cross-signing keys - */ - userHasCrossSigningKeys(): Promise; - /** * Get the verification level for a given user * @@ -76,14 +61,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks { */ checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel; - /** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param room - the room the event is in - */ - prepareToEncrypt(room: Room): void; - /** * Encrypt an event according to the configuration of the room. * @@ -110,16 +87,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks { * @param event - event to be checked */ getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo; - - /** - * Get a list containing all of the room keys - * - * This should be encrypted before returning it to the user. - * - * @returns a promise which resolves to a list of - * session export objects - */ - exportRoomKeys(): Promise; } /** The methods which crypto implementations should expose to the Sync api */ @@ -138,6 +105,34 @@ export interface SyncCryptoCallbacks { */ preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise; + /** + * Called by the /sync loop whenever there are incoming to-device messages. + * + * The implementation may preprocess the received messages (eg, decrypt them) and return an + * updated list of messages for dispatch to the rest of the system. + * + * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device + * messages, rather than the results of any decryption attempts. + * + * @param oneTimeKeysCounts - the received one time key counts + * @returns A list of preprocessed to-device messages. + */ + preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map): Promise; + + /** + * Called by the /sync loop whenever there are incoming to-device messages. + * + * The implementation may preprocess the received messages (eg, decrypt them) and return an + * updated list of messages for dispatch to the rest of the system. + * + * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device + * messages, rather than the results of any decryption attempts. + * + * @param unusedFallbackKeys - the received unused fallback keys + * @returns A list of preprocessed to-device messages. + */ + preprocessUnusedFallbackKeys(unusedFallbackKeys: Set): Promise; + /** * Called by the /sync loop whenever an m.room.encryption event is received. * diff --git a/src/crypto-api.ts b/src/crypto-api.ts new file mode 100644 index 00000000000..50617c9dfa2 --- /dev/null +++ b/src/crypto-api.ts @@ -0,0 +1,75 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { IMegolmSessionData } from "./@types/crypto"; +import { Room } from "./models/room"; + +/** + * Public interface to the cryptography parts of the js-sdk + * + * @remarks Currently, this is a work-in-progress. In time, more methods will be added here. + */ +export interface CryptoApi { + /** + * Global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * If true, all unverified devices will be blacklisted by default + */ + globalBlacklistUnverifiedDevices: boolean; + + /** + * Checks if the user has previously published cross-signing keys + * + * This means downloading the devicelist for the user and checking if the list includes + * the cross-signing pseudo-device. + * + * @returns true if the user has previously published cross-signing keys + */ + userHasCrossSigningKeys(): Promise; + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param room - the room the event is in + */ + prepareToEncrypt(room: Room): void; + + /** + * Discard any existing megolm session for the given room. + * + * This will ensure that a new session is created on the next call to {@link prepareToEncrypt}, + * or the next time a message is sent. + * + * This should not normally be necessary: it should only be used as a debugging tool if there has been a + * problem with encryption. + * + * @param roomId - the room to discard sessions for + */ + forceDiscardSession(roomId: string): Promise; + + /** + * Get a list containing all of the room keys + * + * This should be encrypted before returning it to the user. + * + * @returns a promise which resolves to a list of + * session export objects + */ + exportRoomKeys(): Promise; +} diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index c9454621995..31ed2d4dd2b 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -31,7 +31,7 @@ import { OlmDevice } from "./OlmDevice"; import { ICryptoCallbacks } from "."; import { ISignatures } from "../@types/signed"; import { CryptoStore, SecretStorePrivateKeys } from "./store/base"; -import { ISecretStorageKeyInfo } from "./api"; +import { SecretStorageKeyDescription } from "../secret-storage"; const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; @@ -169,7 +169,7 @@ export class CrossSigningInfo { // check what SSSS keys have encrypted the master key (if any) const stored = (await secretStorage.isStored("m.cross_signing.master")) || {}; // then check which of those SSSS keys have also encrypted the SSK and USK - function intersect(s: Record): void { + function intersect(s: Record): void { for (const k of Object.keys(stored)) { if (!s[k]) { delete stored[k]; diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index f0cf4bf40bd..4efe677ad70 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -28,10 +28,10 @@ import { ISignedKey, KeySignatures, } from "../client"; -import { ISecretStorageKeyInfo } from "./api"; import { IKeyBackupInfo } from "./keybackup"; import { TypedEventEmitter } from "../models/typed-event-emitter"; import { IAccountDataClient } from "./SecretStorage"; +import { SecretStorageKeyDescription } from "../secret-storage"; interface ICrossSigningKeys { authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; @@ -326,7 +326,7 @@ class SSSSCryptoCallbacks { public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {} public async getSecretStorageKey( - { keys }: { keys: Record }, + { keys }: { keys: Record }, name: string, ): Promise<[string, Uint8Array] | null> { for (const keyId of Object.keys(keys)) { @@ -348,7 +348,7 @@ class SSSSCryptoCallbacks { return null; } - public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void { + public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void { this.privateKeys.set(keyId, privKey); // Also pass along to application to cache if it wishes this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index f5e3fb59ce9..5c9049fba3f 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -23,16 +23,17 @@ import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./ import { ICryptoCallbacks, IEncryptedContent } from "."; import { IContent, MatrixEvent } from "../models/event"; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client"; -import { IAddSecretStorageKeyOpts, ISecretStorageKeyInfo } from "./api"; +import { IAddSecretStorageKeyOpts } from "./api"; import { TypedEventEmitter } from "../models/typed-event-emitter"; import { defer, IDeferred } from "../utils"; import { ToDeviceMessageId } from "../@types/event"; +import { SecretStorageKeyDescription, SecretStorageKeyDescriptionAesV1 } from "../secret-storage"; export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; // Some of the key functions use a tuple and some use an object... -export type SecretStorageKeyTuple = [keyId: string, keyInfo: ISecretStorageKeyInfo]; -export type SecretStorageKeyObject = { keyId: string; keyInfo: ISecretStorageKeyInfo }; +export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription]; +export type SecretStorageKeyObject = { keyId: string; keyInfo: SecretStorageKeyDescription }; export interface ISecretRequest { requestId: string; @@ -127,30 +128,30 @@ export class SecretStorage { opts: IAddSecretStorageKeyOpts = {}, keyId?: string, ): Promise { - const keyInfo = { algorithm } as ISecretStorageKeyInfo; + if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) { + throw new Error(`Unknown key algorithm ${algorithm}`); + } + + const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1; if (opts.name) { keyInfo.name = opts.name; } - if (algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - if (opts.passphrase) { - keyInfo.passphrase = opts.passphrase; - } - if (opts.key) { - const { iv, mac } = await calculateKeyCheck(opts.key); - keyInfo.iv = iv; - keyInfo.mac = mac; - } - } else { - throw new Error(`Unknown key algorithm ${algorithm}`); + if (opts.passphrase) { + keyInfo.passphrase = opts.passphrase; + } + if (opts.key) { + const { iv, mac } = await calculateKeyCheck(opts.key); + keyInfo.iv = iv; + keyInfo.mac = mac; } if (!keyId) { do { keyId = randomString(32); } while ( - await this.accountDataAdapter.getAccountDataFromServer( + await this.accountDataAdapter.getAccountDataFromServer( `m.secret_storage.key.${keyId}`, ) ); @@ -181,7 +182,7 @@ export class SecretStorage { return null; } - const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); return keyInfo ? [keyId, keyInfo] : null; @@ -206,7 +207,7 @@ export class SecretStorage { * * @returns whether or not the key matches */ - public async checkKey(key: Uint8Array, info: ISecretStorageKeyInfo): Promise { + public async checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (info.mac) { const { mac } = await calculateKeyCheck(key, info.iv); @@ -245,7 +246,7 @@ export class SecretStorage { for (const keyId of keys) { // get key information from key storage - const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); if (!keyInfo) { @@ -284,10 +285,10 @@ export class SecretStorage { } // get possible keys to decrypt - const keys: Record = {}; + const keys: Record = {}; for (const keyId of Object.keys(secretInfo.encrypted)) { // get key information from key storage - const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); const encInfo = secretInfo.encrypted[keyId]; @@ -322,17 +323,17 @@ export class SecretStorage { * with, or null if it is not present or not encrypted with a trusted * key */ - public async isStored(name: string): Promise | null> { + public async isStored(name: string): Promise | null> { // check if secret exists const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); if (!secretInfo?.encrypted) return null; - const ret: Record = {}; + const ret: Record = {}; // filter secret encryption keys with supported algorithm for (const keyId of Object.keys(secretInfo.encrypted)) { // get key information from key storage - const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( + const keyInfo = await this.accountDataAdapter.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); if (!keyInfo) continue; @@ -544,7 +545,7 @@ export class SecretStorage { } private async getSecretStorageKey( - keys: Record, + keys: Record, name: string, ): Promise<[string, IDecryptors]> { if (!this.cryptoCallbacks.getSecretStorageKey) { diff --git a/src/crypto/api.ts b/src/crypto/api.ts index 468cc993383..9e9ba52c351 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -16,6 +16,13 @@ limitations under the License. import { DeviceInfo } from "./deviceinfo"; import { IKeyBackupInfo } from "./keybackup"; +import { PassphraseInfo } from "../secret-storage"; + +/* re-exports for backwards compatibility. */ +export { + PassphraseInfo as IPassphraseInfo, + SecretStorageKeyDescription as ISecretStorageKeyInfo, +} from "../secret-storage"; // TODO: Merge this with crypto.js once converted @@ -98,26 +105,9 @@ export interface ICreateSecretStorageOpts { getKeyBackupPassphrase?: () => Promise; } -export interface ISecretStorageKeyInfo { - name: string; - algorithm: string; - // technically the below are specific to AES keys. If we ever introduce another type, - // we can split into separate interfaces. - iv: string; - mac: string; - passphrase: IPassphraseInfo; -} - -export interface IPassphraseInfo { - algorithm: "m.pbkdf2"; - iterations: number; - salt: string; - bits?: number; -} - export interface IAddSecretStorageKeyOpts { pubkey?: string; - passphrase?: IPassphraseInfo; + passphrase?: PassphraseInfo; name?: string; key?: Uint8Array; } diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index 26640c8082b..373b236b256 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -21,13 +21,13 @@ import { decodeBase64, encodeBase64 } from "./olmlib"; import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store"; import { decryptAES, encryptAES } from "./aes"; import { logger } from "../logger"; -import { ISecretStorageKeyInfo } from "./api"; import { Crypto } from "./index"; import { Method } from "../http-api"; +import { SecretStorageKeyDescription } from "../secret-storage"; export interface IDehydratedDevice { device_id: string; // eslint-disable-line camelcase - device_data: ISecretStorageKeyInfo & { + device_data: SecretStorageKeyDescription & { // eslint-disable-line camelcase algorithm: string; account: string; // pickle diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 2390268c16c..68df6cacf46 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -48,7 +48,6 @@ import { IEncryptedEventInfo, IImportRoomKeysOpts, IRecoveryKey, - ISecretStorageKeyInfo, } from "./api"; import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager"; import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store"; @@ -91,6 +90,7 @@ import { IMessage } from "./algorithms/olm"; import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend"; import { RoomState, RoomStateEvent } from "../models/room-state"; import { MapWithDefault, recursiveMapToObject } from "../utils"; +import { SecretStorageKeyDescription } from "../secret-storage"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -142,10 +142,10 @@ export interface ICryptoCallbacks { saveCrossSigningKeys?: (keys: Record) => void; shouldUpgradeDeviceVerifications?: (users: Record) => Promise; getSecretStorageKey?: ( - keys: { keys: Record }, + keys: { keys: Record }, name: string, ) => Promise<[string, Uint8Array] | null>; - cacheSecretStorageKey?: (keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array) => void; + cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; onSecretRequested?: ( userId: string, deviceId: string, @@ -153,7 +153,10 @@ export interface ICryptoCallbacks { secretName: string, deviceTrust: DeviceTrustLevel, ) => Promise; - getDehydrationKey?: (keyInfo: ISecretStorageKeyInfo, checkFunc: (key: Uint8Array) => void) => Promise; + getDehydrationKey?: ( + keyInfo: SecretStorageKeyDescription, + checkFunc: (key: Uint8Array) => void, + ) => Promise; getBackupKey?: () => Promise; } @@ -923,7 +926,7 @@ export class Crypto extends TypedEventEmitter => { + const ensureCanCheckPassphrase = async (keyId: string, keyInfo: SecretStorageKeyDescription): Promise => { if (!keyInfo.mac) { const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.( { keys: { [keyId]: keyInfo } }, @@ -1130,7 +1133,7 @@ export class Crypto extends TypedEventEmitter | null> { + public isSecretStored(name: string): Promise | null> { return this.secretStorage.isStored(name); } @@ -1149,7 +1152,7 @@ export class Crypto extends TypedEventEmitter { + public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise { return this.secretStorage.checkKey(key, info); } @@ -2492,13 +2495,14 @@ export class Crypto extends TypedEventEmitter { const alg = this.roomEncryptors.get(roomId); if (alg === undefined) throw new Error("Room not encrypted"); if (alg.forceDiscardSession === undefined) { throw new Error("Room encryption algorithm doesn't support session discarding"); } alg.forceDiscardSession(); + return Promise.resolve(); } /** @@ -3217,6 +3221,17 @@ export class Crypto extends TypedEventEmitter): Promise { + const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0; + this.updateOneTimeKeyCount(currentCount); + return Promise.resolve(); + } + + public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set): Promise { + this.setNeedsNewFallback(!unusedFallbackKeys.has("signed_curve25519")); + return Promise.resolve(); + } + private onToDeviceEvent = (event: MatrixEvent): void => { try { logger.log( diff --git a/src/matrix.ts b/src/matrix.ts index 11367880d5c..591c5e359ac 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -55,11 +55,13 @@ export * from "./@types/requests"; export * from "./@types/search"; export * from "./models/room-summary"; export * as ContentHelpers from "./content-helpers"; +export * as SecretStorage from "./secret-storage"; export type { ICryptoCallbacks } from "./crypto"; // used to be located here export { createNewMatrixCall } from "./webrtc/call"; export type { MatrixCall } from "./webrtc/call"; export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall"; export type { GroupCall } from "./webrtc/groupCall"; +export type { CryptoApi } from "./crypto-api"; let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore(); diff --git a/src/models/beacon.ts b/src/models/beacon.ts index 6a1bb57c766..3801831a1ce 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -48,15 +48,17 @@ export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier => // https://github.com/matrix-org/matrix-spec-proposals/pull/3672 export class Beacon extends TypedEventEmitter, BeaconEventHandlerMap> { public readonly roomId: string; - private _beaconInfo?: BeaconInfoState; + // beaconInfo is assigned by setBeaconInfo in the constructor + // ! to make tsc believe it is definitely assigned + private _beaconInfo!: BeaconInfoState; private _isLive?: boolean; private livenessWatchTimeout?: ReturnType; private _latestLocationEvent?: MatrixEvent; public constructor(private rootEvent: MatrixEvent) { super(); - this.setBeaconInfo(this.rootEvent); this.roomId = this.rootEvent.getRoomId()!; + this.setBeaconInfo(this.rootEvent); } public get isLive(): boolean { @@ -79,7 +81,7 @@ export class Beacon extends TypedEventEmitter this.latestLocationState.timestamp!) ); @@ -196,9 +198,9 @@ export class Beacon extends TypedEventEmitter>; @@ -114,6 +116,11 @@ export interface IEventRelation { "key"?: string; } +export interface IMentions { + user_ids?: string[]; + room?: boolean; +} + /** * When an event is a visibility change event, as per MSC3531, * the visibility change implied by the event. diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 75b2c87c6d9..f975b9cbdce 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -467,7 +467,7 @@ export class RoomState extends TypedEventEmitter this.emit(RoomStateEvent.Update, this); } - public processBeaconEvents(events: MatrixEvent[], matrixClient: MatrixClient): void { + public async processBeaconEvents(events: MatrixEvent[], matrixClient: MatrixClient): Promise { if ( !events.length || // discard locations if we have no beacons @@ -476,10 +476,10 @@ export class RoomState extends TypedEventEmitter return; } - const beaconByEventIdDict: Record = [...this.beacons.values()].reduce( - (dict, beacon) => ({ ...dict, [beacon.beaconInfoId]: beacon }), - {}, - ); + const beaconByEventIdDict = [...this.beacons.values()].reduce>((dict, beacon) => { + dict[beacon.beaconInfoId] = beacon; + return dict; + }, {}); const processBeaconRelation = (beaconInfoEventId: string, event: MatrixEvent): void => { if (!M_BEACON.matches(event.getType())) { @@ -493,22 +493,24 @@ export class RoomState extends TypedEventEmitter } }; - events.forEach((event: MatrixEvent) => { + for (const event of events) { const relatedToEventId = event.getRelation()?.event_id; // not related to a beacon we know about; discard if (!relatedToEventId || !beaconByEventIdDict[relatedToEventId]) return; + if (!M_BEACON.matches(event.getType()) && !event.isEncrypted()) return; - matrixClient.decryptEventIfNeeded(event); - - if (event.isBeingDecrypted() || event.isDecryptionFailure()) { - // add an event listener for once the event is decrypted. - event.once(MatrixEventEvent.Decrypted, async () => { - processBeaconRelation(relatedToEventId, event); - }); - } else { + try { + await matrixClient.decryptEventIfNeeded(event); processBeaconRelation(relatedToEventId, event); + } catch { + if (event.isDecryptionFailure()) { + // add an event listener for once the event is decrypted. + event.once(MatrixEventEvent.Decrypted, async () => { + processBeaconRelation(relatedToEventId, event); + }); + } } - }); + } } /** @@ -970,13 +972,21 @@ export class RoomState extends TypedEventEmitter * @param msc3946ProcessDynamicPredecessor - if true, look for an * m.room.predecessor state event and use it if found (MSC3946). * @returns null if this room has no predecessor. Otherwise, returns - * the roomId and last eventId of the predecessor room. + * the roomId, last eventId and viaServers of the predecessor room. + * * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events * as well as m.room.create events to find predecessors. + * * Note: if an m.predecessor event is used, eventId may be undefined * since last_known_event_id is optional. + * + * Note: viaServers may be undefined, and will definitely be undefined if + * this predecessor comes from a RoomCreate event (rather than a + * RoomPredecessor, which has the optional via_servers property). */ - public findPredecessor(msc3946ProcessDynamicPredecessor = false): { roomId: string; eventId?: string } | null { + public findPredecessor( + msc3946ProcessDynamicPredecessor = false, + ): { roomId: string; eventId?: string; viaServers?: string[] } | null { // Note: the tests for this function are against Room.findPredecessor, // which just calls through to here. @@ -986,14 +996,19 @@ export class RoomState extends TypedEventEmitter const content = predecessorEvent.getContent<{ predecessor_room_id: string; last_known_event_id?: string; + via_servers?: string[]; }>(); const roomId = content.predecessor_room_id; let eventId = content.last_known_event_id; if (typeof eventId !== "string") { eventId = undefined; } + let viaServers = content.via_servers; + if (!Array.isArray(viaServers)) { + viaServers = undefined; + } if (typeof roomId === "string") { - return { roomId, eventId }; + return { roomId, eventId, viaServers }; } } } diff --git a/src/models/room.ts b/src/models/room.ts index 0254429139c..133b210439d 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2028,8 +2028,11 @@ export class Room extends ReadReceipt { }; } - const parentEventId = event.getAssociatedId()!; - const parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId); + const parentEventId = event.getAssociatedId(); + let parentEvent: MatrixEvent | undefined; + if (parentEventId) { + parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId); + } // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead if (parentEvent && (event.isRelation() || event.isRedaction())) { @@ -3054,13 +3057,21 @@ export class Room extends ReadReceipt { * @param msc3946ProcessDynamicPredecessor - if true, look for an * m.room.predecessor state event and use it if found (MSC3946). * @returns null if this room has no predecessor. Otherwise, returns - * the roomId and last eventId of the predecessor room. + * the roomId, last eventId and viaServers of the predecessor room. + * * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events * as well as m.room.create events to find predecessors. + * * Note: if an m.predecessor event is used, eventId may be undefined * since last_known_event_id is optional. + * + * Note: viaServers may be undefined, and will definitely be undefined if + * this predecessor comes from a RoomCreate event (rather than a + * RoomPredecessor, which has the optional via_servers property). */ - public findPredecessor(msc3946ProcessDynamicPredecessor = false): { roomId: string; eventId?: string } | null { + public findPredecessor( + msc3946ProcessDynamicPredecessor = false, + ): { roomId: string; eventId?: string; viaServers?: string[] } | null { const currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!currentState) { return null; diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 614fa3375f7..78d26fe5aa2 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -26,6 +26,7 @@ import { IContainsDisplayNameCondition, IEventMatchCondition, IEventPropertyIsCondition, + IEventPropertyContainsCondition, IPushRule, IPushRules, IRoomMemberCountCondition, @@ -35,6 +36,7 @@ import { PushRuleCondition, PushRuleKind, PushRuleSet, + RuleId, TweakName, } from "./@types/PushRules"; import { EventType } from "./@types/event"; @@ -69,6 +71,36 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ ], actions: [PushRuleActionName.DontNotify], }, + { + rule_id: RuleId.IsUserMention, + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventPropertyContains, + key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids", + value: "", // The user ID is dynamically added in rewriteDefaultRules. + }, + ], + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight }], + }, + { + rule_id: RuleId.IsRoomMention, + default: true, + enabled: true, + conditions: [ + { + kind: ConditionKind.EventPropertyIs, + key: "content.org\\.matrix\\.msc3952\\.mentions.room", + value: true, + }, + { + kind: ConditionKind.SenderNotificationPermission, + key: "room", + }, + ], + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight }], + }, { // For homeservers which don't support MSC3786 yet rule_id: ".org.matrix.msc3786.rule.room.server_acl", @@ -159,9 +191,10 @@ export class PushProcessor { * where applicable. Useful for upgrading push rules to more strict * conditions when the server is falling behind on defaults. * @param incomingRules - The client's existing push rules + * @param userId - The Matrix ID of the client. * @returns The rewritten rules */ - public static rewriteDefaultRules(incomingRules: IPushRules): IPushRules { + public static rewriteDefaultRules(incomingRules: IPushRules, userId: string | undefined = undefined): IPushRules { let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone // These lines are mostly to make the tests happy. We shouldn't run into these @@ -173,8 +206,22 @@ export class PushProcessor { // Merge the client-level defaults with the ones from the server const globalOverrides = newRules.global.override; - for (const override of DEFAULT_OVERRIDE_RULES) { - const existingRule = globalOverrides.find((r) => r.rule_id === override.rule_id); + for (const originalOverride of DEFAULT_OVERRIDE_RULES) { + const existingRule = globalOverrides.find((r) => r.rule_id === originalOverride.rule_id); + + // Dynamically add the user ID as the value for the is_user_mention rule. + let override: IPushRule; + if (originalOverride.rule_id === RuleId.IsUserMention) { + // If the user ID wasn't provided, skip the rule. + if (!userId) { + continue; + } + + override = JSON.parse(JSON.stringify(originalOverride)); // deep clone + override.conditions![0].value = userId; + } else { + override = originalOverride; + } if (existingRule) { // Copy over the actions, default, and conditions. Don't touch the user's preference. @@ -340,6 +387,8 @@ export class PushProcessor { return this.eventFulfillsEventMatchCondition(cond, ev); case ConditionKind.EventPropertyIs: return this.eventFulfillsEventPropertyIsCondition(cond, ev); + case ConditionKind.EventPropertyContains: + return this.eventFulfillsEventPropertyContains(cond, ev); case ConditionKind.ContainsDisplayName: return this.eventFulfillsDisplayNameCondition(cond, ev); case ConditionKind.RoomMemberCount: @@ -488,6 +537,24 @@ export class PushProcessor { return cond.value === this.valueForDottedKey(cond.key, ev); } + /** + * Check whether the given event matches the push rule condition by fetching + * the property from the event and comparing exactly against the condition's + * value. + * @param cond - The push rule condition to check for a match. + * @param ev - The event to check for a match. + */ + private eventFulfillsEventPropertyContains(cond: IEventPropertyContainsCondition, ev: MatrixEvent): boolean { + if (!cond.key || cond.value === undefined) { + return false; + } + const val = this.valueForDottedKey(cond.key, ev); + if (!Array.isArray(val)) { + return false; + } + return val.includes(cond.value); + } + private eventFulfillsCallStartedCondition( _cond: ICallStartedCondition | ICallStartedPrefixCondition, ev: MatrixEvent, @@ -647,6 +714,17 @@ export class PushProcessor { } public ruleMatchesEvent(rule: Partial & Pick, ev: MatrixEvent): boolean { + // Disable the deprecated mentions push rules if the new mentions property exists. + if ( + this.client.supportsIntentionalMentions() && + ev.getContent()["org.matrix.msc3952.mentions"] !== undefined && + (rule.rule_id === RuleId.ContainsUserName || + rule.rule_id === RuleId.ContainsDisplayName || + rule.rule_id === RuleId.AtRoomNotification) + ) { + return false; + } + return !rule.conditions?.some((cond) => !this.eventFulfillsCondition(cond, ev)); } diff --git a/src/rust-crypto/KeyClaimManager.ts b/src/rust-crypto/KeyClaimManager.ts index 0479bfa43c1..9df8f89daf9 100644 --- a/src/rust-crypto/KeyClaimManager.ts +++ b/src/rust-crypto/KeyClaimManager.ts @@ -54,7 +54,12 @@ export class KeyClaimManager { // The Rust-SDK requires that we only have one getMissingSessions process in flight at once. This little dance // ensures that, by only having one call to ensureSessionsForUsersInner active at once (and making them // queue up in order). - const prom = this.currentClaimPromise.finally(() => this.ensureSessionsForUsersInner(userList)); + const prom = this.currentClaimPromise + .catch(() => { + // any errors in the previous claim will have been reported already, so there is nothing to do here. + // we just throw away the error and start anew. + }) + .then(() => this.ensureSessionsForUsersInner(userList)); this.currentClaimPromise = prom; return prom; } diff --git a/src/rust-crypto/RoomEncryptor.ts b/src/rust-crypto/RoomEncryptor.ts index acd0e9ff033..1649a69e722 100644 --- a/src/rust-crypto/RoomEncryptor.ts +++ b/src/rust-crypto/RoomEncryptor.ts @@ -22,6 +22,7 @@ import { Room } from "../models/room"; import { logger, PrefixedLogger } from "../logger"; import { KeyClaimManager } from "./KeyClaimManager"; import { RoomMember } from "../models/room-member"; +import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; /** * RoomEncryptor: responsible for encrypting messages to a given room @@ -38,6 +39,7 @@ export class RoomEncryptor { public constructor( private readonly olmMachine: OlmMachine, private readonly keyClaimManager: KeyClaimManager, + private readonly outgoingRequestProcessor: OutgoingRequestProcessor, private readonly room: Room, private encryptionSettings: IContent, ) { @@ -97,10 +99,31 @@ export class RoomEncryptor { const userList = members.map((u) => new UserId(u.userId)); await this.keyClaimManager.ensureSessionsForUsers(userList); + this.prefixedLogger.debug("Sessions for users are ready; now sharing room key"); + const rustEncryptionSettings = new EncryptionSettings(); /* FIXME historyVisibility, rotation, etc */ - await this.olmMachine.shareRoomKey(new RoomId(this.room.roomId), userList, rustEncryptionSettings); + const shareMessages = await this.olmMachine.shareRoomKey( + new RoomId(this.room.roomId), + userList, + rustEncryptionSettings, + ); + if (shareMessages) { + for (const m of shareMessages) { + await this.outgoingRequestProcessor.makeOutgoingRequest(m); + } + } + } + + /** + * Discard any existing group session for this room + */ + public async forceDiscardSession(): Promise { + const r = await this.olmMachine.invalidateGroupSession(new RoomId(this.room.roomId)); + if (r) { + this.prefixedLogger.info("Discarded existing group session"); + } } /** diff --git a/src/rust-crypto/index.ts b/src/rust-crypto/index.ts index 7faeff15882..e2c541f5ce9 100644 --- a/src/rust-crypto/index.ts +++ b/src/rust-crypto/index.ts @@ -30,7 +30,7 @@ export async function initRustCrypto( await RustSdkCryptoJs.initAsync(); // enable tracing in the rust-sdk - new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn(); + new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Trace).turnOn(); const u = new RustSdkCryptoJs.UserId(userId); const d = new RustSdkCryptoJs.DeviceId(deviceId); diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 2377b8a2177..4a0b1f895f3 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -34,7 +34,6 @@ import { KeyClaimManager } from "./KeyClaimManager"; * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto. */ export class RustCrypto implements CryptoBackend { - public globalBlacklistUnverifiedDevices = false; public globalErrorOnUnknownDevices = false; /** whether {@link stop} has been called */ @@ -81,14 +80,6 @@ export class RustCrypto implements CryptoBackend { this.olmMachine.close(); } - public prepareToEncrypt(room: Room): void { - const encryptor = this.roomEncryptors[room.roomId]; - - if (encryptor) { - encryptor.ensureEncryptionSession(); - } - } - public async encryptEvent(event: MatrixEvent, _room: Room): Promise { const roomId = event.getRoomId()!; const encryptor = this.roomEncryptors[roomId]; @@ -147,24 +138,44 @@ export class RustCrypto implements CryptoBackend { return ret as IEncryptedEventInfo; } - public async userHasCrossSigningKeys(): Promise { + public checkUserTrust(userId: string): UserTrustLevel { // TODO - return false; + return new UserTrustLevel(false, false, false); } - public async exportRoomKeys(): Promise { + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { // TODO - return []; + return new DeviceTrustLevel(false, false, false, false); } - public checkUserTrust(userId: string): UserTrustLevel { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // CryptoApi implementation + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + public globalBlacklistUnverifiedDevices = false; + + public async userHasCrossSigningKeys(): Promise { // TODO - return new UserTrustLevel(false, false, false); + return false; } - public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { + public prepareToEncrypt(room: Room): void { + const encryptor = this.roomEncryptors[room.roomId]; + + if (encryptor) { + encryptor.ensureEncryptionSession(); + } + } + + public forceDiscardSession(roomId: string): Promise { + return this.roomEncryptors[roomId]?.forceDiscardSession(); + } + + public async exportRoomKeys(): Promise { // TODO - return new DeviceTrustLevel(false, false, false, false); + return []; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -173,25 +184,62 @@ export class RustCrypto implements CryptoBackend { // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - /** called by the sync loop to preprocess incoming to-device messages - * + /** + * Apply sync changes to the olm machine * @param events - the received to-device messages + * @param oneTimeKeysCounts - the received one time key counts + * @param unusedFallbackKeys - the received unused fallback keys * @returns A list of preprocessed to-device messages. */ - public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise { - // send the received to-device messages into receiveSyncChanges. We have no info on device-list changes, - // one-time-keys, or fallback keys, so just pass empty data. + private async receiveSyncChanges({ + events, + oneTimeKeysCounts = new Map(), + unusedFallbackKeys = new Set(), + }: { + events?: IToDeviceEvent[]; + oneTimeKeysCounts?: Map; + unusedFallbackKeys?: Set; + }): Promise { const result = await this.olmMachine.receiveSyncChanges( - JSON.stringify(events), + events ? JSON.stringify(events) : "[]", new RustSdkCryptoJs.DeviceLists(), - new Map(), - new Set(), + oneTimeKeysCounts, + unusedFallbackKeys, ); // receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages. return JSON.parse(result); } + /** called by the sync loop to preprocess incoming to-device messages + * + * @param events - the received to-device messages + * @returns A list of preprocessed to-device messages. + */ + public preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise { + // send the received to-device messages into receiveSyncChanges. We have no info on device-list changes, + // one-time-keys, or fallback keys, so just pass empty data. + return this.receiveSyncChanges({ events }); + } + + /** called by the sync loop to preprocess one time key counts + * + * @param oneTimeKeysCounts - the received one time key counts + * @returns A list of preprocessed to-device messages. + */ + public async preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map): Promise { + await this.receiveSyncChanges({ oneTimeKeysCounts }); + } + + /** called by the sync loop to preprocess unused fallback keys + * + * @param unusedFallbackKeys - the received unused fallback keys + * @returns A list of preprocessed to-device messages. + */ + public async preprocessUnusedFallbackKeys(unusedFallbackKeys: Set): Promise { + await this.receiveSyncChanges({ unusedFallbackKeys }); + } + /** called by the sync loop on m.room.encrypted events * * @param room - in which the event was received @@ -204,7 +252,13 @@ export class RustCrypto implements CryptoBackend { if (existingEncryptor) { existingEncryptor.onCryptoEvent(config); } else { - this.roomEncryptors[room.roomId] = new RoomEncryptor(this.olmMachine, this.keyClaimManager, room, config); + this.roomEncryptors[room.roomId] = new RoomEncryptor( + this.olmMachine, + this.keyClaimManager, + this.outgoingRequestProcessor, + room, + config, + ); } // start tracking devices for any users already known to be in this room. diff --git a/src/secret-storage.ts b/src/secret-storage.ts new file mode 100644 index 00000000000..f0c19c44bc8 --- /dev/null +++ b/src/secret-storage.ts @@ -0,0 +1,88 @@ +/* +Copyright 2021-2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Implementation of server-side secret storage + * + * @see https://spec.matrix.org/v1.6/client-server-api/#storage + */ + +/** + * Common base interface for Secret Storage Keys. + * + * The common properties for all encryption keys used in server-side secret storage. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#key-storage + */ +export interface SecretStorageKeyDescriptionCommon { + /** A human-readable name for this key. */ + // XXX: according to the spec, this is optional + name: string; + + /** The encryption algorithm used with this key. */ + algorithm: string; + + /** Information for deriving this key from a passphrase. */ + // XXX: according to the spec, this is optional + passphrase: PassphraseInfo; +} + +/** + * Properties for a SSSS key using the `m.secret_storage.v1.aes-hmac-sha2` algorithm. + * + * Corresponds to `AesHmacSha2KeyDescription` in the specification. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#msecret_storagev1aes-hmac-sha2 + */ +export interface SecretStorageKeyDescriptionAesV1 extends SecretStorageKeyDescriptionCommon { + // XXX: strictly speaking, we should be able to enforce the algorithm here. But + // this interface ends up being incorrectly used where other algorithms are in use (notably + // in device-dehydration support), and unpicking that is too much like hard work + // at the moment. + // algorithm: "m.secret_storage.v1.aes-hmac-sha2"; + + /** The 16-byte AES initialization vector, encoded as base64. */ + iv: string; + + /** The MAC of the result of encrypting 32 bytes of 0, encoded as base64. */ + mac: string; +} + +/** + * Union type for secret storage keys. + * + * For now, this is only {@link SecretStorageKeyDescriptionAesV1}, but other interfaces may be added in future. + */ +export type SecretStorageKeyDescription = SecretStorageKeyDescriptionAesV1; + +/** + * Information on how to generate the key from a passphrase. + * + * @see https://spec.matrix.org/v1.6/client-server-api/#deriving-keys-from-passphrases + */ +export interface PassphraseInfo { + /** The algorithm to be used to derive the key. */ + algorithm: "m.pbkdf2"; + + /** The number of PBKDF2 iterations to use. */ + iterations: number; + + /** The salt to be used for PBKDF2. */ + salt: string; + + /** The number of bits to generate. Defaults to 256. */ + bits?: number; +} diff --git a/src/store/index.ts b/src/store/index.ts index 78d4fe13ce4..650dd9a2d04 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -38,9 +38,10 @@ export interface ISavedSync { export interface IStore { readonly accountData: Map; // type : content - // XXX: The indexeddb store exposes a non-standard emitter for the "degraded" event - // for when it falls back to being a memory store due to errors. - on?: (event: EventEmitterEvents | "degraded", handler: (...args: any[]) => void) => void; + // XXX: The indexeddb store exposes a non-standard emitter for: + // "degraded" event for when it falls back to being a memory store due to errors. + // "closed" event for when the database closes unexpectedly + on?: (event: EventEmitterEvents | "degraded" | "closed", handler: (...args: any[]) => void) => void; /** @returns whether or not the database was newly created in this session. */ isNewlyCreated(): Promise; diff --git a/src/store/indexeddb-backend.ts b/src/store/indexeddb-backend.ts index 5da164c555b..008867dfc33 100644 --- a/src/store/indexeddb-backend.ts +++ b/src/store/indexeddb-backend.ts @@ -19,7 +19,7 @@ import { IEvent, IStateEventWithRoomId, IStoredClientOpts, ISyncResponse } from import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage"; export interface IIndexedDBBackend { - connect(): Promise; + connect(onClose?: () => void): Promise; syncToDatabase(userTuples: UserTuple[]): Promise; isNewlyCreated(): Promise; setSyncData(syncData: ISyncResponse): Promise; diff --git a/src/store/indexeddb-local-backend.ts b/src/store/indexeddb-local-backend.ts index 7e84ad621a5..80fed44c5c0 100644 --- a/src/store/indexeddb-local-backend.ts +++ b/src/store/indexeddb-local-backend.ts @@ -130,7 +130,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { private db?: IDBDatabase; private disconnected = true; private _isNewlyCreated = false; - private isPersisting = false; + private syncToDatabasePromise?: Promise; private pendingUserPresenceData: UserTuple[] = []; /** @@ -153,7 +153,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { * grant permission. * @returns Promise which resolves if successfully connected. */ - public connect(): Promise { + public connect(onClose?: () => void): Promise { if (!this.disconnected) { logger.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`); return Promise.resolve(); @@ -188,7 +188,15 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { // add a poorly-named listener for when deleteDatabase is called // so we can close our db connections. this.db.onversionchange = (): void => { - this.db?.close(); + this.db?.close(); // this does not call onclose + this.disconnected = true; + this.db = undefined; + onClose?.(); + }; + this.db.onclose = (): void => { + this.disconnected = true; + this.db = undefined; + onClose?.(); }; await this.init(); @@ -388,26 +396,34 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend { }); } + /** + * Sync users and all accumulated sync data to the database. + * If a previous sync is in flight, the new data will be added to the + * next sync and the current sync's promise will be returned. + * @param userTuples - The user tuples + * @returns Promise which resolves if the data was persisted. + */ public async syncToDatabase(userTuples: UserTuple[]): Promise { - if (this.isPersisting) { + if (this.syncToDatabasePromise) { logger.warn("Skipping syncToDatabase() as persist already in flight"); this.pendingUserPresenceData.push(...userTuples); - return; - } else { - userTuples.unshift(...this.pendingUserPresenceData); - this.isPersisting = true; + return this.syncToDatabasePromise; } + userTuples.unshift(...this.pendingUserPresenceData); + this.syncToDatabasePromise = this.doSyncToDatabase(userTuples); + return this.syncToDatabasePromise; + } + private async doSyncToDatabase(userTuples: UserTuple[]): Promise { try { const syncData = this.syncAccumulator.getJSON(true); - await Promise.all([ this.persistUserPresenceEvents(userTuples), this.persistAccountData(syncData.accountData), this.persistSyncData(syncData.nextBatch, syncData.roomsData), ]); } finally { - this.isPersisting = false; + this.syncToDatabasePromise = undefined; } } diff --git a/src/store/indexeddb-remote-backend.ts b/src/store/indexeddb-remote-backend.ts index 7406d3a6198..7e2aa0ccbe9 100644 --- a/src/store/indexeddb-remote-backend.ts +++ b/src/store/indexeddb-remote-backend.ts @@ -30,6 +30,8 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { // Once we start connecting, we keep the promise and re-use it // if we try to connect again private startPromise?: Promise; + // Callback for when the IndexedDB gets closed unexpectedly + private onClose?(): void; /** * An IndexedDB store backend where the actual backend sits in a web @@ -48,7 +50,8 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { * grant permission. * @returns Promise which resolves if successfully connected. */ - public connect(): Promise { + public connect(onClose?: () => void): Promise { + this.onClose = onClose; return this.ensureStarted().then(() => this.doCmd("connect")); } @@ -171,7 +174,9 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend { private onWorkerMessage = (ev: MessageEvent): void => { const msg = ev.data; - if (msg.command == "cmd_success" || msg.command == "cmd_fail") { + if (msg.command == "closed") { + this.onClose?.(); + } else if (msg.command == "cmd_success" || msg.command == "cmd_fail") { if (msg.seq === undefined) { logger.error("Got reply from worker with no seq"); return; diff --git a/src/store/indexeddb-store-worker.ts b/src/store/indexeddb-store-worker.ts index df508ffa7ba..52a7fa6bf32 100644 --- a/src/store/indexeddb-store-worker.ts +++ b/src/store/indexeddb-store-worker.ts @@ -49,6 +49,12 @@ export class IndexedDBStoreWorker { */ public constructor(private readonly postMessage: InstanceType["postMessage"]) {} + private onClose = (): void => { + this.postMessage.call(null, { + command: "closed", + }); + }; + /** * Passes a message event from the main script into the class. This method * can be directly assigned to the web worker `onmessage` variable. @@ -57,7 +63,7 @@ export class IndexedDBStoreWorker { */ public onMessage = (ev: MessageEvent): void => { const msg: ICmd = ev.data; - let prom; + let prom: Promise | undefined; switch (msg.command) { case "setupWorker": @@ -67,7 +73,7 @@ export class IndexedDBStoreWorker { prom = Promise.resolve(); break; case "connect": - prom = this.backend?.connect(); + prom = this.backend?.connect(this.onClose); break; case "isNewlyCreated": prom = this.backend?.isNewlyCreated(); diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 1f0961e2bc0..cc77bf9c80f 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -51,7 +51,12 @@ interface IOpts extends IBaseOpts { } type EventHandlerMap = { + // Fired when an IDB command fails on a degradable path, and the store falls back to MemoryStore + // This signals the potential for data volatility. degraded: (e: Error) => void; + // Fired when the IndexedDB gets closed unexpectedly, for example, if the underlying storage is removed or + // if the user clears the database in the browser's history preferences. + closed: () => void; }; export class IndexedDBStore extends MemoryStore { @@ -127,7 +132,7 @@ export class IndexedDBStore extends MemoryStore { logger.log(`IndexedDBStore.startup: connecting to backend`); return this.backend - .connect() + .connect(this.onClose) .then(() => { logger.log(`IndexedDBStore.startup: loading presence events`); return this.backend.getUserPresenceEvents(); @@ -142,9 +147,14 @@ export class IndexedDBStore extends MemoryStore { this.userModifiedMap[u.userId] = u.getLastModifiedTime(); this.storeUser(u); }); + this.startedUp = true; }); } + private onClose = (): void => { + this.emitter.emit("closed"); + }; + /** * @returns Promise which resolves with a sync response to restore the * client state to where it was at the last save, or null if there diff --git a/src/sync.ts b/src/sync.ts index 3fa3616c883..dc5217c7baf 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1525,22 +1525,17 @@ export class SyncApi { } // Handle one_time_keys_count - if (this.syncOpts.crypto && data.device_one_time_keys_count) { - const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; - this.syncOpts.crypto.updateOneTimeKeyCount(currentCount); + if (data.device_one_time_keys_count) { + const map = new Map(Object.entries(data.device_one_time_keys_count)); + this.syncOpts.cryptoCallbacks?.preprocessOneTimeKeyCounts(map); } - if ( - this.syncOpts.crypto && - (data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]) - ) { + if (data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]) { // The presence of device_unused_fallback_key_types indicates that the // server supports fallback keys. If there's no unused // signed_curve25519 fallback key we need a new one. const unusedFallbackKeys = data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]; - this.syncOpts.crypto.setNeedsNewFallback( - Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), - ); + this.syncOpts.cryptoCallbacks?.preprocessUnusedFallbackKeys(new Set(unusedFallbackKeys || null)); } } diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 17318b0c205..cd75c10ab10 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -52,10 +52,11 @@ import { DeviceInfo } from "../crypto/deviceinfo"; import { GroupCallUnknownDeviceError } from "./groupCall"; import { IScreensharingOpts } from "./mediaHandler"; import { MatrixError } from "../http-api"; +import { GroupCallStats } from "./stats/groupCallStats"; interface CallOpts { // The room ID for this call. - roomId?: string; + roomId: string; invitee?: string; // The Matrix Client instance to send events to. client: MatrixClient; @@ -287,20 +288,43 @@ function getCodecParamMods(isPtt: boolean): CodecParamsMod[] { return mods; } +export interface VoipEvent { + type: "toDevice" | "sendEvent"; + eventType: string; + userId?: string; + opponentDeviceId?: string; + roomId?: string; + content: Record; +} + +/** + * These now all have the call object as an argument. Why? Well, to know which call a given event is + * about you have three options: + * 1. Use a closure as the callback that remembers what call it's listening to. This can be + * a pain because you need to pass the listener function again when you remove the listener, + * which might be somewhere else. + * 2. Use not-very-well-known fact that EventEmitter sets 'this' to the emitter object in the + * callback. This doesn't really play well with modern Typescript and eslint and doesn't work + * with our pattern of re-emitting events. + * 3. Pass the object in question as an argument to the callback. + * + * Now that we have group calls which have to deal with multiple call objects, this will + * become more important, and I think methods 1 and 2 are just going to cause issues. + */ export type CallEventHandlerMap = { - [CallEvent.DataChannel]: (channel: RTCDataChannel) => void; - [CallEvent.FeedsChanged]: (feeds: CallFeed[]) => void; - [CallEvent.Replaced]: (newCall: MatrixCall) => void; - [CallEvent.Error]: (error: CallError) => void; - [CallEvent.RemoteHoldUnhold]: (onHold: boolean) => void; - [CallEvent.LocalHoldUnhold]: (onHold: boolean) => void; - [CallEvent.LengthChanged]: (length: number) => void; - [CallEvent.State]: (state: CallState, oldState?: CallState) => void; + [CallEvent.DataChannel]: (channel: RTCDataChannel, call: MatrixCall) => void; + [CallEvent.FeedsChanged]: (feeds: CallFeed[], call: MatrixCall) => void; + [CallEvent.Replaced]: (newCall: MatrixCall, oldCall: MatrixCall) => void; + [CallEvent.Error]: (error: CallError, call: MatrixCall) => void; + [CallEvent.RemoteHoldUnhold]: (onHold: boolean, call: MatrixCall) => void; + [CallEvent.LocalHoldUnhold]: (onHold: boolean, call: MatrixCall) => void; + [CallEvent.LengthChanged]: (length: number, call: MatrixCall) => void; + [CallEvent.State]: (state: CallState, oldState: CallState, call: MatrixCall) => void; [CallEvent.Hangup]: (call: MatrixCall) => void; - [CallEvent.AssertedIdentityChanged]: () => void; + [CallEvent.AssertedIdentityChanged]: (call: MatrixCall) => void; /* @deprecated */ [CallEvent.HoldUnhold]: (onHold: boolean) => void; - [CallEvent.SendVoipEvent]: (event: Record) => void; + [CallEvent.SendVoipEvent]: (event: VoipEvent, call: MatrixCall) => void; }; // The key of the transceiver map (purpose + media type, separated by ':') @@ -314,7 +338,7 @@ function getTransceiverKey(purpose: SDPStreamMetadataPurpose, kind: TransceiverK } export class MatrixCall extends TypedEventEmitter { - public roomId?: string; + public roomId: string; public callId: string; public invitee?: string; public hangupParty?: CallParty; @@ -393,6 +417,7 @@ export class MatrixCall extends TypedEventEmittererror), + this, ); } } @@ -1400,6 +1423,7 @@ export class MatrixCall extends TypedEventEmittererror)); + this.emit(CallEvent.Error, new CallError(code, message, error), this); throw error; } @@ -1977,7 +2001,7 @@ export class MatrixCall extends TypedEventEmitter { this.makingOffer = true; try { + // XXX: in what situations do we believe gotLocalOffer actually throws? It appears + // to handle most of its exceptions itself and terminate the call. I'm not entirely + // sure it would ever throw, so I can't add a test for these lines. + // Also the tense is different between "gotLocalOffer" and "getLocalOfferFailed" so + // it's not entirely clear whether getLocalOfferFailed is just misnamed or whether + // they've been cross-polinated somehow at some point. await this.gotLocalOffer(); } catch (e) { this.getLocalOfferFailed(e as Error); @@ -2124,7 +2154,7 @@ export class MatrixCall extends TypedEventEmittererror)); + this.emit(CallEvent.Error, new CallError(code, message, error), this); this.terminate(CallParty.Local, code, false); // no need to carry on & send the candidate queue, but we also @@ -2148,7 +2178,11 @@ export class MatrixCall extends TypedEventEmitter { logger.error(`Call ${this.callId} getLocalOfferFailed() running`, err); - this.emit(CallEvent.Error, new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err)); + this.emit( + CallEvent.Error, + new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err), + this, + ); this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false); }; @@ -2167,6 +2201,7 @@ export class MatrixCall extends TypedEventEmitter { - this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000)); + this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000), this); }, CALL_LENGTH_INTERVAL); } } else if (this.peerConn?.iceConnectionState == "failed") { @@ -2257,7 +2292,7 @@ export class MatrixCall extends TypedEventEmitter { - this.emit(CallEvent.DataChannel, ev.channel); + this.emit(CallEvent.DataChannel, ev.channel, this); }; /** @@ -2370,13 +2405,17 @@ export class MatrixCall extends TypedEventEmittererror)); + this.emit(CallEvent.Error, new CallError(code, message, error), this); this.hangup(code, false); return; @@ -2760,6 +2805,7 @@ export class MatrixCall extends TypedEventEmitter, enabled: boolean): void { diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index a4113c480bc..4ee183a7f38 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -277,6 +277,11 @@ export class CallEventHandler { } call.callId = content.call_id; + const stats = groupCall?.getGroupCallStats(); + if (stats) { + call.initStats(stats); + } + try { await call.initWithInvite(event); } catch (e) { diff --git a/src/webrtc/groupCall.ts b/src/webrtc/groupCall.ts index 0504066aa85..c0896c4cd23 100644 --- a/src/webrtc/groupCall.ts +++ b/src/webrtc/groupCall.ts @@ -24,6 +24,8 @@ import { CallEventHandlerEvent } from "./callEventHandler"; import { GroupCallEventHandlerEvent } from "./groupCallEventHandler"; import { IScreensharingOpts } from "./mediaHandler"; import { mapsEqual } from "../utils"; +import { GroupCallStats } from "./stats/groupCallStats"; +import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./stats/statsReport"; export enum GroupCallIntent { Ring = "m.ring", @@ -40,6 +42,13 @@ export enum GroupCallTerminationReason { CallEnded = "call_ended", } +export type CallsByUserAndDevice = Map>; + +/** + * Because event names are just strings, they do need + * to be unique over all event types of event emitter. + * Some objects could emit more then one set of events. + */ export enum GroupCallEvent { GroupCallStateChanged = "group_call_state_changed", ActiveSpeakerChanged = "active_speaker_changed", @@ -49,13 +58,13 @@ export enum GroupCallEvent { LocalScreenshareStateChanged = "local_screenshare_state_changed", LocalMuteStateChanged = "local_mute_state_changed", ParticipantsChanged = "participants_changed", - Error = "error", + Error = "group_call_error", } export type GroupCallEventHandlerMap = { [GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void; [GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void; - [GroupCallEvent.CallsChanged]: (calls: Map>) => void; + [GroupCallEvent.CallsChanged]: (calls: CallsByUserAndDevice) => void; [GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void; [GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void; [GroupCallEvent.LocalScreenshareStateChanged]: ( @@ -83,12 +92,26 @@ export type GroupCallEventHandlerMap = { [GroupCallEvent.Error]: (error: GroupCallError) => void; }; +export enum GroupCallStatsReportEvent { + ConnectionStats = "GroupCall.connection_stats", + ByteSentStats = "GroupCall.byte_sent_stats", +} + +export type GroupCallStatsReportEventHandlerMap = { + [GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport) => void; + [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport) => void; +}; + export enum GroupCallErrorCode { NoUserMedia = "no_user_media", UnknownDevice = "unknown_device", PlaceCallFailed = "place_call_failed", } +export interface GroupCallStatsReport { + report: T; +} + export class GroupCallError extends Error { public code: string; @@ -180,8 +203,8 @@ function getCallUserId(call: MatrixCall): string | null { } export class GroupCall extends TypedEventEmitter< - GroupCallEvent | CallEvent, - GroupCallEventHandlerMap & CallEventHandlerMap + GroupCallEvent | CallEvent | GroupCallStatsReportEvent, + GroupCallEventHandlerMap & CallEventHandlerMap & GroupCallStatsReportEventHandlerMap > { // Config public activeSpeakerInterval = 1000; @@ -211,6 +234,8 @@ export class GroupCall extends TypedEventEmitter< private initWithVideoMuted = false; private initCallFeedPromise?: Promise; + private readonly stats: GroupCallStats; + public constructor( private client: MatrixClient, public room: Room, @@ -234,8 +259,23 @@ export class GroupCall extends TypedEventEmitter< this.on(GroupCallEvent.GroupCallStateChanged, this.onStateChanged); this.on(GroupCallEvent.LocalScreenshareStateChanged, this.onLocalFeedsChanged); this.allowCallWithoutVideoAndAudio = !!isCallWithoutVideoAndAudio; + + const userID = this.client.getUserId() || "unknown"; + this.stats = new GroupCallStats(this.groupCallId, userID); + this.stats.reports.on(StatsReport.CONNECTION_STATS, this.onConnectionStats); + this.stats.reports.on(StatsReport.BYTE_SENT_STATS, this.onByteSentStats); } + private onConnectionStats = (report: ConnectionStatsReport): void => { + // @TODO: Implement data argumentation + this.emit(GroupCallStatsReportEvent.ConnectionStats, { report }); + }; + + private onByteSentStats = (report: ByteSentStatsReport): void => { + // @TODO: Implement data argumentation + this.emit(GroupCallStatsReportEvent.ByteSentStats, { report }); + }; + public async create(): Promise { this.creationTs = Date.now(); this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this); @@ -351,6 +391,17 @@ export class GroupCall extends TypedEventEmitter< ); } + /** + * Determines whether the given call is one that we were expecting to exist + * given our knowledge of who is participating in the group call. + */ + private callExpected(call: MatrixCall): boolean { + const userId = getCallUserId(call); + const member = userId === null ? null : this.room.getMember(userId); + const deviceId = call.getOpponentDeviceId(); + return member !== null && deviceId !== undefined && this.participants.get(member)?.get(deviceId) !== undefined; + } + public async initLocalCallFeed(): Promise { if (this.state !== GroupCallState.LocalCallFeedUninitialized) { throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`); @@ -479,12 +530,16 @@ export class GroupCall extends TypedEventEmitter< this.retryCallLoopInterval = undefined; } + if (this.participantsExpirationTimer !== null) { + clearTimeout(this.participantsExpirationTimer); + this.participantsExpirationTimer = null; + } + if (this.state !== GroupCallState.Entered) { return; } - this.forEachCall((call) => this.disposeCall(call, CallErrorCode.UserHangup)); - this.calls.clear(); + this.forEachCall((call) => call.hangup(CallErrorCode.UserHangup, false)); this.activeSpeaker = undefined; clearInterval(this.activeSpeakerLoopInterval); @@ -493,6 +548,7 @@ export class GroupCall extends TypedEventEmitter< clearInterval(this.retryCallLoopInterval); this.client.removeListener(CallEventHandlerEvent.Incoming, this.onIncomingCall); + this.stats.stop(); } public leave(): void { @@ -630,7 +686,9 @@ export class GroupCall extends TypedEventEmitter< this.initWithAudioMuted = muted; } - this.forEachCall((call) => setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted)); + this.forEachCall((call) => + setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted && this.callExpected(call)), + ); this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted()); if (!sendUpdatesBefore) await sendUpdates(); @@ -679,6 +737,12 @@ export class GroupCall extends TypedEventEmitter< this.forEachCall((call) => updates.push(call.setLocalVideoMuted(muted))); await Promise.all(updates); + // We setTracksEnabled again, independently from the call doing it + // internally, since we might not be expecting the call + this.forEachCall((call) => + setTracksEnabled(call.localUsermediaFeed!.stream.getVideoTracks(), !muted && this.callExpected(call)), + ); + this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted); return true; @@ -811,10 +875,20 @@ export class GroupCall extends TypedEventEmitter< `GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`, ); - if (prevCall) this.disposeCall(prevCall, CallErrorCode.Replaced); + if (prevCall) prevCall.hangup(CallErrorCode.Replaced, false); this.initCall(newCall); - newCall.answerWithCallFeeds(this.getLocalFeeds().map((feed) => feed.clone())); + + const feeds = this.getLocalFeeds().map((feed) => feed.clone()); + if (!this.callExpected(newCall)) { + // Disable our tracks for users not explicitly participating in the + // call but trying to receive the feeds + for (const feed of feeds) { + setTracksEnabled(feed.stream.getAudioTracks(), false); + setTracksEnabled(feed.stream.getVideoTracks(), false); + } + } + newCall.answerWithCallFeeds(feeds); deviceMap.set(newCall.getOpponentDeviceId()!, newCall); this.calls.set(opponentUserId, deviceMap); @@ -861,7 +935,7 @@ export class GroupCall extends TypedEventEmitter< logger.debug( `GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`, ); - this.disposeCall(prevCall, CallErrorCode.NewSession); + prevCall.hangup(CallErrorCode.NewSession, false); } const newCall = createNewMatrixCall(this.client, this.room.roomId, { @@ -912,7 +986,7 @@ export class GroupCall extends TypedEventEmitter< ); } - this.disposeCall(newCall, CallErrorCode.SignallingFailed); + newCall.hangup(CallErrorCode.SignallingFailed, false); if (callMap.get(deviceId) === newCall) callMap.delete(deviceId); }); } @@ -1005,6 +1079,8 @@ export class GroupCall extends TypedEventEmitter< this.reEmitter.reEmit(call, Object.values(CallEvent)); + call.initStats(this.stats); + onCallFeedsChanged(); } @@ -1032,10 +1108,6 @@ export class GroupCall extends TypedEventEmitter< return; } - if (call.state !== CallState.Ended) { - call.hangup(hangupReason, false); - } - const usermediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId); if (usermediaFeed) { @@ -1087,6 +1159,8 @@ export class GroupCall extends TypedEventEmitter< }; private onCallStateChanged = (call: MatrixCall, state: CallState, _oldState: CallState | undefined): void => { + if (state === CallState.Ended) return; + const audioMuted = this.localCallFeed!.isAudioMuted(); if (call.localUsermediaStream && call.isMicrophoneMuted() !== audioMuted) { @@ -1131,7 +1205,7 @@ export class GroupCall extends TypedEventEmitter< this.calls.set(opponentUserId, deviceMap); } - this.disposeCall(prevCall, CallErrorCode.Replaced); + prevCall.hangup(CallErrorCode.Replaced, false); this.initCall(newCall); deviceMap.set(prevCall.getOpponentDeviceId()!, newCall); this.emit(GroupCallEvent.CallsChanged, this.calls); @@ -1476,6 +1550,17 @@ export class GroupCall extends TypedEventEmitter< private onRoomState = (): void => this.updateParticipants(); private onParticipantsChanged = (): void => { + // Re-run setTracksEnabled on all calls, so that participants that just + // left get denied access to our media, and participants that just + // joined get granted access + this.forEachCall((call) => { + const expected = this.callExpected(call); + for (const feed of call.getLocalFeeds()) { + setTracksEnabled(feed.stream.getAudioTracks(), !feed.isAudioMuted() && expected); + setTracksEnabled(feed.stream.getVideoTracks(), !feed.isVideoMuted() && expected); + } + }); + if (this.state === GroupCallState.Entered) this.placeOutgoingCalls(); }; @@ -1506,4 +1591,8 @@ export class GroupCall extends TypedEventEmitter< ); } }; + + public getGroupCallStats(): GroupCallStats { + return this.stats; + } } diff --git a/src/webrtc/groupCallEventHandler.ts b/src/webrtc/groupCallEventHandler.ts index 7be97771447..08487bdd234 100644 --- a/src/webrtc/groupCallEventHandler.ts +++ b/src/webrtc/groupCallEventHandler.ts @@ -118,7 +118,7 @@ export class GroupCallEventHandler { for (const callEvent of sortedCallEvents) { const content = callEvent.getContent(); - if (content["m.terminated"]) { + if (content["m.terminated"] || callEvent.isRedacted()) { continue; } @@ -210,10 +210,10 @@ export class GroupCallEventHandler { const currentGroupCall = this.groupCalls.get(state.roomId); - if (!currentGroupCall && !content["m.terminated"]) { + if (!currentGroupCall && !content["m.terminated"] && !event.isRedacted()) { this.createGroupCallFromRoomStateEvent(event); } else if (currentGroupCall && currentGroupCall.groupCallId === groupCallId) { - if (content["m.terminated"]) { + if (content["m.terminated"] || event.isRedacted()) { currentGroupCall.terminate(false); } else if (content["m.type"] !== currentGroupCall.type) { // TODO: Handle the callType changing when the room state changes diff --git a/src/webrtc/stats/connectionStats.ts b/src/webrtc/stats/connectionStats.ts new file mode 100644 index 00000000000..dbde6e50327 --- /dev/null +++ b/src/webrtc/stats/connectionStats.ts @@ -0,0 +1,47 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TransportStats } from "./transportStats"; +import { Bitrate } from "./media/mediaTrackStats"; + +export interface ConnectionStatsBandwidth { + /** + * bytes per second + */ + download: number; + /** + * bytes per second + */ + upload: number; +} + +export interface ConnectionStatsBitrate extends Bitrate { + audio?: Bitrate; + video?: Bitrate; +} + +export interface PacketLoos { + total: number; + download: number; + upload: number; +} + +export class ConnectionStats { + public bandwidth: ConnectionStatsBitrate = {} as ConnectionStatsBitrate; + public bitrate: ConnectionStatsBitrate = {} as ConnectionStatsBitrate; + public packetLoss: PacketLoos = {} as PacketLoos; + public transport: TransportStats[] = []; +} diff --git a/src/webrtc/stats/connectionStatsReporter.ts b/src/webrtc/stats/connectionStatsReporter.ts new file mode 100644 index 00000000000..c43b9b40c19 --- /dev/null +++ b/src/webrtc/stats/connectionStatsReporter.ts @@ -0,0 +1,28 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { Bitrate } from "./media/mediaTrackStats"; + +export class ConnectionStatsReporter { + public static buildBandwidthReport(now: RTCIceCandidatePairStats): Bitrate { + const availableIncomingBitrate = now.availableIncomingBitrate; + const availableOutgoingBitrate = now.availableOutgoingBitrate; + + return { + download: availableIncomingBitrate ? Math.round(availableIncomingBitrate / 1000) : 0, + upload: availableOutgoingBitrate ? Math.round(availableOutgoingBitrate / 1000) : 0, + }; + } +} diff --git a/src/webrtc/stats/groupCallStats.ts b/src/webrtc/stats/groupCallStats.ts new file mode 100644 index 00000000000..6d8c566aac4 --- /dev/null +++ b/src/webrtc/stats/groupCallStats.ts @@ -0,0 +1,64 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { StatsReportGatherer } from "./statsReportGatherer"; +import { StatsReportEmitter } from "./statsReportEmitter"; + +export class GroupCallStats { + private timer: undefined | ReturnType; + private readonly gatherers: Map = new Map(); + public readonly reports = new StatsReportEmitter(); + + public constructor(private groupCallId: string, private userId: string, private interval: number = 10000) {} + + public start(): void { + if (this.timer === undefined) { + this.timer = setInterval(() => { + this.processStats(); + }, this.interval); + } + } + + public stop(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.gatherers.forEach((c) => c.stopProcessingStats()); + } + } + + public hasStatsReportGatherer(callId: string): boolean { + return this.gatherers.has(callId); + } + + public addStatsReportGatherer(callId: string, userId: string, peerConnection: RTCPeerConnection): boolean { + if (this.hasStatsReportGatherer(callId)) { + return false; + } + this.gatherers.set(callId, new StatsReportGatherer(callId, userId, peerConnection, this.reports)); + return true; + } + + public removeStatsReportGatherer(callId: string): boolean { + return this.gatherers.delete(callId); + } + + public getStatsReportGatherer(callId: string): StatsReportGatherer | undefined { + return this.hasStatsReportGatherer(callId) ? this.gatherers.get(callId) : undefined; + } + + private processStats(): void { + this.gatherers.forEach((c) => c.processStats(this.groupCallId, this.userId)); + } +} diff --git a/src/webrtc/stats/media/mediaSsrcHandler.ts b/src/webrtc/stats/media/mediaSsrcHandler.ts new file mode 100644 index 00000000000..e60605152c9 --- /dev/null +++ b/src/webrtc/stats/media/mediaSsrcHandler.ts @@ -0,0 +1,57 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { parse as parseSdp } from "sdp-transform"; + +export type Mid = string; +export type Ssrc = string; +export type MapType = "local" | "remote"; + +export class MediaSsrcHandler { + private readonly ssrcToMid = { local: new Map(), remote: new Map() }; + + public findMidBySsrc(ssrc: Ssrc, type: "local" | "remote"): Mid | undefined { + let mid: Mid | undefined; + this.ssrcToMid[type].forEach((ssrcs, m) => { + if (ssrcs.find((s) => s == ssrc)) { + mid = m; + return; + } + }); + return mid; + } + + public parse(description: string, type: MapType): void { + const sdp = parseSdp(description); + const ssrcToMid = new Map(); + sdp.media.forEach((m) => { + if ((!!m.mid && m.type === "video") || m.type === "audio") { + const ssrcs: Ssrc[] = []; + m.ssrcs?.forEach((ssrc) => { + if (ssrc.attribute === "cname") { + ssrcs.push(`${ssrc.id}`); + } + }); + ssrcToMid.set(`${m.mid}`, ssrcs); + } + }); + this.ssrcToMid[type] = ssrcToMid; + } + + public getSsrcToMidMap(type: MapType): Map { + return this.ssrcToMid[type]; + } +} diff --git a/src/webrtc/stats/media/mediaTrackHandler.ts b/src/webrtc/stats/media/mediaTrackHandler.ts new file mode 100644 index 00000000000..32580b1228a --- /dev/null +++ b/src/webrtc/stats/media/mediaTrackHandler.ts @@ -0,0 +1,71 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export type TrackId = string; + +export class MediaTrackHandler { + public constructor(private readonly pc: RTCPeerConnection) {} + + public getLocalTracks(kind: "audio" | "video"): MediaStreamTrack[] { + const isNotNullAndKind = (track: MediaStreamTrack | null): boolean => { + return track !== null && track.kind === kind; + }; + // @ts-ignore The linter don't get it + return this.pc + .getTransceivers() + .filter((t) => t.currentDirection === "sendonly" || t.currentDirection === "sendrecv") + .filter((t) => t.sender !== null) + .map((t) => t.sender) + .map((s) => s.track) + .filter(isNotNullAndKind); + } + + public getTackById(trackId: string): MediaStreamTrack | undefined { + return this.pc + .getTransceivers() + .map((t) => { + if (t?.sender.track !== null && t.sender.track.id === trackId) { + return t.sender.track; + } + if (t?.receiver.track !== null && t.receiver.track.id === trackId) { + return t.receiver.track; + } + return undefined; + }) + .find((t) => t !== undefined); + } + + public getLocalTrackIdByMid(mid: string): string | undefined { + const transceiver = this.pc.getTransceivers().find((t) => t.mid === mid); + if (transceiver !== undefined && !!transceiver.sender && !!transceiver.sender.track) { + return transceiver.sender.track.id; + } + return undefined; + } + + public getRemoteTrackIdByMid(mid: string): string | undefined { + const transceiver = this.pc.getTransceivers().find((t) => t.mid === mid); + if (transceiver !== undefined && !!transceiver.receiver && !!transceiver.receiver.track) { + return transceiver.receiver.track.id; + } + return undefined; + } + + public getActiveSimulcastStreams(): number { + //@TODO implement this right.. Check how many layer configured + return 3; + } +} diff --git a/src/webrtc/stats/media/mediaTrackStats.ts b/src/webrtc/stats/media/mediaTrackStats.ts new file mode 100644 index 00000000000..69ee9bdfadf --- /dev/null +++ b/src/webrtc/stats/media/mediaTrackStats.ts @@ -0,0 +1,104 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TrackId } from "./mediaTrackHandler"; + +export interface PacketLoss { + packetsTotal: number; + packetsLost: number; + isDownloadStream: boolean; +} + +export interface Bitrate { + /** + * bytes per second + */ + download: number; + /** + * bytes per second + */ + upload: number; +} + +export interface Resolution { + width: number; + height: number; +} + +export type TrackStatsType = "local" | "remote"; + +export class MediaTrackStats { + private loss: PacketLoss = { packetsTotal: 0, packetsLost: 0, isDownloadStream: false }; + private bitrate: Bitrate = { download: 0, upload: 0 }; + private resolution: Resolution = { width: -1, height: -1 }; + private framerate = 0; + private codec = ""; + + public constructor( + public readonly trackId: TrackId, + public readonly type: TrackStatsType, + public readonly kind: "audio" | "video", + ) {} + + public getType(): TrackStatsType { + return this.type; + } + + public setLoss(loos: PacketLoss): void { + this.loss = loos; + } + + public getLoss(): PacketLoss { + return this.loss; + } + + public setResolution(resolution: Resolution): void { + this.resolution = resolution; + } + + public getResolution(): Resolution { + return this.resolution; + } + + public setFramerate(framerate: number): void { + this.framerate = framerate; + } + + public getFramerate(): number { + return this.framerate; + } + + public setBitrate(bitrate: Bitrate): void { + this.bitrate = bitrate; + } + + public getBitrate(): Bitrate { + return this.bitrate; + } + + public setCodec(codecShortType: string): boolean { + this.codec = codecShortType; + return true; + } + + public getCodec(): string { + return this.codec; + } + + public resetBitrate(): void { + this.bitrate = { download: 0, upload: 0 }; + } +} diff --git a/src/webrtc/stats/media/mediaTrackStatsHandler.ts b/src/webrtc/stats/media/mediaTrackStatsHandler.ts new file mode 100644 index 00000000000..6fb119c8a75 --- /dev/null +++ b/src/webrtc/stats/media/mediaTrackStatsHandler.ts @@ -0,0 +1,86 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { TrackID } from "../statsReport"; +import { MediaTrackStats } from "./mediaTrackStats"; +import { MediaTrackHandler } from "./mediaTrackHandler"; +import { MediaSsrcHandler } from "./mediaSsrcHandler"; + +export class MediaTrackStatsHandler { + private readonly track2stats = new Map(); + + public constructor( + public readonly mediaSsrcHandler: MediaSsrcHandler, + public readonly mediaTrackHandler: MediaTrackHandler, + ) {} + + /** + * Find tracks by rtc stats + * Argument report is any because the stats api is not consistent: + * For example `trackIdentifier`, `mid` not existing in every implementations + * https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats + * https://developer.mozilla.org/en-US/docs/Web/API/RTCInboundRtpStreamStats + */ + public findTrack2Stats(report: any, type: "remote" | "local"): MediaTrackStats | undefined { + let trackID; + if (report.trackIdentifier) { + trackID = report.trackIdentifier; + } else if (report.mid) { + trackID = + type === "remote" + ? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid) + : this.mediaTrackHandler.getLocalTrackIdByMid(report.mid); + } else if (report.ssrc) { + const mid = this.mediaSsrcHandler.findMidBySsrc(report.ssrc, type); + if (!mid) { + return undefined; + } + trackID = + type === "remote" + ? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid) + : this.mediaTrackHandler.getLocalTrackIdByMid(report.mid); + } + + if (!trackID) { + return undefined; + } + + let trackStats = this.track2stats.get(trackID); + + if (!trackStats) { + const track = this.mediaTrackHandler.getTackById(trackID); + if (track !== undefined) { + const kind: "audio" | "video" = track.kind === "audio" ? track.kind : "video"; + trackStats = new MediaTrackStats(trackID, type, kind); + this.track2stats.set(trackID, trackStats); + } else { + return undefined; + } + } + return trackStats; + } + + public findLocalVideoTrackStats(report: any): MediaTrackStats | undefined { + const localVideoTracks = this.mediaTrackHandler.getLocalTracks("video"); + if (localVideoTracks.length === 0) { + return undefined; + } + return this.findTrack2Stats(report, "local"); + } + + public getTrack2stats(): Map { + return this.track2stats; + } +} diff --git a/src/webrtc/stats/statsReport.ts b/src/webrtc/stats/statsReport.ts new file mode 100644 index 00000000000..56d6c4b2e48 --- /dev/null +++ b/src/webrtc/stats/statsReport.ts @@ -0,0 +1,56 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ConnectionStatsBandwidth, ConnectionStatsBitrate, PacketLoos } from "./connectionStats"; +import { TransportStats } from "./transportStats"; +import { Resolution } from "./media/mediaTrackStats"; + +export enum StatsReport { + CONNECTION_STATS = "StatsReport.connection_stats", + BYTE_SENT_STATS = "StatsReport.byte_sent_stats", +} + +export type TrackID = string; +export type ByteSend = number; + +export interface ByteSentStatsReport extends Map { + // is a map: `local trackID` => byte send +} + +export interface ConnectionStatsReport { + bandwidth: ConnectionStatsBandwidth; + bitrate: ConnectionStatsBitrate; + packetLoss: PacketLoos; + resolution: ResolutionMap; + framerate: FramerateMap; + codec: CodecMap; + transport: TransportStats[]; +} + +export interface ResolutionMap { + local: Map; + remote: Map; +} + +export interface FramerateMap { + local: Map; + remote: Map; +} + +export interface CodecMap { + local: Map; + remote: Map; +} diff --git a/src/webrtc/stats/statsReportBuilder.ts b/src/webrtc/stats/statsReportBuilder.ts new file mode 100644 index 00000000000..c1af471ce30 --- /dev/null +++ b/src/webrtc/stats/statsReportBuilder.ts @@ -0,0 +1,110 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { CodecMap, ConnectionStatsReport, FramerateMap, ResolutionMap, TrackID } from "./statsReport"; +import { MediaTrackStats, Resolution } from "./media/mediaTrackStats"; + +export class StatsReportBuilder { + public static build(stats: Map): ConnectionStatsReport { + const report = {} as ConnectionStatsReport; + + // process stats + const totalPackets = { + download: 0, + upload: 0, + }; + const lostPackets = { + download: 0, + upload: 0, + }; + let bitrateDownload = 0; + let bitrateUpload = 0; + const resolutions: ResolutionMap = { + local: new Map(), + remote: new Map(), + }; + const framerates: FramerateMap = { local: new Map(), remote: new Map() }; + const codecs: CodecMap = { local: new Map(), remote: new Map() }; + + let audioBitrateDownload = 0; + let audioBitrateUpload = 0; + let videoBitrateDownload = 0; + let videoBitrateUpload = 0; + + for (const [trackId, trackStats] of stats) { + // process packet loss stats + const loss = trackStats.getLoss(); + const type = loss.isDownloadStream ? "download" : "upload"; + + totalPackets[type] += loss.packetsTotal; + lostPackets[type] += loss.packetsLost; + + // process bitrate stats + bitrateDownload += trackStats.getBitrate().download; + bitrateUpload += trackStats.getBitrate().upload; + + // collect resolutions and framerates + if (trackStats.kind === "audio") { + audioBitrateDownload += trackStats.getBitrate().download; + audioBitrateUpload += trackStats.getBitrate().upload; + } else { + videoBitrateDownload += trackStats.getBitrate().download; + videoBitrateUpload += trackStats.getBitrate().upload; + } + + resolutions[trackStats.getType()].set(trackId, trackStats.getResolution()); + framerates[trackStats.getType()].set(trackId, trackStats.getFramerate()); + codecs[trackStats.getType()].set(trackId, trackStats.getCodec()); + + trackStats.resetBitrate(); + } + + report.bitrate = { + upload: bitrateUpload, + download: bitrateDownload, + }; + + report.bitrate.audio = { + upload: audioBitrateUpload, + download: audioBitrateDownload, + }; + + report.bitrate.video = { + upload: videoBitrateUpload, + download: videoBitrateDownload, + }; + + report.packetLoss = { + total: StatsReportBuilder.calculatePacketLoss( + lostPackets.download + lostPackets.upload, + totalPackets.download + totalPackets.upload, + ), + download: StatsReportBuilder.calculatePacketLoss(lostPackets.download, totalPackets.download), + upload: StatsReportBuilder.calculatePacketLoss(lostPackets.upload, totalPackets.upload), + }; + report.framerate = framerates; + report.resolution = resolutions; + report.codec = codecs; + return report; + } + + private static calculatePacketLoss(lostPackets: number, totalPackets: number): number { + if (!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0) { + return 0; + } + + return Math.round((lostPackets / totalPackets) * 100); + } +} diff --git a/src/webrtc/stats/statsReportEmitter.ts b/src/webrtc/stats/statsReportEmitter.ts new file mode 100644 index 00000000000..cf014708e89 --- /dev/null +++ b/src/webrtc/stats/statsReportEmitter.ts @@ -0,0 +1,33 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TypedEventEmitter } from "../../models/typed-event-emitter"; +import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./statsReport"; + +export type StatsReportHandlerMap = { + [StatsReport.BYTE_SENT_STATS]: (report: ByteSentStatsReport) => void; + [StatsReport.CONNECTION_STATS]: (report: ConnectionStatsReport) => void; +}; + +export class StatsReportEmitter extends TypedEventEmitter { + public emitByteSendReport(byteSentStats: ByteSentStatsReport): void { + this.emit(StatsReport.BYTE_SENT_STATS, byteSentStats); + } + + public emitConnectionStatsReport(report: ConnectionStatsReport): void { + this.emit(StatsReport.CONNECTION_STATS, report); + } +} diff --git a/src/webrtc/stats/statsReportGatherer.ts b/src/webrtc/stats/statsReportGatherer.ts new file mode 100644 index 00000000000..769ba6e447b --- /dev/null +++ b/src/webrtc/stats/statsReportGatherer.ts @@ -0,0 +1,183 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ConnectionStats } from "./connectionStats"; +import { StatsReportEmitter } from "./statsReportEmitter"; +import { ByteSend, ByteSentStatsReport, TrackID } from "./statsReport"; +import { ConnectionStatsReporter } from "./connectionStatsReporter"; +import { TransportStatsReporter } from "./transportStatsReporter"; +import { MediaSsrcHandler } from "./media/mediaSsrcHandler"; +import { MediaTrackHandler } from "./media/mediaTrackHandler"; +import { MediaTrackStatsHandler } from "./media/mediaTrackStatsHandler"; +import { TrackStatsReporter } from "./trackStatsReporter"; +import { StatsReportBuilder } from "./statsReportBuilder"; +import { StatsValueFormatter } from "./statsValueFormatter"; + +export class StatsReportGatherer { + private isActive = true; + private previousStatsReport: RTCStatsReport | undefined; + private currentStatsReport: RTCStatsReport | undefined; + private readonly connectionStats = new ConnectionStats(); + + private readonly trackStats: MediaTrackStatsHandler; + + // private readonly ssrcToMid = { local: new Map(), remote: new Map() }; + + public constructor( + public readonly callId: string, + public readonly remoteUserId: string, + private readonly pc: RTCPeerConnection, + private readonly emitter: StatsReportEmitter, + private readonly isFocus = true, + ) { + pc.addEventListener("signalingstatechange", this.onSignalStateChange.bind(this)); + this.trackStats = new MediaTrackStatsHandler(new MediaSsrcHandler(), new MediaTrackHandler(pc)); + } + + public async processStats(groupCallId: string, localUserId: string): Promise { + if (this.isActive) { + const statsPromise = this.pc.getStats(); + if (typeof statsPromise?.then === "function") { + return statsPromise + .then((report) => { + // @ts-ignore + this.currentStatsReport = typeof report?.result === "function" ? report.result() : report; + try { + this.processStatsReport(groupCallId, localUserId); + } catch (error) { + this.isActive = false; + return false; + } + + this.previousStatsReport = this.currentStatsReport; + return true; + }) + .catch((error) => { + this.handleError(error); + return false; + }); + } + this.isActive = false; + } + return Promise.resolve(false); + } + + private processStatsReport(groupCallId: string, localUserId: string): void { + const byteSentStats: ByteSentStatsReport = new Map(); + + this.currentStatsReport?.forEach((now) => { + const before = this.previousStatsReport ? this.previousStatsReport.get(now.id) : null; + // RTCIceCandidatePairStats - https://w3c.github.io/webrtc-stats/#candidatepair-dict* + if (now.type === "candidate-pair" && now.nominated && now.state === "succeeded") { + this.connectionStats.bandwidth = ConnectionStatsReporter.buildBandwidthReport(now); + this.connectionStats.transport = TransportStatsReporter.buildReport( + this.currentStatsReport, + now, + this.connectionStats.transport, + this.isFocus, + ); + + // RTCReceivedRtpStreamStats + // https://w3c.github.io/webrtc-stats/#receivedrtpstats-dict* + // RTCSentRtpStreamStats + // https://w3c.github.io/webrtc-stats/#sentrtpstats-dict* + } else if (now.type === "inbound-rtp" || now.type === "outbound-rtp") { + const trackStats = this.trackStats.findTrack2Stats( + now, + now.type === "inbound-rtp" ? "remote" : "local", + ); + if (!trackStats) { + return; + } + + if (before) { + TrackStatsReporter.buildPacketsLost(trackStats, now, before); + } + + // Get the resolution and framerate for only remote video sources here. For the local video sources, + // 'track' stats will be used since they have the updated resolution based on the simulcast streams + // currently being sent. Promise based getStats reports three 'outbound-rtp' streams and there will be + // more calculations needed to determine what is the highest resolution stream sent by the client if the + // 'outbound-rtp' stats are used. + if (now.type === "inbound-rtp") { + TrackStatsReporter.buildFramerateResolution(trackStats, now); + if (before) { + TrackStatsReporter.buildBitrateReceived(trackStats, now, before); + } + } else if (before) { + byteSentStats.set(trackStats.trackId, StatsValueFormatter.getNonNegativeValue(now.bytesSent)); + TrackStatsReporter.buildBitrateSend(trackStats, now, before); + } + TrackStatsReporter.buildCodec(this.currentStatsReport, trackStats, now); + } else if (now.type === "track" && now.kind === "video" && !now.remoteSource) { + const trackStats = this.trackStats.findLocalVideoTrackStats(now); + if (!trackStats) { + return; + } + TrackStatsReporter.buildFramerateResolution(trackStats, now); + TrackStatsReporter.calculateSimulcastFramerate( + trackStats, + now, + before, + this.trackStats.mediaTrackHandler.getActiveSimulcastStreams(), + ); + } + }); + + this.emitter.emitByteSendReport(byteSentStats); + this.processAndEmitReport(); + } + + public setActive(isActive: boolean): void { + this.isActive = isActive; + } + + public getActive(): boolean { + return this.isActive; + } + + private handleError(_: any): void { + this.isActive = false; + } + + private processAndEmitReport(): void { + const report = StatsReportBuilder.build(this.trackStats.getTrack2stats()); + + this.connectionStats.bandwidth = report.bandwidth; + this.connectionStats.bitrate = report.bitrate; + this.connectionStats.packetLoss = report.packetLoss; + + this.emitter.emitConnectionStatsReport({ + ...report, + transport: this.connectionStats.transport, + }); + + this.connectionStats.transport = []; + } + + public stopProcessingStats(): void {} + + private onSignalStateChange(): void { + if (this.pc.signalingState === "stable") { + if (this.pc.currentRemoteDescription) { + this.trackStats.mediaSsrcHandler.parse(this.pc.currentRemoteDescription.sdp, "remote"); + } + if (this.pc.currentLocalDescription) { + this.trackStats.mediaSsrcHandler.parse(this.pc.currentLocalDescription.sdp, "local"); + } + } + } +} diff --git a/src/webrtc/stats/statsValueFormatter.ts b/src/webrtc/stats/statsValueFormatter.ts new file mode 100644 index 00000000000..c658fa66504 --- /dev/null +++ b/src/webrtc/stats/statsValueFormatter.ts @@ -0,0 +1,27 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +export class StatsValueFormatter { + public static getNonNegativeValue(imput: any): number { + let value = imput; + + if (typeof value !== "number") { + value = Number(value); + } + + if (isNaN(value)) { + return 0; + } + + return Math.max(0, value); + } +} diff --git a/src/webrtc/stats/trackStatsReporter.ts b/src/webrtc/stats/trackStatsReporter.ts new file mode 100644 index 00000000000..1f6fcd6d1ce --- /dev/null +++ b/src/webrtc/stats/trackStatsReporter.ts @@ -0,0 +1,117 @@ +import { MediaTrackStats } from "./media/mediaTrackStats"; +import { StatsValueFormatter } from "./statsValueFormatter"; + +export class TrackStatsReporter { + public static buildFramerateResolution(trackStats: MediaTrackStats, now: any): void { + const resolution = { + height: now.frameHeight, + width: now.frameWidth, + }; + const frameRate = now.framesPerSecond; + + if (resolution.height && resolution.width) { + trackStats.setResolution(resolution); + } + trackStats.setFramerate(Math.round(frameRate || 0)); + } + + public static calculateSimulcastFramerate(trackStats: MediaTrackStats, now: any, before: any, layer: number): void { + let frameRate = trackStats.getFramerate(); + if (!frameRate) { + if (before) { + const timeMs = now.timestamp - before.timestamp; + + if (timeMs > 0 && now.framesSent) { + const numberOfFramesSinceBefore = now.framesSent - before.framesSent; + + frameRate = (numberOfFramesSinceBefore / timeMs) * 1000; + } + } + + if (!frameRate) { + return; + } + } + + // Reset frame rate to 0 when video is suspended as a result of endpoint falling out of last-n. + frameRate = layer ? Math.round(frameRate / layer) : 0; + trackStats.setFramerate(frameRate); + } + + public static buildCodec(report: RTCStatsReport | undefined, trackStats: MediaTrackStats, now: any): void { + const codec = report?.get(now.codecId); + + if (codec) { + /** + * The mime type has the following form: video/VP8 or audio/ISAC, + * so we what to keep just the type after the '/', audio and video + * keys will be added on the processing side. + */ + const codecShortType = codec.mimeType.split("/")[1]; + + codecShortType && trackStats.setCodec(codecShortType); + } + } + + public static buildBitrateReceived(trackStats: MediaTrackStats, now: any, before: any): void { + trackStats.setBitrate({ + download: TrackStatsReporter.calculateBitrate( + now.bytesReceived, + before.bytesReceived, + now.timestamp, + before.timestamp, + ), + upload: 0, + }); + } + + public static buildBitrateSend(trackStats: MediaTrackStats, now: any, before: any): void { + trackStats.setBitrate({ + download: 0, + upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp), + }); + } + + public static buildPacketsLost(trackStats: MediaTrackStats, now: any, before: any): void { + const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived"; + + let packetsNow = now[key]; + if (!packetsNow || packetsNow < 0) { + packetsNow = 0; + } + + const packetsBefore = StatsValueFormatter.getNonNegativeValue(before[key]); + const packetsDiff = Math.max(0, packetsNow - packetsBefore); + + const packetsLostNow = StatsValueFormatter.getNonNegativeValue(now.packetsLost); + const packetsLostBefore = StatsValueFormatter.getNonNegativeValue(before.packetsLost); + const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore); + + trackStats.setLoss({ + packetsTotal: packetsDiff + packetsLostDiff, + packetsLost: packetsLostDiff, + isDownloadStream: now.type !== "outbound-rtp", + }); + } + + private static calculateBitrate( + bytesNowAny: any, + bytesBeforeAny: any, + nowTimestamp: number, + beforeTimestamp: number, + ): number { + const bytesNow = StatsValueFormatter.getNonNegativeValue(bytesNowAny); + const bytesBefore = StatsValueFormatter.getNonNegativeValue(bytesBeforeAny); + const bytesProcessed = Math.max(0, bytesNow - bytesBefore); + + const timeMs = nowTimestamp - beforeTimestamp; + let bitrateKbps = 0; + + if (timeMs > 0) { + // TODO is there any reason to round here? + bitrateKbps = Math.round((bytesProcessed * 8) / timeMs); + } + + return bitrateKbps; + } +} diff --git a/src/webrtc/stats/transportStats.ts b/src/webrtc/stats/transportStats.ts new file mode 100644 index 00000000000..2b6e975484f --- /dev/null +++ b/src/webrtc/stats/transportStats.ts @@ -0,0 +1,26 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface TransportStats { + ip: string; + type: string; + localIp: string; + isFocus: boolean; + localCandidateType: string; + remoteCandidateType: string; + networkType: string; + rtt: number; +} diff --git a/src/webrtc/stats/transportStatsReporter.ts b/src/webrtc/stats/transportStatsReporter.ts new file mode 100644 index 00000000000..d419a73972b --- /dev/null +++ b/src/webrtc/stats/transportStatsReporter.ts @@ -0,0 +1,48 @@ +import { TransportStats } from "./transportStats"; + +export class TransportStatsReporter { + public static buildReport( + report: RTCStatsReport | undefined, + now: RTCIceCandidatePairStats, + conferenceStatsTransport: TransportStats[], + isFocus: boolean, + ): TransportStats[] { + const localUsedCandidate = report?.get(now.localCandidateId); + const remoteUsedCandidate = report?.get(now.remoteCandidateId); + + // RTCIceCandidateStats + // https://w3c.github.io/webrtc-stats/#icecandidate-dict* + if (remoteUsedCandidate && localUsedCandidate) { + const remoteIpAddress = + remoteUsedCandidate.ip !== undefined ? remoteUsedCandidate.ip : remoteUsedCandidate.address; + const remotePort = remoteUsedCandidate.port; + const ip = `${remoteIpAddress}:${remotePort}`; + + const localIpAddress = + localUsedCandidate.ip !== undefined ? localUsedCandidate.ip : localUsedCandidate.address; + const localPort = localUsedCandidate.port; + const localIp = `${localIpAddress}:${localPort}`; + + const type = remoteUsedCandidate.protocol; + + // Save the address unless it has been saved already. + if ( + !conferenceStatsTransport.some( + (t: TransportStats) => t.ip === ip && t.type === type && t.localIp === localIp, + ) + ) { + conferenceStatsTransport.push({ + ip, + type, + localIp, + isFocus, + localCandidateType: localUsedCandidate.candidateType, + remoteCandidateType: remoteUsedCandidate.candidateType, + networkType: localUsedCandidate.networkType, + rtt: now.currentRoundTripTime ? now.currentRoundTripTime * 1000 : NaN, + } as TransportStats); + } + } + return conferenceStatsTransport; + } +} diff --git a/tsconfig.json b/tsconfig.json index 839dba04aab..13fed86c47b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ }, "include": ["./src/**/*.ts", "./spec/**/*.ts"], "typedocOptions": { - "entryPoints": ["src/index.ts"], + "entryPoints": ["src/matrix.ts"], "excludeExternals": true, "out": "_docs" } diff --git a/yarn.lock b/yarn.lock index a1a0cb9b802..19dfc1b72aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -68,7 +68,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.10.tgz#9d92fa81b87542fff50e848ed585b4212c1d34ec" integrity sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg== -"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.5": +"@babel/core@^7.0.0", "@babel/core@^7.12.10", "@babel/core@^7.7.5": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.0.tgz#1341aefdcc14ccc7553fcc688dd8986a2daffc13" integrity sha512-PuxUbxcW6ZYe656yL3EAhpy7qXKq0DmYsrJLpbB8XrsCP9Nm+XCg9XFMb5vIDliPD7+U/+M+QJlH17XOcB7eXA== @@ -89,6 +89,27 @@ json5 "^2.2.2" semver "^6.3.0" +"@babel/core@^7.11.6", "@babel/core@^7.12.3": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.3.tgz#cf1c877284a469da5d1ce1d1e53665253fae712e" + integrity sha512-qIJONzoa/qiHghnm0l1n4i/6IIziDpzqc36FBs4pzMhDUraHqponwJLiAKm1hGLP3OSB/TVNz6rMwVGpwxxySw== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.21.3" + "@babel/helper-compilation-targets" "^7.20.7" + "@babel/helper-module-transforms" "^7.21.2" + "@babel/helpers" "^7.21.0" + "@babel/parser" "^7.21.3" + "@babel/template" "^7.20.7" + "@babel/traverse" "^7.21.3" + "@babel/types" "^7.21.3" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.2" + semver "^6.3.0" + "@babel/eslint-parser@^7.12.10": version "7.19.1" resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz#4f68f6b0825489e00a24b41b6a1ae35414ecd2f4" @@ -114,7 +135,7 @@ "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" -"@babel/generator@^7.20.7", "@babel/generator@^7.21.0", "@babel/generator@^7.7.2": +"@babel/generator@^7.20.7": version "7.21.1" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.1.tgz#951cc626057bc0af2c35cd23e9c64d384dea83dd" integrity sha512-1lT45bAYlQhFn/BHivJs43AiW2rg3/UbLyShGfF3C0KmHvO5fSghWd5kBJy30kpRRucGzXStvnnCFniCR2kXAA== @@ -124,6 +145,16 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" +"@babel/generator@^7.21.0", "@babel/generator@^7.21.3", "@babel/generator@^7.7.2": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.3.tgz#232359d0874b392df04045d72ce2fd9bb5045fce" + integrity sha512-QS3iR1GYC/YGUnW7IdggFeN5c1poPUurnGttOV/bZgPGV+izC/D8HnD6DLwod0fsatNyVn1G3EVWMYIF0nHbeA== + dependencies: + "@babel/types" "^7.21.3" + "@jridgewell/gen-mapping" "^0.3.2" + "@jridgewell/trace-mapping" "^0.3.17" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" @@ -260,7 +291,7 @@ "@babel/traverse" "^7.20.10" "@babel/types" "^7.20.7" -"@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.0": +"@babel/helper-module-transforms@^7.20.11": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.0.tgz#89a8f86ad748870e3d024e470b2e8405e869db67" integrity sha512-eD/JQ21IG2i1FraJnTMbUarAUkA7G988ofehG5MDCRXaUU91rEBJuCeSoou2Sk1y4RbLYXzqEg1QLwEmRU4qcQ== @@ -274,6 +305,20 @@ "@babel/traverse" "^7.21.0" "@babel/types" "^7.21.0" +"@babel/helper-module-transforms@^7.21.0", "@babel/helper-module-transforms@^7.21.2": + version "7.21.2" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.2.tgz#160caafa4978ac8c00ac66636cb0fa37b024e2d2" + integrity sha512-79yj2AR4U/Oqq/WOV7Lx6hUjau1Zfo4cI+JLAVYeMV5XIlbOhmjEk5ulbTc9fMpmlojzZHkUUxAiK+UKn+hNQQ== + dependencies: + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-module-imports" "^7.18.6" + "@babel/helper-simple-access" "^7.20.2" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/helper-validator-identifier" "^7.19.1" + "@babel/template" "^7.20.7" + "@babel/traverse" "^7.21.2" + "@babel/types" "^7.21.2" + "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" @@ -372,10 +417,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0": - version "7.21.1" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.1.tgz#a8f81ee2fe872af23faea4b17a08fcc869de7bcc" - integrity sha512-JzhBFpkuhBNYUY7qs+wTzNmyCWUHEaAFpQQD2YfU1rPL38/L43Wvid0fFkiOCnHvsGncRZgEPyGnltABLcVDTg== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.0", "@babel/parser@^7.21.3": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.3.tgz#1d285d67a19162ff9daa358d4cb41d50c06220b3" + integrity sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ== "@babel/parser@^7.2.3": version "7.20.7" @@ -1076,7 +1121,7 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/traverse@^7.20.10", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.20.10", "@babel/traverse@^7.20.7": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.0.tgz#0e1807abd5db98e6a19c204b80ed1e3f5bca0edc" integrity sha512-Xdt2P1H4LKTO8ApPfnO1KmzYMFpp7D/EinoXzLYN/cHcBNrVCAkAtGUcXnHXrl/VGktureU6fkQrHSBE2URfoA== @@ -1092,10 +1137,26 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.19.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3": - version "7.21.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.0.tgz#1da00d89c2f18b226c9207d96edbeb79316a1819" - integrity sha512-uR7NWq2VNFnDi7EYqiRz2Jv/VQIu38tu64Zy8TX2nQFQ6etJ9V/Rr2msW8BS132mum2rL645qpDrLtAJtVpuow== +"@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.21.3", "@babel/traverse@^7.7.2": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.3.tgz#4747c5e7903d224be71f90788b06798331896f67" + integrity sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.21.3" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.21.0" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.21.3" + "@babel/types" "^7.21.3" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.18.6", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.3", "@babel/types@^7.3.0", "@babel/types@^7.3.3": + version "7.21.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.3.tgz#4865a5357ce40f64e3400b0f3b737dc6d4f64d05" + integrity sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg== dependencies: "@babel/helper-string-parser" "^7.19.4" "@babel/helper-validator-identifier" "^7.19.1" @@ -1110,6 +1171,15 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@babel/types@^7.19.0", "@babel/types@^7.20.0": + version "7.21.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.0.tgz#1da00d89c2f18b226c9207d96edbeb79316a1819" + integrity sha512-uR7NWq2VNFnDi7EYqiRz2Jv/VQIu38tu64Zy8TX2nQFQ6etJ9V/Rr2msW8BS132mum2rL645qpDrLtAJtVpuow== + dependencies: + "@babel/helper-string-parser" "^7.19.4" + "@babel/helper-validator-identifier" "^7.19.1" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1140,10 +1210,10 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint/eslintrc@^1.4.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" - integrity sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA== +"@eslint/eslintrc@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.0.tgz#943309d8697c52fc82c076e90c1c74fbbe69dbff" + integrity sha512-fluIaaV+GyV24CCu/ggiHdV+j4RNh85yQnAYS/G2mZODZgGmmlrgCydjUcV3YvxCm9x8nMAfThsqTni4KiXT4A== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -1155,6 +1225,11 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/js@8.35.0": + version "8.35.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.35.0.tgz#b7569632b0b788a0ca0e438235154e45d42813a7" + integrity sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw== + "@humanwhocodes/config-array@^0.11.8": version "0.11.8" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" @@ -1190,61 +1265,61 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.4.3.tgz#1f25a99f7f860e4c46423b5b1038262466fadde1" - integrity sha512-W/o/34+wQuXlgqlPYTansOSiBnuxrTv61dEVkA6HNmpcgHLUjfaUbdqt6oVvOzaawwo9IdW9QOtMgQ1ScSZC4A== +"@jest/console@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.5.0.tgz#593a6c5c0d3f75689835f1b3b4688c4f8544cb57" + integrity sha512-NEpkObxPwyw/XxZVLPmAGKE89IQRp4puc6IQRPru6JKd1M3fW9v1xM1AnzIJE65hbCkzQAdnL8P47e9hzhiYLQ== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^29.4.3" - jest-util "^29.4.3" + jest-message-util "^29.5.0" + jest-util "^29.5.0" slash "^3.0.0" -"@jest/core@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.4.3.tgz#829dd65bffdb490de5b0f69e97de8e3b5eadd94b" - integrity sha512-56QvBq60fS4SPZCuM7T+7scNrkGIe7Mr6PVIXUpu48ouvRaWOFqRPV91eifvFM0ay2HmfswXiGf97NGUN5KofQ== - dependencies: - "@jest/console" "^29.4.3" - "@jest/reporters" "^29.4.3" - "@jest/test-result" "^29.4.3" - "@jest/transform" "^29.4.3" - "@jest/types" "^29.4.3" +"@jest/core@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.5.0.tgz#76674b96904484e8214614d17261cc491e5f1f03" + integrity sha512-28UzQc7ulUrOQw1IsN/kv1QES3q2kkbl/wGslyhAclqZ/8cMdB5M68BffkIdSJgKBUt50d3hbwJ92XESlE7LiQ== + dependencies: + "@jest/console" "^29.5.0" + "@jest/reporters" "^29.5.0" + "@jest/test-result" "^29.5.0" + "@jest/transform" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" ci-info "^3.2.0" exit "^0.1.2" graceful-fs "^4.2.9" - jest-changed-files "^29.4.3" - jest-config "^29.4.3" - jest-haste-map "^29.4.3" - jest-message-util "^29.4.3" + jest-changed-files "^29.5.0" + jest-config "^29.5.0" + jest-haste-map "^29.5.0" + jest-message-util "^29.5.0" jest-regex-util "^29.4.3" - jest-resolve "^29.4.3" - jest-resolve-dependencies "^29.4.3" - jest-runner "^29.4.3" - jest-runtime "^29.4.3" - jest-snapshot "^29.4.3" - jest-util "^29.4.3" - jest-validate "^29.4.3" - jest-watcher "^29.4.3" + jest-resolve "^29.5.0" + jest-resolve-dependencies "^29.5.0" + jest-runner "^29.5.0" + jest-runtime "^29.5.0" + jest-snapshot "^29.5.0" + jest-util "^29.5.0" + jest-validate "^29.5.0" + jest-watcher "^29.5.0" micromatch "^4.0.4" - pretty-format "^29.4.3" + pretty-format "^29.5.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.4.3.tgz#9fe2f3169c3b33815dc4bd3960a064a83eba6548" - integrity sha512-dq5S6408IxIa+lr54zeqce+QgI+CJT4nmmA+1yzFgtcsGK8c/EyiUb9XQOgz3BMKrRDfKseeOaxj2eO8LlD3lA== +"@jest/environment@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.5.0.tgz#9152d56317c1fdb1af389c46640ba74ef0bb4c65" + integrity sha512-5FXw2+wD29YU1d4I2htpRX7jYnAyTRjP2CsXQdo9SAM8g3ifxWPSV0HnClSn71xwctr0U3oZIIH+dtbfmnbXVQ== dependencies: - "@jest/fake-timers" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/fake-timers" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" - jest-mock "^29.4.3" + jest-mock "^29.5.0" "@jest/expect-utils@^28.1.3": version "28.1.3" @@ -1253,60 +1328,53 @@ dependencies: jest-get-type "^28.0.2" -"@jest/expect-utils@^29.4.0": - version "29.4.0" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.4.0.tgz#97819d0da7027792888d9d2f1a41443be0baef80" - integrity sha512-w/JzTYIqjmPFIM5OOQHF9CawFx2daw1256Nzj4ZqWX96qRKbCq9WYRVqdySBKHHzuvsXLyTDIF6y61FUyrhmwg== - dependencies: - jest-get-type "^29.2.0" - -"@jest/expect-utils@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.4.3.tgz#95ce4df62952f071bcd618225ac7c47eaa81431e" - integrity sha512-/6JWbkxHOP8EoS8jeeTd9dTfc9Uawi+43oLKHfp6zzux3U2hqOOVnV3ai4RpDYHOccL6g+5nrxpoc8DmJxtXVQ== +"@jest/expect-utils@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.5.0.tgz#f74fad6b6e20f924582dc8ecbf2cb800fe43a036" + integrity sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg== dependencies: jest-get-type "^29.4.3" -"@jest/expect@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.4.3.tgz#d31a28492e45a6bcd0f204a81f783fe717045c6e" - integrity sha512-iktRU/YsxEtumI9zsPctYUk7ptpC+AVLLk1Ax3AsA4g1C+8OOnKDkIQBDHtD5hA/+VtgMd5AWI5gNlcAlt2vxQ== +"@jest/expect@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.5.0.tgz#80952f5316b23c483fbca4363ce822af79c38fba" + integrity sha512-PueDR2HGihN3ciUNGr4uelropW7rqUfTiOn+8u0leg/42UhblPxHkfoh0Ruu3I9Y1962P3u2DY4+h7GVTSVU6g== dependencies: - expect "^29.4.3" - jest-snapshot "^29.4.3" + expect "^29.5.0" + jest-snapshot "^29.5.0" -"@jest/fake-timers@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.4.3.tgz#31e982638c60fa657d310d4b9d24e023064027b0" - integrity sha512-4Hote2MGcCTWSD2gwl0dwbCpBRHhE6olYEuTj8FMowdg3oQWNKr2YuxenPQYZ7+PfqPY1k98wKDU4Z+Hvd4Tiw== +"@jest/fake-timers@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.5.0.tgz#d4d09ec3286b3d90c60bdcd66ed28d35f1b4dc2c" + integrity sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@sinonjs/fake-timers" "^10.0.2" "@types/node" "*" - jest-message-util "^29.4.3" - jest-mock "^29.4.3" - jest-util "^29.4.3" + jest-message-util "^29.5.0" + jest-mock "^29.5.0" + jest-util "^29.5.0" -"@jest/globals@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.4.3.tgz#63a2c4200d11bc6d46f12bbe25b07f771fce9279" - integrity sha512-8BQ/5EzfOLG7AaMcDh7yFCbfRLtsc+09E1RQmRBI4D6QQk4m6NSK/MXo+3bJrBN0yU8A2/VIcqhvsOLFmziioA== +"@jest/globals@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.5.0.tgz#6166c0bfc374c58268677539d0c181f9c1833298" + integrity sha512-S02y0qMWGihdzNbUiqSAiKSpSozSuHX5UYc7QbnHP+D9Lyw8DgGGCinrN9uSuHPeKgSSzvPom2q1nAtBvUsvPQ== dependencies: - "@jest/environment" "^29.4.3" - "@jest/expect" "^29.4.3" - "@jest/types" "^29.4.3" - jest-mock "^29.4.3" + "@jest/environment" "^29.5.0" + "@jest/expect" "^29.5.0" + "@jest/types" "^29.5.0" + jest-mock "^29.5.0" -"@jest/reporters@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.4.3.tgz#0a68a0c0f20554760cc2e5443177a0018969e353" - integrity sha512-sr2I7BmOjJhyqj9ANC6CTLsL4emMoka7HkQpcoMRlhCbQJjz2zsRzw0BDPiPyEFDXAbxKgGFYuQZiSJ1Y6YoTg== +"@jest/reporters@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.5.0.tgz#985dfd91290cd78ddae4914ba7921bcbabe8ac9b" + integrity sha512-D05STXqj/M8bP9hQNSICtPqz97u7ffGzZu+9XLucXhkOFBqKcXe04JLZOgIekOxdb73MAoBUFnqvf7MCpKk5OA== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.4.3" - "@jest/test-result" "^29.4.3" - "@jest/transform" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/console" "^29.5.0" + "@jest/test-result" "^29.5.0" + "@jest/transform" "^29.5.0" + "@jest/types" "^29.5.0" "@jridgewell/trace-mapping" "^0.3.15" "@types/node" "*" chalk "^4.0.0" @@ -1319,9 +1387,9 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.1.3" - jest-message-util "^29.4.3" - jest-util "^29.4.3" - jest-worker "^29.4.3" + jest-message-util "^29.5.0" + jest-util "^29.5.0" + jest-worker "^29.5.0" slash "^3.0.0" string-length "^4.0.1" strip-ansi "^6.0.0" @@ -1334,14 +1402,7 @@ dependencies: "@sinclair/typebox" "^0.24.1" -"@jest/schemas@^29.4.0": - version "29.4.2" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.2.tgz#cf7cfe97c5649f518452b176c47ed07486270fc1" - integrity sha512-ZrGzGfh31NtdVH8tn0mgJw4khQuNHiKqdzJAFbCaERbyCP9tHlxWuL/mnMu8P7e/+k4puWjI1NOzi/sFsjce/g== - dependencies: - "@sinclair/typebox" "^0.25.16" - -"@jest/schemas@^29.4.2", "@jest/schemas@^29.4.3": +"@jest/schemas@^29.4.3": version "29.4.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.3.tgz#39cf1b8469afc40b6f5a2baaa146e332c4151788" integrity sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg== @@ -1357,42 +1418,42 @@ callsites "^3.0.0" graceful-fs "^4.2.9" -"@jest/test-result@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.4.3.tgz#e13d973d16c8c7cc0c597082d5f3b9e7f796ccb8" - integrity sha512-Oi4u9NfBolMq9MASPwuWTlC5WvmNRwI4S8YrQg5R5Gi47DYlBe3sh7ILTqi/LGrK1XUE4XY9KZcQJTH1WJCLLA== +"@jest/test-result@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.5.0.tgz#7c856a6ca84f45cc36926a4e9c6b57f1973f1408" + integrity sha512-fGl4rfitnbfLsrfx1uUpDEESS7zM8JdgZgOCQuxQvL1Sn/I6ijeAVQWGfXI9zb1i9Mzo495cIpVZhA0yr60PkQ== dependencies: - "@jest/console" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/console" "^29.5.0" + "@jest/types" "^29.5.0" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.4.3.tgz#0862e876a22993385a0f3e7ea1cc126f208a2898" - integrity sha512-yi/t2nES4GB4G0mjLc0RInCq/cNr9dNwJxcGg8sslajua5Kb4kmozAc+qPLzplhBgfw1vLItbjyHzUN92UXicw== +"@jest/test-sequencer@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.5.0.tgz#34d7d82d3081abd523dbddc038a3ddcb9f6d3cc4" + integrity sha512-yPafQEcKjkSfDXyvtgiV4pevSeyuA6MQr6ZIdVkWJly9vkqjnFfcfhRQqpD5whjoU8EORki752xQmjaqoFjzMQ== dependencies: - "@jest/test-result" "^29.4.3" + "@jest/test-result" "^29.5.0" graceful-fs "^4.2.9" - jest-haste-map "^29.4.3" + jest-haste-map "^29.5.0" slash "^3.0.0" -"@jest/transform@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.4.3.tgz#f7d17eac9cb5bb2e1222ea199c7c7e0835e0c037" - integrity sha512-8u0+fBGWolDshsFgPQJESkDa72da/EVwvL+II0trN2DR66wMwiQ9/CihaGfHdlLGFzbBZwMykFtxuwFdZqlKwg== +"@jest/transform@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.5.0.tgz#cf9c872d0965f0cbd32f1458aa44a2b1988b00f9" + integrity sha512-8vbeZWqLJOvHaDfeMuoHITGKSz5qWc9u04lnWrQE3VyuSw604PzQM824ZeX9XSjUCeDiE3GuxZe5UKa8J61NQw== dependencies: "@babel/core" "^7.11.6" - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@jridgewell/trace-mapping" "^0.3.15" babel-plugin-istanbul "^6.1.1" chalk "^4.0.0" convert-source-map "^2.0.0" fast-json-stable-stringify "^2.1.0" graceful-fs "^4.2.9" - jest-haste-map "^29.4.3" + jest-haste-map "^29.5.0" jest-regex-util "^29.4.3" - jest-util "^29.4.3" + jest-util "^29.5.0" micromatch "^4.0.4" pirates "^4.0.4" slash "^3.0.0" @@ -1410,22 +1471,10 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" -"@jest/types@^29.4.0": - version "29.4.2" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.4.2.tgz#8f724a414b1246b2bfd56ca5225d9e1f39540d82" - integrity sha512-CKlngyGP0fwlgC1BRUtPZSiWLBhyS9dKwKmyGxk8Z6M82LBEGB2aLQSg+U1MyLsU+M7UjnlLllBM2BLWKVm/Uw== - dependencies: - "@jest/schemas" "^29.4.2" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jest/types@^29.4.3": - version "29.4.3" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.4.3.tgz#9069145f4ef09adf10cec1b2901b2d390031431f" - integrity sha512-bPYfw8V65v17m2Od1cv44FH+SiKW7w2Xu7trhcdTLUmSv85rfKsP+qXSjO4KGJr4dtPSzl/gvslZBXctf1qGEA== +"@jest/types@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.5.0.tgz#f59ef9b031ced83047c67032700d8c807d6e1593" + integrity sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog== dependencies: "@jest/schemas" "^29.4.3" "@types/istanbul-lib-coverage" "^2.0.0" @@ -1489,10 +1538,10 @@ dependencies: lodash "^4.17.21" -"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3": - version "0.1.0-alpha.4" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.4.tgz#1b20294e0354c3dcc9c7dc810d883198a4042f04" - integrity sha512-mdaDKrw3P5ZVCpq0ioW0pV6ihviDEbS8ZH36kpt9stLKHwwDSopPogE6CkQhi0B1jn1yBUtOYi32mBV/zcOR7g== +"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.5": + version "0.1.0-alpha.5" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.5.tgz#60ede2c43b9d808ba8cf46085a3b347b290d9658" + integrity sha512-2KjAgWNGfuGLNjJwsrs6gGX157vmcTfNrA4u249utgnMPbJl7QwuUqh1bGxQ0PpK06yvZjgPlkna0lTbuwtuQw== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14" @@ -1665,9 +1714,9 @@ integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA== "@sinclair/typebox@^0.25.16": - version "0.25.23" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.23.tgz#1c15b0d2b872d89cc0f47c7243eacb447df8b8bd" - integrity sha512-VEB8ygeP42CFLWyAJhN5OklpxUliqdNEUcXb4xZ/CINqtYGTjL5ukluKdKzQ0iWdUxyQ7B0539PAUhHKrCNWSQ== + version "0.25.24" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" + integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== "@sinonjs/commons@^2.0.0": version "2.0.0" @@ -1791,9 +1840,9 @@ "@types/istanbul-lib-report" "*" "@types/jest@^29.0.0": - version "29.4.0" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.4.0.tgz#a8444ad1704493e84dbf07bb05990b275b3b9206" - integrity sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ== + version "29.5.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.0.tgz#337b90bbcfe42158f39c2fb5619ad044bbb518ac" + integrity sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg== dependencies: expect "^29.0.0" pretty-format "^29.0.0" @@ -1823,14 +1872,14 @@ integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== "@types/node@*": - version "18.14.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.0.tgz#94c47b9217bbac49d4a67a967fdcdeed89ebb7d0" - integrity sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A== + version "18.15.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" + integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== "@types/node@18": - version "18.14.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1" - integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA== + version "18.14.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.6.tgz#ae1973dd2b1eeb1825695bb11ebfb746d27e3e93" + integrity sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA== "@types/normalize-package-data@^2.4.0": version "2.4.1" @@ -1883,20 +1932,20 @@ integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== "@types/yargs@^17.0.8": - version "17.0.22" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.22.tgz#7dd37697691b5f17d020f3c63e7a45971ff71e9a" - integrity sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g== + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== dependencies: "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^5.45.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.53.0.tgz#24b8b4a952f3c615fe070e3c461dd852b5056734" - integrity sha512-alFpFWNucPLdUOySmXCJpzr6HKC3bu7XooShWM+3w/EL6J2HIoB2PFxpLnq4JauWVk6DiVeNKzQlFEaE+X9sGw== + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.54.1.tgz#0c5091289ce28372e38ab8d28e861d2dbe1ab29e" + integrity sha512-a2RQAkosH3d3ZIV08s3DcL/mcGc2M/UC528VkPULFxR9VnVPT8pBu0IyBAJJmVsCmhVfwQX1v6q+QGnmSe1bew== dependencies: - "@typescript-eslint/scope-manager" "5.53.0" - "@typescript-eslint/type-utils" "5.53.0" - "@typescript-eslint/utils" "5.53.0" + "@typescript-eslint/scope-manager" "5.54.1" + "@typescript-eslint/type-utils" "5.54.1" + "@typescript-eslint/utils" "5.54.1" debug "^4.3.4" grapheme-splitter "^1.0.4" ignore "^5.2.0" @@ -1906,13 +1955,13 @@ tsutils "^3.21.0" "@typescript-eslint/parser@^5.45.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.53.0.tgz#a1f2b9ae73b83181098747e96683f1b249ecab52" - integrity sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ== + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.54.1.tgz#05761d7f777ef1c37c971d3af6631715099b084c" + integrity sha512-8zaIXJp/nG9Ff9vQNh7TI+C3nA6q6iIsGJ4B4L6MhZ7mHnTMR4YP5vp2xydmFXIy8rpyIVbNAG44871LMt6ujg== dependencies: - "@typescript-eslint/scope-manager" "5.53.0" - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/typescript-estree" "5.53.0" + "@typescript-eslint/scope-manager" "5.54.1" + "@typescript-eslint/types" "5.54.1" + "@typescript-eslint/typescript-estree" "5.54.1" debug "^4.3.4" "@typescript-eslint/scope-manager@5.51.0": @@ -1923,21 +1972,21 @@ "@typescript-eslint/types" "5.51.0" "@typescript-eslint/visitor-keys" "5.51.0" -"@typescript-eslint/scope-manager@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.53.0.tgz#42b54f280e33c82939275a42649701024f3fafef" - integrity sha512-Opy3dqNsp/9kBBeCPhkCNR7fmdSQqA+47r21hr9a14Bx0xnkElEQmhoHga+VoaoQ6uDHjDKmQPIYcUcKJifS7w== +"@typescript-eslint/scope-manager@5.54.1": + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.54.1.tgz#6d864b4915741c608a58ce9912edf5a02bb58735" + integrity sha512-zWKuGliXxvuxyM71UA/EcPxaviw39dB2504LqAmFDjmkpO8qNLHcmzlh6pbHs1h/7YQ9bnsO8CCcYCSA8sykUg== dependencies: - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/visitor-keys" "5.53.0" + "@typescript-eslint/types" "5.54.1" + "@typescript-eslint/visitor-keys" "5.54.1" -"@typescript-eslint/type-utils@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.53.0.tgz#41665449935ba9b4e6a1ba6e2a3f4b2c31d6cf97" - integrity sha512-HO2hh0fmtqNLzTAme/KnND5uFNwbsdYhCZghK2SoxGp3Ifn2emv+hi0PBUjzzSh0dstUIFqOj3bp0AwQlK4OWw== +"@typescript-eslint/type-utils@5.54.1": + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.54.1.tgz#4825918ec27e55da8bb99cd07ec2a8e5f50ab748" + integrity sha512-WREHsTz0GqVYLIbzIZYbmUUr95DKEKIXZNH57W3s+4bVnuF1TKe2jH8ZNH8rO1CeMY3U4j4UQeqPNkHMiGem3g== dependencies: - "@typescript-eslint/typescript-estree" "5.53.0" - "@typescript-eslint/utils" "5.53.0" + "@typescript-eslint/typescript-estree" "5.54.1" + "@typescript-eslint/utils" "5.54.1" debug "^4.3.4" tsutils "^3.21.0" @@ -1946,10 +1995,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.51.0.tgz#e7c1622f46c7eea7e12bbf1edfb496d4dec37c90" integrity sha512-SqOn0ANn/v6hFn0kjvLwiDi4AzR++CBZz0NV5AnusT2/3y32jdc0G4woXPWHCumWtUXZKPAS27/9vziSsC9jnw== -"@typescript-eslint/types@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.53.0.tgz#f79eca62b97e518ee124086a21a24f3be267026f" - integrity sha512-5kcDL9ZUIP756K6+QOAfPkigJmCPHcLN7Zjdz76lQWWDdzfOhZDTj1irs6gPBKiXx5/6O3L0+AvupAut3z7D2A== +"@typescript-eslint/types@5.54.1": + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.54.1.tgz#29fbac29a716d0f08c62fe5de70c9b6735de215c" + integrity sha512-G9+1vVazrfAfbtmCapJX8jRo2E4MDXxgm/IMOF4oGh3kq7XuK3JRkOg6y2Qu1VsTRmWETyTkWt1wxy7X7/yLkw== "@typescript-eslint/typescript-estree@5.51.0": version "5.51.0" @@ -1964,29 +2013,29 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.53.0.tgz#bc651dc28cf18ab248ecd18a4c886c744aebd690" - integrity sha512-eKmipH7QyScpHSkhbptBBYh9v8FxtngLquq292YTEQ1pxVs39yFBlLC1xeIZcPPz1RWGqb7YgERJRGkjw8ZV7w== +"@typescript-eslint/typescript-estree@5.54.1": + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.54.1.tgz#df7b6ae05fd8fef724a87afa7e2f57fa4a599be1" + integrity sha512-bjK5t+S6ffHnVwA0qRPTZrxKSaFYocwFIkZx5k7pvWfsB1I57pO/0M0Skatzzw1sCkjJ83AfGTL0oFIFiDX3bg== dependencies: - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/visitor-keys" "5.53.0" + "@typescript-eslint/types" "5.54.1" + "@typescript-eslint/visitor-keys" "5.54.1" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.53.0.tgz#e55eaad9d6fffa120575ffaa530c7e802f13bce8" - integrity sha512-VUOOtPv27UNWLxFwQK/8+7kvxVC+hPHNsJjzlJyotlaHjLSIgOCKj9I0DBUjwOOA64qjBwx5afAPjksqOxMO0g== +"@typescript-eslint/utils@5.54.1": + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.54.1.tgz#7a3ee47409285387b9d4609ea7e1020d1797ec34" + integrity sha512-IY5dyQM8XD1zfDe5X8jegX6r2EVU5o/WJnLu/znLPWCBF7KNGC+adacXnt5jEYS9JixDcoccI6CvE4RCjHMzCQ== dependencies: "@types/json-schema" "^7.0.9" "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.53.0" - "@typescript-eslint/types" "5.53.0" - "@typescript-eslint/typescript-estree" "5.53.0" + "@typescript-eslint/scope-manager" "5.54.1" + "@typescript-eslint/types" "5.54.1" + "@typescript-eslint/typescript-estree" "5.54.1" eslint-scope "^5.1.1" eslint-utils "^3.0.0" semver "^7.3.7" @@ -2013,12 +2062,12 @@ "@typescript-eslint/types" "5.51.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.53.0": - version "5.53.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.53.0.tgz#8a5126623937cdd909c30d8fa72f79fa56cc1a9f" - integrity sha512-JqNLnX3leaHFZEN0gCh81sIvgrp/2GOACZNgO4+Tkf64u51kTpAyWFOY8XHx8XuXr3N2C9zgPPHtcpMg6z1g0w== +"@typescript-eslint/visitor-keys@5.54.1": + version "5.54.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.54.1.tgz#d7a8a0f7181d6ac748f4d47b2306e0513b98bf8b" + integrity sha512-q8iSoHTgwCfgcRJ2l2x+xCbu8nBlRAlsQ33k24Adj8eoVBE0f8dUeI+bAa8F84Mv05UGbAx57g2zrRsYIooqQg== dependencies: - "@typescript-eslint/types" "5.53.0" + "@typescript-eslint/types" "5.54.1" eslint-visitor-keys "^3.3.0" JSONStream@^1.0.3: @@ -2295,15 +2344,15 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -babel-jest@^29.0.0, babel-jest@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.4.3.tgz#478b84d430972b277ad67dd631be94abea676792" - integrity sha512-o45Wyn32svZE+LnMVWv/Z4x0SwtLbh4FyGcYtR20kIWd+rdrDZ9Fzq8Ml3MYLD+mZvEdzCjZsCnYZ2jpJyQ+Nw== +babel-jest@^29.0.0, babel-jest@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.5.0.tgz#3fe3ddb109198e78b1c88f9ebdecd5e4fc2f50a5" + integrity sha512-mA4eCDh5mSo2EcA9xQjVTpmbbNk32Zb3Q3QFQsNhaK56Q+yoXowzFodLux30HRgyOho5rsQ6B0P9QpMkvvnJ0Q== dependencies: - "@jest/transform" "^29.4.3" + "@jest/transform" "^29.5.0" "@types/babel__core" "^7.1.14" babel-plugin-istanbul "^6.1.1" - babel-preset-jest "^29.4.3" + babel-preset-jest "^29.5.0" chalk "^4.0.0" graceful-fs "^4.2.9" slash "^3.0.0" @@ -2319,10 +2368,10 @@ babel-plugin-istanbul@^6.1.1: istanbul-lib-instrument "^5.0.4" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.4.3.tgz#ad1dfb5d31940957e00410ef7d9b2aa94b216101" - integrity sha512-mB6q2q3oahKphy5V7CpnNqZOCkxxZ9aokf1eh82Dy3jQmg4xvM1tGrh5y6BQUJh4a3Pj9+eLfwvAZ7VNKg7H8Q== +babel-plugin-jest-hoist@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.5.0.tgz#a97db437936f441ec196990c9738d4b88538618a" + integrity sha512-zSuuuAlTMT4mzLj2nPnUm6fsE6270vdOfnpbJ+RmruU75UhLFvL0N2NgI7xpeS7NaB6hGqmd5pVpGTDYvi4Q3w== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" @@ -2371,12 +2420,12 @@ babel-preset-current-node-syntax@^1.0.0: "@babel/plugin-syntax-optional-chaining" "^7.8.3" "@babel/plugin-syntax-top-level-await" "^7.8.3" -babel-preset-jest@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.4.3.tgz#bb926b66ae253b69c6e3ef87511b8bb5c53c5b52" - integrity sha512-gWx6COtSuma6n9bw+8/F+2PCXrIgxV/D1TJFnp6OyBK2cxPWg0K9p/sriNYeifKjpUkMViWQ09DSWtzJQRETsw== +babel-preset-jest@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.5.0.tgz#57bc8cc88097af7ff6a5ab59d1cd29d52a5916e2" + integrity sha512-JOMloxOqdiBSxMAzjRaH023/vvcaSaec49zvg+2LmNsktC7ei39LTJGw02J+9uUtTZUq6xbLyJ4dxe9sSmIuAg== dependencies: - babel-plugin-jest-hoist "^29.4.3" + babel-plugin-jest-hoist "^29.5.0" babel-preset-current-node-syntax "^1.0.0" babel-runtime@^6.26.0: @@ -2748,9 +2797,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001449: - version "1.0.30001457" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001457.tgz#6af34bb5d720074e2099432aa522c21555a18301" - integrity sha512-SDIV6bgE1aVbK6XyxdURbUE89zY7+k1BBBaOwYwkNCglXlel/E7mELiHC64HQ+W0xSKlqWhV9Wh7iHxUjMs4fA== + version "1.0.30001473" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001473.tgz#3859898b3cab65fc8905bb923df36ad35058153c" + integrity sha512-ewDad7+D2vlyy+E4UJuVfiBsU69IL+8oVmTuZnH5Q6CIUbxNfI50uVpRHbUPDD6SUaN2o0Lh4DhTrvLG/Tn1yg== center-align@^0.1.1: version "0.1.3" @@ -3172,9 +3221,9 @@ deep-is@^0.1.3, deep-is@~0.1.3: integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== deepmerge@^4.2.2: - version "4.3.0" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b" - integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og== + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== define-lazy-prop@^2.0.0: version "2.0.0" @@ -3246,7 +3295,7 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== -diff-sequences@^29.4.2, diff-sequences@^29.4.3: +diff-sequences@^29.4.3: version "29.4.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== @@ -3320,9 +3369,9 @@ duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2: readable-stream "^2.0.2" electron-to-chromium@^1.4.284: - version "1.4.305" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.305.tgz#e4dfe3e06ab783f33171f9bde9e8ed092510fcd0" - integrity sha512-WETy6tG0CT5gm1O+xCbyapWNsCcmIvrn4NHViIGYo2AT8FV2qUCXdaB+WqYxSv/vS5mFqhBYnfZAAkVArjBmUg== + version "1.4.346" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.346.tgz#1a084516b5d62fb24ca65ba8e2ebe4a348ebb307" + integrity sha512-9ZpKQD8hyWAoYf5bccm2qpaWogAGxb833DVC0arHo9nIbiAMh+aAKHZWABR2P9sK4a3zoCq7eXg8tylqPAnuNw== elliptic@^6.5.3: version "6.5.4" @@ -3567,9 +3616,9 @@ eslint-plugin-jest@^27.1.6: "@typescript-eslint/utils" "^5.10.0" eslint-plugin-jsdoc@^40.0.0: - version "40.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-40.0.0.tgz#7f433757aa91721e4b88a527dc17ac0437c3c075" - integrity sha512-LOPyIu1vAVvGPkye3ci0moj0iNf3f8bmin6do2DYDj+77NRXWnkmhKRy8swWsatUs3mB5jYPWPUsFg9pyfEiyA== + version "40.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-40.0.1.tgz#5f028b4928d5c77f54bfd3c42c00acb61d27bb9f" + integrity sha512-KkiRInury7YrjjV5aCHDxwsPy6XFt5p2b2CnpDMITnWs8patNPf5kj24+VXIWw45kP6z/B0GOKfrYczB56OjQQ== dependencies: "@es-joy/jsdoccomment" "~0.36.1" comment-parser "1.3.1" @@ -3580,9 +3629,9 @@ eslint-plugin-jsdoc@^40.0.0: spdx-expression-parse "^3.0.1" eslint-plugin-matrix-org@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-1.0.0.tgz#cead71391e2a36d63cb8f8018a38305ecf81b4b8" - integrity sha512-JSjw+hswEcFR+N4N2JXZttK65cK6huykZKkbnwcITxPTelsaOfZ8qXG0Az9BfmVADaLgY3MGmHK1YYKbykUfBQ== + version "1.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-1.1.0.tgz#cb3c313b58aa84ee0dd52c57f4a614a1795e8744" + integrity sha512-UArLqthBuaCljVajS2TtlPQLXNMZZAPKRt+gA8D0ayzcAj+Ghl50amwGtvLHMzISGv3sqNDBFBMD9cElntE1zA== eslint-plugin-tsdoc@^0.2.17: version "0.2.17" @@ -3592,10 +3641,10 @@ eslint-plugin-tsdoc@^0.2.17: "@microsoft/tsdoc" "0.14.2" "@microsoft/tsdoc-config" "0.16.2" -eslint-plugin-unicorn@^45.0.0: - version "45.0.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-45.0.2.tgz#d6ba704793a6909fe5dfe013900d2b05b715284c" - integrity sha512-Y0WUDXRyGDMcKLiwgL3zSMpHrXI00xmdyixEGIg90gHnj0PcHY4moNv3Ppje/kDivdAy5vUeUr7z211ImPv2gw== +eslint-plugin-unicorn@^46.0.0: + version "46.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-46.0.0.tgz#b5cdcc9465fd6e46ab7968b87dd4a43adc8d6031" + integrity sha512-j07WkC+PFZwk8J33LYp6JMoHa1lXc1u6R45pbSAipjpfpb7KIGr17VE2D685zCxR5VL4cjrl65kTJflziQWMDA== dependencies: "@babel/helper-validator-identifier" "^7.19.1" "@eslint-community/eslint-utils" "^4.1.2" @@ -3652,12 +3701,13 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.34.0: - version "8.34.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.34.0.tgz#fe0ab0ef478104c1f9ebc5537e303d25a8fb22d6" - integrity sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg== +eslint@8.35.0: + version "8.35.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.35.0.tgz#fffad7c7e326bae606f0e8f436a6158566d42323" + integrity sha512-BxAf1fVL7w+JLRQhWl2pzGeSiGqbWumV4WNvc9Rhp6tiCtm4oHnyPBSEtMGZwrQgudFQ+otqzWoPB7x+hxoWsw== dependencies: - "@eslint/eslintrc" "^1.4.1" + "@eslint/eslintrc" "^2.0.0" + "@eslint/js" "8.35.0" "@humanwhocodes/config-array" "^0.11.8" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -3671,7 +3721,7 @@ eslint@8.34.0: eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" espree "^9.4.0" - esquery "^1.4.0" + esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" @@ -3711,10 +3761,10 @@ esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.0: - version "1.4.2" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.2.tgz#c6d3fee05dd665808e2ad870631f221f5617b1d1" - integrity sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng== +esquery@^1.4.0, esquery@^1.4.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== dependencies: estraverse "^5.1.0" @@ -3811,27 +3861,16 @@ expect@^28.1.0: jest-message-util "^28.1.3" jest-util "^28.1.3" -expect@^29.0.0: - version "29.4.0" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.4.0.tgz#e2d58a73bf46399deac7db6ec16842827525ce35" - integrity sha512-pzaAwjBgLEVxBh6ZHiqb9Wv3JYuv6m8ntgtY7a48nS+2KbX0EJkPS3FQlKiTZNcqzqJHNyQsfjqN60w1hPUBfQ== - dependencies: - "@jest/expect-utils" "^29.4.0" - jest-get-type "^29.2.0" - jest-matcher-utils "^29.4.0" - jest-message-util "^29.4.0" - jest-util "^29.4.0" - -expect@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.4.3.tgz#5e47757316df744fe3b8926c3ae8a3ebdafff7fe" - integrity sha512-uC05+Q7eXECFpgDrHdXA4k2rpMyStAYPItEDLyQDo5Ta7fVkJnNA/4zh/OIVkVVNZ1oOK1PipQoyNjuZ6sz6Dg== +expect@^29.0.0, expect@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.5.0.tgz#68c0509156cb2a0adb8865d413b137eeaae682f7" + integrity sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg== dependencies: - "@jest/expect-utils" "^29.4.3" + "@jest/expect-utils" "^29.5.0" jest-get-type "^29.4.3" - jest-matcher-utils "^29.4.3" - jest-message-util "^29.4.3" - jest-util "^29.4.3" + jest-matcher-utils "^29.5.0" + jest-message-util "^29.5.0" + jest-util "^29.5.0" ext@^1.1.2: version "1.7.0" @@ -4116,6 +4155,16 @@ glob@^7.1.0, glob@^7.1.3, glob@^7.1.4, glob@^7.2.0: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^9.2.0: + version "9.2.1" + resolved "https://registry.yarnpkg.com/glob/-/glob-9.2.1.tgz#f47e34e1119e7d4f93a546e75851ba1f1e68de50" + integrity sha512-Pxxgq3W0HyA3XUvSXcFhRSs+43Jsx0ddxcFrbjxNGkL2Ak5BAUBxLqI5G6ADDeCHLfzzXFhe0b1yYcctGmytMA== + dependencies: + fs.realpath "^1.0.0" + minimatch "^7.4.1" + minipass "^4.2.4" + path-scurry "^1.6.1" + glob@~3.2.7: version "3.2.11" resolved "https://registry.yarnpkg.com/glob/-/glob-3.2.11.tgz#4a973f635b9190f715d10987d5c00fd2815ebe3d" @@ -4183,11 +4232,16 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.2.4: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + grapheme-splitter@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" @@ -4709,82 +4763,83 @@ istanbul-reports@^3.1.3, istanbul-reports@^3.1.4: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jest-changed-files@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.4.3.tgz#7961fe32536b9b6d5c28dfa0abcfab31abcf50a7" - integrity sha512-Vn5cLuWuwmi2GNNbokPOEcvrXGSGrqVnPEZV7rC6P7ck07Dyw9RFnvWglnupSh+hGys0ajGtw/bc2ZgweljQoQ== +jest-changed-files@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.5.0.tgz#e88786dca8bf2aa899ec4af7644e16d9dcf9b23e" + integrity sha512-IFG34IUMUaNBIxjQXF/iu7g6EcdMrGRRxaUSw92I/2g2YC6vCdTltl4nHvt7Ci5nSJwXIkCu8Ka1DKF+X7Z1Ag== dependencies: execa "^5.0.0" p-limit "^3.1.0" -jest-circus@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.4.3.tgz#fff7be1cf5f06224dd36a857d52a9efeb005ba04" - integrity sha512-Vw/bVvcexmdJ7MLmgdT3ZjkJ3LKu8IlpefYokxiqoZy6OCQ2VAm6Vk3t/qHiAGUXbdbJKJWnc8gH3ypTbB/OBw== +jest-circus@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.5.0.tgz#b5926989449e75bff0d59944bae083c9d7fb7317" + integrity sha512-gq/ongqeQKAplVxqJmbeUOJJKkW3dDNPY8PjhJ5G0lBRvu0e3EWGxGy5cI4LAGA7gV2UHCtWBI4EMXK8c9nQKA== dependencies: - "@jest/environment" "^29.4.3" - "@jest/expect" "^29.4.3" - "@jest/test-result" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/environment" "^29.5.0" + "@jest/expect" "^29.5.0" + "@jest/test-result" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" dedent "^0.7.0" is-generator-fn "^2.0.0" - jest-each "^29.4.3" - jest-matcher-utils "^29.4.3" - jest-message-util "^29.4.3" - jest-runtime "^29.4.3" - jest-snapshot "^29.4.3" - jest-util "^29.4.3" + jest-each "^29.5.0" + jest-matcher-utils "^29.5.0" + jest-message-util "^29.5.0" + jest-runtime "^29.5.0" + jest-snapshot "^29.5.0" + jest-util "^29.5.0" p-limit "^3.1.0" - pretty-format "^29.4.3" + pretty-format "^29.5.0" + pure-rand "^6.0.0" slash "^3.0.0" stack-utils "^2.0.3" -jest-cli@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.4.3.tgz#fe31fdd0c90c765f392b8b7c97e4845071cd2163" - integrity sha512-PiiAPuFNfWWolCE6t3ZrDXQc6OsAuM3/tVW0u27UWc1KE+n/HSn5dSE6B2juqN7WP+PP0jAcnKtGmI4u8GMYCg== +jest-cli@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.5.0.tgz#b34c20a6d35968f3ee47a7437ff8e53e086b4a67" + integrity sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw== dependencies: - "@jest/core" "^29.4.3" - "@jest/test-result" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/core" "^29.5.0" + "@jest/test-result" "^29.5.0" + "@jest/types" "^29.5.0" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.9" import-local "^3.0.2" - jest-config "^29.4.3" - jest-util "^29.4.3" - jest-validate "^29.4.3" + jest-config "^29.5.0" + jest-util "^29.5.0" + jest-validate "^29.5.0" prompts "^2.0.1" yargs "^17.3.1" -jest-config@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.4.3.tgz#fca9cdfe6298ae6d04beef1624064d455347c978" - integrity sha512-eCIpqhGnIjdUCXGtLhz4gdDoxKSWXKjzNcc5r+0S1GKOp2fwOipx5mRcwa9GB/ArsxJ1jlj2lmlD9bZAsBxaWQ== +jest-config@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.5.0.tgz#3cc972faec8c8aaea9ae158c694541b79f3748da" + integrity sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA== dependencies: "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.4.3" - "@jest/types" "^29.4.3" - babel-jest "^29.4.3" + "@jest/test-sequencer" "^29.5.0" + "@jest/types" "^29.5.0" + babel-jest "^29.5.0" chalk "^4.0.0" ci-info "^3.2.0" deepmerge "^4.2.2" glob "^7.1.3" graceful-fs "^4.2.9" - jest-circus "^29.4.3" - jest-environment-node "^29.4.3" + jest-circus "^29.5.0" + jest-environment-node "^29.5.0" jest-get-type "^29.4.3" jest-regex-util "^29.4.3" - jest-resolve "^29.4.3" - jest-runner "^29.4.3" - jest-util "^29.4.3" - jest-validate "^29.4.3" + jest-resolve "^29.5.0" + jest-runner "^29.5.0" + jest-util "^29.5.0" + jest-validate "^29.5.0" micromatch "^4.0.4" parse-json "^5.2.0" - pretty-format "^29.4.3" + pretty-format "^29.5.0" slash "^3.0.0" strip-json-comments "^3.1.1" @@ -4798,25 +4853,15 @@ jest-diff@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" -jest-diff@^29.4.0: - version "29.4.2" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.4.2.tgz#b88502d5dc02d97f6512d73c37da8b36f49b4871" - integrity sha512-EK8DSajVtnjx9sa1BkjZq3mqChm2Cd8rIzdXkQMA8e0wuXq53ypz6s5o5V8HRZkoEt2ywJ3eeNWFKWeYr8HK4g== - dependencies: - chalk "^4.0.0" - diff-sequences "^29.4.2" - jest-get-type "^29.4.2" - pretty-format "^29.4.2" - -jest-diff@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.4.3.tgz#42f4eb34d0bf8c0fb08b0501069b87e8e84df347" - integrity sha512-YB+ocenx7FZ3T5O9lMVMeLYV4265socJKtkwgk/6YUz/VsEzYDkiMuMhWzZmxm3wDRQvayJu/PjkjjSkjoHsCA== +jest-diff@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.5.0.tgz#e0d83a58eb5451dcc1fa61b1c3ee4e8f5a290d63" + integrity sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw== dependencies: chalk "^4.0.0" diff-sequences "^29.4.3" jest-get-type "^29.4.3" - pretty-format "^29.4.3" + pretty-format "^29.5.0" jest-docblock@^29.4.3: version "29.4.3" @@ -4825,84 +4870,79 @@ jest-docblock@^29.4.3: dependencies: detect-newline "^3.0.0" -jest-each@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.4.3.tgz#a434c199a2f6151c5e3dc80b2d54586bdaa72819" - integrity sha512-1ElHNAnKcbJb/b+L+7j0/w7bDvljw4gTv1wL9fYOczeJrbTbkMGQ5iQPFJ3eFQH19VPTx1IyfePdqSpePKss7Q== +jest-each@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.5.0.tgz#fc6e7014f83eac68e22b7195598de8554c2e5c06" + integrity sha512-HM5kIJ1BTnVt+DQZ2ALp3rzXEl+g726csObrW/jpEGl+CDSSQpOJJX2KE/vEg8cxcMXdyEPu6U4QX5eruQv5hA== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" chalk "^4.0.0" jest-get-type "^29.4.3" - jest-util "^29.4.3" - pretty-format "^29.4.3" + jest-util "^29.5.0" + pretty-format "^29.5.0" jest-environment-jsdom@^29.0.0: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.4.3.tgz#bd8ed3808e6d3f616403fbaf8354f77019613d90" - integrity sha512-rFjf8JXrw3OjUzzmSE5l0XjMj0/MSVEUMCSXBGPDkfwb1T03HZI7iJSL0cGctZApPSyJxbjyKDVxkZuyhHkuTw== + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.5.0.tgz#cfe86ebaf1453f3297b5ff3470fbe94739c960cb" + integrity sha512-/KG8yEK4aN8ak56yFVdqFDzKNHgF4BAymCx2LbPNPsUshUlfAl0eX402Xm1pt+eoG9SLZEUVifqXtX8SK74KCw== dependencies: - "@jest/environment" "^29.4.3" - "@jest/fake-timers" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/environment" "^29.5.0" + "@jest/fake-timers" "^29.5.0" + "@jest/types" "^29.5.0" "@types/jsdom" "^20.0.0" "@types/node" "*" - jest-mock "^29.4.3" - jest-util "^29.4.3" + jest-mock "^29.5.0" + jest-util "^29.5.0" jsdom "^20.0.0" -jest-environment-node@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.4.3.tgz#579c4132af478befc1889ddc43c2413a9cdbe014" - integrity sha512-gAiEnSKF104fsGDXNkwk49jD/0N0Bqu2K9+aMQXA6avzsA9H3Fiv1PW2D+gzbOSR705bWd2wJZRFEFpV0tXISg== +jest-environment-node@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.5.0.tgz#f17219d0f0cc0e68e0727c58b792c040e332c967" + integrity sha512-ExxuIK/+yQ+6PRGaHkKewYtg6hto2uGCgvKdb2nfJfKXgZ17DfXjvbZ+jA1Qt9A8EQSfPnt5FKIfnOO3u1h9qw== dependencies: - "@jest/environment" "^29.4.3" - "@jest/fake-timers" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/environment" "^29.5.0" + "@jest/fake-timers" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" - jest-mock "^29.4.3" - jest-util "^29.4.3" + jest-mock "^29.5.0" + jest-util "^29.5.0" jest-get-type@^28.0.2: version "28.0.2" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== -jest-get-type@^29.2.0: - version "29.4.2" - resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.2.tgz#7cb63f154bca8d8f57364d01614477d466fa43fe" - integrity sha512-vERN30V5i2N6lqlFu4ljdTqQAgrkTFMC9xaIIfOPYBw04pufjXRty5RuXBiB1d72tGbURa/UgoiHB90ruOSivg== - -jest-get-type@^29.4.2, jest-get-type@^29.4.3: +jest-get-type@^29.4.3: version "29.4.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg== -jest-haste-map@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.4.3.tgz#085a44283269e7ace0645c63a57af0d2af6942e2" - integrity sha512-eZIgAS8tvm5IZMtKlR8Y+feEOMfo2pSQkmNbufdbMzMSn9nitgGxF1waM/+LbryO3OkMcKS98SUb+j/cQxp/vQ== +jest-haste-map@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.5.0.tgz#69bd67dc9012d6e2723f20a945099e972b2e94de" + integrity sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@types/graceful-fs" "^4.1.3" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.9" jest-regex-util "^29.4.3" - jest-util "^29.4.3" - jest-worker "^29.4.3" + jest-util "^29.5.0" + jest-worker "^29.5.0" micromatch "^4.0.4" walker "^1.0.8" optionalDependencies: fsevents "^2.3.2" -jest-leak-detector@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.4.3.tgz#2b35191d6b35aa0256e63a9b79b0f949249cf23a" - integrity sha512-9yw4VC1v2NspMMeV3daQ1yXPNxMgCzwq9BocCwYrRgXe4uaEJPAN0ZK37nFBhcy3cUwEVstFecFLaTHpF7NiGA== +jest-leak-detector@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.5.0.tgz#cf4bdea9615c72bac4a3a7ba7e7930f9c0610c8c" + integrity sha512-u9YdeeVnghBUtpN5mVxjID7KbkKE1QU4f6uUwuxiY0vYRi9BUCLKlPEZfDGR67ofdFmDz9oPAy2G92Ujrntmow== dependencies: jest-get-type "^29.4.3" - pretty-format "^29.4.3" + pretty-format "^29.5.0" jest-localstorage-mock@^2.4.6: version "2.4.26" @@ -4919,25 +4959,15 @@ jest-matcher-utils@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" -jest-matcher-utils@^29.4.0: - version "29.4.0" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.4.0.tgz#c2f804f95152216c8b80afbe73d82ae0ba89f652" - integrity sha512-pU4OjBn96rDdRIaPUImbPiO2ETyRVzkA1EZVu9AxBDv/XPDJ7JWfkb6IiDT5jwgicaPHMrB/fhVa6qjG6potfA== - dependencies: - chalk "^4.0.0" - jest-diff "^29.4.0" - jest-get-type "^29.2.0" - pretty-format "^29.4.0" - -jest-matcher-utils@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.4.3.tgz#ea68ebc0568aebea4c4213b99f169ff786df96a0" - integrity sha512-TTciiXEONycZ03h6R6pYiZlSkvYgT0l8aa49z/DLSGYjex4orMUcafuLXYyyEDWB1RKglq00jzwY00Ei7yFNVg== +jest-matcher-utils@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.5.0.tgz#d957af7f8c0692c5453666705621ad4abc2c59c5" + integrity sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw== dependencies: chalk "^4.0.0" - jest-diff "^29.4.3" + jest-diff "^29.5.0" jest-get-type "^29.4.3" - pretty-format "^29.4.3" + pretty-format "^29.5.0" jest-message-util@^28.1.3: version "28.1.3" @@ -4954,44 +4984,29 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" -jest-message-util@^29.4.0: - version "29.4.0" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.4.0.tgz#60d3f3dd6b5ef08ec9b698f434fbbafdb0af761d" - integrity sha512-0FvobqymmhE9pDEifvIcni9GeoKLol8eZspzH5u41g1wxYtLS60a9joT95dzzoCgrKRidNz64eaAXyzaULV8og== - dependencies: - "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.4.0" - "@types/stack-utils" "^2.0.0" - chalk "^4.0.0" - graceful-fs "^4.2.9" - micromatch "^4.0.4" - pretty-format "^29.4.0" - slash "^3.0.0" - stack-utils "^2.0.3" - -jest-message-util@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.4.3.tgz#65b5280c0fdc9419503b49d4f48d4999d481cb5b" - integrity sha512-1Y8Zd4ZCN7o/QnWdMmT76If8LuDv23Z1DRovBj/vcSFNlGCJGoO8D1nJDw1AdyAGUk0myDLFGN5RbNeJyCRGCw== +jest-message-util@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.5.0.tgz#1f776cac3aca332ab8dd2e3b41625435085c900e" + integrity sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA== dependencies: "@babel/code-frame" "^7.12.13" - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.9" micromatch "^4.0.4" - pretty-format "^29.4.3" + pretty-format "^29.5.0" slash "^3.0.0" stack-utils "^2.0.3" -jest-mock@^29.0.0, jest-mock@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.4.3.tgz#23d84a20a74cdfff0510fdbeefb841ed57b0fe7e" - integrity sha512-LjFgMg+xed9BdkPMyIJh+r3KeHt1klXPJYBULXVVAkbTaaKjPX1o1uVCAZADMEp/kOxGTwy/Ot8XbvgItOrHEg== +jest-mock@^29.0.0, jest-mock@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.5.0.tgz#26e2172bcc71d8b0195081ff1f146ac7e1518aed" + integrity sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@types/node" "*" - jest-util "^29.4.3" + jest-util "^29.5.0" jest-pnp-resolver@^1.2.2: version "1.2.3" @@ -5003,88 +5018,88 @@ jest-regex-util@^29.4.3: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.4.3.tgz#a42616141e0cae052cfa32c169945d00c0aa0bb8" integrity sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg== -jest-resolve-dependencies@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.4.3.tgz#9ad7f23839a6d88cef91416bda9393a6e9fd1da5" - integrity sha512-uvKMZAQ3nmXLH7O8WAOhS5l0iWyT3WmnJBdmIHiV5tBbdaDZ1wqtNX04FONGoaFvSOSHBJxnwAVnSn1WHdGVaw== +jest-resolve-dependencies@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.5.0.tgz#f0ea29955996f49788bf70996052aa98e7befee4" + integrity sha512-sjV3GFr0hDJMBpYeUuGduP+YeCRbd7S/ck6IvL3kQ9cpySYKqcqhdLLC2rFwrcL7tz5vYibomBrsFYWkIGGjOg== dependencies: jest-regex-util "^29.4.3" - jest-snapshot "^29.4.3" + jest-snapshot "^29.5.0" -jest-resolve@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.4.3.tgz#3c5b5c984fa8a763edf9b3639700e1c7900538e2" - integrity sha512-GPokE1tzguRyT7dkxBim4wSx6E45S3bOQ7ZdKEG+Qj0Oac9+6AwJPCk0TZh5Vu0xzeX4afpb+eDmgbmZFFwpOw== +jest-resolve@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.5.0.tgz#b053cc95ad1d5f6327f0ac8aae9f98795475ecdc" + integrity sha512-1TzxJ37FQq7J10jPtQjcc+MkCkE3GBpBecsSUWJ0qZNJpmg6m0D9/7II03yJulm3H/fvVjgqLh/k2eYg+ui52w== dependencies: chalk "^4.0.0" graceful-fs "^4.2.9" - jest-haste-map "^29.4.3" + jest-haste-map "^29.5.0" jest-pnp-resolver "^1.2.2" - jest-util "^29.4.3" - jest-validate "^29.4.3" + jest-util "^29.5.0" + jest-validate "^29.5.0" resolve "^1.20.0" resolve.exports "^2.0.0" slash "^3.0.0" -jest-runner@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.4.3.tgz#68dc82c68645eda12bea42b5beece6527d7c1e5e" - integrity sha512-GWPTEiGmtHZv1KKeWlTX9SIFuK19uLXlRQU43ceOQ2hIfA5yPEJC7AMkvFKpdCHx6pNEdOD+2+8zbniEi3v3gA== - dependencies: - "@jest/console" "^29.4.3" - "@jest/environment" "^29.4.3" - "@jest/test-result" "^29.4.3" - "@jest/transform" "^29.4.3" - "@jest/types" "^29.4.3" +jest-runner@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.5.0.tgz#6a57c282eb0ef749778d444c1d758c6a7693b6f8" + integrity sha512-m7b6ypERhFghJsslMLhydaXBiLf7+jXy8FwGRHO3BGV1mcQpPbwiqiKUR2zU2NJuNeMenJmlFZCsIqzJCTeGLQ== + dependencies: + "@jest/console" "^29.5.0" + "@jest/environment" "^29.5.0" + "@jest/test-result" "^29.5.0" + "@jest/transform" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" chalk "^4.0.0" emittery "^0.13.1" graceful-fs "^4.2.9" jest-docblock "^29.4.3" - jest-environment-node "^29.4.3" - jest-haste-map "^29.4.3" - jest-leak-detector "^29.4.3" - jest-message-util "^29.4.3" - jest-resolve "^29.4.3" - jest-runtime "^29.4.3" - jest-util "^29.4.3" - jest-watcher "^29.4.3" - jest-worker "^29.4.3" + jest-environment-node "^29.5.0" + jest-haste-map "^29.5.0" + jest-leak-detector "^29.5.0" + jest-message-util "^29.5.0" + jest-resolve "^29.5.0" + jest-runtime "^29.5.0" + jest-util "^29.5.0" + jest-watcher "^29.5.0" + jest-worker "^29.5.0" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.4.3.tgz#f25db9874dcf35a3ab27fdaabca426666cc745bf" - integrity sha512-F5bHvxSH+LvLV24vVB3L8K467dt3y3dio6V3W89dUz9nzvTpqd/HcT9zfYKL2aZPvD63vQFgLvaUX/UpUhrP6Q== +jest-runtime@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.5.0.tgz#c83f943ee0c1da7eb91fa181b0811ebd59b03420" + integrity sha512-1Hr6Hh7bAgXQP+pln3homOiEZtCDZFqwmle7Ew2j8OlbkIu6uE3Y/etJQG8MLQs3Zy90xrp2C0BRrtPHG4zryw== dependencies: - "@jest/environment" "^29.4.3" - "@jest/fake-timers" "^29.4.3" - "@jest/globals" "^29.4.3" + "@jest/environment" "^29.5.0" + "@jest/fake-timers" "^29.5.0" + "@jest/globals" "^29.5.0" "@jest/source-map" "^29.4.3" - "@jest/test-result" "^29.4.3" - "@jest/transform" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/test-result" "^29.5.0" + "@jest/transform" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" chalk "^4.0.0" cjs-module-lexer "^1.0.0" collect-v8-coverage "^1.0.0" glob "^7.1.3" graceful-fs "^4.2.9" - jest-haste-map "^29.4.3" - jest-message-util "^29.4.3" - jest-mock "^29.4.3" + jest-haste-map "^29.5.0" + jest-message-util "^29.5.0" + jest-mock "^29.5.0" jest-regex-util "^29.4.3" - jest-resolve "^29.4.3" - jest-snapshot "^29.4.3" - jest-util "^29.4.3" + jest-resolve "^29.5.0" + jest-snapshot "^29.5.0" + jest-util "^29.5.0" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.4.3.tgz#183d309371450d9c4a3de7567ed2151eb0e91145" - integrity sha512-NGlsqL0jLPDW91dz304QTM/SNO99lpcSYYAjNiX0Ou+sSGgkanKBcSjCfp/pqmiiO1nQaOyLp6XQddAzRcx3Xw== +jest-snapshot@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.5.0.tgz#c9c1ce0331e5b63cd444e2f95a55a73b84b1e8ce" + integrity sha512-x7Wolra5V0tt3wRs3/ts3S6ciSQVypgGQlJpz2rsdQYoUKxMxPNaoHMGJN6qAuPJqS+2iQ1ZUn5kl7HCyls84g== dependencies: "@babel/core" "^7.11.6" "@babel/generator" "^7.7.2" @@ -5092,23 +5107,22 @@ jest-snapshot@^29.4.3: "@babel/plugin-syntax-typescript" "^7.7.2" "@babel/traverse" "^7.7.2" "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.4.3" - "@jest/transform" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/expect-utils" "^29.5.0" + "@jest/transform" "^29.5.0" + "@jest/types" "^29.5.0" "@types/babel__traverse" "^7.0.6" "@types/prettier" "^2.1.5" babel-preset-current-node-syntax "^1.0.0" chalk "^4.0.0" - expect "^29.4.3" + expect "^29.5.0" graceful-fs "^4.2.9" - jest-diff "^29.4.3" + jest-diff "^29.5.0" jest-get-type "^29.4.3" - jest-haste-map "^29.4.3" - jest-matcher-utils "^29.4.3" - jest-message-util "^29.4.3" - jest-util "^29.4.3" + jest-matcher-utils "^29.5.0" + jest-message-util "^29.5.0" + jest-util "^29.5.0" natural-compare "^1.4.0" - pretty-format "^29.4.3" + pretty-format "^29.5.0" semver "^7.3.5" jest-util@^28.1.3: @@ -5123,75 +5137,63 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-util@^29.4.0: - version "29.4.0" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.4.0.tgz#1f3743c3dda843049623501c7e6f8fa5efdc2c2f" - integrity sha512-lCCwlze7UEV8TpR9ArS8w0cTbcMry5tlBkg7QSc5og5kNyV59dnY2aKHu5fY2k5aDJMQpCUGpvL2w6ZU44lveA== - dependencies: - "@jest/types" "^29.4.0" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-util@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.4.3.tgz#851a148e23fc2b633c55f6dad2e45d7f4579f496" - integrity sha512-ToSGORAz4SSSoqxDSylWX8JzkOQR7zoBtNRsA7e+1WUX5F8jrOwaNpuh1YfJHJKDHXLHmObv5eOjejUd+/Ws+Q== +jest-util@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f" + integrity sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" "@types/node" "*" chalk "^4.0.0" ci-info "^3.2.0" graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-validate@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.4.3.tgz#a13849dec4f9e95446a7080ad5758f58fa88642f" - integrity sha512-J3u5v7aPQoXPzaar6GndAVhdQcZr/3osWSgTeKg5v574I9ybX/dTyH0AJFb5XgXIB7faVhf+rS7t4p3lL9qFaw== +jest-validate@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.5.0.tgz#8e5a8f36178d40e47138dc00866a5f3bd9916ffc" + integrity sha512-pC26etNIi+y3HV8A+tUGr/lph9B18GnzSRAkPaaZJIE1eFdiYm6/CewuiJQ8/RlfHd1u/8Ioi8/sJ+CmbA+zAQ== dependencies: - "@jest/types" "^29.4.3" + "@jest/types" "^29.5.0" camelcase "^6.2.0" chalk "^4.0.0" jest-get-type "^29.4.3" leven "^3.1.0" - pretty-format "^29.4.3" + pretty-format "^29.5.0" -jest-watcher@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.4.3.tgz#e503baa774f0c2f8f3c8db98a22ebf885f19c384" - integrity sha512-zwlXH3DN3iksoIZNk73etl1HzKyi5FuQdYLnkQKm5BW4n8HpoG59xSwpVdFrnh60iRRaRBGw0gcymIxjJENPcA== +jest-watcher@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.5.0.tgz#cf7f0f949828ba65ddbbb45c743a382a4d911363" + integrity sha512-KmTojKcapuqYrKDpRwfqcQ3zjMlwu27SYext9pt4GlF5FUgB+7XE1mcCnSm6a4uUpFyQIkb6ZhzZvHl+jiBCiA== dependencies: - "@jest/test-result" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/test-result" "^29.5.0" + "@jest/types" "^29.5.0" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" emittery "^0.13.1" - jest-util "^29.4.3" + jest-util "^29.5.0" string-length "^4.0.1" -jest-worker@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.4.3.tgz#9a4023e1ea1d306034237c7133d7da4240e8934e" - integrity sha512-GLHN/GTAAMEy5BFdvpUfzr9Dr80zQqBrh0fz1mtRMe05hqP45+HfQltu7oTBfduD0UeZs09d+maFtFYAXFWvAA== +jest-worker@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.5.0.tgz#bdaefb06811bd3384d93f009755014d8acb4615d" + integrity sha512-NcrQnevGoSp4b5kg+akIpthoAFHxPBcb5P6mYPY0fUNT+sSvmtu6jlkEle3anczUKIKEbMxFimk9oTP/tpIPgA== dependencies: "@types/node" "*" - jest-util "^29.4.3" + jest-util "^29.5.0" merge-stream "^2.0.0" supports-color "^8.0.0" jest@^29.0.0: - version "29.4.3" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.4.3.tgz#1b8be541666c6feb99990fd98adac4737e6e6386" - integrity sha512-XvK65feuEFGZT8OO0fB/QAQS+LGHvQpaadkH5p47/j3Ocqq3xf2pK9R+G0GzgfuhXVxEv76qCOOcMb5efLk6PA== + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.5.0.tgz#f75157622f5ce7ad53028f2f8888ab53e1f1f24e" + integrity sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ== dependencies: - "@jest/core" "^29.4.3" - "@jest/types" "^29.4.3" + "@jest/core" "^29.5.0" + "@jest/types" "^29.5.0" import-local "^3.0.2" - jest-cli "^29.4.3" + jest-cli "^29.5.0" jju@~1.4.0: version "1.4.0" @@ -5482,6 +5484,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + lru-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -5533,10 +5540,10 @@ matrix-mock-request@^2.5.0: dependencies: expect "^28.1.0" -matrix-widget-api@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.1.1.tgz#d3fec45033d0cbc14387a38ba92dac4dbb1be962" - integrity sha512-gNSgmgSwvOsOcWK9k2+tOhEMYBiIMwX95vMZu0JqY7apkM02xrOzUBuPRProzN8CnbIALH7e3GAhatF6QCNvtA== +matrix-widget-api@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.3.1.tgz#e38f404c76bb15c113909505c1c1a5b4d781c2f5" + integrity sha512-+rN6vGvnXm+fn0uq9r2KWSL/aPtehD6ObC50jYmUcEfgo8CUpf9eUurmjbRlwZkWq3XHXFuKQBUCI9UzqWg37Q== dependencies: "@types/events" "^3.0.0" events "^3.2.0" @@ -5637,10 +5644,10 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatc dependencies: brace-expansion "^1.1.7" -minimatch@^6.1.6: - version "6.2.0" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-6.2.0.tgz#2b70fd13294178c69c04dfc05aebdb97a4e79e42" - integrity sha512-sauLxniAmvnhhRjFwPNnJKaPFYyddAgbYdeUpHULtCT/GhzdCx/MDNy+Y40lBxTQUrMzDE8e0S43Z5uqfO0REg== +minimatch@^7.1.3, minimatch@^7.4.1: + version "7.4.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.2.tgz#157e847d79ca671054253b840656720cb733f10f" + integrity sha512-xy4q7wou3vUoC9k1xGTXc+awNdGaGVHtFUaey8tiX4H1QRc04DZ/rmDFwNm2EBsuYEhAZ6SgMmYf3InGY6OauA== dependencies: brace-expansion "^2.0.1" @@ -5654,6 +5661,11 @@ minimist@^1.1.0, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minipass@^4.0.2, minipass@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.4.tgz#7d0d97434b6a19f59c5c3221698b48bbf3b2cd06" + integrity sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ== + mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" @@ -6002,6 +6014,14 @@ path-platform@~0.11.15: resolved "https://registry.yarnpkg.com/path-platform/-/path-platform-0.11.15.tgz#e864217f74c36850f0852b78dc7bf7d4a5721bf2" integrity sha512-Y30dB6rab1A/nfEKsZxmr01nUotHX0c/ZiIAsCTatEe1CmS5Pm5He7fZ195bPT7RdquoaL8lLxFCMQi/bS7IJg== +path-scurry@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.6.1.tgz#dab45f7bb1d3f45a0e271ab258999f4ab7e23132" + integrity sha512-OW+5s+7cw6253Q4E+8qQ/u1fVvcJQCJo/VFD8pje+dbJCF1n5ZRMV2AEHbGp+5Q7jxQIYJxkHopnj6nzdGeZLA== + dependencies: + lru-cache "^7.14.1" + minipass "^4.0.2" + path-to-regexp@^2.2.1: version "2.4.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.4.0.tgz#35ce7f333d5616f1c1e1bfe266c3aba2e5b2e704" @@ -6087,28 +6107,10 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^29.0.0: - version "29.4.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.4.0.tgz#766f071bb1c53f1ef8000c105bbeb649e86eb993" - integrity sha512-J+EVUPXIBHCdWAbvGBwXs0mk3ljGppoh/076g1S8qYS8nVG4u/yrhMvyTFHYYYKWnDdgRLExx0vA7pzxVGdlNw== - dependencies: - "@jest/schemas" "^29.4.0" - ansi-styles "^5.0.0" - react-is "^18.0.0" - -pretty-format@^29.4.0: - version "29.4.2" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.4.2.tgz#64bf5ccc0d718c03027d94ac957bdd32b3fb2401" - integrity sha512-qKlHR8yFVCbcEWba0H0TOC8dnLlO4vPlyEjRPw31FZ2Rupy9nLa8ZLbYny8gWEl8CkEhJqAE6IzdNELTBVcBEg== - dependencies: - "@jest/schemas" "^29.4.2" - ansi-styles "^5.0.0" - react-is "^18.0.0" - -pretty-format@^29.4.2, pretty-format@^29.4.3: - version "29.4.3" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.4.3.tgz#25500ada21a53c9e8423205cf0337056b201244c" - integrity sha512-cvpcHTc42lcsvOOAzd3XuNWTcvk1Jmnzqeu+WsOuiPmxUJTnkbAcFNsRKvEpBEUFVUgy/GTZLulZDcDEi+CIlA== +pretty-format@^29.0.0, pretty-format@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.5.0.tgz#283134e74f70e2e3e7229336de0e4fce94ccde5a" + integrity sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw== dependencies: "@jest/schemas" "^29.4.3" ansi-styles "^5.0.0" @@ -6300,6 +6302,11 @@ punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +pure-rand@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.1.tgz#31207dddd15d43f299fdcdb2f572df65030c19af" + integrity sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg== + querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -6571,9 +6578,9 @@ resolve-from@^5.0.0: integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== resolve.exports@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.0.tgz#c1a0028c2d166ec2fbf7d0644584927e76e7400e" - integrity sha512-6K/gDlqgQscOlg9fSRpWstA8sYe8rbELsSTNpx+3kTrsVCzvSl0zIvRErM7fdl9ERWDsKnrLnwB+Ne89918XOg== + version "2.0.2" + resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" + integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== resolve@^1.1.4, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.20.0, resolve@^1.22.1, resolve@^1.4.0: version "1.22.1" @@ -6622,9 +6629,11 @@ rimraf@^3.0.2: glob "^7.1.3" rimraf@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.1.2.tgz#20dfbc98083bdfaa28b01183162885ef213dbf7c" - integrity sha512-BlIbgFryTbw3Dz6hyoWFhKk+unCcHMSkZGrTFVAx2WmttdBSonsdtRlwiuTbDqTKr+UlXIUqJVS4QT5tUzGENQ== + version "4.3.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.3.1.tgz#ccb3525e39100478acb334fae6d23029b87912ea" + integrity sha512-GfHJHBzFQra23IxDzIdBqhOWfbtdgS1/dCHrDy+yvhpoJY5TdwdT28oWaHWfRpKFDLd3GZnGTx6Mlt4+anbsxQ== + dependencies: + glob "^9.2.0" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.2" @@ -7324,13 +7333,13 @@ typedoc-plugin-missing-exports@^1.0.0: integrity sha512-7s6znXnuAj1eD9KYPyzVzR1lBF5nwAY8IKccP5sdoO9crG4lpd16RoFpLsh2PccJM+I2NASpr0+/NMka6ThwVA== typedoc@^0.23.20: - version "0.23.25" - resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.25.tgz#5f8f1850fd044c4d15d453117affddf11a265610" - integrity sha512-O1he153qVyoCgJYSvIyY3bPP1wAJTegZfa6tL3APinSZhJOf8CSd8F/21M6ex8pUY/fuY6n0jAsT4fIuMGA6sA== + version "0.23.26" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.23.26.tgz#ae082683698bad68757d8fe619242a56d6b5bf36" + integrity sha512-5m4KwR5tOLnk0OtMaRn9IdbeRM32uPemN9kur7YK9wFqx8U0CYrvO9aVq6ysdZSV1c824BTm+BuQl2Ze/k1HtA== dependencies: lunr "^2.3.9" marked "^4.2.12" - minimatch "^6.1.6" + minimatch "^7.1.3" shiki "^0.14.1" typescript@^3.2.2: @@ -7748,9 +7757,9 @@ write-file-atomic@^4.0.2: signal-exit "^3.0.7" ws@^8.11.0: - version "8.12.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.1.tgz#c51e583d79140b5e42e39be48c934131942d4a8f" - integrity sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew== + version "8.13.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" + integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== xml-name-validator@^4.0.0: version "4.0.0"