diff --git a/README.md b/README.md index 90d063a..176c6f3 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ them. | Config option | Description | Default | | --- | --- | :--: | | **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` | +| **downloadApiUrl**: *string* | Deprecated fallback URL for download GET requests when `measurementTargets` is empty. | `https://speed.cloudflare.com/__down` | +| **uploadApiUrl**: *string* | Deprecated fallback URL for upload POST requests when `measurementTargets` is empty. | `https://speed.cloudflare.com/__up` | +| **measurementTargets**: *string[]* | Origins used for latency, download, and upload requests. The engine appends `/__down` or `/__up` and distributes requests across the targets, starting at a random target. Duplicate targets are preserved. | `[]` | | **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. | - | | **turnServerUser**: *string* | The username for the TURN server credentials. | - | diff --git a/src/Results/MeasurementCalculations.ts b/src/Results/MeasurementCalculations.ts index dc87fe1..2dc73c9 100644 --- a/src/Results/MeasurementCalculations.ts +++ b/src/Results/MeasurementCalculations.ts @@ -82,8 +82,16 @@ class MeasurementCalculations { Object.entries(bandwidthResults) .map(([bytes, { timings }]) => timings.map( - ({ bps, duration, ping, measTime, serverTime, transferSize }) => ({ - bytes: +bytes, + ({ + bps, + duration, + ping, + measTime, + serverTime, + transferSize, + transferredBytes + }) => ({ + bytes: transferredBytes ?? +bytes, bps, duration, ping, diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index 4105efc..a8b825d 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -10,6 +10,12 @@ export interface BandwidthMeasurementConfig { bytes: number; /** Number of requests to issue at this payload size. */ count: number; + /** + * Runs this step using one continuously replenished request lane per target. + * + * @experimental Unstable — may change or be removed in any release. + */ + parallel?: boolean; /** If `true`, skip the minimum-duration filter for this round. */ bypassMinDuration?: boolean; } @@ -45,10 +51,20 @@ export interface Config { /** Whether to start the test immediately on construction. Default: `true`. */ autoStart: boolean; - /** URL for download requests. Default: `https://speed.cloudflare.com/__down`. */ + /** + * URL for download requests. + * + * @deprecated Use {@link measurementTargets}. This remains the fallback when no targets are configured. + */ downloadApiUrl: string; - /** URL for upload requests. Default: `https://speed.cloudflare.com/__up`. */ + /** + * URL for upload requests. + * + * @deprecated Use {@link measurementTargets}. This remains the fallback when no targets are configured. + */ uploadApiUrl: string; + /** Origins used for latency, download, and upload requests. */ + measurementTargets: string[]; /** URL for per-measurement logging. Set to `null` to disable. Default: `null`. */ logMeasurementApiUrl: string | null; /** URL for logging test results. Set to `null` to disable. Default: `https://speed.cloudflare.com/__results`. */ @@ -162,6 +178,7 @@ const defaultConfig: Config = { // APIs downloadApiUrl: `${REL_API_URL}/__down`, uploadApiUrl: `${REL_API_URL}/__up`, + measurementTargets: [], logMeasurementApiUrl: null, logAimApiUrl: `${REL_API_URL}/__results`, turnServerUri: 'turn.speed.cloudflare.com:50000', diff --git a/src/engines/BandwidthEngine/BandwidthEngine.ts b/src/engines/BandwidthEngine/BandwidthEngine.ts index a3353df..b168a76 100644 --- a/src/engines/BandwidthEngine/BandwidthEngine.ts +++ b/src/engines/BandwidthEngine/BandwidthEngine.ts @@ -77,6 +77,106 @@ const calcUploadSpeed = ( return !secs ? undefined : bits / secs; }; +interface TimeInterval { + start: number; + end: number; +} + +const getCoveredDuration = ( + intervals: TimeInterval[], + rangeStart: number, + rangeEnd: number +): number => { + const clipped = intervals + .map(({ start, end }) => ({ + start: Math.max(start, rangeStart), + end: Math.min(end, rangeEnd) + })) + .filter(({ start, end }) => end > start) + .sort((a, b) => a.start - b.start); + let covered = 0; + let currentEnd = rangeStart; + clipped.forEach(({ start, end }) => { + if (end <= currentEnd) return; + covered += end - Math.max(start, currentEnd); + currentEnd = end; + }); + return covered; +}; + +export const aggregateRequestTimings = ( + timings: RequestTiming[], + isDown: boolean, + numBytes: number, + pausedIntervals: TimeInterval[] = [] +): BandwidthMeasurementTiming => { + if (timings.length === 1) return timings[0]; + + const requestStart = Math.min(...timings.map(timing => timing.requestStart)); + const responseStart = Math.min( + ...timings.map(timing => timing.responseStart) + ); + const responseEnd = Math.max(...timings.map(timing => timing.responseEnd)); + const intervalEnd = isDown + ? responseEnd + : Math.max(...timings.map(timing => timing.responseStart)); + const serverIntervals = isDown + ? timings.map(timing => { + const rawDuration = timing.responseEnd - timing.requestStart; + const adjustment = Math.min( + Math.max(0, rawDuration - timing.duration), + timing.responseStart - timing.requestStart + ); + return { + start: timing.responseStart - adjustment, + end: timing.responseStart + }; + }) + : []; + const duration = + intervalEnd - + requestStart - + getCoveredDuration( + [...serverIntervals, ...pausedIntervals], + requestStart, + intervalEnd + ); + const transferSize = timings.reduce( + (total, timing) => total + timing.transferSize, + 0 + ); + const transferredBytes = numBytes * timings.length; + const effectiveTransferSize = timings.reduce( + (total, timing) => + total + + (timing.transferSize || numBytes * (1 + ESTIMATED_HEADER_FRACTION)), + 0 + ); + const serverTimes = timings + .map(timing => timing.serverTime) + .filter(serverTime => serverTime >= 0); + + return { + transferSize, + transferredBytes, + ttfb: responseStart - requestStart, + payloadDownloadTime: isDown ? duration : 0, + serverTime: serverTimes.length + ? serverTimes.reduce((total, serverTime) => total + serverTime, 0) / + serverTimes.length + : -1, + measTime: new Date(), + ping: Math.min(...timings.map(timing => timing.ping)), + duration, + bps: isDown + ? calcDownloadSpeed( + { duration, transferSize: effectiveTransferSize }, + transferredBytes + ) + : calcUploadSpeed({ duration }, transferredBytes) + }; +}; + const genContent = (() => { const cache = new Map(); return (numBytes: number): string => { @@ -103,6 +203,13 @@ export interface BandwidthMeasurementTiming { ping: number; duration: number; bps: number | undefined; + transferredBytes?: number; +} + +export interface RequestTiming extends BandwidthMeasurementTiming { + requestStart: number; + responseStart: number; + responseEnd: number; } export interface BandwidthTimingResult extends BandwidthMeasurementTiming { @@ -129,6 +236,11 @@ export interface ResponseHookPayload { export interface BandwidthEngineOptions { downloadApiUrl?: string; uploadApiUrl?: string; + downloadApiUrls?: string[]; + uploadApiUrls?: string[]; + getDownloadApiUrl?: () => string; + getUploadApiUrl?: () => string; + parallel?: boolean; throttleMs?: number; estimatedServerTime?: number; serverTimeDelta?: number; @@ -136,7 +248,7 @@ export interface BandwidthEngineOptions { } /** - * Measures download and upload bandwidth via sequential HTTP requests. + * Measures download and upload bandwidth via configurable HTTP requests. * Each request's timing is extracted from the browser's PerformanceResourceTiming * API, providing accurate transfer duration independent of JS execution overhead. * Supports configurable retry logic and abort thresholds. @@ -147,6 +259,11 @@ class BandwidthMeasurementEngine implements Engine { { downloadApiUrl, uploadApiUrl, + downloadApiUrls, + uploadApiUrls, + getDownloadApiUrl, + getUploadApiUrl, + parallel = false, throttleMs = 0, estimatedServerTime = 0, serverTimeDelta = 0, @@ -154,12 +271,22 @@ class BandwidthMeasurementEngine implements Engine { }: BandwidthEngineOptions = {} ) { if (!measurements) throw new Error('Missing measurements argument'); - if (!downloadApiUrl) throw new Error('Missing downloadApiUrl argument'); - if (!uploadApiUrl) throw new Error('Missing uploadApiUrl argument'); + if (!downloadApiUrl && !downloadApiUrls?.length && !getDownloadApiUrl) { + throw new Error('Missing download API URL argument'); + } + if (!uploadApiUrl && !uploadApiUrls?.length && !getUploadApiUrl) { + throw new Error('Missing upload API URL argument'); + } this.#measurements = measurements; - this.#downloadApi = downloadApiUrl; - this.#uploadApi = uploadApiUrl; + this.#downloadApis = downloadApiUrls?.length + ? downloadApiUrls + : [downloadApiUrl!]; + this.#uploadApis = uploadApiUrls?.length ? uploadApiUrls : [uploadApiUrl!]; + this.#getDownloadApiUrl = + getDownloadApiUrl ?? (() => this.#downloadApis[0]); + this.#getUploadApiUrl = getUploadApiUrl ?? (() => this.#uploadApis[0]); + this.#parallel = parallel; this.#throttleMs = throttleMs; this.#estimatedServerTime = Math.max(0, estimatedServerTime); this.#serverTimeDelta = Math.max(0, serverTimeDelta); @@ -226,6 +353,10 @@ class BandwidthMeasurementEngine implements Engine { ) { this.#onMeasurementResult = f; } + #onRequestResult: (result: BandwidthTimingResult) => void = () => {}; + set onRequestResult(f: (result: BandwidthTimingResult) => void) { + this.#onRequestResult = f; + } #onFinished: (results: BandwidthEngineResults) => void = () => {}; // callback invoked when all the measurements are finished set onFinished(f: (results: BandwidthEngineResults) => void) { this.#onFinished = f; @@ -237,12 +368,22 @@ class BandwidthMeasurementEngine implements Engine { // Public methods pause(): void { + if (this.#parallel && this.#running && this.#pauseStartedAt === undefined) { + this.#pauseStartedAt = performance.now(); + } this.#cancelCurrentMeasurement(`pause()`); this.#setRunning(false); } play(): void { if (!this.#running) { + if (this.#pauseStartedAt !== undefined) { + this.#pausedIntervals.push({ + start: this.#pauseStartedAt, + end: performance.now() + }); + this.#pauseStartedAt = undefined; + } this.#setRunning(true); this.#nextMeasurement(); } @@ -250,15 +391,21 @@ class BandwidthMeasurementEngine implements Engine { // Internal state #measurements: BandwidthMeasurement[]; - #downloadApi: string; - #uploadApi: string; + #downloadApis: string[]; + #uploadApis: string[]; + #getDownloadApiUrl: () => string; + #getUploadApiUrl: () => string; + #parallel: boolean; #running: boolean = false; #finished: Record = { down: false, up: false }; #results: BandwidthEngineResults = { down: {}, up: {} }; #measIdx: number = 0; #counter: number = 0; - #retries: number = 0; + #requestId: number = 0; + #parallelTimings: RequestTiming[] = []; + #pausedIntervals: TimeInterval[] = []; + #pauseStartedAt: number | undefined; #minDuration: number = -Infinity; // of current measurement #throttleMs: number = 0; #estimatedServerTime: number = 0; @@ -294,10 +441,10 @@ class BandwidthMeasurementEngine implements Engine { ? results[dir][bytes] : { timings: [], - // count all measurements with same bytes and direction + // Parallel steps produce one logical result for all physical requests. numMeasurements: this.#measurements .filter(({ bytes: b, dir: d }) => bytes === b && dir === d) - .map(m => m.count) + .map(m => (this.#parallel ? 1 : m.count)) .reduce((agg, cnt) => agg + cnt, 0) }; @@ -320,11 +467,24 @@ class BandwidthMeasurementEngine implements Engine { ); }); } else { - this.#onNewMeasurementStarted(this.#measurements[measIdx], results); + this.#onNewMeasurementStarted( + { + ...this.#measurements[measIdx], + count: this.#parallel ? 1 : this.#measurements[measIdx].count + }, + results + ); } } #nextMeasurement(): void { + this.#runNextMeasurement().catch(error => { + this.#setRunning(false); + this.#onConnectionError(String(error)); + }); + } + + async #runNextMeasurement(): Promise { const measurements = this.#measurements; let meas = measurements[this.#measIdx]; @@ -345,6 +505,9 @@ class BandwidthMeasurementEngine implements Engine { // clear settings this.#counter = 0; this.#minDuration = -Infinity; + this.#parallelTimings = []; + this.#pausedIntervals = []; + this.#pauseStartedAt = undefined; performance.clearResourceTimings(); do { @@ -374,209 +537,308 @@ class BandwidthMeasurementEngine implements Engine { const { bytes: numBytes, dir } = meas; const isDown = dir === 'down'; - const apiUrl = isDown ? this.#downloadApi : this.#uploadApi; - const qsParams: Record = Object.assign({}, this.#qsParams); - qsParams.bytes = `${numBytes}`; + this.#currentAbortController?.abort('restarting engine'); + this.#currentAbortController = new AbortController(); + const abortController = this.#currentAbortController; + + try { + let timing: BandwidthMeasurementTiming; + if (this.#parallel) { + const timings = await this.#runParallelPool( + meas, + isDown, + abortController + ); + if (abortController.signal.aborted) return; + timing = aggregateRequestTimings( + timings, + isDown, + numBytes, + this.#pausedIntervals + ); + this.#counter = meas.count; + this.#minDuration = Math.min(...timings.map(timing => timing.duration)); + } else { + const apiUrl = isDown + ? this.#getDownloadApiUrl() + : this.#getUploadApiUrl(); + timing = await this.#fetchMeasurement( + apiUrl, + numBytes, + isDown, + abortController, + `${this.#measIdx}-${this.#requestId++}` + ); + if (abortController.signal.aborted) return; + this.#counter += 1; + this.#minDuration = + this.#minDuration < 0 + ? timing.duration + : Math.min(this.#minDuration, timing.duration); + } - const urlObj = new URL(apiUrl, window.location.origin); - Object.entries(qsParams).forEach(([k, v]) => urlObj.searchParams.set(k, v)); - const url = urlObj.href; + this.#saveMeasurementResults(measIdx, timing); - const fetchOpt: RequestInit = withAuthorizationHeader( - Object.assign( - {}, - isDown - ? {} - : { - method: 'POST', - body: genContent(numBytes) - }, - this.#fetchOptions - ), - this.#authorization, - url - ); + if (this.#throttleMs) { + const throttleTimeout = setTimeout( + () => this.#nextMeasurement(), + this.#throttleMs + ); + abortController.signal.addEventListener('abort', () => + clearTimeout(throttleTimeout) + ); + } else { + this.#nextMeasurement(); + } + } catch (error) { + if (abortController.signal.aborted) return; + this.#setRunning(false); + this.#onConnectionError(String(error)); + } + } - if (this.#retries === 0) { - // abort existing abort controller - this.#currentAbortController?.abort('restarting engine'); - - // create new abort 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.#cancelCurrentMeasurement(errorMessage); - this.#retries = 0; - this.#setRunning(false); - this.#onConnectionError(errorMessage); - }, this.abortRequestDuration); - this.#currentAbortController.signal.addEventListener('abort', () => - clearTimeout(abortTimeout) + async #runParallelPool( + measurement: BandwidthMeasurement, + isDown: boolean, + abortController: AbortController + ): Promise { + const configuredApis = isDown ? this.#downloadApis : this.#uploadApis; + const apis = configuredApis.length + ? configuredApis + : [isDown ? this.#getDownloadApiUrl() : this.#getUploadApiUrl()]; + let nextRequest = this.#parallelTimings.length; + const runLane = async (apiUrl: string): Promise => { + while ( + !abortController.signal.aborted && + this.#currentAbortController === abortController && + nextRequest < measurement.count + ) { + const requestId = `${this.#measIdx}-${this.#requestId++}`; + nextRequest += 1; + await this.#fetchMeasurement( + apiUrl, + measurement.bytes, + isDown, + abortController, + requestId, + completedTiming => { + if (this.#currentAbortController !== abortController) return false; + this.#parallelTimings.push(completedTiming); + return true; + } ); + if ( + abortController.signal.aborted || + this.#currentAbortController !== abortController + ) { + return; + } } + }; + + await Promise.all( + apis.slice(0, measurement.count).map(apiUrl => runLane(apiUrl)) + ); + return this.#parallelTimings; + } + + async #fetchMeasurement( + apiUrl: string, + numBytes: number, + isDown: boolean, + abortController: AbortController, + requestId: string, + recordCompletion?: (timing: RequestTiming) => boolean + ): Promise { + if (abortController.signal.aborted) { + throw new Error(String(abortController.signal.reason)); } - let serverTime: number | undefined; - fetch(url, { - ...fetchOpt, - signal: this.#currentAbortController!.signal - }) - .then(r => { - if (r.ok) return r; - throw Error(r.statusText); - }) - .then(r => { - this.getServerTime && (serverTime = this.getServerTime(r)); - return r; + const qsParams: Record = { + ...this.#qsParams, + bytes: `${numBytes}`, + ...(this.#parallel && { + __cf_speedtest_request: requestId }) - .then(r => - r.text().then(body => { - this.#responseHook({ + }; + const urlObj = new URL(apiUrl, window.location.origin); + Object.entries(qsParams).forEach(([key, value]) => + urlObj.searchParams.set(key, value) + ); + const url = urlObj.href; + const fetchOptions = withAuthorizationHeader( + { + ...(isDown ? {} : { method: 'POST', body: genContent(numBytes) }), + ...this.#fetchOptions + }, + this.#authorization, + url + ); + + const requestController = new AbortController(); + const abortRequest = () => + requestController.abort(abortController.signal.reason); + abortController.signal.addEventListener('abort', abortRequest, { + once: true + }); + const timeoutMessage = `${isDown ? 'Download' : 'Upload'} measurement of ${numBytes} bytes aborted. Measurement exceeded bandwidthAbortRequestDuration (${this.abortRequestDuration}ms)`; + const abortTimeout = this.abortRequestDuration + ? setTimeout( + () => requestController.abort(timeoutMessage), + this.abortRequestDuration + ) + : undefined; + + try { + let lastError: unknown; + for (let retry = 0; retry <= MAX_RETRIES; retry += 1) { + try { + const timing = await this.#performFetch( url, - headers: r.headers, - body + fetchOptions, + numBytes, + isDown, + qsParams, + requestController.signal + ); + if (recordCompletion && !recordCompletion(timing)) return timing; + this.#onRequestResult({ + type: isDown ? 'down' : 'up', + bytes: numBytes, + ...timing }); - - return body; - }) - ) - .then(() => { - const perf = performance - .getEntriesByName(url) - .slice(-1)[0] as PerformanceResourceTiming; // get latest perf timing - const timing: BandwidthMeasurementTiming = { - transferSize: perf.transferSize, - ttfb: getTtfb(perf), - payloadDownloadTime: getPayloadDownload(perf), - serverTime: serverTime || -1, - measTime: new Date(), - ping: 0, - duration: 0, - bps: undefined - }; - // Detect new TCP connection from handshake timings. - let connectTime = 0; - if (perf.secureConnectionStart > perf.connectStart) { - connectTime = perf.secureConnectionStart - perf.connectStart; - } else { - connectTime = perf.connectEnd - perf.connectStart; - } - - const protoMatch = perf.nextHopProtocol.match(/([0-9.]+)/); - const httpVersion = protoMatch ? +protoMatch[1] : 0; - - // Calibrate serverTimeDelta from new TCP connections (HTTP/1.1) - if (serverTime && connectTime && httpVersion > 0 && httpVersion < 2) { - const derivedTotalServerTime = Math.max(0, timing.ttfb - connectTime); - const delta = derivedTotalServerTime - serverTime; - if ( - delta > 0 && - delta <= SERVER_TIME_DELTA_MAX && - delta <= serverTime && - serverTime <= SERVER_TIME_CALIBRATION_MAX - ) { - this.#serverTimeDelta = - this.#serverTimeDelta * (1 - SERVER_TIME_DELTA_WEIGHT) + - delta * SERVER_TIME_DELTA_WEIGHT; - console.log( - `serverTimeDelta (estimated): ${this.#serverTimeDelta.toFixed(2)}ms` + return timing; + } catch (error) { + if (requestController.signal.aborted) { + throw new Error( + typeof requestController.signal.reason === 'string' + ? requestController.signal.reason + : String(error) ); - } else if (delta > 0) { - console.log(`serverTimeDelta (skipped): ${delta.toFixed(2)}ms`); } + lastError = error; + console.warn(`Error fetching ${url}: ${error}`); } + } - const baseServerTime = serverTime || this.#estimatedServerTime; - timing.ping = timing.ttfb - baseServerTime - this.#serverTimeDelta; + throw new Error( + `Connection failed to ${url}. Gave up after ${MAX_RETRIES} retries: ${lastError}` + ); + } finally { + clearTimeout(abortTimeout); + abortController.signal.removeEventListener('abort', abortRequest); + } + } - // Discard the delta adjustment if it would collapse the ping - if (timing.ping <= 1) { - timing.ping = Math.max(0, timing.ttfb - baseServerTime); - } - timing.duration = (isDown ? calcDownloadDuration : calcUploadDuration)( - timing - ); - timing.bps = (isDown ? calcDownloadSpeed : calcUploadSpeed)( - timing, - numBytes + async #performFetch( + url: string, + fetchOptions: RequestInit, + numBytes: number, + isDown: boolean, + qsParams: Record, + signal: AbortSignal + ): Promise { + const response = await fetch(url, { ...fetchOptions, signal }); + if (!response.ok) throw Error(response.statusText); + + const serverTime = this.getServerTime?.(response); + const body = await response.text(); + this.#responseHook({ url, headers: response.headers, body }); + + const perf = performance.getEntriesByName(url).slice(-1)[0] as + | PerformanceResourceTiming + | undefined; + if (!perf) throw new Error(`Missing resource timing for ${url}`); + + const timing: RequestTiming = { + transferSize: perf.transferSize, + ttfb: getTtfb(perf), + payloadDownloadTime: getPayloadDownload(perf), + serverTime: serverTime || -1, + measTime: new Date(), + ping: 0, + duration: 0, + bps: undefined, + requestStart: perf.requestStart, + responseStart: perf.responseStart, + responseEnd: perf.responseEnd + }; + + let connectTime = 0; + if (perf.secureConnectionStart > perf.connectStart) { + connectTime = perf.secureConnectionStart - perf.connectStart; + } else { + connectTime = perf.connectEnd - perf.connectStart; + } + const protoMatch = perf.nextHopProtocol.match(/([0-9.]+)/); + const httpVersion = protoMatch ? +protoMatch[1] : 0; + if (serverTime && connectTime && httpVersion > 0 && httpVersion < 2) { + const derivedTotalServerTime = Math.max(0, timing.ttfb - connectTime); + const delta = derivedTotalServerTime - serverTime; + if ( + delta > 0 && + delta <= SERVER_TIME_DELTA_MAX && + delta <= serverTime && + serverTime <= SERVER_TIME_CALIBRATION_MAX + ) { + this.#serverTimeDelta = + this.#serverTimeDelta * (1 - SERVER_TIME_DELTA_WEIGHT) + + delta * SERVER_TIME_DELTA_WEIGHT; + console.log( + `serverTimeDelta (estimated): ${this.#serverTimeDelta.toFixed(2)}ms` ); + } else if (delta > 0) { + console.log(`serverTimeDelta (skipped): ${delta.toFixed(2)}ms`); + } + } - // Log measurement details - const delta = this.#serverTimeDelta; - if (+numBytes === 0) { - console.log('latency', { - phase: `during ${qsParams.during || 'idle'}`, - ttfb: timing.ttfb, - serverTime: baseServerTime, - ...(delta && { serverTimeDelta: delta }), - ping: timing.ping - }); - } else { - console.log(isDown ? 'download' : 'upload', { - bytes: +numBytes, - bps: timing.bps, - ttfb: timing.ttfb, - serverTime: baseServerTime, - ...(delta && { serverTimeDelta: delta }), - ping: timing.ping - }); - } - - if (isDown && numBytes) { - const reqSize = +numBytes; - if ( - timing.transferSize && - (timing.transferSize < reqSize || - timing.transferSize / reqSize > 1.05) - ) { - // log if transferSize is too different from requested size - console.warn( - `Requested ${reqSize}B but received ${timing.transferSize}B (${ - Math.round((timing.transferSize / reqSize) * 1e4) / 1e2 - }%).` - ); - } - } + const baseServerTime = serverTime || this.#estimatedServerTime; + timing.ping = timing.ttfb - baseServerTime - this.#serverTimeDelta; + if (timing.ping <= 1) { + timing.ping = Math.max(0, timing.ttfb - baseServerTime); + } + timing.duration = (isDown ? calcDownloadDuration : calcUploadDuration)( + timing + ); + timing.bps = (isDown ? calcDownloadSpeed : calcUploadSpeed)( + timing, + numBytes + ); - this.#saveMeasurementResults(measIdx, timing); - const requestDuration = timing.duration; - this.#minDuration = - this.#minDuration < 0 - ? requestDuration - : Math.min(this.#minDuration, requestDuration); // carry minimum request duration + const delta = this.#serverTimeDelta; + if (numBytes === 0) { + console.log('latency', { + phase: `during ${qsParams.during || 'idle'}`, + ttfb: timing.ttfb, + serverTime: baseServerTime, + ...(delta && { serverTimeDelta: delta }), + ping: timing.ping + }); + } else { + console.log(isDown ? 'download' : 'upload', { + bytes: numBytes, + bps: timing.bps, + ttfb: timing.ttfb, + serverTime: baseServerTime, + ...(delta && { serverTimeDelta: delta }), + ping: timing.ping + }); + } - this.#counter += 1; - this.#retries = 0; + if ( + isDown && + numBytes && + timing.transferSize && + (timing.transferSize < numBytes || timing.transferSize / numBytes > 1.05) + ) { + console.warn( + `Requested ${numBytes}B but received ${timing.transferSize}B (${ + Math.round((timing.transferSize / numBytes) * 1e4) / 1e2 + }%).` + ); + } - if (this.#throttleMs) { - const throttleTimeout = setTimeout( - () => this.#nextMeasurement(), - this.#throttleMs - ); - this.#currentAbortController!.signal.addEventListener('abort', () => - clearTimeout(throttleTimeout) - ); - } else { - this.#nextMeasurement(); - } - }) - .catch(error => { - if (this.#currentAbortController!.signal.aborted) { - return; - } - console.warn(`Error fetching ${url}: ${error}`); - - if (this.#retries++ < MAX_RETRIES) { - this.#nextMeasurement(); // keep trying - } else { - this.#retries = 0; - this.#setRunning(false); - this.#onConnectionError( - `Connection failed to ${url}. Gave up after ${MAX_RETRIES} retries.` - ); - } - }); + return timing; } #cancelCurrentMeasurement(reason?: string): void { diff --git a/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts b/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts index a8127cf..06c1575 100644 --- a/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts +++ b/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts @@ -45,7 +45,7 @@ class LoggingBandwidthEngine extends BandwidthEngine { super.qsParams = logApiUrl ? { measId: this.#measurementId! } : {}; super.responseHook = (r: ResponseHookPayload) => this.#loggingResponseHook(r); - super.onMeasurementResult = (meas: BandwidthTimingResult) => + super.onRequestResult = (meas: BandwidthTimingResult) => this.#logMeasurement(meas); } @@ -74,7 +74,6 @@ class LoggingBandwidthEngine extends BandwidthEngine { ...restArgs: [BandwidthEngineResults] ) => { onMeasurementResult(meas, ...restArgs); - this.#logMeasurement(meas); }; } diff --git a/src/engines/BandwidthEngine/ParallelLatency.ts b/src/engines/BandwidthEngine/ParallelLatency.ts index 6db7228..1261ff3 100644 --- a/src/engines/BandwidthEngine/ParallelLatency.ts +++ b/src/engines/BandwidthEngine/ParallelLatency.ts @@ -8,6 +8,7 @@ import type { export interface ParallelLatencyOptions extends BandwidthEngineOptions { measureParallelLatency?: boolean; parallelLatencyThrottleMs?: number; + getLoadedLatencyApiUrl?: () => string; } /** @@ -24,6 +25,7 @@ class BandwidthWithParallelLatencyEngine extends BandwidthEngine { parallelLatencyThrottleMs = 100, downloadApiUrl, uploadApiUrl, + getLoadedLatencyApiUrl, estimatedServerTime = 0, serverTimeDelta = 0, authorization = null, @@ -52,6 +54,7 @@ class BandwidthWithParallelLatencyEngine extends BandwidthEngine { { downloadApiUrl, uploadApiUrl, + getDownloadApiUrl: getLoadedLatencyApiUrl, estimatedServerTime, serverTimeDelta, authorization, diff --git a/src/index.ts b/src/index.ts index d38ee3a..9be26cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import logFinalResults, { type AimLogResponse } from './logging/logFinalResults'; import type { AuthorizationOptions } from './utils/authorization'; +import { appendParallelism } from './utils/parallelism'; const DEFAULT_OPTIMAL_DOWNLOAD_SIZE = 1e6; const DEFAULT_OPTIMAL_UPLOAD_SIZE = 1e6; @@ -50,6 +51,8 @@ interface MeasurementStep { count?: number; /** Skip the minimum-duration filter for this round (download/upload types). */ bypassMinDuration?: boolean; + /** Whether this bandwidth step uses one request lane per target. */ + parallel?: boolean; /** Number of packets sent per batch (packetLoss types). */ batchSize?: number; /** Delay between batches in ms (packetLoss types). */ @@ -108,6 +111,13 @@ const pausableTypes: MeasurementType[] = [ // TODO: consider replacing with crypto.randomUUID() for better uniqueness const genMeasId = (): string => `${Math.round(Math.random() * 1e16)}`; +const hasParallelMeasurement = (config: SpeedTestConfig): boolean => + config.measurements.some( + measurement => + (measurement.type === 'download' || measurement.type === 'upload') && + measurement.parallel === true + ); + /** * Core speed test engine that orchestrates measurement phases (latency, * download, upload, packet loss, reachability) and exposes results via @@ -129,6 +139,12 @@ class MeasurementEngine { userConfig, internalConfig ) as SpeedTestConfig; + this.#targetIndex = this.#config.measurementTargets.length + ? Math.floor(Math.random() * this.#config.measurementTargets.length) + : 0; + this.#loadedLatencyTargetIndex = this.#config.measurementTargets.length + ? Math.floor(Math.random() * this.#config.measurementTargets.length) + : 0; // Built once: the insecure-transport warning is latched per object, so a // fresh one per access would warn on every request. this.#authorization = { @@ -154,6 +170,15 @@ class MeasurementEngine { return this.#authorization; } + protected get loggingSessionId(): string | undefined { + return appendParallelism( + this.#config.sessionId, + hasParallelMeasurement(this.#config) + ? Math.max(1, this.#config.measurementTargets.length) + : undefined + ); + } + /** Not paused and not finished. */ get isRunning(): boolean { return this.#running; @@ -229,6 +254,8 @@ class MeasurementEngine { #curEngine: Engine | undefined; #optimalDownloadChunkSize: number = DEFAULT_OPTIMAL_DOWNLOAD_SIZE; #optimalUploadChunkSize: number = DEFAULT_OPTIMAL_UPLOAD_SIZE; + #targetIndex: number; + #loadedLatencyTargetIndex: number; /** * High-resolution timestamp (from performance.now()) of the test start or @@ -279,6 +306,45 @@ class MeasurementEngine { : this.#config.measurements[this.#curMsmIdx].type; } + #measurementApiUrls(type: 'download' | 'upload'): string[] | undefined { + if (!this.#config.measurementTargets.length) return undefined; + const path = type === 'download' ? '/__down' : '/__up'; + return this.#config.measurementTargets.map(origin => + new URL(path, origin).toString() + ); + } + + #nextMeasurementApiUrl = (type: 'download' | 'upload'): string => { + const urls = this.#measurementApiUrls(type); + if (!urls) { + return type === 'download' + ? this.#config.downloadApiUrl + : this.#config.uploadApiUrl; + } + const url = urls[this.#targetIndex % urls.length]; + this.#targetIndex += 1; + return url; + }; + + #parallelMeasurementApiUrls( + type: 'download' | 'upload', + count: number + ): string[] | undefined { + const urls = this.#measurementApiUrls(type); + if (!urls) return undefined; + const startIndex = this.#targetIndex % urls.length; + this.#targetIndex += count; + return [...urls.slice(startIndex), ...urls.slice(0, startIndex)]; + } + + #nextLoadedLatencyApiUrl = (): string => { + const urls = this.#measurementApiUrls('download'); + if (!urls) return this.#config.downloadApiUrl; + const url = urls[this.#loadedLatencyTargetIndex % urls.length]; + this.#loadedLatencyTargetIndex += 1; + return url; + }; + #curTypeResults(): MeasurementResult | undefined { const type = this.#curType(); if (!type) return undefined; @@ -294,6 +360,12 @@ class MeasurementEngine { this.#measurementId = genMeasId(); this.#curMsmIdx = -1; this.#curEngine = undefined; + this.#targetIndex = this.#config.measurementTargets.length + ? Math.floor(Math.random() * this.#config.measurementTargets.length) + : 0; + this.#loadedLatencyTargetIndex = this.#config.measurementTargets.length + ? Math.floor(Math.random() * this.#config.measurementTargets.length) + : 0; this.#setRunning(false); this.#setFinished(false); @@ -506,11 +578,13 @@ class MeasurementEngine { { downloadApiUrl, uploadApiUrl, + getDownloadApiUrl: () => this.#nextMeasurementApiUrl('download'), + getUploadApiUrl: () => this.#nextMeasurementApiUrl('upload'), estimatedServerTime, serverTimeDelta: this.#serverTimeDelta, logApiUrl: this.#config.logMeasurementApiUrl ?? undefined, measurementId: this.#measurementId, - sessionId: this.#config.sessionId, + sessionId: this.loggingSessionId, authorization: this.authorization, // if under load @@ -593,13 +667,31 @@ class MeasurementEngine { { downloadApiUrl, uploadApiUrl, + downloadApiUrls: + msmConfig.parallel === true && type === 'download' + ? this.#parallelMeasurementApiUrls( + 'download', + msmConfig.count ?? 1 + ) + : undefined, + uploadApiUrls: + msmConfig.parallel === true && type === 'upload' + ? this.#parallelMeasurementApiUrls( + 'upload', + msmConfig.count ?? 1 + ) + : undefined, + getDownloadApiUrl: () => this.#nextMeasurementApiUrl('download'), + getUploadApiUrl: () => this.#nextMeasurementApiUrl('upload'), + getLoadedLatencyApiUrl: this.#nextLoadedLatencyApiUrl, + parallel: msmConfig.parallel === true, estimatedServerTime, serverTimeDelta: this.#serverTimeDelta, logApiUrl: this.#config.logMeasurementApiUrl ?? undefined, measurementId: this.#measurementId, measureParallelLatency, parallelLatencyThrottleMs: this.#config.loadedLatencyThrottle, - sessionId: this.#config.sessionId, + sessionId: this.loggingSessionId, authorization: this.authorization } ) as Engine; @@ -793,7 +885,7 @@ class SpeedTestEngine extends MeasurementEngine { } logFinalResults(results, { apiUrl, - sessionId: this.config.sessionId, + sessionId: this.loggingSessionId, authorization: this.authorization }).then(response => { this.onResultsLogged(response); diff --git a/src/types.ts b/src/types.ts index 3660ee9..174acce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,9 @@ export interface BandwidthTiming { /** Actual number of bytes transferred (from `PerformanceResourceTiming`). */ transferSize: number; + + /** Total payload bytes represented by an aggregated parallel sample. */ + transferredBytes?: number; } /** diff --git a/src/utils/parallelism.ts b/src/utils/parallelism.ts new file mode 100644 index 0000000..ea02839 --- /dev/null +++ b/src/utils/parallelism.ts @@ -0,0 +1,12 @@ +export const appendParallelism = ( + sessionId: string | undefined, + parallelism: number | undefined +): string | undefined => { + if (!sessionId) return sessionId; + const fields = sessionId + .split('&') + .filter(field => !field.startsWith('parallel=')); + return parallelism === undefined + ? fields.join('&') + : [...fields, `parallel=${parallelism}`].join('&'); +}; diff --git a/tests/unit/Results/MeasurementCalculations.test.ts b/tests/unit/Results/MeasurementCalculations.test.ts index c727d35..a4c4239 100644 --- a/tests/unit/Results/MeasurementCalculations.test.ts +++ b/tests/unit/Results/MeasurementCalculations.test.ts @@ -119,6 +119,27 @@ describe('MeasurementCalculations', () => { expect(result[0].bytes).toBe(100000); expect(result[1].bytes).toBe(1000000); }); + + it('uses the aggregate byte count from parallel samples', () => { + const calc = createCalc(); + const [result] = calc.getBandwidthPoints({ + 100000: { + timings: [ + { + bps: 10e6, + duration: 100, + ping: 10, + measTime: new Date(100), + serverTime: 5, + transferSize: 400000, + transferredBytes: 400000 + } + ] + } + }); + + expect(result.bytes).toBe(400000); + }); }); describe('getBandwidth', () => { diff --git a/tests/unit/config/defaultConfig.test.ts b/tests/unit/config/defaultConfig.test.ts index d94c68c..4b213b0 100644 --- a/tests/unit/config/defaultConfig.test.ts +++ b/tests/unit/config/defaultConfig.test.ts @@ -52,6 +52,10 @@ describe('defaultConfig', () => { expect(defaultConfig.includeCredentials).toBe(false); }); + it('runs bandwidth requests sequentially by default', () => { + expect(defaultConfig.measurementTargets).toEqual([]); + }); + it('has null values for optional TURN server credentials', () => { expect(defaultConfig.turnServerUser).toBeNull(); expect(defaultConfig.turnServerPass).toBeNull(); diff --git a/tests/unit/engines/parallelism.test.ts b/tests/unit/engines/parallelism.test.ts new file mode 100644 index 0000000..d294bc5 --- /dev/null +++ b/tests/unit/engines/parallelism.test.ts @@ -0,0 +1,424 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import SpeedTest from '../../../src/index.ts'; +import { appendParallelism } from '../../../src/utils/parallelism.ts'; +import BandwidthEngine, { + aggregateRequestTimings, + type RequestTiming +} from '../../../src/engines/BandwidthEngine/BandwidthEngine.ts'; + +const timing = ( + requestStart: number, + responseStart: number, + responseEnd: number +): RequestTiming => ({ + requestStart, + responseStart, + responseEnd, + transferSize: 1000, + ttfb: responseStart - requestStart, + payloadDownloadTime: responseEnd - responseStart, + serverTime: 2, + measTime: new Date(), + ping: 8, + duration: responseEnd - requestStart, + bps: 1 +}); + +describe('parallel bandwidth aggregation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('measures downloads from the first request to the last completion', () => { + const result = aggregateRequestTimings( + [timing(0, 10, 110), timing(5, 20, 120)], + true, + 1000 + ); + + expect(result.duration).toBe(120); + expect(result.transferredBytes).toBe(2000); + expect(result.transferSize).toBe(2000); + expect(result.bps).toBeCloseTo(16000 / 0.12); + }); + + it('subtracts overlapping server and delta adjustments', () => { + const first = { + ...timing(0, 20, 120), + serverTime: 8, + duration: 110 + }; + const second = { + ...timing(0, 25, 125), + serverTime: 8, + duration: 115 + }; + + const result = aggregateRequestTimings([first, second], true, 1000); + + expect(result.duration).toBe(110); + expect(result.bps).toBeCloseTo(16000 / 0.11); + }); + + it('subtracts paused intervals from the aggregate duration', () => { + const result = aggregateRequestTimings( + [timing(0, 10, 100), timing(300, 310, 400)], + true, + 1000, + [{ start: 100, end: 300 }] + ); + + expect(result.duration).toBe(200); + expect(result.bps).toBeCloseTo(16000 / 0.2); + }); + + it('estimates bytes missing from resource timing', () => { + const hiddenTiming = { ...timing(5, 20, 120), transferSize: 0 }; + const result = aggregateRequestTimings( + [timing(0, 10, 110), hiddenTiming], + true, + 1000 + ); + + expect(result.transferSize).toBe(1000); + expect(result.bps).toBeCloseTo(((1000 + 1005) * 8) / 0.12); + }); + + it('measures uploads from the first request start to the last response', () => { + const result = aggregateRequestTimings( + [timing(0, 100, 105), timing(10, 120, 125)], + false, + 1000 + ); + + expect(result.duration).toBe(120); + expect(result.transferredBytes).toBe(2000); + expect(result.bps).toBeCloseTo(16080 / 0.12); + }); + + it('preserves sequential timing calculations', () => { + const singleTiming = timing(0, 10, 110); + expect(aggregateRequestTimings([singleTiming], true, 1000)).toBe( + singleTiming + ); + }); + + it('continuously replenishes one request lane per target', async () => { + const releases: Array<() => void> = []; + const fetchMock = vi.fn( + (_url: RequestInfo | URL) => + new Promise(resolve => { + releases.push(() => resolve(new Response('body'))); + }) + ); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('window', { location: { origin: 'https://app.example' } }); + vi.stubGlobal('performance', { + clearResourceTimings: vi.fn(), + getEntriesByName: (url: string) => { + const index = Number( + new URL(url).searchParams.get('__cf_speedtest_request')?.split('-')[1] + ); + return [ + { + transferSize: 1000, + requestStart: 0, + responseStart: 10 + index, + responseEnd: 110 + index, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' + } + ]; + } + }); + + const origins = Array.from( + { length: 4 }, + (_, index) => `https://t${index}.example/__down` + ); + const engine = new BandwidthEngine( + [{ dir: 'down', bytes: 1000, count: 6 }], + { + downloadApiUrls: origins, + uploadApiUrl: 'https://upload.example/__up', + parallel: true + } + ); + const onRequestResult = vi.fn(); + const onMeasurementResult = vi.fn(); + engine.onRequestResult = onRequestResult; + engine.onMeasurementResult = onMeasurementResult; + const finished = new Promise(resolve => { + engine.onFinished = resolve; + }); + + engine.play(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + expect( + fetchMock.mock.calls.map(([url]) => new URL(url.toString()).origin) + ).toEqual(origins.map(origin => new URL(origin).origin)); + + releases[0](); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(5)); + expect(new URL(fetchMock.mock.calls[4][0].toString()).origin).toBe( + new URL(origins[0]).origin + ); + releases[4](); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(6)); + expect(new URL(fetchMock.mock.calls[5][0].toString()).origin).toBe( + new URL(origins[0]).origin + ); + releases.slice(1, 4).forEach(release => release()); + releases[5](); + await finished; + + expect(engine.results.down[1000].timings).toHaveLength(1); + expect(engine.results.down[1000].timings[0].transferredBytes).toBe(6000); + expect(onRequestResult).toHaveBeenCalledTimes(6); + await vi.waitFor(() => expect(onMeasurementResult).toHaveBeenCalledOnce()); + }); + + it('retains completed requests when a parallel step resumes', async () => { + let clock = 0; + const releases: Array<() => void> = []; + const fetchMock = vi.fn( + (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve, reject) => { + releases.push(() => resolve(new Response('body'))); + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true } + ); + }) + ); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('window', { location: { origin: 'https://app.example' } }); + vi.stubGlobal('performance', { + now: () => clock, + clearResourceTimings: vi.fn(), + getEntriesByName: (url: string) => { + const index = Number( + new URL(url).searchParams.get('__cf_speedtest_request')?.split('-')[1] + ); + const requestStart = index === 0 ? 0 : index < 4 ? 300 : 400; + return [ + { + transferSize: 1000, + requestStart, + responseStart: requestStart + 10, + responseEnd: requestStart + 100, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' + } + ]; + } + }); + + const engine = new BandwidthEngine( + [{ dir: 'down', bytes: 1000, count: 4 }], + { + downloadApiUrls: [ + 'https://speed-0.example/__down', + 'https://speed-1.example/__down' + ], + uploadApiUrl: 'https://upload.example/__up', + parallel: true + } + ); + const onRequestResult = vi.fn(); + engine.onRequestResult = onRequestResult; + onRequestResult.mockImplementationOnce(() => { + clock = 100; + engine.pause(); + }); + const finished = new Promise(resolve => { + engine.onFinished = resolve; + }); + + engine.play(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + releases[0](); + await vi.waitFor(() => expect(onRequestResult).toHaveBeenCalledOnce()); + clock = 300; + engine.play(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + releases[2](); + releases[3](); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(5)); + releases[4](); + await finished; + + expect(onRequestResult).toHaveBeenCalledTimes(4); + expect(engine.results.down[1000].timings[0].transferredBytes).toBe(4000); + expect(engine.results.down[1000].timings[0].duration).toBe(300); + }); + + it('rotates sequential and parallel requests from a randomized target', async () => { + const fetchMock = vi.fn((url: RequestInfo | URL) => + Promise.resolve(new Response(url.toString())) + ); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('window', { location: { origin: 'https://app.example' } }); + vi.stubGlobal('performance', { + now: vi.fn(() => 1), + clearResourceTimings: vi.fn(), + setResourceTimingBufferSize: vi.fn(), + getEntriesByName: () => [ + { + transferSize: 1000, + requestStart: 0, + responseStart: 10, + responseEnd: 110, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' + } + ] + }); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + + const engine = new SpeedTest({ + autoStart: false, + measurementTargets: [ + 'https://speed-0.example', + 'https://speed-1.example', + 'https://speed-1.example' + ], + measurements: [ + { type: 'download', bytes: 1000, count: 3 }, + { type: 'download', bytes: 2000, count: 2, parallel: true } + ], + measureDownloadLoadedLatency: false, + logAimApiUrl: null + }); + const finished = new Promise((resolve, reject) => { + engine.onFinish = resolve; + engine.onError = reject; + }); + + engine.play(); + await finished; + + expect( + fetchMock.mock.calls.map(([url]) => new URL(url.toString()).origin) + ).toEqual([ + 'https://speed-1.example', + 'https://speed-1.example', + 'https://speed-0.example', + 'https://speed-1.example', + 'https://speed-1.example' + ]); + }); + + it('applies measurement targets and step parallelism through the public API', async () => { + const resultsUrl = 'https://results.example/__results'; + const fetchMock = vi.fn((url: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve( + new Response(url.toString() === resultsUrl ? '{}' : url.toString()) + ) + ); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('window', { location: { origin: 'https://app.example' } }); + vi.stubGlobal('performance', { + now: vi.fn(() => 1), + clearResourceTimings: vi.fn(), + setResourceTimingBufferSize: vi.fn(), + getEntriesByName: (url: string) => { + const index = Number( + new URL(url).searchParams.get('__cf_speedtest_request')?.split('-')[1] + ); + return [ + { + transferSize: 1000, + requestStart: 0, + responseStart: 10 + index, + responseEnd: 110 + index, + connectStart: 0, + connectEnd: 0, + secureConnectionStart: 0, + nextHopProtocol: 'h2' + } + ]; + } + }); + vi.spyOn(Math, 'random').mockReturnValue(0); + + const engine = new SpeedTest({ + autoStart: false, + measurementTargets: [ + 'https://speed-0.example', + 'https://speed-1.example' + ], + measurements: [ + { type: 'download', bytes: 1000, count: 2, parallel: true }, + { type: 'upload', bytes: 1000, count: 4, parallel: true } + ], + measureDownloadLoadedLatency: false, + measureUploadLoadedLatency: false, + logAimApiUrl: resultsUrl, + sessionId: 'session=abc' + }); + const finished = new Promise((resolve, reject) => { + engine.onFinish = resolve; + engine.onError = reject; + }); + const logged = new Promise(resolve => { + engine.onResultsLogged = resolve; + }); + + engine.play(); + const results = await finished; + await logged; + + const measurementCalls = fetchMock.mock.calls.filter( + ([url]) => url.toString() !== resultsUrl + ); + const urls = measurementCalls.map(([url]) => new URL(url.toString())); + expect(urls.map(url => `${url.origin}${url.pathname}`)).toEqual([ + 'https://speed-0.example/__down', + 'https://speed-1.example/__down', + 'https://speed-0.example/__up', + 'https://speed-1.example/__up', + 'https://speed-0.example/__up', + 'https://speed-1.example/__up' + ]); + expect(results.getDownloadBandwidthPoints()[0].bytes).toBe(2000); + expect(results.getUploadBandwidthPoints()[0].bytes).toBe(4000); + + const resultsCall = fetchMock.mock.calls.find( + ([url]) => url.toString() === resultsUrl + ); + const body = JSON.parse(resultsCall?.[1]?.body as string); + expect(body.sessionId).toBe('session=abc¶llel=2'); + expect(body.download).toEqual([expect.objectContaining({ bytes: 2000 })]); + expect(body.upload).toEqual([expect.objectContaining({ bytes: 4000 })]); + }); +}); + +describe('parallel session metadata', () => { + it('appends the target count', () => { + expect(appendParallelism('session=abc&tier=test', 4)).toBe( + 'session=abc&tier=test¶llel=4' + ); + }); + + it('replaces an existing parallelism value', () => { + expect(appendParallelism('session=abc¶llel=2', 4)).toBe( + 'session=abc¶llel=4' + ); + }); + + it('removes metadata for sequential sessions', () => { + expect(appendParallelism('session=abc¶llel=2', undefined)).toBe( + 'session=abc' + ); + expect(appendParallelism(undefined, 4)).toBeUndefined(); + }); +});