From bac13d58ada1889326be0f11854e620bfe63dccc Mon Sep 17 00:00:00 2001 From: Thomas Frivold Date: Wed, 29 Jul 2026 12:04:39 +0200 Subject: [PATCH] common: Add pre call bandwidth test --- .changeset/heavy-pugs-count.md | 6 + packages/browser-sdk/README.md | 68 +++++ packages/browser-sdk/src/lib/react/index.ts | 13 + .../src/lib/react/usePreCallTest/index.ts | 55 ++++ .../lib/react/usePreCallTest/initialState.ts | 7 + .../src/lib/react/usePreCallTest/types.ts | 16 + .../components/PreCallTestExperience.tsx | 89 ++++++ .../src/stories/pre-call-test.stories.tsx | 133 +++++++++ packages/browser-sdk/src/stories/styles.css | 22 ++ packages/core/README.md | 25 ++ .../core/src/client/PreCallTest/events.ts | 12 + packages/core/src/client/PreCallTest/index.ts | 99 +++++++ .../core/src/client/PreCallTest/selector.ts | 17 ++ packages/core/src/client/PreCallTest/types.ts | 19 ++ packages/core/src/client/WherebyClient.ts | 8 + packages/core/src/index.ts | 3 + packages/core/src/redux/index.ts | 1 + packages/core/src/redux/slices/preCallTest.ts | 279 ++++++++++++++++++ packages/core/src/redux/store.ts | 2 + .../src/redux/tests/store/preCallTest.spec.ts | 195 ++++++++++++ 20 files changed, 1069 insertions(+) create mode 100644 .changeset/heavy-pugs-count.md create mode 100644 packages/browser-sdk/src/lib/react/usePreCallTest/index.ts create mode 100644 packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts create mode 100644 packages/browser-sdk/src/lib/react/usePreCallTest/types.ts create mode 100644 packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx create mode 100644 packages/browser-sdk/src/stories/pre-call-test.stories.tsx create mode 100644 packages/core/src/client/PreCallTest/events.ts create mode 100644 packages/core/src/client/PreCallTest/index.ts create mode 100644 packages/core/src/client/PreCallTest/selector.ts create mode 100644 packages/core/src/client/PreCallTest/types.ts create mode 100644 packages/core/src/redux/slices/preCallTest.ts create mode 100644 packages/core/src/redux/tests/store/preCallTest.spec.ts diff --git a/.changeset/heavy-pugs-count.md b/.changeset/heavy-pugs-count.md new file mode 100644 index 000000000..aa612311e --- /dev/null +++ b/.changeset/heavy-pugs-count.md @@ -0,0 +1,6 @@ +--- +"@whereby.com/browser-sdk": minor +"@whereby.com/core": minor +--- + +Add a headless pre-call network test: `PreCallTestClient` (via `client.getPreCallTest()`) in core, and the `usePreCallTest` hook in browser-sdk. It measures available bitrate and packet loss against the Whereby media servers before joining a room, without needing a room or local media. diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 59ec81c64..1e916bd22 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -66,6 +66,74 @@ function MyPreCallUX() { } ``` +#### usePreCallTest + +The `usePreCallTest` hook measures the connection between the end user and the +Whereby media servers, so you can warn people about a poor network before they +join a room. It needs no room and no local media, and runs on its own +connection. + +Because the test saturates the connection to measure it, run it *before* +joining — running it during a call takes bandwidth away from the call. + +```js +import { usePreCallTest } from "@whereby.com/browser-sdk/react"; + +function MyNetworkCheck() { + const { state, actions } = usePreCallTest(); + const { status, result, error } = state; + + return ( +
+ + + {status === "completed" && result.success &&

Your connection looks good.

} + {status === "completed" && result.warning && ( +

+ Your connection may struggle: {result.details.recvAvailableBitrate.toFixed(1)} Mbps down,{" "} + {(result.details.recvLoss * 100).toFixed(1)}% packet loss. +

+ )} + {status === "failed" &&

Could not test your connection: {error.message}

} +
+ ); +} +``` + +The test takes 15 seconds by default; pass `{ durationSeconds }` to +`startTest()` to change that (minimum 10 — below that there is not enough media +to report on). `startTest()` also resolves with the same result object, if you +prefer to await it rather than read `state`. Call `actions.stopTest()` to abort +a run. + +`result.success` means no problems were found. `result.warning` means the test +completed but the connection is degraded — the flags in `result.details` say +which check tripped: `lowRecvAvailableBitrate` (below 1.5 Mbps), `highSendLoss` +or `highRecvLoss` (above 3% packet loss). A test that could not produce a +verdict at all sets `status` to `"failed"` and fills in `error`. + +##### Checking camera, microphone and speakers + +Unlike the network test, whether a camera or microphone *works* is a judgement +only the end user can make — they have to see themselves and hear themselves. +The SDK gives you the pieces to build that; the verdict comes from your UI: + +- **Camera** — render `localMedia.state.localStream` in a `` and let + the user switch between `cameraDevices`, then ask "can you see yourself?". +- **Speakers** — play a sound and route it to the chosen device with + [`HTMLMediaElement.setSinkId()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId) + using `localMedia.state.currentSpeakerDeviceId`, then ask "can you hear it?". +- **Microphone** — feed the local stream's audio track into a Web Audio + [`AnalyserNode`](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode) + to draw a level meter, or record a few seconds with `MediaRecorder` and play + it back, then ask "can you hear yourself?". + +Device errors that *are* machine-detectable — blocked permissions, a device +that fails to open — surface on `useLocalMedia` as `cameraDeviceError`, +`microphoneDeviceError` and `startError`. + #### useRoomConnection The `useRoomConnection` hook provides a way to connect participants in a given diff --git a/packages/browser-sdk/src/lib/react/index.ts b/packages/browser-sdk/src/lib/react/index.ts index 4513df6e7..c2a2248aa 100644 --- a/packages/browser-sdk/src/lib/react/index.ts +++ b/packages/browser-sdk/src/lib/react/index.ts @@ -2,6 +2,7 @@ export { Provider as WherebyProvider } from "./Provider"; export { VideoView } from "./VideoView"; export { useRoomConnection } from "./useRoomConnection"; export { useLocalMedia } from "./useLocalMedia"; +export { usePreCallTest } from "./usePreCallTest"; export { Grid as VideoGrid, GridCell, GridVideoView } from "./Grid"; export { MAX_FILES_PER_UPLOAD, MAX_FILE_SIZE, ACCEPTED_FILE_TYPES } from "@whereby.com/core"; export { @@ -15,8 +16,20 @@ export { getUsableCameraEffectPresets, isAudioDenoiserSupported } from "@whereby export type { UseLocalMediaResult } from "./useLocalMedia/types"; +export type { UsePreCallTestOptions, UsePreCallTestResult } from "./usePreCallTest/types"; + export type { RoomConnectionActions, RoomConnectionOptions } from "./useRoomConnection/types"; +export type { + PreCallTestDetails, + PreCallTestError, + PreCallTestErrorReason, + PreCallTestOptions, + PreCallTestResult, + PreCallTestState, + PreCallTestStatus, +} from "@whereby.com/core"; + export type { ChatMessageState as ChatMessage, ChatFileShare, diff --git a/packages/browser-sdk/src/lib/react/usePreCallTest/index.ts b/packages/browser-sdk/src/lib/react/usePreCallTest/index.ts new file mode 100644 index 000000000..31f4ca651 --- /dev/null +++ b/packages/browser-sdk/src/lib/react/usePreCallTest/index.ts @@ -0,0 +1,55 @@ +import * as React from "react"; +import { PreCallTestOptions, PreCallTestState } from "@whereby.com/core"; +import { UsePreCallTestResult } from "./types"; +import { WherebyContext } from "../Provider"; +import { initialState } from "./initialState"; + +/** + * Measures the connection between this client and the Whereby media servers, so + * you can warn people about a bad network before they join a room. + * + * The test is never started for you - call `actions.startTest()`. It needs no + * room and no local media, but it does compete for bandwidth with any call + * already in progress, so run it before joining. + */ +export function usePreCallTest(): UsePreCallTestResult { + const client = React.useContext(WherebyContext)?.getPreCallTest(); + const [preCallTestState, setPreCallTestState] = React.useState(() => initialState); + + if (!client) { + throw new Error("WherebyClient is not initialized. Please wrap your component with WherebyProvider."); + } + + const isRunning = preCallTestState.status === "running"; + const isRunningRef = React.useRef(isRunning); + isRunningRef.current = isRunning; + + React.useEffect(() => { + // A test may already be running - started elsewhere, or before this + // component mounted. + setPreCallTestState(client.getState()); + + const unsubscribe = client.subscribe(setPreCallTestState); + + return () => { + unsubscribe(); + + // Leave a finished test's result in place for anyone else reading it; + // only tear down a test that is still consuming bandwidth. + if (isRunningRef.current) { + client.stopTest(); + } + }; + }, [client]); + + const startTest = React.useCallback((options?: PreCallTestOptions) => client.startTest(options), [client]); + const stopTest = React.useCallback(() => client.stopTest(), [client]); + + return { + state: preCallTestState, + actions: { + startTest, + stopTest, + }, + }; +} diff --git a/packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts b/packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts new file mode 100644 index 000000000..3c9ee4d7c --- /dev/null +++ b/packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts @@ -0,0 +1,7 @@ +import { PreCallTestState } from "@whereby.com/core"; + +export const initialState: PreCallTestState = { + status: "idle", + result: null, + error: null, +}; diff --git a/packages/browser-sdk/src/lib/react/usePreCallTest/types.ts b/packages/browser-sdk/src/lib/react/usePreCallTest/types.ts new file mode 100644 index 000000000..b4f3bedb8 --- /dev/null +++ b/packages/browser-sdk/src/lib/react/usePreCallTest/types.ts @@ -0,0 +1,16 @@ +import { PreCallTestOptions, PreCallTestResult, PreCallTestState } from "@whereby.com/core"; + +interface PreCallTestActions { + /** + * Runs a test and resolves with its result, or with `null` if it could not + * complete - `state.error` says why. A call made while a test is running + * resolves with `null` and leaves the running test alone. + */ + startTest: (options?: PreCallTestOptions) => Promise; + /** Aborts a running test and returns to the idle state. */ + stopTest: () => void; +} + +export type UsePreCallTestResult = { state: PreCallTestState; actions: PreCallTestActions }; + +export type UsePreCallTestOptions = PreCallTestOptions; diff --git a/packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx b/packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx new file mode 100644 index 000000000..be0512330 --- /dev/null +++ b/packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx @@ -0,0 +1,89 @@ +import * as React from "react"; +import { usePreCallTest } from "../../lib/react"; +import type { PreCallTestDetails, PreCallTestResult } from "../../lib/react"; + +function Verdict({ result }: { result: PreCallTestResult }) { + if (result.success) { + return Connection looks good; + } + + if (result.warning) { + return Connection is degraded; + } + + return Connection is too poor for a call; +} + +function Details({ details }: { details: PreCallTestDetails }) { + const rows: Array<[string, string, boolean]> = [ + ["Test time", `${(details.testTime / 1000).toFixed(1)} s`, false], + [ + "Available downstream bitrate", + `${details.recvAvailableBitrate.toFixed(2)} Mbps`, + details.lowRecvAvailableBitrate, + ], + ["Upstream packet loss", `${(details.sendLoss * 100).toFixed(1)} %`, details.highSendLoss], + ["Downstream packet loss", `${(details.recvLoss * 100).toFixed(1)} %`, details.highRecvLoss], + ]; + + return ( + + + {rows.map(([label, value, isBad]) => ( + + + + + ))} + +
{label}{value}
+ ); +} + +export default function PreCallTestExperience({ + durationSeconds, + sfuServerOverrideHost, +}: { + durationSeconds?: number; + sfuServerOverrideHost?: string; +}) { + const { + state: { status, result, error }, + actions: { startTest, stopTest }, + } = usePreCallTest(); + + const isRunning = status === "running"; + + return ( +
+

Pre-call test

+

+ Measures the connection between this client and the Whereby media servers. It needs no room and no local + media, but it competes for bandwidth with any call in progress - run it before joining. +

+
+ + +
+

+ Status: {status} + {isRunning && ` (running for up to ${durationSeconds ?? 15} s)`} +

+ {error && ( +

+ Test failed ({error.reason}): {error.message} +

+ )} + {result && ( +
+ +
+
+ )} +
+ ); +} diff --git a/packages/browser-sdk/src/stories/pre-call-test.stories.tsx b/packages/browser-sdk/src/stories/pre-call-test.stories.tsx new file mode 100644 index 000000000..eda5d731d --- /dev/null +++ b/packages/browser-sdk/src/stories/pre-call-test.stories.tsx @@ -0,0 +1,133 @@ +import * as React from "react"; + +import { Meta } from "@storybook/react-vite"; +import "./styles.css"; +import { Provider as WherebyProvider } from "../lib/react/Provider"; +import { usePreCallTest } from "../lib/react"; +import PreCallTestExperience from "./components/PreCallTestExperience"; +import VideoExperience from "./components/VideoExperience"; + +const defaultArgs: Meta = { + title: "Examples/Pre-call test", + argTypes: { + durationSeconds: { control: { type: "number", min: 10 } }, + sfuServerOverrideHost: { control: "text" }, + roomUrl: { control: "text" }, + displayName: { control: "text" }, + }, + args: { + durationSeconds: 15, + sfuServerOverrideHost: "", + roomUrl: process.env.STORYBOOK_ROOM, + displayName: "SDK", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default defaultArgs; + +const roomRegEx = new RegExp(/^https:\/\/.*\/.*/); + +export const BandwidthTest = ({ + durationSeconds, + sfuServerOverrideHost, +}: { + durationSeconds?: number; + sfuServerOverrideHost?: string; +}) => { + return ( + + ); +}; + +/** + * The test survives the component that started it: unmounting a finished test + * keeps its result around for the next reader, while unmounting a running test + * stops it so it no longer eats bandwidth. + */ +export const BandwidthTestUnmount = ({ + durationSeconds, + sfuServerOverrideHost, +}: { + durationSeconds?: number; + sfuServerOverrideHost?: string; +}) => { + const [isMounted, setIsMounted] = React.useState(true); + + return ( +
+
+ +
+ {isMounted ? ( + + ) : ( +

Unmounted. Mount again to see the state the test was left in.

+ )} +
+ ); +}; + +/** + * Warn people about a bad connection before they join. Run the test first, then + * let them join anyway if they want to. + */ +export const BandwidthTestBeforeJoiningRoom = ({ + durationSeconds, + roomUrl, + displayName, +}: { + durationSeconds?: number; + roomUrl: string; + displayName?: string; +}) => { + const { + state: { status, result }, + actions: { startTest }, + } = usePreCallTest(); + const [hasJoined, setHasJoined] = React.useState(false); + + if (!roomUrl || !roomUrl.match(roomRegEx)) { + return

Set room url on the Controls panel

; + } + + if (hasJoined) { + return ; + } + + return ( +
+

Check your connection before joining

+
+ + +
+ {status === "failed" &&

We could not test your connection. You can still join.

} + {result && + (result.success ? ( +

Your connection looks good.

+ ) : ( +

+ Your connection may struggle with video ({result.details.recvAvailableBitrate.toFixed(2)} Mbps + available). Consider turning your camera off. +

+ ))} +
+ ); +}; diff --git a/packages/browser-sdk/src/stories/styles.css b/packages/browser-sdk/src/stories/styles.css index 3ebef5d04..9e68f3cc7 100644 --- a/packages/browser-sdk/src/stories/styles.css +++ b/packages/browser-sdk/src/stories/styles.css @@ -197,3 +197,25 @@ margin: 0; padding-left: 18px; } + +.preCallTestVerdictSuccess { + color: #0a7d38; +} + +.preCallTestVerdictWarning { + color: #b06000; +} + +.preCallTestVerdictFailure { + color: #b00020; +} + +.preCallTestDetails { + margin-top: 8px; + border-collapse: collapse; +} + +.preCallTestDetails td { + padding: 2px 12px 2px 0; + font-size: 14px; +} diff --git a/packages/core/README.md b/packages/core/README.md index 70f24c6ac..b20a2eb06 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -41,6 +41,9 @@ const localMedia = client.getLocalMedia(); // manage room connection const roomConnection = client.getRoomConnection(); + +// check the network before joining +const preCallTest = client.getPreCallTest(); ``` ### Core Concepts @@ -83,6 +86,28 @@ roomConnection.subscribeToConnectionStatus((state) => { }); ``` +#### Pre-call Test + +The `PreCallTestClient` measures the connection between this client and the Whereby media servers, so you can warn people about a poor network before they join. It needs no room and no local media, but it does need a browser environment, and it competes for bandwidth with any call already in progress — run it before joining. + +Typical usage: + +```js +// runs for 15 seconds by default; pass { durationSeconds } to change it (minimum 10) +const result = await preCallTest.startTest(); + +if (result?.warning) { + console.log("Degraded connection", result.details); +} + +// or drive your UI from state instead of awaiting +preCallTest.subscribeStatus((status) => { + console.log("Pre-call test:", status); // idle | running | completed | failed +}); +``` + +Whether a camera or microphone *works* is a judgement only the end user can make, so there is no equivalent client for those — build that on `LocalMediaClient` and let the user confirm what they see and hear. Errors that are machine-detectable, like blocked permissions, surface on `LocalMediaClient` as `cameraDeviceError`, `microphoneDeviceError` and `startError`. + ### Learn More - Core SDK API Reference: [API Documentation](https://docs.whereby.com/reference/core-sdk-reference) diff --git a/packages/core/src/client/PreCallTest/events.ts b/packages/core/src/client/PreCallTest/events.ts new file mode 100644 index 000000000..22f390817 --- /dev/null +++ b/packages/core/src/client/PreCallTest/events.ts @@ -0,0 +1,12 @@ +import type { PreCallTestError, PreCallTestResult, PreCallTestStatus } from "../../redux/slices/preCallTest"; + +/* Pre-call test events */ +export const PRE_CALL_TEST_STATUS_CHANGED = "pre-call-test:status-changed"; +export const PRE_CALL_TEST_RESULT_CHANGED = "pre-call-test:result-changed"; +export const PRE_CALL_TEST_ERROR_CHANGED = "pre-call-test:error-changed"; + +export type PreCallTestEvents = { + [PRE_CALL_TEST_STATUS_CHANGED]: [status: PreCallTestStatus]; + [PRE_CALL_TEST_RESULT_CHANGED]: [result: PreCallTestResult | null]; + [PRE_CALL_TEST_ERROR_CHANGED]: [error: PreCallTestError | null]; +}; diff --git a/packages/core/src/client/PreCallTest/index.ts b/packages/core/src/client/PreCallTest/index.ts new file mode 100644 index 000000000..d3280d090 --- /dev/null +++ b/packages/core/src/client/PreCallTest/index.ts @@ -0,0 +1,99 @@ +import { doStartPreCallTest, doStopPreCallTest, type PreCallTestOptions, type PreCallTestResult } from "../../redux"; +import type { Store as AppStore } from "../../redux/store"; +import { BaseClient } from "../BaseClient"; +import { + PRE_CALL_TEST_ERROR_CHANGED, + PRE_CALL_TEST_RESULT_CHANGED, + PRE_CALL_TEST_STATUS_CHANGED, + type PreCallTestEvents, +} from "./events"; +import { selectPreCallTestState } from "./selector"; +import type { PreCallTestError, PreCallTestState, PreCallTestStatus } from "./types"; + +/** + * Measures the connection between this client and the Whereby media servers, + * so you can warn people about a bad network before they join a room. + * + * The test runs on its own connection - it needs no room and no local media - + * but it does need a browser environment, and it competes for bandwidth with + * any call already in progress. + */ +export class PreCallTestClient extends BaseClient { + private statusSubscribers = new Set<(status: PreCallTestStatus) => void>(); + private resultSubscribers = new Set<(result: PreCallTestResult | null) => void>(); + private errorSubscribers = new Set<(error: PreCallTestError | null) => void>(); + + constructor(store: AppStore) { + super(store); + } + + protected handleStateChanges(state: PreCallTestState, previousState: PreCallTestState): void { + if (state.status !== previousState.status) { + this.statusSubscribers.forEach((cb) => cb(state.status)); + this.emit(PRE_CALL_TEST_STATUS_CHANGED, state.status); + } + + if (state.result !== previousState.result) { + this.resultSubscribers.forEach((cb) => cb(state.result)); + this.emit(PRE_CALL_TEST_RESULT_CHANGED, state.result); + } + + if (state.error !== previousState.error) { + this.errorSubscribers.forEach((cb) => cb(state.error)); + this.emit(PRE_CALL_TEST_ERROR_CHANGED, state.error); + } + } + + public getState(): PreCallTestState { + return selectPreCallTestState(this.store.getState()); + } + + /* Subscriptions */ + + public subscribeStatus(callback: (status: PreCallTestStatus) => void): () => void { + this.statusSubscribers.add(callback); + + return () => this.statusSubscribers.delete(callback); + } + + public subscribeResult(callback: (result: PreCallTestResult | null) => void): () => void { + this.resultSubscribers.add(callback); + + return () => this.resultSubscribers.delete(callback); + } + + public subscribeError(callback: (error: PreCallTestError | null) => void): () => void { + this.errorSubscribers.add(callback); + + return () => this.errorSubscribers.delete(callback); + } + + /* Actions */ + + /** + * Runs a test and resolves with its result, or with `null` if the test could + * not complete - read `getState().error` for why. Calling this while a test + * is already running resolves with `null` and leaves that test alone. + */ + public async startTest(options?: PreCallTestOptions): Promise { + return await this.store.dispatch(doStartPreCallTest(options)).unwrap(); + } + + /** Aborts a running test and returns to the idle state. */ + public stopTest() { + return this.store.dispatch(doStopPreCallTest()); + } + + /** + * Destroy the PreCallTestClient instance. + * This method cleans up any resources and event listeners. + */ + public destroy() { + super.destroy(); + this.stopTest(); + this.removeAllListeners(); + this.statusSubscribers.clear(); + this.resultSubscribers.clear(); + this.errorSubscribers.clear(); + } +} diff --git a/packages/core/src/client/PreCallTest/selector.ts b/packages/core/src/client/PreCallTest/selector.ts new file mode 100644 index 000000000..cd3945bfe --- /dev/null +++ b/packages/core/src/client/PreCallTest/selector.ts @@ -0,0 +1,17 @@ +import { createSelector } from "@reduxjs/toolkit"; +import { selectPreCallTestError, selectPreCallTestResult, selectPreCallTestStatus } from "../../redux"; +import { PreCallTestState } from "./types"; + +export const selectPreCallTestState = createSelector( + selectPreCallTestStatus, + selectPreCallTestResult, + selectPreCallTestError, + (status, result, error) => { + const state: PreCallTestState = { + status, + result, + error, + }; + return state; + }, +); diff --git a/packages/core/src/client/PreCallTest/types.ts b/packages/core/src/client/PreCallTest/types.ts new file mode 100644 index 000000000..733d45d82 --- /dev/null +++ b/packages/core/src/client/PreCallTest/types.ts @@ -0,0 +1,19 @@ +import type { PreCallTestError, PreCallTestResult, PreCallTestStatus } from "../../redux/slices/preCallTest"; + +export interface PreCallTestState { + /** `idle` before the first run, then `running`, `completed` or `failed`. */ + status: PreCallTestStatus; + /** Set once a test completes. Cleared when a new test starts. */ + result: PreCallTestResult | null; + /** Set when a test could not produce a result. Cleared when a new test starts. */ + error: PreCallTestError | null; +} + +export type { + PreCallTestDetails, + PreCallTestError, + PreCallTestErrorReason, + PreCallTestOptions, + PreCallTestResult, + PreCallTestStatus, +} from "../../redux/slices/preCallTest"; diff --git a/packages/core/src/client/WherebyClient.ts b/packages/core/src/client/WherebyClient.ts index e9b6b413a..2143b989b 100644 --- a/packages/core/src/client/WherebyClient.ts +++ b/packages/core/src/client/WherebyClient.ts @@ -2,6 +2,7 @@ import { createStore, type Store as AppStore } from "../redux/store"; import { createServices } from "../services"; import { GridClient } from "./Grid"; import { LocalMediaClient } from "./LocalMedia"; +import { PreCallTestClient } from "./PreCallTest"; import { RoomConnectionClient } from "./RoomConnection"; export class WherebyClient { @@ -10,6 +11,7 @@ export class WherebyClient { private localMediaClient: LocalMediaClient; protected roomConnectionClient: RoomConnectionClient; private gridClient: GridClient; + private preCallTestClient: PreCallTestClient; constructor() { this.services = createServices(); @@ -18,6 +20,7 @@ export class WherebyClient { this.localMediaClient = new LocalMediaClient(this.store); this.roomConnectionClient = new RoomConnectionClient(this.store); this.gridClient = new GridClient(this.store); + this.preCallTestClient = new PreCallTestClient(this.store); } public getLocalMedia(): LocalMediaClient { @@ -32,6 +35,10 @@ export class WherebyClient { return this.gridClient; } + public getPreCallTest(): PreCallTestClient { + return this.preCallTestClient; + } + public getStore(): AppStore { return this.store; } @@ -40,5 +47,6 @@ export class WherebyClient { this.localMediaClient.destroy(); this.roomConnectionClient.destroy(); this.gridClient.destroy(); + this.preCallTestClient.destroy(); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 446d72c97..dcb85d2b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,9 @@ export * from "./client/LocalMedia/events"; export * from "./client/RoomConnection"; export * from "./client/RoomConnection/types"; export * from "./client/RoomConnection/events"; +export * from "./client/PreCallTest"; +export * from "./client/PreCallTest/types"; +export * from "./client/PreCallTest/events"; export * from "./client/Grid"; export * from "./client/Grid/types"; export * from "./client/Grid/events"; diff --git a/packages/core/src/redux/index.ts b/packages/core/src/redux/index.ts index 4f9e913a8..f97528777 100644 --- a/packages/core/src/redux/index.ts +++ b/packages/core/src/redux/index.ts @@ -19,6 +19,7 @@ export * from "./slices/localParticipant/selectors"; export * from "./slices/localScreenshare"; export * from "./slices/notifications"; export * from "./slices/organization"; +export * from "./slices/preCallTest"; export * from "./slices/remoteParticipants"; export * from "./slices/room"; export * from "./slices/roomConnection"; diff --git a/packages/core/src/redux/slices/preCallTest.ts b/packages/core/src/redux/slices/preCallTest.ts new file mode 100644 index 000000000..e643fa07e --- /dev/null +++ b/packages/core/src/redux/slices/preCallTest.ts @@ -0,0 +1,279 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { BandwidthTester } from "@whereby.com/media"; +import { createAppAsyncThunk } from "../thunk"; +import { RootState } from "../store"; +import { startAppListening } from "../listenerMiddleware"; +import { doAppStop } from "./app"; +import { selectIsHDModeEnabled, selectIsLowDataModeEnabled } from "./localMedia"; + +export const MIN_PRE_CALL_TEST_DURATION_S = 10; +export const DEFAULT_PRE_CALL_TEST_DURATION_S = 15; + +export type PreCallTestStatus = "idle" | "running" | "completed" | "failed"; + +export type PreCallTestErrorReason = "timeout" | "unsupported" | "invalid-duration" | "unknown"; + +export interface PreCallTestError { + reason: PreCallTestErrorReason; + message: string; +} + +export interface PreCallTestDetails { + testTime: number; + recvAvailableBitrate: number; + lowRecvAvailableBitrate: boolean; + sendLoss: number; + recvLoss: number; + highSendLoss: boolean; + highRecvLoss: boolean; +} + +export interface PreCallTestResult { + success: boolean; + warning: boolean; + details: PreCallTestDetails; +} + +export interface PreCallTestOptions { + durationSeconds?: number; + sfuServerOverrideHost?: string; +} + +export interface PreCallTestState { + status: PreCallTestStatus; + result: PreCallTestResult | null; + error: PreCallTestError | null; + raw: { + tester?: BandwidthTester; + }; +} + +const initialState: PreCallTestState = { + status: "idle", + result: null, + error: null, + raw: {}, +}; + +export const preCallTestSlice = createSlice({ + name: "preCallTest", + initialState, + reducers: { + preCallTestStarted(state, action: PayloadAction<{ tester: BandwidthTester }>) { + state.status = "running"; + state.result = null; + state.error = null; + // Immer leaves class instances alone at runtime; the cast is only to + // stop it expanding BandwidthTester into a WritableDraft. + state.raw = { tester: action.payload.tester } as typeof state.raw; + }, + preCallTestCompleted(state, action: PayloadAction<{ result: PreCallTestResult }>) { + state.status = "completed"; + state.result = action.payload.result; + state.error = null; + state.raw = {}; + }, + preCallTestFailed(state, action: PayloadAction<{ error: PreCallTestError }>) { + state.status = "failed"; + state.result = null; + state.error = action.payload.error; + state.raw = {}; + }, + preCallTestStopped(state) { + state.status = "idle"; + state.result = null; + state.error = null; + state.raw = {}; + }, + }, +}); + +export const { preCallTestStarted, preCallTestCompleted, preCallTestFailed, preCallTestStopped } = + preCallTestSlice.actions; + +/** + * Selectors + */ + +export const selectPreCallTestRaw = (state: RootState) => state.preCallTest.raw; +export const selectPreCallTestStatus = (state: RootState) => state.preCallTest.status; +export const selectPreCallTestResult = (state: RootState) => state.preCallTest.result; +export const selectPreCallTestError = (state: RootState) => state.preCallTest.error; +export const selectIsPreCallTestRunning = (state: RootState) => state.preCallTest.status === "running"; + +/** + * Thunks + */ + +export const doStartPreCallTest = createAppAsyncThunk( + "preCallTest/start", + async (options, { dispatch, getState }) => { + const state = getState(); + + if (selectIsPreCallTestRunning(state)) { + return null; + } + + const durationSeconds = options?.durationSeconds ?? DEFAULT_PRE_CALL_TEST_DURATION_S; + + if (!Number.isFinite(durationSeconds) || durationSeconds < MIN_PRE_CALL_TEST_DURATION_S) { + dispatch( + preCallTestFailed({ + error: { + reason: "invalid-duration", + message: `durationSeconds must be at least ${MIN_PRE_CALL_TEST_DURATION_S}, got ${durationSeconds}`, + }, + }), + ); + return null; + } + + if (typeof document === "undefined") { + dispatch( + preCallTestFailed({ + error: { + reason: "unsupported", + message: "The pre-call test needs a browser environment and is not available here", + }, + }), + ); + return null; + } + + let tester: BandwidthTester; + + try { + tester = new BandwidthTester({ + features: { + sfuServerOverrideHost: options?.sfuServerOverrideHost, + sfuVp9On: false, + h264On: false, + simulcastScreenshareOn: false, + lowDataModeEnabled: selectIsLowDataModeEnabled(state) || !selectIsHDModeEnabled(state), + }, + }); + } catch (error) { + dispatch( + preCallTestFailed({ + error: { + reason: "unknown", + message: error instanceof Error ? error.message : "Failed to set up the pre-call test", + }, + }), + ); + return null; + } + + dispatch(preCallTestStarted({ tester })); + + return await new Promise((resolve) => { + let settled = false; + + const settle = (value: PreCallTestResult | null) => { + if (settled) { + return; + } + settled = true; + tester.removeListener("result", onResult); + tester.removeListener("close", onClose); + resolve(value); + }; + + const wasStopped = () => selectPreCallTestStatus(getState()) !== "running"; + + const onResult = (result: { + error?: boolean; + success?: boolean; + warning?: boolean; + details?: Partial & { timeout?: boolean }; + }) => { + if (wasStopped()) { + settle(null); + return; + } + + if (result?.error) { + dispatch( + preCallTestFailed({ + error: { + reason: result.details?.timeout ? "timeout" : "unknown", + message: result.details?.timeout + ? "Timed out connecting to the Whereby media servers" + : "The connection to the Whereby media servers failed", + }, + }), + ); + settle(null); + return; + } + + const preCallTestResult: PreCallTestResult = { + success: !!result.success, + warning: !!result.warning, + details: { + testTime: result.details?.testTime ?? 0, + recvAvailableBitrate: result.details?.recvAvailableBitrate ?? 0, + lowRecvAvailableBitrate: !!result.details?.lowRecvAvailableBitrate, + sendLoss: result.details?.sendLoss ?? 0, + recvLoss: result.details?.recvLoss ?? 0, + highSendLoss: !!result.details?.highSendLoss, + highRecvLoss: !!result.details?.highRecvLoss, + }, + }; + + dispatch(preCallTestCompleted({ result: preCallTestResult })); + settle(preCallTestResult); + }; + + const onClose = () => { + if (wasStopped()) { + settle(null); + return; + } + + dispatch( + preCallTestFailed({ + error: { + reason: "unknown", + message: "The pre-call test ended before reporting a result", + }, + }), + ); + settle(null); + }; + + tester.on("result", onResult); + tester.on("close", onClose); + + try { + tester.start(durationSeconds); + } catch (error) { + dispatch( + preCallTestFailed({ + error: { + reason: "unknown", + message: error instanceof Error ? error.message : "Failed to start the pre-call test", + }, + }), + ); + settle(null); + } + }); + }, +); + +export const doStopPreCallTest = createAppAsyncThunk("preCallTest/stop", async (_, { dispatch, getState }) => { + const { tester } = selectPreCallTestRaw(getState()); + + dispatch(preCallTestStopped()); + tester?.close(); +}); + +startAppListening({ + actionCreator: doAppStop, + effect: (_, { dispatch, getState }) => { + if (selectPreCallTestRaw(getState()).tester) { + dispatch(doStopPreCallTest()); + } + }, +}); diff --git a/packages/core/src/redux/store.ts b/packages/core/src/redux/store.ts index 420875516..327b2b281 100644 --- a/packages/core/src/redux/store.ts +++ b/packages/core/src/redux/store.ts @@ -17,6 +17,7 @@ import { localParticipantSlice } from "./slices/localParticipant"; import { localScreenshareSlice } from "./slices/localScreenshare"; import { notificationsSlice } from "./slices/notifications"; import { organizationSlice } from "./slices/organization"; +import { preCallTestSlice } from "./slices/preCallTest"; import { remoteParticipantsSlice } from "./slices/remoteParticipants"; import { roomSlice } from "./slices/room"; import { roomConnectionSlice } from "./slices/roomConnection"; @@ -49,6 +50,7 @@ const appReducer = combineReducers({ localScreenshare: localScreenshareSlice.reducer, notifications: notificationsSlice.reducer, organization: organizationSlice.reducer, + preCallTest: preCallTestSlice.reducer, remoteParticipants: remoteParticipantsSlice.reducer, room: roomSlice.reducer, roomConnection: roomConnectionSlice.reducer, diff --git a/packages/core/src/redux/tests/store/preCallTest.spec.ts b/packages/core/src/redux/tests/store/preCallTest.spec.ts new file mode 100644 index 000000000..dc684bff4 --- /dev/null +++ b/packages/core/src/redux/tests/store/preCallTest.spec.ts @@ -0,0 +1,195 @@ +import { BandwidthTester } from "@whereby.com/media"; +import { + doStartPreCallTest, + doStopPreCallTest, + selectPreCallTestError, + selectPreCallTestResult, + selectPreCallTestStatus, +} from "../../slices/preCallTest"; +import { createStore } from "../store.setup"; + +jest.mock("@whereby.com/media", () => { + const { EventEmitter } = jest.requireActual("events"); + + return { + __esModule: true, + getStream: jest.fn(() => Promise.resolve()), + getUpdatedDevices: jest.fn(() => Promise.resolve({ addedDevices: {}, changedDevices: {} })), + getDeviceData: jest.fn(() => ({})), + BandwidthTester: jest.fn().mockImplementation(() => { + const tester = new EventEmitter(); + tester.start = jest.fn(); + tester.close = jest.fn(() => tester.emit("close")); + return tester; + }), + }; +}); + +const mockedBandwidthTester = BandwidthTester as unknown as jest.Mock; + +// Flush queued microtasks so the thunk reaches its event listeners. +const flushMicrotasks = async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +}; + +const startTest = async (store: ReturnType, options?: { durationSeconds?: number }) => { + const dispatched = store.dispatch(doStartPreCallTest(options)); + await flushMicrotasks(); + + return { + dispatched, + tester: mockedBandwidthTester.mock.results.at(-1)?.value, + }; +}; + +const successResult = { + success: true, + warning: false, + details: { + testTime: 15000, + recvAvailableBitrate: 5, + lowRecvAvailableBitrate: false, + sendLoss: 0, + recvLoss: 0, + highSendLoss: false, + highRecvLoss: false, + }, +}; + +describe("preCallTest", () => { + describe("doStartPreCallTest", () => { + it("runs for 15 seconds by default", async () => { + const store = createStore(); + + const { tester } = await startTest(store); + + expect(tester.start).toHaveBeenCalledWith(15); + expect(selectPreCallTestStatus(store.getState())).toEqual("running"); + }); + + it("rejects a duration shorter than the tester can report on", async () => { + const store = createStore(); + + const { dispatched } = await startTest(store, { durationSeconds: 5 }); + + expect(await dispatched.unwrap()).toEqual(null); + expect(mockedBandwidthTester).not.toHaveBeenCalled(); + expect(selectPreCallTestStatus(store.getState())).toEqual("failed"); + expect(selectPreCallTestError(store.getState())).toEqual({ + reason: "invalid-duration", + message: "durationSeconds must be at least 10, got 5", + }); + }); + + it("reports the result of a completed test", async () => { + const store = createStore(); + + const { dispatched, tester } = await startTest(store); + tester.emit("result", successResult); + + expect(await dispatched.unwrap()).toEqual(successResult); + expect(selectPreCallTestStatus(store.getState())).toEqual("completed"); + expect(selectPreCallTestResult(store.getState())).toEqual(successResult); + expect(selectPreCallTestError(store.getState())).toEqual(null); + }); + + it("fills in details the tester left out", async () => { + const store = createStore(); + + const { dispatched, tester } = await startTest(store); + tester.emit("result", { warning: true, details: { recvAvailableBitrate: 0.5 } }); + + expect(await dispatched.unwrap()).toEqual({ + success: false, + warning: true, + details: { + testTime: 0, + recvAvailableBitrate: 0.5, + lowRecvAvailableBitrate: false, + sendLoss: 0, + recvLoss: 0, + highSendLoss: false, + highRecvLoss: false, + }, + }); + }); + + it("reports a timeout as a failure", async () => { + const store = createStore(); + + const { dispatched, tester } = await startTest(store); + tester.emit("result", { error: true, details: { timeout: true } }); + + expect(await dispatched.unwrap()).toEqual(null); + expect(selectPreCallTestStatus(store.getState())).toEqual("failed"); + expect(selectPreCallTestError(store.getState())).toEqual({ + reason: "timeout", + message: "Timed out connecting to the Whereby media servers", + }); + expect(selectPreCallTestResult(store.getState())).toEqual(null); + }); + + it("fails when the tester closes without reporting", async () => { + const store = createStore(); + + const { dispatched, tester } = await startTest(store); + tester.emit("close"); + + expect(await dispatched.unwrap()).toEqual(null); + expect(selectPreCallTestStatus(store.getState())).toEqual("failed"); + expect(selectPreCallTestError(store.getState())?.reason).toEqual("unknown"); + }); + + it("keeps the first result when the tester reports twice", async () => { + const store = createStore(); + + const { tester } = await startTest(store); + tester.emit("result", successResult); + tester.emit("result", { error: true }); + tester.emit("close"); + + expect(selectPreCallTestStatus(store.getState())).toEqual("completed"); + expect(selectPreCallTestResult(store.getState())).toEqual(successResult); + }); + + it("leaves a running test alone", async () => { + const store = createStore(); + + const { tester } = await startTest(store); + const { dispatched: second } = await startTest(store); + + expect(await second.unwrap()).toEqual(null); + expect(mockedBandwidthTester).toHaveBeenCalledTimes(1); + expect(tester.close).not.toHaveBeenCalled(); + expect(selectPreCallTestStatus(store.getState())).toEqual("running"); + }); + }); + + describe("doStopPreCallTest", () => { + it("closes the tester and goes back to idle", async () => { + const store = createStore(); + + const { dispatched, tester } = await startTest(store); + store.dispatch(doStopPreCallTest()); + + expect(await dispatched.unwrap()).toEqual(null); + expect(tester.close).toHaveBeenCalled(); + expect(selectPreCallTestStatus(store.getState())).toEqual("idle"); + expect(selectPreCallTestError(store.getState())).toEqual(null); + }); + + it("does not turn an aborted test into a failure", async () => { + const store = createStore(); + + const { tester } = await startTest(store); + store.dispatch(doStopPreCallTest()); + // The tester emits on its way down, well after we stopped caring. + tester.emit("result", { error: true, details: { timeout: true } }); + + expect(selectPreCallTestStatus(store.getState())).toEqual("idle"); + expect(selectPreCallTestError(store.getState())).toEqual(null); + }); + }); +});