diff --git a/.gitignore b/.gitignore index b9e3d6af..4d905d27 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /.playwright /blob-report /coverage +/.vitest # Prevent temporary test pages from getting commited example/basic-page/*.html diff --git a/README.md b/README.md index 4851cf33..09a1b3cb 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ them. | **measureUploadLoadedLatency**: *boolean* | Whether to perform additional latency measurements simultaneously with upload requests, to measure loaded latency (during upload). | `true` | | **loadedLatencyThrottle**: *number* | Time interval to wait in between loaded latency requests (in milliseconds). | 400 | | **bandwidthFinishRequestDuration**: *number* | The minimum duration (in milliseconds) to reach in download/upload measurement sets for halting further measurements with larger file sizes in the same direction. | 1000 | -| **bandwidthAbortRequestDuration**: *number* | The minimum duration (in milliseconds) to reach in download/upload measurement sets for aborting the test early | 0 (disabled) | +| **abortRequestDuration**: *number* | The maximum duration (in milliseconds) of a single request. Requests that take longer are aborted and skipped. `bandwidthAbortRequestDuration` is a deprecated alias. | 0 (disabled) | +| **abortTestDuration**: *number* | The maximum running time (in milliseconds, excluding paused time) of the whole test. When it's reached, the test ends with the results collected so far. | 0 (disabled) | | **estimatedServerTime**: *number* | If the download/upload APIs do not return a `server-timing` response header containing the time spent in the server, this fixed value (in milliseconds) will be subtracted from all time-to-first-byte calculations. | 0 | | **latencyPercentile**: *number* | The percentile (between 0 and 1) used to calculate latency from a set of measurements. | 0.5 | | **bandwidthPercentile**: *number* | The percentile (between 0 and 1) used to calculate bandwidth from a set of measurements. | 0.9 | @@ -88,8 +89,8 @@ them. | --- | --- | --- | | **onRunningChange** | running: *boolean* | Invoked whenever the test engine starts or stops. The current state is included as a function argument. | | **onResultsChange** | { type: *string* } | Invoked whenever any item changes in the results, usually indicating the completion of a measurement. The type of measurement that changed is included as an info attribute in the function argument. | -| **onFinish** | results: *[Results](#results-object)* | Invoked whenever the test engine finishes all the measurements. The final [results object](#results-object) is included as a function argument. | -| **onError** | error: *string* | Invoked whenever an error occurs during one of the measurements. The error details are included as a function argument. | +| **onFinish** | results: *[Results](#results-object)* | Invoked when the test ends, with the [results object](#results-object) collected so far as a function argument. | +| **onError** | error: *string*, status: *number \| undefined* | Invoked whenever an error occurs during one of the measurements, with the HTTP status when available. Most errors don't stop the test; call `pause()` from this callback to stop it. | ### Measurement config @@ -131,7 +132,7 @@ Each of these measurement sets are bound to a specific file size. The engine fol | Field | Required | Description | Default | | --- | :--: | --- | :--: | | **bytes**: *number* | yes | The file size to request from the download API, or post to the upload API. The bandwidth (calculated as bits per second, or bps) for each request is calculated by dividing the `transferSize` (in bits) by the request duration (excluding the server processing time). Durations are measured from the final response headers where the browser reports them (`finalResponseHeadersStart`), so interim `1xx` responses do not shorten them. | - | -| **count**: *number* | yes | The number of requests to perform for this file size. A request whose reported duration is zero or negative, or that has no resource timing entry, has no measurable bandwidth: it still counts towards `count`, but is discarded from the results with a console warning, so a set can return fewer data points than requested. If three requests in a row within one measurement set have no resource timing entry, the test stops and reports it through `onError`. | - | +| **count**: *number* | yes | The number of requests to perform for this file size. A request whose reported duration is zero or negative, or that has no resource timing entry, has no measurable bandwidth: it still counts towards `count`, but is discarded from the results with a console warning, so a set can return fewer data points than requested. If three requests in a row within one measurement set have no resource timing entry, the test ends with the results collected so far, and reports it through `onError`. | - | | **bypassMinDuration**: *boolean* | no | Whether the `bandwidthMinRequestDuration` check should be ignored, and the engine is instructed to proceed with the measurements of this direction in any case. | `false` | #### packetLoss diff --git a/src/Results/index.ts b/src/Results/index.ts index f58e0a45..52cdeff8 100644 --- a/src/Results/index.ts +++ b/src/Results/index.ts @@ -36,11 +36,25 @@ export interface RawMeasurementEntry { [key: string]: unknown; } -export interface RawResults { +/** All measurement types the engine can run; each configured one has an entry in {@link RawResults}. */ +export type MeasurementType = + | 'latency' + | 'latencyUnderLoad' + | 'download' + | 'upload' + | 'packetLoss' + | 'packetLossUnderLoad' + | 'v4Reachability' + | 'v6Reachability' + | 'rpki' + | 'nxdomain'; + +export type RawResults = { /** Excluding paused time (ms). */ totalDurationMs: number | undefined; - [key: string]: RawMeasurementEntry | number | undefined; -} + /** Why the test ended early, when it did (e.g. `abortTestDuration`). */ + error?: string; +} & { [type in MeasurementType]?: RawMeasurementEntry }; // --- Re-exports for consumers --- @@ -220,7 +234,7 @@ class Results { // Internal methods #calcGetter = ( calcFn: keyof MeasurementCalculations, - resKey: string, + resKey: MeasurementType, defaultVal: unknown = undefined, surfaceError = false // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index ea0cd6da..e3cd9efa 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -130,10 +130,17 @@ export interface Config { estimatedServerTime: number; /** - * Per-request duration (ms) at which to abort the current request and stop - * further rounds of that bandwidth type. Set to `0` to disable. Default: `0`. + * Maximum duration (ms) of a single request. Longer requests are aborted and + * skipped. `0` disables it. Default: `0`. */ - bandwidthAbortRequestDuration: number; + abortRequestDuration: number; + + /** + * Maximum running time (ms, excluding paused time) of the whole test. When + * reached, the test ends with the results collected so far. `0` or less + * disables it. Default: `0`. + */ + abortTestDuration: number; /** Percentile used to compute latency from the sample set (0–1). Default: `0.5` (median). */ latencyPercentile: number; @@ -154,7 +161,10 @@ export interface Config { * override individual options; omitted properties fall back to their defaults * in {@link defaultConfig}. */ -export type ConfigOptions = Partial; +export type ConfigOptions = Partial & { + /** @deprecated Use `abortRequestDuration`, which wins if both are set. */ + bandwidthAbortRequestDuration?: number; +}; const REL_API_URL = 'https://speed.cloudflare.com'; @@ -212,7 +222,8 @@ const defaultConfig: Config = { estimatedServerTime: 0, // ms to discount from latency calculation (if not present in response headers) // Test abort - bandwidthAbortRequestDuration: 0, // download/upload duration (ms) to abort measurement early and stop further measurements of that type + abortRequestDuration: 0, // request duration (ms) after which a request is aborted and skipped + abortTestDuration: 0, // total running time (ms) to abort the whole test, keeping the results so far // Result interpretation latencyPercentile: 0.5, // Percentile used to calculate latency from a set of measurements diff --git a/src/engines/BandwidthEngine/BandwidthEngine.ts b/src/engines/BandwidthEngine/BandwidthEngine.ts index bf589015..e875f390 100644 --- a/src/engines/BandwidthEngine/BandwidthEngine.ts +++ b/src/engines/BandwidthEngine/BandwidthEngine.ts @@ -249,7 +249,13 @@ class BandwidthMeasurementEngine implements Engine { } finishRequestDuration: number = 1000; // download/upload duration (ms) to reach for stopping further measurements - abortRequestDuration: number = 0; + #abortRequestDuration: number = 0; // request duration (ms) after which a request is aborted and skipped + get abortRequestDuration(): number { + return this.#abortRequestDuration; + } + set abortRequestDuration(v: number) { + this.#abortRequestDuration = v; + } getServerTime: ((r: Response) => number | undefined) | null = cfGetServerTime; // method to extract server time from response #responseHook: (r: ResponseHookPayload) => void = () => {}; // pipe-through of response objects @@ -290,6 +296,10 @@ class BandwidthMeasurementEngine implements Engine { set onConnectionError(f: (error: string, status?: number) => void) { this.#onConnectionError = f; } + #onFatalError: (error: string) => void = () => {}; // Invoked when the engine can't measure at all, after pausing itself + set onFatalError(f: (error: string) => void) { + this.#onFatalError = f; + } // Public methods pause(): void { @@ -297,12 +307,14 @@ class BandwidthMeasurementEngine implements Engine { clearTimeout(this.#retryTimeout); this.#retryTimeout = undefined; } + clearTimeout(this.#abortTimeout); + clearTimeout(this.#throttleTimeout); this.#cancelCurrentMeasurement(`pause()`); this.#setRunning(false); } play(): void { - if (!this.#failed && !this.#running) { + if (!this.#running) { this.#setRunning(true); this.#nextMeasurement(); } @@ -314,7 +326,6 @@ class BandwidthMeasurementEngine implements Engine { #uploadApi: string; #running: boolean = false; - #failed: boolean = false; #finished: Record = { down: false, up: false }; #results: BandwidthEngineResults = { down: {}, up: {} }; #measIdx: number = 0; @@ -322,6 +333,8 @@ class BandwidthMeasurementEngine implements Engine { #retries: number = 0; #missingEntries: number = 0; #retryTimeout: ReturnType | undefined; + #abortTimeout: ReturnType | undefined; + #throttleTimeout: ReturnType | undefined; #minDuration: number = -Infinity; // of current measurement #throttleMs: number = 0; #estimatedServerTime: number = 0; @@ -467,20 +480,16 @@ class BandwidthMeasurementEngine implements Engine { ) { // abort existing abort controller this.#currentAbortController?.abort('restarting engine'); + clearTimeout(this.#abortTimeout); // create new abort controller - this.#currentAbortController = new AbortController(); + const controller = (this.#currentAbortController = new AbortController()); if (this.abortRequestDuration) { - const abortTimeout = setTimeout(() => { - const errorMessage = `${isDown ? 'Download' : 'Upload'} measurement of ${numBytes} bytes aborted. Measurement exceeded bandwidthAbortRequestDuration (${this.abortRequestDuration}ms)`; - this.#retries = 0; - this.pause(); - this.#failed = true; - this.#onConnectionError(errorMessage); + this.#abortTimeout = setTimeout(() => { + const errorMessage = `${isDown ? 'Download' : 'Upload'} request of ${numBytes} bytes aborted. Request exceeded abortRequestDuration (${this.abortRequestDuration}ms)`; + controller.abort(errorMessage); + this.#skipFailedRequest(errorMessage); }, this.abortRequestDuration); - this.#currentAbortController.signal.addEventListener('abort', () => - clearTimeout(abortTimeout) - ); } } @@ -536,8 +545,7 @@ class BandwidthMeasurementEngine implements Engine { ); if (++this.#missingEntries >= MAX_MISSING_ENTRIES) { this.pause(); - this.#failed = true; - this.#onConnectionError( + this.#onFatalError( `No resource timing entries for ${url}. The host page may be clearing or filling the buffer, or the runtime doesn't record resource timing.` ); return; @@ -681,7 +689,7 @@ class BandwidthMeasurementEngine implements Engine { ) { this.#retryTimeout = setTimeout(() => { this.#retryTimeout = undefined; - if (this.#running && !this.#failed) this.#nextMeasurement(); + if (this.#running) this.#nextMeasurement(); }, getRetryDelay(error.retryAfter)); return; } @@ -693,25 +701,33 @@ class BandwidthMeasurementEngine implements Engine { : status === undefined ? `Connection failed to ${url}.` : `Request failed with ${status}: ${url}`; - this.#retries = 0; - this.pause(); - this.#failed = true; - this.#onConnectionError(message, status); + this.#skipFailedRequest(message, status); }); } + // Reports a failed request and moves on, so one failure can't end the set + #skipFailedRequest(message: string, status?: number): void { + // An abort can land during a 429 backoff; its retry must not run too + clearTimeout(this.#retryTimeout); + this.#retryTimeout = undefined; + this.#retries = 0; + this.#onConnectionError(message, status); + this.#running && this.#scheduleNextMeasurement(); + } + #scheduleNextMeasurement(): void { + // The request has settled, so its abort timer must not fire during the throttle + clearTimeout(this.#abortTimeout); this.#counter += 1; this.#retries = 0; if (this.#throttleMs) { - const throttleTimeout = setTimeout( - () => this.#nextMeasurement(), - this.#throttleMs - ); - this.#currentAbortController!.signal.addEventListener('abort', () => - clearTimeout(throttleTimeout) - ); + // Cleared by pause(): the controller may already be aborted, so its + // abort event can't cancel this + this.#throttleTimeout = setTimeout(() => { + this.#throttleTimeout = undefined; + if (this.#running) this.#nextMeasurement(); + }, this.#throttleMs); } else { this.#nextMeasurement(); } diff --git a/src/engines/BandwidthEngine/ParallelLatency.ts b/src/engines/BandwidthEngine/ParallelLatency.ts index b29fef69..03bb2398 100644 --- a/src/engines/BandwidthEngine/ParallelLatency.ts +++ b/src/engines/BandwidthEngine/ParallelLatency.ts @@ -63,7 +63,6 @@ class BandwidthWithParallelLatencyEngine extends BandwidthEngine { }; super.onRunningChange = this.#setLatencyRunning; - super.onConnectionError = () => this.#latencyEngine!.pause(); } } @@ -80,6 +79,11 @@ class BandwidthWithParallelLatencyEngine extends BandwidthEngine { f(res)); } + // Called when a parallel latency request fails; both engines keep measuring + set onParallelLatencyError(f: (error: string, status?: number) => void) { + this.#latencyEngine && (this.#latencyEngine.onConnectionError = f); + } + // Overridden attributes get fetchOptions(): RequestInit { return super.fetchOptions; @@ -89,6 +93,24 @@ class BandwidthWithParallelLatencyEngine extends BandwidthEngine { this.#latencyEngine && (this.#latencyEngine.fetchOptions = fetchOptions); } + get abortRequestDuration(): number { + return super.abortRequestDuration; + } + set abortRequestDuration(abortRequestDuration: number) { + super.abortRequestDuration = abortRequestDuration; + this.#latencyEngine && + (this.#latencyEngine.abortRequestDuration = abortRequestDuration); + } + + set onFatalError(onFatalError: (error: string) => void) { + super.onFatalError = onFatalError; + this.#latencyEngine && + (this.#latencyEngine.onFatalError = (error: string) => { + this.pause(); + onFatalError(error); + }); + } + set onRunningChange(onRunningChange: (running: boolean) => void) { super.onRunningChange = (running: boolean) => { this.#setLatencyRunning(running); @@ -96,21 +118,6 @@ class BandwidthWithParallelLatencyEngine extends BandwidthEngine { }; } - set onConnectionError( - onConnectionError: (error: string, status?: number) => void - ) { - super.onConnectionError = (...args: [string, number?]) => { - this.#latencyEngine && this.#latencyEngine.pause(); - onConnectionError(...args); - }; - if (this.#latencyEngine) { - this.#latencyEngine.onConnectionError = (...args: [string, number?]) => { - this.pause(); - onConnectionError(...args); - }; - } - } - // Internal state #latencyEngine: BandwidthEngine | undefined; #latencyTimeout: ReturnType | undefined; diff --git a/src/engines/Engine.ts b/src/engines/Engine.ts index e4aaf77c..99eb56e1 100644 --- a/src/engines/Engine.ts +++ b/src/engines/Engine.ts @@ -20,9 +20,12 @@ export interface Engine { */ onFinished: (...args: any[]) => void; - /** Invoked when a connection-level error prevents measurement. */ + /** Invoked when a request fails; the engine skips it and carries on. */ onConnectionError?: (...args: any[]) => void; + /** Invoked when the engine can't measure at all and has paused itself. */ + onFatalError?: (...args: any[]) => void; + /** Invoked when a message is received (packet-loss engines). */ onMsgReceived?: (...args: any[]) => void; @@ -38,6 +41,9 @@ export interface Engine { /** Invoked for each parallel-latency sample collected alongside a bandwidth measurement. */ onParallelLatencyResult?: (...args: any[]) => void; + /** Invoked when a parallel-latency request fails (bandwidth engines). */ + onParallelLatencyError?: (...args: any[]) => void; + pause?: () => void; play?: () => void; @@ -55,8 +61,8 @@ export interface Engine { finishRequestDuration?: number; /** - * Maximum allowed request duration (ms). If a single request exceeds this - * value the engine aborts the measurement. + * Maximum allowed request duration (ms). A request that exceeds it is + * aborted and skipped. */ abortRequestDuration?: number; } diff --git a/src/index.ts b/src/index.ts index 20b8e36d..056df711 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import BandwidthEngine from './engines/BandwidthEngine/LoggingBandwidthEngine'; import PacketLossEngine from './engines/PacketLossEngine'; import ReachabilityEngine from './engines/ReachabilityEngine'; -import Results from './Results'; +import Results, { type MeasurementType } from './Results'; import logFinalResults, { type AimLogResponse } from './logging/logFinalResults'; @@ -21,19 +21,6 @@ const DEFAULT_OPTIMAL_DOWNLOAD_SIZE = 1e6; const DEFAULT_OPTIMAL_UPLOAD_SIZE = 1e6; const OPTIMAL_SIZE_RATIO = 0.5; // of largest size reached in measurement -/** All measurement types the engine can encounter at runtime. */ -type MeasurementType = - | 'latency' - | 'latencyUnderLoad' - | 'download' - | 'upload' - | 'packetLoss' - | 'packetLossUnderLoad' - | 'v4Reachability' - | 'v6Reachability' - | 'rpki' - | 'nxdomain'; - /** * A single measurement step configuration. The exact properties depend on * `type`, but in the switch statement we destructure dynamically so we use a @@ -123,12 +110,18 @@ const genMeasId = (): string => `${Math.round(Math.random() * 1e16)}`; */ class MeasurementEngine { constructor(userConfig: ConfigOptions = {}) { + // The deprecated alias is resolved here and kept out of the merged config + const { bandwidthAbortRequestDuration, ...options } = userConfig; this.#config = Object.assign( {}, defaultConfig, - userConfig, + options, internalConfig ) as SpeedTestConfig; + this.#config.abortRequestDuration = + options.abortRequestDuration ?? + bandwidthAbortRequestDuration ?? + defaultConfig.abortRequestDuration; // Built once: the insecure-transport warning is latched per object, so a // fresh one per access would warn on every request. this.#authorization = { @@ -169,7 +162,8 @@ class MeasurementEngine { onPhaseChange: (payload: PhaseChangePayload) => void = () => {}; - #onFinish: (results: Results) => void = () => {}; + // skipLog is internal: SpeedTestEngine reads it to skip the AIM log + #onFinish: (results: Results, end?: { skipLog: boolean }) => void = () => {}; set onFinish(f: (results: Results) => void) { this.#onFinish = f; @@ -177,6 +171,11 @@ class MeasurementEngine { #onError: (message: string, status?: number) => void = () => {}; + /** + * Called when an error occurs, with the HTTP status when available. Most + * errors don't stop the test; call {@link pause} from this callback to stop + * it. + */ set onError(f: (message: string, status?: number) => void) { this.#onError = f; } @@ -201,7 +200,7 @@ class MeasurementEngine { * current phase. */ play(): void { - if (!this.#failed && !this.#running) { + if (!this.#running) { // Clear timings before running the engine performance.clearResourceTimings(); @@ -242,9 +241,11 @@ class MeasurementEngine { */ #accumulatedRuntimeMs: number = 0; + #abortTestTimeout: ReturnType | undefined; + #running: boolean = false; #finished: boolean = false; - #failed: boolean = false; + #skipLog: boolean = false; // Internal methods #setRunning(running: boolean): void { @@ -261,6 +262,55 @@ class MeasurementEngine { this.#startTime = undefined; } } + this.#scheduleAbortTest(); + } + + // Paused time doesn't count, so the timer only runs while the test does + #scheduleAbortTest(): void { + clearTimeout(this.#abortTestTimeout); + this.#abortTestTimeout = undefined; + + const limit = this.#config.abortTestDuration; + if (!(limit > 0) || !this.#running || this.#finished) return; + + this.#abortTestTimeout = setTimeout( + () => + this.#endTest( + `Test aborted. Test exceeded abortTestDuration (${this.#config.abortTestDuration}ms)` + ), + Math.max(0, limit - this.#accumulatedRuntimeMs) + ); + } + + #reportErrorAndContinue(...error: [message: string, status?: number]): void { + const measurementId = this.#measurementId; + this.#onError(...error); + if (measurementId !== this.#measurementId) return; // restarted from onError + this.#running && this.#next(); + } + + // Ends the test early, keeping the results collected so far + #endTest(message: string, { skipLog = false } = {}): void { + const type = this.#curType(); + const msmResults = this.#curTypeResults(); + + this.#destroyCurEngine(); + this.#curMsmIdx = this.#config.measurements.length; + + this.#results.raw.error = message; + if (type && msmResults) { + msmResults.error = message; + this.onResultsChange({ type }); + } + + // Set before onError, which can finish the test itself via pause() + play() + this.#skipLog = skipLog; + const measurementId = this.#measurementId; + this.#onError(message); + if (measurementId !== this.#measurementId) return; // restarted from onError + + this.#setRunning(false); + this.#setFinished(true); } #setFinished(finished: boolean): void { @@ -268,7 +318,8 @@ class MeasurementEngine { this.#finished = finished; if (finished) { this.#results.raw.totalDurationMs = this.#accumulatedRuntimeMs; - setTimeout(() => this.#onFinish(this.results)); + const skipLog = this.#skipLog; + setTimeout(() => this.#onFinish(this.results, { skipLog })); } } } @@ -284,8 +335,7 @@ class MeasurementEngine { const type = this.#curType(); if (!type) return undefined; return ( - (this.#results.raw as Record)[type] || - undefined + (this.#results.raw[type] as MeasurementResult | undefined) || undefined ); } @@ -298,7 +348,7 @@ class MeasurementEngine { this.#setRunning(false); this.#setFinished(false); - this.#failed = false; + this.#skipLog = false; this.#results.clear(); this.#accumulatedRuntimeMs = 0; @@ -313,6 +363,10 @@ class MeasurementEngine { engine.onMsgReceived = engine.onCredentialsFailure = engine.onMeasurementResult = + engine.onNewMeasurementStarted = + engine.onParallelLatencyResult = + engine.onParallelLatencyError = + engine.onFatalError = () => {}; const curType = this.#curType(); @@ -328,8 +382,7 @@ class MeasurementEngine { resumeResults && resumeResults.started && !resumeResults.finished && - !resumeResults.finishedCurrentRound && - !resumeResults.error + !resumeResults.finishedCurrentRound ) { this.#curEngine?.play?.(); return; @@ -471,8 +524,9 @@ class MeasurementEngine { msmResults.finished = true; msmResults.error = message; this.onResultsChange({ type }); - this.#onError(`Error while measuring packet loss: ${message}`); - this.#running && this.#next(); + this.#reportErrorAndContinue( + `Error while measuring packet loss: ${message}` + ); }); return; } @@ -489,25 +543,25 @@ class MeasurementEngine { engine!.onFinished = () => { msmResults.finished = true; this.onResultsChange({ type }); - this.#next(); + this.#running && this.#next(); }; engine!.onConnectionError = (e: unknown) => { msmResults.finished = true; msmResults.error = e; this.onResultsChange({ type }); - this.#onError(`Connection error while measuring packet loss: ${e}`); - this.#next(); + this.#reportErrorAndContinue( + `Connection error while measuring packet loss: ${e}` + ); }; engine!.onCredentialsFailure = () => { msmResults.finished = true; msmResults.error = 'unable to get turn server credentials'; this.onResultsChange({ type }); - this.#onError( + this.#reportErrorAndContinue( 'Error while measuring packet loss: unable to get turn server credentials.' ); - this.#next(); }; break; @@ -550,7 +604,7 @@ class MeasurementEngine { : {}; ( engine as Engine & { abortRequestDuration: number } - ).abortRequestDuration = this.#config.bandwidthAbortRequestDuration; + ).abortRequestDuration = this.#config.abortRequestDuration; if (!msmConfig.loadDown && !msmConfig.loadUp) { const eng = engine as Engine & { qsParams: Record }; @@ -582,20 +636,23 @@ class MeasurementEngine { this.#running && this.#next(); }; - engine.onConnectionError = (e: unknown, status?: number) => { - msmResults.error = e; - this.#failed = true; - this.pause(); - this.onResultsChange({ type }); - this.#onError(String(e), status); - }; + // The engine skips the failed request and carries on with the step + engine.onConnectionError = (e: unknown, status?: number) => + this.#onError( + `Connection error while measuring latency: ${e}`, + status + ); + engine.onFatalError = (e: unknown) => + this.#endTest(`Connection error while measuring latency: ${e}`, { + skipLog: true + }); (engine as Engine & { play: () => void }).play!(); break; } case 'download': case 'upload': - if (msmResults.finished || msmResults.error) { + if (msmResults.finished) { // Skip: this direction already concluded. Return so the nested // #next() keeps ownership of #curEngine. this.#next(); @@ -637,7 +694,7 @@ class MeasurementEngine { ).finishRequestDuration = this.#config.bandwidthFinishRequestDuration; ( engine as Engine & { abortRequestDuration: number } - ).abortRequestDuration = this.#config.bandwidthAbortRequestDuration; + ).abortRequestDuration = this.#config.abortRequestDuration; engine.onNewMeasurementStarted = (...args: unknown[]) => { const { count, bytes } = args[0] as { @@ -742,12 +799,30 @@ class MeasurementEngine { this.#running && this.#next(); }; - engine.onConnectionError = (e: unknown, status?: number) => { - msmResults.error = e; - this.#failed = true; - this.pause(); - this.onResultsChange({ type }); - this.#onError(String(e), status); + // The engine skips the failed request and carries on with the step + engine.onConnectionError = (e: unknown, status?: number) => + this.#onError( + `Connection error while measuring ${type}: ${e}`, + status + ); + engine.onFatalError = (e: unknown) => + this.#endTest(`Connection error while measuring ${type}: ${e}`, { + skipLog: true + }); + + const measurementId = this.#measurementId; + const msmIdx = this.#curMsmIdx; + engine.onParallelLatencyError = (e: unknown, status?: number) => { + // Ignore late errors once this step is over or the test restarted + if ( + measurementId !== this.#measurementId || + msmIdx !== this.#curMsmIdx + ) + return; + this.#onError( + `Connection error while measuring loaded latency: ${e}`, + status + ); }; (engine as Engine & { play: () => void }).play!(); @@ -794,9 +869,9 @@ class SpeedTestEngine extends MeasurementEngine { * to the AIM API automatically. */ set onFinish(onFinish: (results: Results) => void) { - super.onFinish = (results: Results) => { + super.onFinish = (results: Results, end?: { skipLog: boolean }) => { onFinish(results); - this.#logFinalResults(results); + this.#logFinalResults(results, end); }; } @@ -812,9 +887,9 @@ class SpeedTestEngine extends MeasurementEngine { onResultsLogged: (response: AimLogResponse) => void = () => {}; // Internal methods - #logFinalResults = (results: Results): void => { + #logFinalResults = (results: Results, end?: { skipLog: boolean }): void => { const apiUrl = this.config.logAimApiUrl; - if (!apiUrl) { + if (!apiUrl || end?.skipLog) { return; } logFinalResults(results, { diff --git a/tests/e2e/httpError.test.ts b/tests/e2e/httpError.test.ts index 0d069aba..1bac7e6e 100644 --- a/tests/e2e/httpError.test.ts +++ b/tests/e2e/httpError.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import SpeedTest from '../../src'; +import SpeedTest, { type ConfigOptions } from '../../src'; import BandwidthEngine from '../../src/engines/BandwidthEngine/BandwidthEngine'; const originalFetch = window.fetch; @@ -10,73 +10,869 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe.each([400, 401, 403, 408, 500])('bandwidth HTTP %i', status => { - it('stops the top-level engine without retrying, advancing, or resuming', async () => { - const fetchMock = vi.fn(() => - Promise.resolve(new Response(null, { status })) - ); +// Stamped at lookup time, so the entry always counts as the request's own +const mockPerformanceTiming = () => + vi.spyOn(performance, 'getEntriesByName').mockImplementation(() => [ + { + connectEnd: 0, + connectStart: 0, + nextHopProtocol: 'h2', + requestStart: 0, + responseEnd: 20, + responseStart: 10, + secureConnectionStart: 0, + startTime: performance.now(), + transferSize: 1_000 + } as PerformanceResourceTiming + ]); + +const createEngine = ( + measurements: ConfigOptions['measurements'], + config: ConfigOptions = {} +) => + new SpeedTest({ + autoStart: false, + downloadApiUrl: 'https://example.com/down', + uploadApiUrl: 'https://example.com/up', + logAimApiUrl: null, + logMeasurementApiUrl: null, + measureDownloadLoadedLatency: false, + measureUploadLoadedLatency: false, + measurements, + ...config + }); + +const trackResultErrors = (engine: SpeedTest): string[] => { + const resultErrors: string[] = []; + engine.onResultsChange = ({ type }) => { + const results = engine.results.raw[type]; + if ( + typeof results === 'object' && + results.error && + !resultErrors.includes(results.error) + ) { + resultErrors.push(results.error); + } + }; + return resultErrors; +}; + +describe.each([ + [ + 'HTTP 400', + () => Promise.resolve(new Response(null, { status: 400 })), + 400, + 'Request failed with 400' + ], + [ + 'HTTP 401', + () => Promise.resolve(new Response(null, { status: 401 })), + 401, + 'Request failed with 401' + ], + [ + 'HTTP 403', + () => Promise.resolve(new Response(null, { status: 403 })), + 403, + 'Request failed with 403' + ], + [ + 'HTTP 408', + () => Promise.resolve(new Response(null, { status: 408 })), + 408, + 'Request failed with 408' + ], + [ + 'HTTP 500', + () => Promise.resolve(new Response(null, { status: 500 })), + 500, + 'Request failed with 500' + ], + [ + 'network failure', + () => Promise.reject(new TypeError('fetch failed')), + undefined, + 'Connection failed' + ] +])('bandwidth %s', (_label, respond, status, reason) => { + it('skips each failed request and runs every step', async () => { + const fetchMock = vi.fn(respond); window.fetch = fetchMock; vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const engine = new SpeedTest({ - autoStart: false, - downloadApiUrl: 'https://example.com/down', - uploadApiUrl: 'https://example.com/up', - logAimApiUrl: null, - logMeasurementApiUrl: null, - measurements: [ - { type: 'latency', numPackets: 1 }, - { type: 'download', bytes: 1_000, count: 1 } - ] - }); + const engine = createEngine([ + { type: 'latency', numPackets: 1 }, + { type: 'download', bytes: 1_000, count: 1 }, + { type: 'upload', bytes: 1_000, count: 1 }, + { type: 'download', bytes: 10_000, count: 1 } + ]); const phases: string[] = []; - const resultErrors: string[] = []; - const onFinish = vi.fn(); engine.onPhaseChange = ({ measurement }) => phases.push(measurement.type); - engine.onResultsChange = ({ type }) => { - const results = engine.results.raw[type]; - if (typeof results === 'object' && results.error) { - resultErrors.push(results.error); - } + const resultErrors = trackResultErrors(engine); + const errors: { message: string; status?: number }[] = []; + engine.onError = (message, responseStatus) => + errors.push({ message, status: responseStatus }); + + const finished = new Promise(resolve => (engine.onFinish = resolve)); + engine.play(); + await finished; + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(phases).toEqual(['latency', 'download', 'upload', 'download']); + expect(errors).toEqual( + ['latency', 'download', 'upload', 'download'].map(type => ({ + message: expect.stringContaining( + `Connection error while measuring ${type}: ${reason}` + ), + status + })) + ); + // A failed request doesn't end its step, so no step records an error + expect(resultErrors).toEqual([]); + expect(engine.isFinished).toBe(true); + expect(engine.isRunning).toBe(false); + expect(() => engine.results.getSummary()).not.toThrow(); + expect(() => engine.results.getScores()).not.toThrow(); + }); +}); + +it('stops when paused from onError and retries the request on resume', async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(null, { status: 401 })) + ); + window.fetch = fetchMock; + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const engine = createEngine([ + { type: 'latency', numPackets: 1 }, + { type: 'download', bytes: 1_000, count: 1 } + ]); + const phases: string[] = []; + engine.onPhaseChange = ({ measurement }) => phases.push(measurement.type); + const onFinish = vi.fn(); + const finished = new Promise(resolve => { + engine.onFinish = results => { + onFinish(results); + resolve(results); }; - engine.onFinish = onFinish; + }); + + const errored = new Promise(resolve => { + engine.onError = () => { + engine.pause(); + engine.onError = () => {}; + resolve(); + }; + }); + + engine.play(); + await errored; + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(phases).toEqual(['latency']); + expect(engine.isRunning).toBe(false); + expect(onFinish).not.toHaveBeenCalled(); + + engine.play(); + await finished; - const error = new Promise<{ - message: string; - running: boolean; - status?: number; - }>(resolve => { - engine.onError = (message, responseStatus) => { - resolve({ - message, - running: engine.isRunning, - status: responseStatus + // The paused latency request is sent again, then the download + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(phases).toEqual(['latency', 'download']); +}); + +it('skips each request that exceeds abortRequestDuration and runs every step', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const requests: string[] = []; + const fetchMock = vi.fn( + (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(input.toString()); + const bytes = url.searchParams.get('bytes'); + requests.push(`${url.pathname.slice(1)} ${bytes}`); + if (bytes === '10000000') { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(init.signal?.reason) + ); }); - }; + } + return Promise.resolve(new Response('ok')); + } + ); + window.fetch = fetchMock; + + const engine = createEngine( + [ + { type: 'download', bytes: 1e5, count: 1 }, + { type: 'upload', bytes: 1e5, count: 1 }, + { type: 'download', bytes: 1e7, count: 2 }, + { type: 'upload', bytes: 5e6, count: 1 }, + { type: 'download', bytes: 2.5e7, count: 1 }, + { type: 'latency', numPackets: 1 } + ], + { abortRequestDuration: 50 } + ); + const resultErrors = trackResultErrors(engine); + const errors: string[] = []; + engine.onError = message => errors.push(message); + + const results = await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + expect(requests).toEqual([ + 'down 100000', + 'up 100000', + 'down 10000000', + 'down 10000000', + 'up 5000000', + 'down 25000000', + 'down 0' + ]); + expect(errors).toEqual([ + expect.stringContaining('exceeded abortRequestDuration (50ms)'), + expect.stringContaining('exceeded abortRequestDuration (50ms)') + ]); + expect(resultErrors).toEqual([]); + expect(results.getDownloadBandwidthPoints().map(d => d.bytes)).toEqual([ + 1e5, 2.5e7 + ]); + expect(results.getUploadBandwidthPoints().map(d => d.bytes)).toEqual([ + 1e5, 5e6 + ]); + expect(results.getSummary()).toMatchObject({ + download: expect.any(Number), + upload: expect.any(Number), + latency: expect.any(Number) + }); +}); + +it.each([500, 401, 403])( + 'skips a %i mid-step and carries on with the step', + async status => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const requests: string[] = []; + let downloads = 0; + window.fetch = vi.fn((input: RequestInfo | URL): Promise => { + const url = new URL(input.toString()); + requests.push( + `${url.pathname.slice(1)} ${url.searchParams.get('bytes')}` + ); + const failed = url.pathname === '/down' && ++downloads === 3; + return Promise.resolve( + new Response('ok', { status: failed ? status : 200 }) + ); + }); + + const engine = createEngine([ + { type: 'download', bytes: 1e5, count: 4 }, + { type: 'upload', bytes: 1e5, count: 1 }, + { type: 'download', bytes: 1e6, count: 1 } + ]); + const errors: { message: string; status?: number }[] = []; + engine.onError = (message, responseStatus) => + errors.push({ message, status: responseStatus }); + + const results = await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + expect(requests).toEqual([ + 'down 100000', + 'down 100000', + 'down 100000', + 'down 100000', + 'up 100000', + 'down 1000000' + ]); + expect(errors).toEqual([ + { + message: expect.stringContaining( + `Connection error while measuring download: Request failed with ${status}` + ), + status + } + ]); + expect(results.getDownloadBandwidthPoints().map(d => d.bytes)).toEqual([ + 1e5, 1e5, 1e5, 1e6 + ]); + expect(results.getUploadBandwidthPoints()).toHaveLength(1); + expect(results.raw.download).not.toHaveProperty('error'); + } +); + +it('reports a failed loaded-latency request and keeps measuring bandwidth', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let resolveBandwidthRequest: ((response: Response) => void) | undefined; + const fetchMock = vi.fn((input: RequestInfo | URL): Promise => { + const url = new URL(input.toString()); + if (url.searchParams.get('bytes') === '0') { + setTimeout(() => resolveBandwidthRequest?.(new Response('ok')), 10); + return Promise.resolve(new Response(null, { status: 401 })); + } + return new Promise(resolve => { + resolveBandwidthRequest = resolve; }); + }); + window.fetch = fetchMock; + + const engine = createEngine([{ type: 'download', bytes: 1_000, count: 1 }], { + measureDownloadLoadedLatency: true + }); + const resultErrors = trackResultErrors(engine); + const onError = vi.fn(); + engine.onError = onError; + const results = await new Promise(resolve => { + engine.onFinish = resolve; engine.play(); + }); - await expect(error).resolves.toEqual({ - message: expect.stringMatching( - new RegExp(`^Request failed with ${status}: https://example\\.com/down`) - ), - running: false, - status - }); - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(phases).toEqual(['latency']); - expect(resultErrors).toEqual([ - expect.stringMatching(`^Request failed with ${status}:`) + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.stringMatching( + /^Connection error while measuring loaded latency: Request failed with 401:/ + ), + 401 + ); + expect(resultErrors).toEqual([]); + expect(results.getDownloadBandwidthPoints()).toHaveLength(1); +}); + +it('ends the test without logging after three missing resource timing entries', async () => { + vi.spyOn(performance, 'getEntriesByName').mockReturnValue([]); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const fetchMock = vi.fn(() => Promise.resolve(new Response('ok'))); + window.fetch = fetchMock; + + const engine = createEngine( + [ + { type: 'download', bytes: 1e5, count: 5 }, + { type: 'upload', bytes: 1e5, count: 1 } + ], + { logAimApiUrl: 'https://example.com/__results' } + ); + const errors: string[] = []; + engine.onError = message => errors.push(message); + const onResultsLogged = vi.fn(); + engine.onResultsLogged = onResultsLogged; + + const results = await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(errors).toEqual([ + expect.stringContaining( + 'Connection error while measuring download: No resource timing entries' + ) + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(results.raw.error).toBe(errors[0]); + expect(results.raw.download).toMatchObject({ error: errors[0] }); + expect(engine.isFinished).toBe(true); + expect(engine.isRunning).toBe(false); + expect(onResultsLogged).not.toHaveBeenCalled(); +}); + +it('does not log a test ended by missing entries when resumed from onError', async () => { + vi.spyOn(performance, 'getEntriesByName').mockReturnValue([]); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const fetchMock = vi.fn(() => Promise.resolve(new Response('ok'))); + window.fetch = fetchMock; + + const engine = createEngine([{ type: 'download', bytes: 1e5, count: 5 }], { + logAimApiUrl: 'https://example.com/__results' + }); + engine.onError = () => { + engine.pause(); + engine.play(); + }; + const onFinish = vi.fn(); + const onResultsLogged = vi.fn(); + engine.onResultsLogged = onResultsLogged; + + await new Promise(resolve => { + engine.onFinish = results => { + onFinish(results); + resolve(); + }; + engine.play(); + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onFinish).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(onResultsLogged).not.toHaveBeenCalled(); +}); + +it('keeps sampling loaded latency after a failed request', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let pings = 0; + window.fetch = vi.fn((input: RequestInfo | URL): Promise => { + const url = new URL(input.toString()); + if (url.searchParams.get('bytes') === '0') { + const status = ++pings === 1 ? 500 : 200; + return Promise.resolve(new Response('ok', { status })); + } + return new Promise(resolve => + setTimeout(() => resolve(new Response('ok')), 150) + ); + }); + + const engine = createEngine([{ type: 'download', bytes: 1_000, count: 1 }], { + measureDownloadLoadedLatency: true, + loadedLatencyThrottle: 10 + }); + const onError = vi.fn(); + engine.onError = onError; + + await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + expect(onError).toHaveBeenCalledOnce(); + const download = engine.results.raw.download!.results as Record< + number, + { sideLatency: unknown[] } + >; + expect(download[1_000].sideLatency.length).toBeGreaterThan(0); +}); + +it('aborts a loaded-latency ping that exceeds abortRequestDuration', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let pings = 0; + window.fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(input.toString()); + if (url.searchParams.get('bytes') === '0' && ++pings === 1) { + return new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ); + } + // Each download stays under the abort, so only the hung ping is aborted + return new Promise(resolve => + setTimeout(() => resolve(new Response('ok')), 40) + ); + } + ); + + const engine = createEngine([{ type: 'download', bytes: 1_000, count: 8 }], { + abortRequestDuration: 100, + measureDownloadLoadedLatency: true, + loadedLatencyThrottle: 10 + }); + const errors: string[] = []; + engine.onError = message => errors.push(message); + + await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + expect(errors).toEqual([ + expect.stringMatching( + /^Connection error while measuring loaded latency: .*exceeded abortRequestDuration \(100ms\)/ + ) + ]); + const download = engine.results.raw.download!.results as Record< + number, + { sideLatency: unknown[]; timings: unknown[] } + >; + expect(download[1_000].timings).toHaveLength(8); + expect(download[1_000].sideLatency.length).toBeGreaterThan(0); +}); + +it('does not abort a loaded-latency ping that succeeded during the throttle wait', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + window.fetch = vi.fn((input: RequestInfo | URL): Promise => { + const url = new URL(input.toString()); + const delay = url.searchParams.get('bytes') === '0' ? 10 : 60; + return new Promise(resolve => + setTimeout(() => resolve(new Response('ok')), delay) + ); + }); + + const engine = createEngine([{ type: 'download', bytes: 1_000, count: 10 }], { + abortRequestDuration: 100, + measureDownloadLoadedLatency: true, + loadedLatencyThrottle: 200 + }); + const onError = vi.fn(); + engine.onError = onError; + + await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + expect(onError).not.toHaveBeenCalled(); +}); + +it('stops loaded-latency pings when the step ends after an aborted ping', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let pings = 0; + window.fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = new URL(input.toString()); + if (url.searchParams.get('bytes') === '0' && ++pings === 1) { + return new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ); + } + return new Promise(resolve => + setTimeout(() => resolve(new Response('ok')), 150) + ); + } + ); + + // The ping aborts at 200ms and its throttle runs past the step's end (300ms) + const engine = createEngine([{ type: 'download', bytes: 1_000, count: 2 }], { + abortRequestDuration: 200, + measureDownloadLoadedLatency: true, + loadedLatencyThrottle: 150 + }); + engine.onError = () => undefined; + + await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + const pingsAtFinish = pings; + await new Promise(resolve => setTimeout(resolve, 600)); + + expect(pings).toBe(pingsAtFinish); +}); + +describe('abortTestDuration', () => { + it('aborts the request in flight and finishes with the results so far', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const signals: AbortSignal[] = []; + const fetchMock = vi.fn( + (input: RequestInfo | URL, init?: RequestInit): Promise => { + const bytes = new URL(input.toString()).searchParams.get('bytes'); + if (bytes !== '10000000') return Promise.resolve(new Response('ok')); + signals.push(init!.signal!); + return new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ); + } + ); + window.fetch = fetchMock; + + const engine = createEngine( + [ + { type: 'download', bytes: 1e5, count: 1, bypassMinDuration: true }, + { type: 'download', bytes: 1e7, count: 1 }, + { type: 'upload', bytes: 1e5, count: 1 } + ], + { abortTestDuration: 100 } + ); + const errors: string[] = []; + engine.onError = message => errors.push(message); + + const results = await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + const message = 'Test aborted. Test exceeded abortTestDuration (100ms)'; + expect(signals).toHaveLength(1); + expect(signals[0].aborted).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(errors).toEqual([message]); + expect(results.raw.error).toBe(message); + expect(results.raw.download).toMatchObject({ error: message }); + expect(results.getDownloadBandwidthPoints().map(d => d.bytes)).toEqual([ + 1e5 ]); + expect(engine.isFinished).toBe(true); + expect(engine.isRunning).toBe(false); + }); + + it('still submits the results to logAimApiUrl', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + window.fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit): Promise => + new URL(input.toString()).pathname === '/__results' + ? Promise.resolve(Response.json({ requestId: 'abc' })) + : new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ) + ); + + const engine = createEngine([{ type: 'download', bytes: 1e5, count: 1 }], { + abortTestDuration: 50, + logAimApiUrl: 'https://example.com/__results' + }); + engine.onError = () => {}; + + await expect( + new Promise(resolve => { + engine.onResultsLogged = resolve; + engine.play(); + }) + ).resolves.toEqual({ requestId: 'abc' }); + }); + + it('does not count paused time', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + window.fetch = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit): Promise => + new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ) + ); + + const engine = createEngine([{ type: 'download', bytes: 1e5, count: 1 }], { + abortTestDuration: 500 + }); + const onFinish = vi.fn(); + engine.onFinish = onFinish; + + // Wide margins: the pause alone outlasts the limit + engine.play(); + await new Promise(resolve => setTimeout(resolve, 100)); + engine.pause(); + await new Promise(resolve => setTimeout(resolve, 600)); expect(onFinish).not.toHaveBeenCalled(); + const finished = new Promise(resolve => (engine.onFinish = resolve)); + engine.play(); + await finished; + expect(engine.results.raw.error).toContain('abortTestDuration (500ms)'); + }); + + it('is disabled by a negative value', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + window.fetch = vi.fn(async () => new Response('ok')); + + const engine = createEngine([{ type: 'download', bytes: 1e5, count: 1 }], { + abortTestDuration: -1 + }); + const results = await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + + expect(results.raw.error).toBeUndefined(); + expect(results.getDownloadBandwidthPoints()).toHaveLength(1); + }); + + it('clears the reason when restarted', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let hang = true; + window.fetch = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit): Promise => + hang + ? new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ) + : Promise.resolve(new Response('ok')) + ); + + const engine = createEngine([{ type: 'download', bytes: 1e5, count: 1 }], { + abortTestDuration: 50 + }); + await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + expect(engine.results.raw.error).toBeDefined(); + + hang = false; + await new Promise(resolve => { + engine.onFinish = resolve; + engine.restart(); + }); + expect(engine.results.raw.error).toBeUndefined(); + expect(engine.results.getDownloadBandwidthPoints()).toHaveLength(1); + }); + + it('runs a single new test when restarted from its onError', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + let hang = true; + const fetchMock = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit): Promise => + hang + ? new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(new TypeError('Failed to fetch')) + ) + ) + : Promise.resolve(new Response('ok')) + ); + window.fetch = fetchMock; + + const engine = createEngine([{ type: 'download', bytes: 1e5, count: 1 }], { + abortTestDuration: 50 + }); + const errors: string[] = []; + engine.onError = message => { + errors.push(message); + hang = false; + engine.restart(); + engine.play(); + }; + const onFinish = vi.fn(); + + await new Promise(resolve => { + engine.onFinish = results => { + onFinish(results); + resolve(); + }; + engine.play(); + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(errors).toEqual([ + 'Test aborted. Test exceeded abortTestDuration (50ms)' + ]); + expect(onFinish).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(engine.results.raw.error).toBeUndefined(); + expect(engine.results.getDownloadBandwidthPoints()).toHaveLength(1); + }); + + it('reports nothing after onFinish when the aborted request fails', async () => { + mockPerformanceTiming(); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + window.fetch = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit): Promise => + new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + setTimeout(() => reject(new TypeError('Failed to fetch')), 10) + ) + ) + ); + + const engine = createEngine( + [ + { type: 'latency', numPackets: 1 }, + { type: 'download', bytes: 1e5, count: 1 } + ], + { abortTestDuration: 50 } + ); + const errors: string[] = []; + engine.onError = message => errors.push(message); + + await new Promise(resolve => { + engine.onFinish = resolve; + engine.play(); + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(errors).toEqual([ + 'Test aborted. Test exceeded abortTestDuration (50ms)' + ]); + }); +}); + +it('ends the test when loaded latency gets no resource timing entries', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + // The download hangs, so only the loaded-latency pings look up an entry + vi.spyOn(performance, 'getEntriesByName').mockReturnValue([]); + + let downloadSignal: AbortSignal | undefined; + const fetchMock = vi.fn( + (input: RequestInfo | URL, init?: RequestInit): Promise => { + if (new URL(input.toString()).searchParams.get('bytes') === '0') { + return Promise.resolve(new Response('ok')); + } + downloadSignal = init!.signal!; + return new Promise((_resolve, reject) => + init!.signal!.addEventListener('abort', () => + reject(init!.signal!.reason) + ) + ); + } + ); + window.fetch = fetchMock; + + const engine = createEngine( + [ + { type: 'download', bytes: 1e5, count: 1 }, + { type: 'upload', bytes: 1e5, count: 1 } + ], + { measureDownloadLoadedLatency: true, loadedLatencyThrottle: 10 } + ); + const onFinish = vi.fn(); + engine.onFinish = onFinish; + const message = await new Promise(resolve => { + engine.onError = resolve; engine.play(); - await new Promise(resolve => setTimeout(resolve, 20)); - expect(fetchMock).toHaveBeenCalledTimes(1); }); + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(message).toContain( + 'Connection error while measuring download: No resource timing entries' + ); + expect(downloadSignal?.aborted).toBe(true); + expect(engine.isRunning).toBe(false); + expect(onFinish).toHaveBeenCalledOnce(); + expect(engine.results.raw.error).toBe(message); + expect( + fetchMock.mock.calls.filter(([input]) => + new URL(input.toString()).pathname.endsWith('/up') + ) + ).toHaveLength(0); }); describe.each([ @@ -148,6 +944,39 @@ it('cancels a pending 429 retry when paused', async () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); +it('drops a pending 429 retry when the request is aborted', async () => { + vi.useFakeTimers(); + let calls = 0; + const fetchMock = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit): Promise => { + if (++calls === 1) + return Promise.resolve( + new Response(null, { status: 429, headers: { 'retry-after': '1' } }) + ); + return new Promise((_resolve, reject) => + init?.signal?.addEventListener('abort', () => + reject(init.signal?.reason) + ) + ); + } + ); + window.fetch = fetchMock; + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const engine = new BandwidthEngine([{ dir: 'down', bytes: 0, count: 5 }], { + downloadApiUrl: 'https://example.com/down', + uploadApiUrl: 'https://example.com/up' + }); + engine.abortRequestDuration = 900; + + engine.play(); + // The abort fires during the 1 s backoff and moves on to the next request + await vi.advanceTimersByTimeAsync(1_100); + engine.pause(); + + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + it('cancels the old engine 429 retry when restarted', async () => { vi.useFakeTimers(); const fetchMock = vi @@ -162,14 +991,7 @@ it('cancels the old engine 429 retry when restarted', async () => { window.fetch = fetchMock; vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const engine = new SpeedTest({ - autoStart: false, - downloadApiUrl: 'https://example.com/down', - uploadApiUrl: 'https://example.com/up', - logAimApiUrl: null, - logMeasurementApiUrl: null, - measurements: [{ type: 'download', bytes: 1_000, count: 1 }] - }); + const engine = createEngine([{ type: 'download', bytes: 1_000, count: 1 }]); engine.play(); await vi.advanceTimersByTimeAsync(0); @@ -179,57 +1001,7 @@ it('cancels the old engine 429 retry when restarted', async () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); -it('stops and cannot resume after a loaded-latency error', async () => { - const fetchMock = vi.fn( - (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = new URL(input.toString()); - if (url.searchParams.get('bytes') === '0') { - return Promise.resolve(new Response(null, { status: 401 })); - } - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => - reject(init.signal?.reason) - ); - }); - } - ); - window.fetch = fetchMock; - vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - const engine = new SpeedTest({ - autoStart: false, - downloadApiUrl: 'https://example.com/down', - uploadApiUrl: 'https://example.com/up', - logAimApiUrl: null, - logMeasurementApiUrl: null, - measureDownloadLoadedLatency: true, - measurements: [{ type: 'download', bytes: 1_000, count: 1 }] - }); - const resultErrors: string[] = []; - engine.onResultsChange = ({ type }) => { - const results = engine.results.raw[type]; - if (typeof results === 'object' && results.error) { - resultErrors.push(results.error); - } - }; - const error = new Promise<{ running: boolean; status?: number }>(resolve => { - engine.onError = (_message, status) => { - resolve({ running: engine.isRunning, status }); - }; - }); - - engine.play(); - - await expect(error).resolves.toEqual({ running: false, status: 401 }); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(resultErrors).toEqual([expect.stringContaining('Request failed')]); - - engine.play(); - await new Promise(resolve => setTimeout(resolve, 20)); - expect(fetchMock).toHaveBeenCalledTimes(2); -}); - -it('stops immediately after a network failure', async () => { +it('fails without retrying after a network failure', async () => { const fetchMock = vi.fn(() => Promise.reject(new TypeError('fetch failed'))); window.fetch = fetchMock; vi.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/tests/unit/Results/Results.test.ts b/tests/unit/Results/Results.test.ts index 973601cd..4511a412 100644 --- a/tests/unit/Results/Results.test.ts +++ b/tests/unit/Results/Results.test.ts @@ -37,7 +37,7 @@ describe('Results', () => { it('marks all measurements as not started and not finished', () => { const results = createResults(); - for (const key of ['latency', 'download', 'upload']) { + for (const key of ['latency', 'download', 'upload'] as const) { const entry = results.raw[key] as RawMeasurementEntry; expect(entry.started).toBe(false); expect(entry.finished).toBe(false); diff --git a/tests/unit/config/abortRequestDuration.test.ts b/tests/unit/config/abortRequestDuration.test.ts new file mode 100644 index 00000000..4e02b667 --- /dev/null +++ b/tests/unit/config/abortRequestDuration.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import SpeedTestEngine from '../../../src/index.ts'; +import type { ConfigOptions } from '../../../src/config/defaultConfig.ts'; + +/** `config` is protected, so a subclass is the supported way to inspect it. */ +class InspectableEngine extends SpeedTestEngine { + get resolvedConfig() { + return this.config; + } +} + +const resolveConfig = (userConfig: ConfigOptions) => + new InspectableEngine({ autoStart: false, ...userConfig }).resolvedConfig; + +const resolveAbort = (userConfig: ConfigOptions) => + resolveConfig(userConfig).abortRequestDuration; + +describe('abortRequestDuration', () => { + it('is disabled by default', () => { + expect(resolveAbort({})).toBe(0); + }); + + it('accepts the new name', () => { + expect(resolveAbort({ abortRequestDuration: 5_000 })).toBe(5_000); + }); + + it('still accepts the deprecated bandwidthAbortRequestDuration', () => { + expect(resolveAbort({ bandwidthAbortRequestDuration: 5_000 })).toBe(5_000); + }); + + it('prefers the new name when both are set', () => { + expect( + resolveAbort({ + abortRequestDuration: 3_000, + bandwidthAbortRequestDuration: 5_000 + }) + ).toBe(3_000); + expect( + resolveAbort({ + abortRequestDuration: 0, + bandwidthAbortRequestDuration: 5_000 + }) + ).toBe(0); + }); + + it('keeps the deprecated key out of the resolved config', () => { + const config = resolveConfig({ bandwidthAbortRequestDuration: 5_000 }); + expect(config).not.toHaveProperty('bandwidthAbortRequestDuration'); + }); +}); diff --git a/tests/unit/engines/unusableSamples.test.ts b/tests/unit/engines/unusableSamples.test.ts index ee15e992..4feb49ce 100644 --- a/tests/unit/engines/unusableSamples.test.ts +++ b/tests/unit/engines/unusableSamples.test.ts @@ -52,7 +52,8 @@ type Timing = number | PerfEntry | LateEntry | OtherRequestEntry | null; const run = ( measurements: BandwidthMeasurement[], timings: Timing[], - onConnectionError = vi.fn() + onConnectionError = vi.fn(), + onFatalError = vi.fn() ) => { const entries: (PerfEntry & { name: string })[] = []; const queue = [...timings]; @@ -100,8 +101,9 @@ const run = ( engine.onFinished = results => // Results are emitted on the next tick. setTimeout(() => resolve({ results, onMeasurementResult })); - engine.onConnectionError = (...args) => { - onConnectionError(...args); + engine.onConnectionError = onConnectionError; + engine.onFatalError = (...args) => { + onFatalError(...args); resolve({ results: engine.results, onMeasurementResult }); }; engine.play(); @@ -300,14 +302,17 @@ describe('BandwidthEngine unusable samples', () => { it('fails after three missing entries in a row', async () => { const onConnectionError = vi.fn(); + const onFatalError = vi.fn(); await run( [{ dir: 'down', bytes: 1e5, count: 5 }], [null, null, null, 300, 300], - onConnectionError + onConnectionError, + onFatalError ); expect(fetch).toHaveBeenCalledTimes(3); - expect(onConnectionError).toHaveBeenCalledExactlyOnceWith( + expect(onConnectionError).not.toHaveBeenCalled(); + expect(onFatalError).toHaveBeenCalledExactlyOnceWith( expect.stringContaining('No resource timing entries for') ); }); diff --git a/tests/unit/packetLossFlow.test.ts b/tests/unit/packetLossFlow.test.ts new file mode 100644 index 00000000..0cf3c473 --- /dev/null +++ b/tests/unit/packetLossFlow.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Lets each test finish or fail the packet loss phase on demand +const instances: { + onFinished: () => void; + onCredentialsFailure: (error: string) => void; +}[] = []; +let throwOnCreate = false; +vi.mock('../../src/engines/PacketLossEngine', () => ({ + default: class { + onFinished = () => {}; + onConnectionError = () => {}; + onCredentialsFailure = () => {}; + onMsgReceived = () => {}; + results = {}; + constructor() { + if (throwOnCreate) throw new Error('No TURN server configured'); + instances.push(this); + } + } +})); + +const { default: SpeedTest } = await import('../../src'); + +const perfEntry = () => ({ + startTime: 0, + requestStart: 10, + responseStart: 20, + responseEnd: 30, + transferSize: 1_000, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' +}); + +const createEngine = () => + new SpeedTest({ + autoStart: false, + logAimApiUrl: null, + logMeasurementApiUrl: null, + measureDownloadLoadedLatency: false, + measurements: [ + { + type: 'packetLoss', + numPackets: 1, + batchSize: 1, + batchWaitTime: 10, + responsesWaitTime: 100 + }, + { type: 'download', bytes: 1_000, count: 1, bypassMinDuration: true } + ] + }); + +describe('SpeedTest packet loss phase', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + instances.length = 0; + throwOnCreate = false; + const entries: ReturnType[] = []; + vi.stubGlobal('window', { + location: { origin: 'https://speed.example.com' } + }); + fetchMock = vi.fn(async () => { + entries.push({ ...perfEntry(), startTime: entries.length + 1 }); + return new Response(''); + }); + vi.stubGlobal('fetch', fetchMock); + let clock = 0; + vi.stubGlobal('performance', { + now: () => clock++, + getEntriesByName: () => entries, + clearResourceTimings: () => {}, + setResourceTimingBufferSize: () => {} + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('does not start the next phase when it finishes while paused', async () => { + const engine = createEngine(); + const phases: string[] = []; + engine.onPhaseChange = ({ measurement }) => phases.push(measurement.type); + + engine.play(); + engine.pause(); + instances[0].onFinished(); + + expect(phases).toEqual(['packetLoss']); + expect(fetchMock).not.toHaveBeenCalled(); + + const finished = new Promise(resolve => (engine.onFinish = resolve)); + engine.play(); + await finished; + expect(phases).toEqual(['packetLoss', 'download']); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('runs a single test when restarted from onError', async () => { + const engine = createEngine(); + const phases: string[] = []; + engine.onPhaseChange = ({ measurement }) => phases.push(measurement.type); + let restarted = false; + engine.onError = () => { + if (restarted) return; + restarted = true; + engine.restart(); + }; + + engine.play(); + instances[0].onCredentialsFailure('nope'); + + // The old run's error handler must not advance the restarted run + expect(phases).toEqual(['packetLoss', 'packetLoss']); + expect(fetchMock).not.toHaveBeenCalled(); + + const finished = new Promise(resolve => (engine.onFinish = resolve)); + instances[1].onCredentialsFailure('nope'); + await finished; + expect(phases).toEqual(['packetLoss', 'packetLoss', 'download']); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('runs a single test when restarted from a setup error', async () => { + throwOnCreate = true; + const engine = createEngine(); + const errors: string[] = []; + engine.onError = message => { + errors.push(message); + if (errors.length === 1) engine.restart(); + }; + let errorsAtDownload: number | undefined; + engine.onPhaseChange = ({ measurement }) => { + if (measurement.type === 'download') errorsAtDownload ??= errors.length; + }; + + const finished = new Promise(resolve => (engine.onFinish = resolve)); + engine.play(); + await finished; + + // The download must wait for the restarted run's own packet loss error + expect(errorsAtDownload).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +});