diff --git a/README.md b/README.md index f87605f0..1e0a8f88 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ them. | **autoStart**: *boolean* | Whether to automatically start the measurements on instantiation. | `true` | | **downloadApiUrl**: *string* | The URL of the API for performing download GET requests. | `https://speed.cloudflare.com/__down` | | **uploadApiUrl**: *string* | The URL of the API for performing upload POST requests. | `https://speed.cloudflare.com/__up` | -| **turnServerUri**: *string* | The URI of the TURN server used to measure packet loss. | `turn.cloudflare.com:3478` | -| **turnServerCredsApiUrl**: *string* | A URI that returns TURN server credentials. Expects a JSON response with `username` and `credential` keys. | - | +| **turnServerUri**: *string* | The URI of the TURN server used to measure packet loss, e.g. `turn.cloudflare.com:3478`. See [packetLoss](#packetloss). | `null` | +| **turnServerCredsApiUrl**: *string* | A URI that returns TURN server credentials. Expects a JSON response with `username` and `credential` keys. See [packetLoss](#packetloss). | `null` | | **turnServerUser**: *string* | The username for the TURN server credentials. | - | | **turnServerPass**: *string* | The password for the TURN server credentials. | - | | **authorizationToken**: *string* | ๐Ÿงช *Experimental.* An opaque token attributing the test to a registered customer, sent as an `Authorization: Bearer` header on the measurement, TURN credential and results-logging requests. Obtain it from your own backend โ€” the engine never requests one itself. Requires `authorizationEnabled`; on its own it is never sent. Not sent over plain HTTP unless `allowInsecureAuthorizationToken` is set. | `null` | @@ -105,7 +105,6 @@ The default set of measurements that is performed by the engine is: { type: 'download', bytes: 1e5, count: 9 }, { type: 'download', bytes: 1e6, count: 8 }, { type: 'upload', bytes: 1e5, count: 8 }, - { type: 'packetLoss', numPackets: 1e3, responsesWaitTime: 3000 }, { type: 'upload', bytes: 1e6, count: 6 }, { type: 'download', bytes: 1e7, count: 6 }, { type: 'upload', bytes: 1e7, count: 4 }, @@ -139,9 +138,25 @@ Each of these measurement sets are bound to a specific file size. The engine fol Packet loss is measured by submitting a set of UDP packets to a WebRTC TURN server in a round-trip fashion, and determining how many packets do not arrive. The submission of these packets can be done in a batching method, in which there's a sleep time in between batches. -**Note:** You must provide your own TURN server configuration if you'd like to get packet loss results from this engine. +Packet loss is opt-in: it is not part of the default measurements, and it needs your own TURN server configuration. To enable it, add a `packetLoss` measurement and either: -Example code to enable this feature through a Cloudflare Worker, using a [Cloudflare Realtime TURN server](https://developers.cloudflare.com/realtime/turn/), is available at [example/turn-worker](example/turn-worker). Refer to the worker's [documentation](example/turn-worker/README.md) for setup and deployment instructions. +- set `turnServerCredsApiUrl` to an endpoint you host that returns TURN credentials, plus `turnServerUri` (e.g. `turn.cloudflare.com:3478`) unless the endpoint also returns a `server` field, or +- set `turnServerUri` together with your own `turnServerUser` and `turnServerPass`. + +```js +new SpeedTest({ + turnServerUri: 'turn.cloudflare.com:3478', + turnServerCredsApiUrl: 'https://turn.example.com/turn-credentials', + measurements: [ + // ... + { type: 'packetLoss', numPackets: 1e3, responsesWaitTime: 3000 } + ] +}); +``` + +A `packetLoss` measurement without either option fails with an error, and the engine moves on to the next measurement. + +Example code to serve TURN credentials from a Cloudflare Worker, using a [Cloudflare Realtime TURN server](https://developers.cloudflare.com/realtime/turn/), is available at [example/turn-worker](example/turn-worker). Refer to the worker's [documentation](example/turn-worker/README.md) for setup and deployment instructions. | Field | Required | Description | Default | | --- | :--: | --- | :--: | @@ -174,7 +189,7 @@ An instance object used to access the results of the speedtest measurements. The | **getPacketLoss()** | Returns the reduced value of the measured packet loss ratio (between 0 and 1). Requires a `packetLoss` measurement set. | | **getPacketLossDetails()** | Returns an object with the details of the packet loss measurement. Includes the following fields: `{ packetLoss, totalMessages, numMessagesSent, lostMessages }`. Requires a `packetLoss` measurement set. | | **getTotalDurationMs()** | Returns the total time (in milliseconds) the engine spent running, excluding any time spent paused. Only available after the engine has finished. | -| **getScores()** | Returns the computed [AIM scores](https://developers.cloudflare.com/fundamentals/speed/aim/) that categorize the quality of the network connection according to use cases such as streaming, gaming or real-time communications. This score is only available after the engine has finished performing all of the measurements. | +| **getScores()** | Returns the computed [AIM scores](https://developers.cloudflare.com/fundamentals/speed/aim/) that categorize the quality of the network connection according to use cases such as streaming, gaming or real-time communications. This score is only available after the engine has finished performing all of the measurements. Without a `packetLoss` measurement, packet loss is left out of the scores, so they come out lower than for a connection measured with under 5% loss. A failed `packetLoss` measurement counts as 0 points. | [npm-img]: https://img.shields.io/npm/v/@cloudflare/speedtest [npm-url]: https://npmjs.org/package/@cloudflare/speedtest diff --git a/example/turn-worker/README.md b/example/turn-worker/README.md index 2ae84a12..8c21c677 100644 --- a/example/turn-worker/README.md +++ b/example/turn-worker/README.md @@ -34,9 +34,9 @@ Run `npm run deploy` and follow the instructions. ### Configuring the measurement engine -When instantiating the measurement engine, set the `turnServerCredsApiUrl` option to `https:///turn-credentials`. +When instantiating the measurement engine, set the `turnServerCredsApiUrl` option to `https:///turn-credentials` and `turnServerUri` to `turn.cloudflare.com:3478`, and add a `packetLoss` measurement (it is not part of the defaults). -For local development, run `npm run start` and set `turnServerCredsApiUrl` to `http://localhost:8787/turn-credentials` +For local development, run `npm run start` and set `turnServerCredsApiUrl` to `http://localhost:8787/turn-credentials`, keeping `turnServerUri` set to `turn.cloudflare.com:3478`. ### More information diff --git a/src/Results/ScoresCalculations.ts b/src/Results/ScoresCalculations.ts index e7c19304..4bac6312 100644 --- a/src/Results/ScoresCalculations.ts +++ b/src/Results/ScoresCalculations.ts @@ -113,12 +113,14 @@ class ScoresCalculations { * via the experience's `pointThresholds`. * * Experiences are omitted from the result if any of their required - * input metrics are not yet available. + * input metrics are not yet available. A missing packet loss value counts + * as 0 points (e.g. a failed measurement), unless it is listed in `omit`. * * @param measurements - Flat summary from {@link Results.getSummary}. + * @param omit - Metrics that were not measured, left out of every experience's inputs. * @returns Map of experience names to their score, classification index, and classification name. */ - getScores(measurements: MeasurementSummary): Scores { + getScores(measurements: MeasurementSummary, omit: string[] = []): Scores { const scores: Record = Object.assign( {} as Record, ...Object.entries(this.#config.aimMeasurementScoring).map( @@ -140,6 +142,13 @@ class ScoresCalculations { return Object.assign( {} as Scores, ...Object.entries(this.#config.aimExperiencesDefs) + .map( + ([k, { input, pointThresholds }]) => + [ + k, + { input: input.filter(m => !omit.includes(m)), pointThresholds } + ] as const + ) .filter(([, { input }]) => input.every(k => scores.hasOwnProperty(k))) .map(([k, { input, pointThresholds }]) => { const sumPoints = Math.max(0, sum(input.map(k => scores[k]))); diff --git a/src/Results/index.ts b/src/Results/index.ts index f152080a..f58e0a45 100644 --- a/src/Results/index.ts +++ b/src/Results/index.ts @@ -203,9 +203,14 @@ class Results { /** * Computes AIM experience scores (streaming, gaming, rtc) from the current * measurement summary. Each score includes a point total, classification - * index (0โ€“4), and classification name (bad โ†’ great). + * index (0โ€“4), and classification name (bad โ†’ great). Packet loss is left + * out when no `packetLoss` measurement is configured. */ - getScores = (): Scores => this.#scoresCalc.getScores(this.getSummary()); + getScores = (): Scores => + this.#scoresCalc.getScores( + this.getSummary(), + this.raw.packetLoss ? [] : ['packetLoss'] + ); // Internal state #config: ResultsConfig; diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index 4105efc9..ea0cd6da 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -53,10 +53,13 @@ export interface Config { logMeasurementApiUrl: string | null; /** URL for logging test results. Set to `null` to disable. Default: `https://speed.cloudflare.com/__results`. */ logAimApiUrl: string | null; - /** TURN server URI for packet loss measurement. Default: `turn.speed.cloudflare.com:50000`. */ - turnServerUri: string; - /** URL for fetching TURN server credentials. */ - turnServerCredsApiUrl: string; + /** + * TURN server URI for packet loss measurement, e.g. `turn.cloudflare.com:3478`. + * Default: `null`, so a `packetLoss` measurement needs your own TURN setup. + */ + turnServerUri: string | null; + /** URL for fetching TURN server credentials. Default: `null`. */ + turnServerCredsApiUrl: string | null; /** Static TURN username override. Set to `null` to fetch credentials dynamically. */ turnServerUser: string | null; /** Static TURN password override. Set to `null` to fetch credentials dynamically. */ @@ -164,8 +167,8 @@ const defaultConfig: Config = { uploadApiUrl: `${REL_API_URL}/__up`, logMeasurementApiUrl: null, logAimApiUrl: `${REL_API_URL}/__results`, - turnServerUri: 'turn.speed.cloudflare.com:50000', - turnServerCredsApiUrl: `${REL_API_URL}/turn-creds`, + turnServerUri: null, + turnServerCredsApiUrl: null, turnServerUser: null, turnServerPass: null, rpkiInvalidHost: 'invalid.rpki.cloudflare.com', @@ -186,13 +189,6 @@ const defaultConfig: Config = { { type: 'latency', numPackets: 2 }, { type: 'upload', bytes: 1e5, count: 8 }, { type: 'latency', numPackets: 2 }, - { - type: 'packetLoss', - numPackets: 1e3, - batchSize: 10, - batchWaitTime: 10, // ms (in between batches) - responsesWaitTime: 3000 // ms (silent time after last sent msg) - }, { type: 'upload', bytes: 1e6, count: 6 }, { type: 'latency', numPackets: 2 }, { type: 'download', bytes: 1e7, count: 6 }, diff --git a/src/engines/PacketLossEngine/PacketLossEngine.ts b/src/engines/PacketLossEngine/PacketLossEngine.ts index 0d508ace..3a7bcda6 100644 --- a/src/engines/PacketLossEngine/PacketLossEngine.ts +++ b/src/engines/PacketLossEngine/PacketLossEngine.ts @@ -69,11 +69,13 @@ export default class PacketLossEngine implements Engine { connectionTimeout = 5000 // ms }: PacketLossEngineOptions = {}) { if (!turnServerUri && !turnServerCredsApi) - throw new Error('Missing turnServerCredsApi or turnServerUri argument'); + throw new Error( + 'No TURN server configured: set turnServerCredsApiUrl or turnServerUri' + ); if ((!turnServerUser || !turnServerPass) && !turnServerCredsApi) throw new Error( - 'Missing either turnServerCredsApi or turnServerUser+turnServerPass arguments' + 'No TURN credentials configured: set turnServerCredsApiUrl, or turnServerUser and turnServerPass' ); this.#numMsgs = numMsgs; @@ -110,10 +112,18 @@ export default class PacketLossEngine implements Engine { turnServerUri: credsApiTurnServerUri } = creds; + const uri = credsApiTurnServerUri || turnServerUri; + if (!uri) { + this.#onConnectionError( + 'No TURN server URI: set turnServerUri or return server from the credentials API' + ); + return; + } + const c = new SelfWebRtcDataConnection({ iceServers: [ { - urls: `turn:${credsApiTurnServerUri || turnServerUri}?transport=udp`, + urls: `turn:${uri}?transport=udp`, username: credsUser, credential: credsPass } diff --git a/src/index.ts b/src/index.ts index a730ba5f..4540a200 100644 --- a/src/index.ts +++ b/src/index.ts @@ -438,27 +438,44 @@ class MeasurementEngine { turnServerPass, includeCredentials } = this.#config; - engine = new PacketLossEngine({ - turnServerUri, - turnServerCredsApi, - turnServerCredsApiIncludeCredentials: includeCredentials, - turnServerUser: turnServerUser ?? undefined, - turnServerPass: turnServerPass ?? undefined, - authorization: this.authorization, - numMsgs, - // if under load - downloadChunkSize: msmConfig.loadDown - ? this.#optimalDownloadChunkSize - : undefined, - uploadChunkSize: msmConfig.loadUp - ? this.#optimalUploadChunkSize - : undefined, - downloadApiUrl, - uploadApiUrl, + try { + engine = new PacketLossEngine({ + turnServerUri: turnServerUri ?? undefined, + turnServerCredsApi: turnServerCredsApi ?? undefined, + turnServerCredsApiIncludeCredentials: includeCredentials, + turnServerUser: turnServerUser ?? undefined, + turnServerPass: turnServerPass ?? undefined, + authorization: this.authorization, + numMsgs, + + // if under load + downloadChunkSize: msmConfig.loadDown + ? this.#optimalDownloadChunkSize + : undefined, + uploadChunkSize: msmConfig.loadUp + ? this.#optimalUploadChunkSize + : undefined, + downloadApiUrl, + uploadApiUrl, - ...ptCfg - }) as Engine; + ...ptCfg + }) as Engine; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + const measurementId = this.#measurementId; + // Deferred so that, with autoStart, the caller can set onError first + queueMicrotask(() => { + if (measurementId !== this.#measurementId) return; // restarted since + msmResults.started = true; + msmResults.finished = true; + msmResults.error = message; + this.onResultsChange({ type }); + this.#onError(`Error while measuring packet loss: ${message}`); + this.#running && this.#next(); + }); + return; + } } engine!.onMsgReceived = () => { @@ -476,6 +493,7 @@ class MeasurementEngine { }; engine!.onConnectionError = (e: unknown) => { + msmResults.finished = true; msmResults.error = e; this.onResultsChange({ type }); this.#onError(`Connection error while measuring packet loss: ${e}`); @@ -483,6 +501,7 @@ class MeasurementEngine { }; engine!.onCredentialsFailure = () => { + msmResults.finished = true; msmResults.error = 'unable to get turn server credentials'; this.onResultsChange({ type }); this.#onError( diff --git a/tests/unit/Results/Results.test.ts b/tests/unit/Results/Results.test.ts index a131a58f..973601cd 100644 --- a/tests/unit/Results/Results.test.ts +++ b/tests/unit/Results/Results.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import Results from '../../../src/Results/index.ts'; import type { RawMeasurementEntry, @@ -15,6 +15,10 @@ function createResults(configOverrides: Partial = {}) { } as ResultsConfig); } +const WITH_PACKET_LOSS: ResultsConfig['measurements'] = [ + { type: 'packetLoss' } +]; + describe('Results', () => { describe('constructor and clear', () => { it('initializes raw results with measurement types from config', () => { @@ -22,13 +26,18 @@ describe('Results', () => { expect(results.raw).toHaveProperty('latency'); expect(results.raw).toHaveProperty('download'); expect(results.raw).toHaveProperty('upload'); - expect(results.raw).toHaveProperty('packetLoss'); expect(results.raw).toHaveProperty('totalDurationMs'); + expect(results.raw).not.toHaveProperty('packetLoss'); + }); + + it('initializes packet loss results only when configured', () => { + const results = createResults({ measurements: WITH_PACKET_LOSS }); + expect(results.raw).toHaveProperty('packetLoss'); }); it('marks all measurements as not started and not finished', () => { const results = createResults(); - for (const key of ['latency', 'download', 'upload', 'packetLoss']) { + for (const key of ['latency', 'download', 'upload']) { const entry = results.raw[key] as RawMeasurementEntry; expect(entry.started).toBe(false); expect(entry.finished).toBe(false); @@ -137,7 +146,7 @@ describe('Results', () => { }); it('returns packet loss details with error when error exists', () => { - const results = createResults(); + const results = createResults({ measurements: WITH_PACKET_LOSS }); const packetLoss = results.raw.packetLoss as RawMeasurementEntry; packetLoss.started = true; packetLoss.error = 'Connection failed'; @@ -207,4 +216,48 @@ describe('Results', () => { expect(typeof scores).toBe('object'); }); }); + + describe('getScores packet loss handling', () => { + // 20 (latency) + 30 (download) + 20 (loadedLatencyIncrease) for streaming + const SUMMARY = { + latency: 5, + jitter: 5, + download: 100e6, + downLoadedLatency: 10, + upLoadedLatency: 8 + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('counts a measured packet loss', () => { + const results = createResults({ measurements: WITH_PACKET_LOSS }); + vi.spyOn(results, 'getSummary').mockReturnValue({ + ...SUMMARY, + packetLoss: 0 // 10 points + }); + + expect(results.getScores().streaming.points).toBe(80); + }); + + it('scores every experience without packet loss when it is not configured', () => { + const results = createResults(); + vi.spyOn(results, 'getSummary').mockReturnValue(SUMMARY); + + const scores = results.getScores(); + expect(Object.keys(scores)).toEqual(['streaming', 'gaming', 'rtc']); + expect(scores.streaming.points).toBe(70); + }); + + it('scores a failed packet loss measurement as 0 points', () => { + const results = createResults({ measurements: WITH_PACKET_LOSS }); + const packetLoss = results.raw.packetLoss as RawMeasurementEntry; + packetLoss.started = true; + packetLoss.error = 'Connection failed'; + vi.spyOn(results, 'getSummary').mockReturnValue(SUMMARY); + + expect(results.getScores().streaming.points).toBe(70); + }); + }); }); diff --git a/tests/unit/Results/ScoresCalculations.test.ts b/tests/unit/Results/ScoresCalculations.test.ts index ed481519..dba0f82c 100644 --- a/tests/unit/Results/ScoresCalculations.test.ts +++ b/tests/unit/Results/ScoresCalculations.test.ts @@ -105,5 +105,24 @@ describe('ScoresCalculations', () => { // + download (30) + loadedLatencyIncrease (10-5=5 => 20 points) expect(scores.streaming).toBeDefined(); }); + + it('leaves omitted packetLoss out of every experience', () => { + const calc = createScoresCalc(); + const scores = calc.getScores( + { + latency: 5, // 20 points + jitter: 5, // 10 points + packetLoss: 0, // 10 points, unless omitted + download: 100e6, // 30 points + downLoadedLatency: 10, // loadedLatencyIncrease = 5 => 20 points + upLoadedLatency: 8 + }, + ['packetLoss'] + ); + + expect(scores.streaming.points).toBe(70); + expect(scores.gaming.points).toBe(40); + expect(scores.rtc.points).toBe(50); + }); }); }); diff --git a/tests/unit/config/authorizationToken.test.ts b/tests/unit/config/authorizationToken.test.ts index 3ea9174a..44ce0b12 100644 --- a/tests/unit/config/authorizationToken.test.ts +++ b/tests/unit/config/authorizationToken.test.ts @@ -28,8 +28,9 @@ describe('authorizationToken', () => { it('never appears in any configured API URL', () => { const config = resolveConfig({ authorizationToken: TOKEN, - // Null by default, so set it to cover every key in the list. - logMeasurementApiUrl: 'https://speed.cloudflare.com/__log' + // Null by default, so set them to cover every key in the list. + logMeasurementApiUrl: 'https://speed.cloudflare.com/__log', + turnServerCredsApiUrl: 'https://speed.example.com/turn-credentials' }); for (const key of API_URL_KEYS) { @@ -97,7 +98,8 @@ describe('authorizationToken', () => { const config = resolveConfig({ authorizationToken: TOKEN, allowInsecureAuthorizationToken: true, - logMeasurementApiUrl: 'http://localhost:8787/__log' + logMeasurementApiUrl: 'http://localhost:8787/__log', + turnServerCredsApiUrl: 'http://localhost:8787/turn-credentials' }); for (const key of API_URL_KEYS) { @@ -107,10 +109,13 @@ describe('authorizationToken', () => { }); it('leaves the non-HTTP endpoints untouched', () => { - const config = resolveConfig({ authorizationToken: TOKEN }); + const config = resolveConfig({ + authorizationToken: TOKEN, + turnServerUri: 'turn.cloudflare.com:3478' + }); expect(config.rpkiInvalidHost).toBe('invalid.rpki.cloudflare.com'); - expect(config.turnServerUri).toBe('turn.speed.cloudflare.com:50000'); + expect(config.turnServerUri).toBe('turn.cloudflare.com:3478'); }); it('does not mutate the shared defaultConfig object', () => { diff --git a/tests/unit/config/defaultConfig.test.ts b/tests/unit/config/defaultConfig.test.ts index d94c68c8..9077f30f 100644 --- a/tests/unit/config/defaultConfig.test.ts +++ b/tests/unit/config/defaultConfig.test.ts @@ -13,9 +13,6 @@ describe('defaultConfig', () => { expect(defaultConfig.uploadApiUrl).toBe( 'https://speed.cloudflare.com/__up' ); - expect(defaultConfig.turnServerCredsApiUrl).toBe( - 'https://speed.cloudflare.com/turn-creds' - ); }); it('has a measurements array with expected types', () => { @@ -26,7 +23,13 @@ describe('defaultConfig', () => { expect(types).toContain('latency'); expect(types).toContain('download'); expect(types).toContain('upload'); - expect(types).toContain('packetLoss'); + }); + + it('leaves packet loss opt-in, with no default TURN server', () => { + const types = defaultConfig.measurements.map(m => m.type); + expect(types).not.toContain('packetLoss'); + expect(defaultConfig.turnServerUri).toBeNull(); + expect(defaultConfig.turnServerCredsApiUrl).toBeNull(); }); it('has valid percentile values between 0 and 1', () => { diff --git a/tests/unit/packetLossConfig.test.ts b/tests/unit/packetLossConfig.test.ts new file mode 100644 index 00000000..e07868b5 --- /dev/null +++ b/tests/unit/packetLossConfig.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import SpeedTest from '../../src'; +import type { ConfigOptions } from '../../src/config/defaultConfig.ts'; + +const perfEntry = () => ({ + startTime: 0, + requestStart: 10, + responseStart: 1510, + responseEnd: 1510, + transferSize: 0, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' +}); + +const CREDS_API_URL = 'https://turn.example.com/turn-credentials'; +const MISSING_SERVER = + 'No TURN server configured: set turnServerCredsApiUrl or turnServerUri'; +const MISSING_CREDENTIALS = + 'No TURN credentials configured: set turnServerCredsApiUrl, or turnServerUser and turnServerPass'; + +/** Runs a packet loss measurement followed by an upload, until finished. */ +async function runPacketLoss(config: ConfigOptions = {}) { + const engine = new SpeedTest({ + autoStart: false, + logAimApiUrl: null, + measureUploadLoadedLatency: false, + measurements: [ + { + type: 'packetLoss', + numPackets: 10, + batchSize: 10, + batchWaitTime: 10, + responsesWaitTime: 100 + }, + { type: 'upload', bytes: 1e5, count: 1 } + ], + ...config + }); + const onError = vi.fn(); + engine.onError = onError; + const results = new Promise(resolve => { + engine.onFinish = resolve; + }); + engine.play(); + + return { finished: await results, onError }; +} + +describe('SpeedTest packetLoss TURN config', () => { + beforeEach(() => { + const entries: ReturnType[] = []; + vi.stubGlobal('window', { + location: { origin: 'https://speed.example.com' } + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + entries.push(perfEntry()); + return new Response(''); + }) + ); + vi.stubGlobal('performance', { + now: () => 0, + getEntriesByName: () => entries, + clearResourceTimings: () => {}, + setResourceTimingBufferSize: () => {} + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('reports an error and moves on when no TURN server is configured', async () => { + const { finished, onError } = await runPacketLoss(); + + expect(onError).toHaveBeenCalledWith( + `Error while measuring packet loss: ${MISSING_SERVER}` + ); + expect(finished.getPacketLossDetails()).toEqual({ error: MISSING_SERVER }); + expect(finished.getUploadBandwidthPoints()).toHaveLength(1); + expect(finished.isFinished).toBe(true); + }); + + it.each<[string, ConfigOptions]>([ + [ + 'a URI without credentials', + { turnServerUri: 'turn.cloudflare.com:3478' } + ], + [ + 'a URI with only a username', + { turnServerUri: 'turn.cloudflare.com:3478', turnServerUser: 'user' } + ] + ])('rejects %s', async (_, config) => { + const { finished } = await runPacketLoss(config); + + expect(finished.getPacketLossDetails()).toEqual({ + error: MISSING_CREDENTIALS + }); + expect(finished.getUploadBandwidthPoints()).toHaveLength(1); + }); + + it('builds the engine when a credentials API URL is set', async () => { + const { finished, onError } = await runPacketLoss({ + turnServerCredsApiUrl: CREDS_API_URL + }); + + expect(fetch).toHaveBeenCalledWith(CREDS_API_URL, expect.anything()); + // The stubbed fetch returns no credentials, so the engine itself fails. + expect(finished.getPacketLossDetails()).toEqual({ + error: 'unable to get turn server credentials' + }); + expect(onError).not.toHaveBeenCalledWith( + expect.stringContaining('configured') + ); + expect(finished.isFinished).toBe(true); + }); + + it('fails early when no TURN server URI is available', async () => { + vi.mocked(fetch).mockImplementation(async () => + Response.json({ username: 'user', credential: 'pass' }) + ); + const { finished, onError } = await runPacketLoss({ + turnServerCredsApiUrl: CREDS_API_URL, + measurements: [ + { + type: 'packetLoss', + numPackets: 10, + batchSize: 10, + batchWaitTime: 10, + responsesWaitTime: 100 + } + ] + }); + + expect(onError).toHaveBeenCalledWith( + expect.stringContaining('No TURN server URI') + ); + expect(finished.isFinished).toBe(true); + }); + + it('drops a pending setup error when restarted in the same tick', async () => { + const engine = new SpeedTest({ + autoStart: false, + logAimApiUrl: null, + measureUploadLoadedLatency: false, + measurements: [ + { + type: 'packetLoss', + numPackets: 10, + batchSize: 10, + batchWaitTime: 10, + responsesWaitTime: 100 + }, + { type: 'upload', bytes: 1e5, count: 1 } + ] + }); + const onError = vi.fn(); + engine.onError = onError; + const results = new Promise(resolve => { + engine.onFinish = resolve; + }); + engine.play(); + engine.restart(); + + const finished = await results; + expect(onError).toHaveBeenCalledTimes(1); + expect(finished.getUploadBandwidthPoints()).toHaveLength(1); + }); + + it('does not move on from a setup error while paused', async () => { + const engine = new SpeedTest({ + autoStart: false, + logAimApiUrl: null, + measureUploadLoadedLatency: false, + measurements: [ + { + type: 'packetLoss', + numPackets: 10, + batchSize: 10, + batchWaitTime: 10, + responsesWaitTime: 100 + }, + { type: 'upload', bytes: 1e5, count: 1 } + ] + }); + const onError = vi.fn(); + engine.onError = onError; + engine.play(); + engine.pause(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(fetch).not.toHaveBeenCalled(); + + const results = new Promise(resolve => { + engine.onFinish = resolve; + }); + engine.play(); + + expect((await results).getUploadBandwidthPoints()).toHaveLength(1); + }); + + it('reports a setup error after construction with autoStart', async () => { + const engine = new SpeedTest({ + logAimApiUrl: null, + measurements: [ + { + type: 'packetLoss', + numPackets: 10, + batchSize: 10, + batchWaitTime: 10, + responsesWaitTime: 100 + } + ] + }); + const onError = vi.fn(); + engine.onError = onError; + await new Promise(resolve => { + engine.onFinish = resolve; + }); + + expect(onError).toHaveBeenCalledWith( + `Error while measuring packet loss: ${MISSING_SERVER}` + ); + }); +}); diff --git a/tests/unit/engines/unusableSamplesOrchestration.test.ts b/tests/unit/unusableSamplesOrchestration.test.ts similarity index 98% rename from tests/unit/engines/unusableSamplesOrchestration.test.ts rename to tests/unit/unusableSamplesOrchestration.test.ts index d0d595b4..0e734c51 100644 --- a/tests/unit/engines/unusableSamplesOrchestration.test.ts +++ b/tests/unit/unusableSamplesOrchestration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import SpeedTest from '../../../src'; +import SpeedTest from '../../src'; /** * The orchestrator runs one bandwidth engine per size and decides from the