From 693bb05653a1ffe4c2a89dd85ce224d1b2bd79ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Fri, 16 Oct 2026 18:13:29 +0100 Subject: [PATCH 01/13] feat: make packet loss opt-in and drop dead TURN defaults --- README.md | 27 +++++-- example/turn-worker/README.md | 2 +- src/config/defaultConfig.ts | 22 +++--- src/index.ts | 19 ++++- tests/unit/Results/Results.test.ts | 15 +++- tests/unit/config/authorizationToken.test.ts | 15 ++-- tests/unit/config/defaultConfig.test.ts | 11 +-- tests/unit/engines/packetLossConfig.test.ts | 75 ++++++++++++++++++++ 8 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 tests/unit/engines/packetLossConfig.test.ts diff --git a/README.md b/README.md index f87605f0..a99aebbd 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 counts as 0 points (the same as 5โ€“25% loss), so scores come out lower. | [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..ec2b26a6 100644 --- a/example/turn-worker/README.md +++ b/example/turn-worker/README.md @@ -34,7 +34,7 @@ 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` 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/index.ts b/src/index.ts index a730ba5f..ee44adcf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -438,9 +438,24 @@ class MeasurementEngine { turnServerPass, includeCredentials } = this.#config; + + if ( + !turnServerCredsApi && + !(turnServerUri && turnServerUser && turnServerPass) + ) { + msmResults.started = true; + msmResults.error = 'no turn server configured'; + this.onResultsChange({ type }); + this.#onError( + 'Error while measuring packet loss: no TURN server configured. Set turnServerCredsApiUrl, or turnServerUri with turnServerUser and turnServerPass.' + ); + this.#next(); + return; + } + engine = new PacketLossEngine({ - turnServerUri, - turnServerCredsApi, + turnServerUri: turnServerUri ?? undefined, + turnServerCredsApi: turnServerCredsApi ?? undefined, turnServerCredsApiIncludeCredentials: includeCredentials, turnServerUser: turnServerUser ?? undefined, turnServerPass: turnServerPass ?? undefined, diff --git a/tests/unit/Results/Results.test.ts b/tests/unit/Results/Results.test.ts index a131a58f..57904d6f 100644 --- a/tests/unit/Results/Results.test.ts +++ b/tests/unit/Results/Results.test.ts @@ -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'; 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/engines/packetLossConfig.test.ts b/tests/unit/engines/packetLossConfig.test.ts new file mode 100644 index 00000000..2cc7ccda --- /dev/null +++ b/tests/unit/engines/packetLossConfig.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import SpeedTest from '../../../src'; + +const perfEntry = () => ({ + startTime: 0, + requestStart: 10, + responseStart: 1510, + responseEnd: 1510, + transferSize: 0, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' +}); + +describe('SpeedTest packetLoss without a TURN server', () => { + 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 to the next measurement', 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(); + + const finished = await results; + expect(onError).toHaveBeenCalledWith( + expect.stringContaining('no TURN server configured') + ); + expect(finished.getPacketLossDetails()).toEqual({ + error: 'no turn server configured' + }); + expect(finished.getUploadBandwidthPoints()).toHaveLength(1); + }); +}); From dcc86847d90dc64392be112836804e437ed666d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 10:29:45 +0100 Subject: [PATCH 02/13] fix: mark unconfigured packet loss measurement as finished --- src/index.ts | 1 + tests/unit/engines/packetLossConfig.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index ee44adcf..46dec76c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -444,6 +444,7 @@ class MeasurementEngine { !(turnServerUri && turnServerUser && turnServerPass) ) { msmResults.started = true; + msmResults.finished = true; msmResults.error = 'no turn server configured'; this.onResultsChange({ type }); this.#onError( diff --git a/tests/unit/engines/packetLossConfig.test.ts b/tests/unit/engines/packetLossConfig.test.ts index 2cc7ccda..3cff60ee 100644 --- a/tests/unit/engines/packetLossConfig.test.ts +++ b/tests/unit/engines/packetLossConfig.test.ts @@ -71,5 +71,6 @@ describe('SpeedTest packetLoss without a TURN server', () => { error: 'no turn server configured' }); expect(finished.getUploadBandwidthPoints()).toHaveLength(1); + expect(finished.isFinished).toBe(true); }); }); From c4445f56b31c03422361230abd1ee3e6d6dcdf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 10:49:34 +0100 Subject: [PATCH 03/13] docs: clarify getScores() note on missing packet loss --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a99aebbd..6fbeb893 100644 --- a/README.md +++ b/README.md @@ -189,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. Without a `packetLoss` measurement, packet loss counts as 0 points (the same as 5โ€“25% loss), so scores come out lower. | +| **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 counts as 0 points (the same as 5โ€“25% loss), so scores come out lower than for a connection measured with under 5% loss. | [npm-img]: https://img.shields.io/npm/v/@cloudflare/speedtest [npm-url]: https://npmjs.org/package/@cloudflare/speedtest From 8c65addcf9d9963a54ebe67f98bf1b51b9f6c7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 10:55:32 +0100 Subject: [PATCH 04/13] test: cover partial TURN configs in packet loss guard --- tests/unit/engines/packetLossConfig.test.ts | 91 +++++++++++++++------ 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/tests/unit/engines/packetLossConfig.test.ts b/tests/unit/engines/packetLossConfig.test.ts index 3cff60ee..fca10ccd 100644 --- a/tests/unit/engines/packetLossConfig.test.ts +++ b/tests/unit/engines/packetLossConfig.test.ts @@ -1,5 +1,6 @@ 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, @@ -13,7 +14,37 @@ const perfEntry = () => ({ nextHopProtocol: 'h2' }); -describe('SpeedTest packetLoss without a TURN server', () => { +const CREDS_API_URL = 'https://turn.example.com/turn-credentials'; + +/** 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', { @@ -40,30 +71,9 @@ describe('SpeedTest packetLoss without a TURN server', () => { vi.restoreAllMocks(); }); - it('reports an error and moves on to the next measurement', 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(); + it('reports an error and moves on when no TURN server is configured', async () => { + const { finished, onError } = await runPacketLoss(); - const finished = await results; expect(onError).toHaveBeenCalledWith( expect.stringContaining('no TURN server configured') ); @@ -73,4 +83,37 @@ describe('SpeedTest packetLoss without a TURN 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' } + ] + ])('treats %s as unconfigured', async (_, config) => { + const { finished } = await runPacketLoss(config); + + expect(finished.getPacketLossDetails()).toEqual({ + error: 'no turn server configured' + }); + 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('no TURN server configured') + ); + }); }); From 7c9b21fadfad48523bf23b16d5a837d06e30e0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 11:04:19 +0100 Subject: [PATCH 05/13] docs: fix packet loss scoring range in getScores() note --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6fbeb893..0a952c39 100644 --- a/README.md +++ b/README.md @@ -189,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. Without a `packetLoss` measurement, packet loss counts as 0 points (the same as 5โ€“25% loss), so scores come out lower than for a connection measured with under 5% loss. | +| **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 counts as 0 points (the same as 5% to under 25% loss), so scores come out lower than for a connection measured with under 5% loss. | [npm-img]: https://img.shields.io/npm/v/@cloudflare/speedtest [npm-url]: https://npmjs.org/package/@cloudflare/speedtest From 59643ad7ec2aa41d05628c0a9eb9d850a2123c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 11:11:30 +0100 Subject: [PATCH 06/13] docs: keep turnServerUri in turn-worker local dev instructions --- example/turn-worker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/turn-worker/README.md b/example/turn-worker/README.md index ec2b26a6..8c21c677 100644 --- a/example/turn-worker/README.md +++ b/example/turn-worker/README.md @@ -36,7 +36,7 @@ Run `npm run deploy` and follow the instructions. 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 From 7714415e878b8c13dee25de05d1bed1e016a1cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 14:29:08 +0100 Subject: [PATCH 07/13] refactor: leave unconfigured packet loss out of AIM scores --- README.md | 2 +- src/Results/ScoresCalculations.ts | 13 ++++++++-- src/Results/index.ts | 9 +++++-- tests/unit/Results/Results.test.ts | 25 ++++++++++++++++++- tests/unit/Results/ScoresCalculations.test.ts | 18 +++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0a952c39..1e0a8f88 100644 --- a/README.md +++ b/README.md @@ -189,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. Without a `packetLoss` measurement, packet loss counts as 0 points (the same as 5% to under 25% loss), so scores come out lower than for a connection measured with under 5% loss. | +| **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/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/tests/unit/Results/Results.test.ts b/tests/unit/Results/Results.test.ts index 57904d6f..5b80e88d 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, @@ -6,6 +6,7 @@ import type { } from '../../../src/Results/index.ts'; import defaultConfig from '../../../src/config/defaultConfig.ts'; import internalConfig from '../../../src/config/internalConfig.ts'; +import ScoresCalculations from '../../../src/Results/ScoresCalculations.ts'; function createResults(configOverrides: Partial = {}) { return new Results({ @@ -216,4 +217,26 @@ describe('Results', () => { expect(typeof scores).toBe('object'); }); }); + + describe('getScores packet loss handling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('leaves packet loss out when it is not configured', () => { + const spy = vi.spyOn(ScoresCalculations.prototype, 'getScores'); + createResults().getScores(); + expect(spy).toHaveBeenCalledWith(expect.anything(), ['packetLoss']); + }); + + it('keeps a failed packet loss measurement in the scores', () => { + const spy = vi.spyOn(ScoresCalculations.prototype, 'getScores'); + const results = createResults({ measurements: WITH_PACKET_LOSS }); + const packetLoss = results.raw.packetLoss as RawMeasurementEntry; + packetLoss.started = true; + packetLoss.error = 'Connection failed'; + results.getScores(); + expect(spy).toHaveBeenCalledWith(expect.anything(), []); + }); + }); }); diff --git a/tests/unit/Results/ScoresCalculations.test.ts b/tests/unit/Results/ScoresCalculations.test.ts index ed481519..cdb59b24 100644 --- a/tests/unit/Results/ScoresCalculations.test.ts +++ b/tests/unit/Results/ScoresCalculations.test.ts @@ -105,5 +105,23 @@ 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 + 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); + }); }); }); From 190ccb33252c2383ea3fe8cd193a06468dbc59e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 14:30:39 +0100 Subject: [PATCH 08/13] refactor: reuse PacketLossEngine validation and fail early without a TURN URI --- .../PacketLossEngine/PacketLossEngine.ts | 10 +++- src/index.ts | 56 +++++++++---------- tests/unit/engines/packetLossConfig.test.ts | 37 +++++++++--- 3 files changed, 65 insertions(+), 38 deletions(-) diff --git a/src/engines/PacketLossEngine/PacketLossEngine.ts b/src/engines/PacketLossEngine/PacketLossEngine.ts index 0d508ace..9b057ad4 100644 --- a/src/engines/PacketLossEngine/PacketLossEngine.ts +++ b/src/engines/PacketLossEngine/PacketLossEngine.ts @@ -110,10 +110,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 46dec76c..12137441 100644 --- a/src/index.ts +++ b/src/index.ts @@ -439,42 +439,38 @@ class MeasurementEngine { includeCredentials } = this.#config; - if ( - !turnServerCredsApi && - !(turnServerUri && turnServerUser && turnServerPass) - ) { + 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; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); msmResults.started = true; msmResults.finished = true; - msmResults.error = 'no turn server configured'; + msmResults.error = message; this.onResultsChange({ type }); - this.#onError( - 'Error while measuring packet loss: no TURN server configured. Set turnServerCredsApiUrl, or turnServerUri with turnServerUser and turnServerPass.' - ); + this.#onError(`Error while measuring packet loss: ${message}`); this.#next(); return; } - - 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; } engine!.onMsgReceived = () => { diff --git a/tests/unit/engines/packetLossConfig.test.ts b/tests/unit/engines/packetLossConfig.test.ts index fca10ccd..8d11d97d 100644 --- a/tests/unit/engines/packetLossConfig.test.ts +++ b/tests/unit/engines/packetLossConfig.test.ts @@ -15,6 +15,9 @@ const perfEntry = () => ({ }); const CREDS_API_URL = 'https://turn.example.com/turn-credentials'; +const MISSING_SERVER = 'Missing turnServerCredsApi or turnServerUri argument'; +const MISSING_CREDENTIALS = + 'Missing either turnServerCredsApi or turnServerUser+turnServerPass arguments'; /** Runs a packet loss measurement followed by an upload, until finished. */ async function runPacketLoss(config: ConfigOptions = {}) { @@ -75,11 +78,9 @@ describe('SpeedTest packetLoss TURN config', () => { const { finished, onError } = await runPacketLoss(); expect(onError).toHaveBeenCalledWith( - expect.stringContaining('no TURN server configured') + `Error while measuring packet loss: ${MISSING_SERVER}` ); - expect(finished.getPacketLossDetails()).toEqual({ - error: 'no turn server configured' - }); + expect(finished.getPacketLossDetails()).toEqual({ error: MISSING_SERVER }); expect(finished.getUploadBandwidthPoints()).toHaveLength(1); expect(finished.isFinished).toBe(true); }); @@ -93,11 +94,11 @@ describe('SpeedTest packetLoss TURN config', () => { 'a URI with only a username', { turnServerUri: 'turn.cloudflare.com:3478', turnServerUser: 'user' } ] - ])('treats %s as unconfigured', async (_, config) => { + ])('rejects %s', async (_, config) => { const { finished } = await runPacketLoss(config); expect(finished.getPacketLossDetails()).toEqual({ - error: 'no turn server configured' + error: MISSING_CREDENTIALS }); expect(finished.getUploadBandwidthPoints()).toHaveLength(1); }); @@ -113,7 +114,29 @@ describe('SpeedTest packetLoss TURN config', () => { error: 'unable to get turn server credentials' }); expect(onError).not.toHaveBeenCalledWith( - expect.stringContaining('no TURN server configured') + expect.stringContaining('Missing') + ); + }); + + it('fails early when no TURN server URI is available', async () => { + vi.mocked(fetch).mockImplementation(async () => + Response.json({ username: 'user', credential: 'pass' }) + ); + const { 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') ); }); }); From e4582b7bde05227402dc06ae9c8b929cc9461e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 14:54:01 +0100 Subject: [PATCH 09/13] fix: mark failed packet loss measurements finished and report setup errors after construction --- .../PacketLossEngine/PacketLossEngine.ts | 6 ++-- src/index.ts | 17 +++++---- tests/unit/engines/packetLossConfig.test.ts | 35 ++++++++++++++++--- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/engines/PacketLossEngine/PacketLossEngine.ts b/src/engines/PacketLossEngine/PacketLossEngine.ts index 9b057ad4..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; diff --git a/src/index.ts b/src/index.ts index 12137441..3aeaddea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,12 +463,15 @@ class MeasurementEngine { }) as Engine; } catch (e) { const message = e instanceof Error ? e.message : String(e); - msmResults.started = true; - msmResults.finished = true; - msmResults.error = message; - this.onResultsChange({ type }); - this.#onError(`Error while measuring packet loss: ${message}`); - this.#next(); + // Deferred so that, with autoStart, the caller can set onError first + queueMicrotask(() => { + msmResults.started = true; + msmResults.finished = true; + msmResults.error = message; + this.onResultsChange({ type }); + this.#onError(`Error while measuring packet loss: ${message}`); + this.#next(); + }); return; } } @@ -488,6 +491,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}`); @@ -495,6 +499,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/engines/packetLossConfig.test.ts b/tests/unit/engines/packetLossConfig.test.ts index 8d11d97d..23f41664 100644 --- a/tests/unit/engines/packetLossConfig.test.ts +++ b/tests/unit/engines/packetLossConfig.test.ts @@ -15,9 +15,10 @@ const perfEntry = () => ({ }); const CREDS_API_URL = 'https://turn.example.com/turn-credentials'; -const MISSING_SERVER = 'Missing turnServerCredsApi or turnServerUri argument'; +const MISSING_SERVER = + 'No TURN server configured: set turnServerCredsApiUrl or turnServerUri'; const MISSING_CREDENTIALS = - 'Missing either turnServerCredsApi or turnServerUser+turnServerPass arguments'; + '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 = {}) { @@ -114,15 +115,16 @@ describe('SpeedTest packetLoss TURN config', () => { error: 'unable to get turn server credentials' }); expect(onError).not.toHaveBeenCalledWith( - expect.stringContaining('Missing') + 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 { onError } = await runPacketLoss({ + const { finished, onError } = await runPacketLoss({ turnServerCredsApiUrl: CREDS_API_URL, measurements: [ { @@ -138,5 +140,30 @@ describe('SpeedTest packetLoss TURN config', () => { expect(onError).toHaveBeenCalledWith( expect.stringContaining('No TURN server URI') ); + expect(finished.isFinished).toBe(true); + }); + + 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}` + ); }); }); From 03d57a39f6d92ec64e581f28b9d67b24c4aa16b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 14:54:41 +0100 Subject: [PATCH 10/13] test: check AIM scores instead of spying on ScoresCalculations --- tests/unit/Results/Results.test.ts | 39 ++++++++++++++----- tests/unit/Results/ScoresCalculations.test.ts | 1 + 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/unit/Results/Results.test.ts b/tests/unit/Results/Results.test.ts index 5b80e88d..973601cd 100644 --- a/tests/unit/Results/Results.test.ts +++ b/tests/unit/Results/Results.test.ts @@ -6,7 +6,6 @@ import type { } from '../../../src/Results/index.ts'; import defaultConfig from '../../../src/config/defaultConfig.ts'; import internalConfig from '../../../src/config/internalConfig.ts'; -import ScoresCalculations from '../../../src/Results/ScoresCalculations.ts'; function createResults(configOverrides: Partial = {}) { return new Results({ @@ -219,24 +218,46 @@ describe('Results', () => { }); 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('leaves packet loss out when it is not configured', () => { - const spy = vi.spyOn(ScoresCalculations.prototype, 'getScores'); - createResults().getScores(); - expect(spy).toHaveBeenCalledWith(expect.anything(), ['packetLoss']); + 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('keeps a failed packet loss measurement in the scores', () => { - const spy = vi.spyOn(ScoresCalculations.prototype, 'getScores'); + 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'; - results.getScores(); - expect(spy).toHaveBeenCalledWith(expect.anything(), []); + 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 cdb59b24..dba0f82c 100644 --- a/tests/unit/Results/ScoresCalculations.test.ts +++ b/tests/unit/Results/ScoresCalculations.test.ts @@ -112,6 +112,7 @@ describe('ScoresCalculations', () => { { 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 From f4ddec81ceae86e20befdba777652c65d5a4d701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Mon, 19 Oct 2026 14:55:04 +0100 Subject: [PATCH 11/13] test: move orchestrator tests next to the other src/index.ts tests --- tests/unit/{engines => }/packetLossConfig.test.ts | 4 ++-- tests/unit/{engines => }/unusableSamplesOrchestration.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename tests/unit/{engines => }/packetLossConfig.test.ts (97%) rename tests/unit/{engines => }/unusableSamplesOrchestration.test.ts (98%) diff --git a/tests/unit/engines/packetLossConfig.test.ts b/tests/unit/packetLossConfig.test.ts similarity index 97% rename from tests/unit/engines/packetLossConfig.test.ts rename to tests/unit/packetLossConfig.test.ts index 23f41664..19976b4e 100644 --- a/tests/unit/engines/packetLossConfig.test.ts +++ b/tests/unit/packetLossConfig.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import SpeedTest from '../../../src'; -import type { ConfigOptions } from '../../../src/config/defaultConfig.ts'; +import SpeedTest from '../../src'; +import type { ConfigOptions } from '../../src/config/defaultConfig.ts'; const perfEntry = () => ({ startTime: 0, 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 From ca246c78cd4ba8fcf3e9b7a0976cd15c95ee63a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Tue, 20 Oct 2026 15:14:26 +0100 Subject: [PATCH 12/13] fix: skip a deferred packet loss error after restart --- src/index.ts | 2 ++ tests/unit/packetLossConfig.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3aeaddea..735e4fad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,8 +463,10 @@ class MeasurementEngine { }) 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; diff --git a/tests/unit/packetLossConfig.test.ts b/tests/unit/packetLossConfig.test.ts index 19976b4e..20e8c7a5 100644 --- a/tests/unit/packetLossConfig.test.ts +++ b/tests/unit/packetLossConfig.test.ts @@ -143,6 +143,35 @@ describe('SpeedTest packetLoss TURN config', () => { 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('reports a setup error after construction with autoStart', async () => { const engine = new SpeedTest({ logAimApiUrl: null, From 8e5a2e607320cc2d94b856ace9b8bc8a3431ca06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Tue, 20 Oct 2026 15:20:08 +0100 Subject: [PATCH 13/13] fix: don't advance past a deferred packet loss error while paused --- src/index.ts | 2 +- tests/unit/packetLossConfig.test.ts | 33 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 735e4fad..4540a200 100644 --- a/src/index.ts +++ b/src/index.ts @@ -472,7 +472,7 @@ class MeasurementEngine { msmResults.error = message; this.onResultsChange({ type }); this.#onError(`Error while measuring packet loss: ${message}`); - this.#next(); + this.#running && this.#next(); }); return; } diff --git a/tests/unit/packetLossConfig.test.ts b/tests/unit/packetLossConfig.test.ts index 20e8c7a5..e07868b5 100644 --- a/tests/unit/packetLossConfig.test.ts +++ b/tests/unit/packetLossConfig.test.ts @@ -172,6 +172,39 @@ describe('SpeedTest packetLoss TURN config', () => { 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,