diff --git a/index.html b/index.html
index 7891bb7f7b..1ee236062b 100644
--- a/index.html
+++ b/index.html
@@ -27,7 +27,7 @@
+
+
diff --git a/resources/benchmark-configurator.mjs b/resources/benchmark-configurator.mjs
index caac91775f..2e7d6729a6 100644
--- a/resources/benchmark-configurator.mjs
+++ b/resources/benchmark-configurator.mjs
@@ -56,6 +56,8 @@ export class BenchmarkConfigurator {
this.#suites.forEach((suite) => {
if (!suite.tags)
suite.tags = [];
+ if (!("measurePrepare" in suite))
+ suite.measurePrepare = false;
if (suite.url.startsWith("experimental/"))
suite.tags.unshift("all", "experimental");
else
@@ -115,8 +117,9 @@ export class BenchmarkConfigurator {
}
_loadSuite(suite) {
- suite.tags.forEach((tag) => this.#tags.add(tag));
- this.#suites.push(suite);
+ suite.tags?.forEach((tag) => this.#tags.add(tag));
+
+ this.#suites.push({ ...suite, tags: suite.tags ? [...suite.tags] : [] });
}
enableSuites(names, tags) {
diff --git a/resources/developer-mode.mjs b/resources/developer-mode.mjs
index 1a549c8267..c604b9ecc8 100644
--- a/resources/developer-mode.mjs
+++ b/resources/developer-mode.mjs
@@ -29,6 +29,7 @@ export function createDeveloperModeContainer() {
settings.append(createUIForWaitAfterSuite());
settings.append(createUIForAsyncSteps());
settings.append(createUIForLayoutMode());
+ settings.append(createUIForPreload());
settings.append(createUIForComplexity());
content.append(document.createElement("hr"));
@@ -54,6 +55,12 @@ function createUIForWarmupSuite() {
});
}
+function createUIForPreload() {
+ return createCheckboxUI("Use service worker for resource preloading", params.preload, (isChecked) => {
+ params.preload = isChecked;
+ });
+}
+
function createUIForMeasurePrepare() {
return createCheckboxUI("Measure Prepare", params.measurePrepare, (isChecked) => {
params.measurePrepare = isChecked;
diff --git a/resources/main.css b/resources/main.css
index a571bd6eb8..0945a2c5eb 100644
--- a/resources/main.css
+++ b/resources/main.css
@@ -474,7 +474,8 @@ button.show-about {
display: none;
}
-#progress {
+#progress,
+#preload-progress {
position: absolute;
bottom: -6px;
left: 60px;
@@ -484,7 +485,8 @@ button.show-about {
border-right: 6px solid var(--background);
}
-#progress-completed {
+#progress-completed,
+#preload-progress-completed {
position: absolute;
top: 0;
left: 0;
@@ -495,11 +497,13 @@ button.show-about {
background-color: var(--inactive-color);
}
-#progress-completed::-webkit-progress-value {
+#progress-completed::-webkit-progress-value,
+#preload-progress-completed::-webkit-progress-value {
background-color: var(--foreground);
}
-#progress-completed::-moz-progress-bar {
+#progress-completed::-moz-progress-bar,
+#preload-progress-completed::-moz-progress-bar {
background-color: var(--foreground);
}
@@ -510,7 +514,9 @@ button.show-about {
background-color: var(--background);
}
-#info {
+#info,
+#preload-info {
+ display: none;
position: absolute;
bottom: -25px;
left: 60px;
@@ -520,11 +526,13 @@ button.show-about {
text-align: center;
font-size: 12px;
}
-#info-label {
+#info-label,
+#preload-info-label {
position: absolute;
left: 6px;
}
-#info-progress {
+#info-progress,
+#preload-info-progress {
position: absolute;
right: 6px;
text-align: right;
@@ -1001,3 +1009,46 @@ section#about .note {
color: #fff;
stroke: #fff;
}
+
+#preload-progress,
+#preload-info {
+ display: none;
+}
+
+[data-benchmark-state="PRELOADING"] {
+ cursor: wait;
+ pointer-events: none;
+}
+
+[data-benchmark-state="PRELOADING"] #preload-progress,
+[data-benchmark-state="PRELOADING"] #preload-info {
+ display: block;
+}
+
+[data-benchmark-state="RUNNING"] #info {
+ display: block;
+}
+
+[data-benchmark-state="PRELOADING"] .start-tests-button {
+ color: var(--foreground);
+ background-image: linear-gradient(-45deg, var(--foreground) 3px, transparent 3px, transparent 50%, var(--foreground) 50%, var(--foreground) calc(50% + 3px), transparent calc(50% + 3px), transparent 100%);
+ background-size: 20px 20px;
+ animation: barber-pole 1s linear infinite;
+ pointer-events: none;
+}
+
+[data-benchmark-state="PRELOADING"] .start-tests-button > div {
+ background-color: var(--background);
+ border-radius: 10px;
+ padding: 0 0.3em;
+ display: inline-block;
+}
+
+@keyframes barber-pole {
+ 0% {
+ background-position: 0 0, 0 0;
+ }
+ 100% {
+ background-position: 20px 0, 0 0;
+ }
+}
diff --git a/resources/main.mjs b/resources/main.mjs
index b63dc06a17..8feace7012 100644
--- a/resources/main.mjs
+++ b/resources/main.mjs
@@ -4,8 +4,11 @@ import { renderMetricView } from "./metric-ui.mjs";
import { defaultParams, params } from "./shared/params.mjs";
import { createDeveloperModeContainer } from "./developer-mode.mjs";
+import { ResourcePreloader, PreloadStatusUpdater } from "./preloader.mjs";
+
const BENCHMARK_STATE = Object.freeze({
IDLE: "IDLE",
+ PRELOADING: "PRELOADING",
READY: "READY",
RUNNING: "RUNNING",
DONE: "DONE",
@@ -23,30 +26,46 @@ class MainBenchmarkClient {
_state = BENCHMARK_STATE.IDLE;
_developerModeContainer = null;
_metrics = Object.create(null);
+ _resourcePreloader = new ResourcePreloader();
_steppingPromise = null;
_steppingResolver = null;
_benchmarkConfiguratorPromise = null;
+ _isInitialized = false;
+
+ get isInitialized() {
+ return this._isInitialized;
+ }
constructor() {
this._benchmarkConfiguratorPromise = import("./benchmark-configurator.mjs");
this.prepareUI();
this.evaluateParams();
this._showSection(window.location.hash);
-
- this._benchmarkConfiguratorPromise.then(() => {
- window.dispatchEvent(new Event("SpeedometerReady"));
- });
}
isRunning() {
return this._state === BENCHMARK_STATE.RUNNING;
}
+ isPreloading() {
+ return this._state === BENCHMARK_STATE.PRELOADING;
+ }
+
+ hasError() {
+ return this._state === BENCHMARK_STATE.ERROR;
+ }
+
hasFinished() {
- return this._state === BENCHMARK_STATE.DONE || this._state === BENCHMARK_STATE.ERROR;
+ return this._state === BENCHMARK_STATE.DONE || this.hasError();
+ }
+
+ isDisabled() {
+ return this._state !== BENCHMARK_STATE.READY && this._state !== BENCHMARK_STATE.DONE && this._state !== BENCHMARK_STATE.IDLE;
}
async start() {
+ if (this.isPreloading())
+ return;
if (this._isStepping())
this._clearStepping();
else if (await this._startBenchmark())
@@ -54,14 +73,18 @@ class MainBenchmarkClient {
}
async step() {
+ if (this.isPreloading())
+ return;
const currentSteppingResolver = this._steppingResolver;
this._steppingPromise = new Promise((resolve) => {
this._steppingResolver = resolve;
});
currentSteppingResolver?.();
if (!this.isRunning()) {
- await this._startBenchmark();
- this._showSection("#running");
+ if (await this._startBenchmark())
+ this._showSection("#running");
+ else
+ this._clearStepping();
}
}
@@ -82,7 +105,7 @@ class MainBenchmarkClient {
}
async _startBenchmark() {
- if (this.isRunning())
+ if (this.isRunning() || this.isPreloading())
return false;
const { benchmarkConfigurator } = await this._benchmarkConfiguratorPromise;
@@ -103,6 +126,11 @@ class MainBenchmarkClient {
}
if (!this._isStepping())
this._developerModeContainer?.remove();
+
+ await this._preloadResources(benchmarkConfigurator);
+ if (this.hasError())
+ return false;
+
this._progressCompleted = document.getElementById("progress-completed");
if (params.iterationCount < 50) {
const progressNode = document.getElementById("progress");
@@ -114,10 +142,10 @@ class MainBenchmarkClient {
}
this._metrics = Object.create(null);
-
this.stepCount = params.iterationCount * totalSuitesCount;
this._progressCompleted.max = this.stepCount;
this.suitesCount = enabledSuites.length;
+
this._setBenchmarkState(BENCHMARK_STATE.RUNNING);
const runner = new BenchmarkRunner(benchmarkConfigurator.suites, this);
runner.runMultipleIterations(params.iterationCount);
@@ -129,7 +157,7 @@ class MainBenchmarkClient {
}
_assertNotAborted() {
- if (this._state === BENCHMARK_STATE.ERROR)
+ if (this.hasError())
throw new Error("Benchmark aborted by another process.");
}
@@ -167,11 +195,13 @@ class MainBenchmarkClient {
this._finishedTestCount = 0;
}
- didFinishLastIteration(metrics) {
+ async didFinishLastIteration(metrics) {
console.assert(this.isRunning());
+ this._clearStepping();
this._metrics = metrics;
this._setBenchmarkState(BENCHMARK_STATE.DONE);
+ await this._resourcePreloader.teardown();
const scoreResults = this._computeResults(this._measuredValuesList, "score");
if (scoreResults.isValid)
@@ -188,8 +218,9 @@ class MainBenchmarkClient {
}
handleError(error) {
- if (this._state === BENCHMARK_STATE.ERROR)
+ if (this.hasError())
return;
+ this._clearStepping();
this._metrics = Object.create(null);
this._setBenchmarkState(BENCHMARK_STATE.ERROR);
this._populateErrorMessage(error.message);
@@ -392,25 +423,67 @@ class MainBenchmarkClient {
document.body.append(this._developerModeContainer);
}
- if (params.startAutomatically)
- this.start();
- else
+ await this._resourcePreloader.setup();
+ if (!params.developerMode)
+ await this._preloadResources(benchmarkConfigurator);
+
+ if (!this.hasError()) {
this._setBenchmarkState(BENCHMARK_STATE.READY);
+ if (params.startAutomatically)
+ await this.start();
+ }
+
+ this._isInitialized = true;
+ window.dispatchEvent(new Event("SpeedometerReady"));
+ }
+
+ async _preloadResources(benchmarkConfigurator) {
+ await this._resourcePreloader.stopPreloading();
+ await this._resourcePreloader.setup();
+ if (!params.preload)
+ return;
+
+ const enabledSuites = benchmarkConfigurator.suites.filter((suite) => suite.enabled);
+ if (this._resourcePreloader.isCached(enabledSuites))
+ return;
+
+ const clearCache = !params.isDefault();
+ const preloadStatusUpdater = new PreloadStatusUpdater();
+
+ try {
+ this._setBenchmarkState(BENCHMARK_STATE.PRELOADING);
+ preloadStatusUpdater.start();
+ await this._resourcePreloader.preloadSuites(enabledSuites, clearCache, preloadStatusUpdater.onProgress.bind(preloadStatusUpdater));
+ } catch (error) {
+ this.handleError(error);
+ } finally {
+ preloadStatusUpdater.stop();
+ }
}
- async _setBenchmarkState(state) {
+ _setBenchmarkState(state) {
this._state = state;
document.body.setAttribute("data-benchmark-state", state);
const startButtons = document.querySelectorAll(".start-tests-button");
- if (state !== BENCHMARK_STATE.RUNNING) {
+ if (this.isPreloading()) {
+ if (document.documentElement.getAttribute("data-visible-section") !== "home")
+ this._showSection("#home");
+ startButtons.forEach((btn) => {
+ btn.disabled = true;
+ btn.innerHTML = "Preloading
";
+ });
+ } else if (this.isRunning()) {
+ document.body.style.removeProperty("--preload-progress");
+ startButtons.forEach((btn) => {
+ btn.disabled = true;
+ });
+ } else {
+ document.body.style.removeProperty("--preload-progress");
+ const isDisabled = this.isDisabled();
startButtons.forEach((btn) => {
btn.innerHTML = "Start Test
";
+ btn.disabled = isDisabled;
});
- if (state === BENCHMARK_STATE.READY) {
- startButtons.forEach((btn) => {
- btn.disabled = false;
- });
- }
}
}
@@ -434,7 +507,7 @@ class MainBenchmarkClient {
_logoClickHandler(event) {
// Prevent any accidental UI changes during benchmark runs.
- if (!this.isRunning())
+ if (!this.isRunning() && !this.isPreloading())
this._showSection("#home");
event.preventDefault();
return false;
diff --git a/resources/preloader.mjs b/resources/preloader.mjs
new file mode 100644
index 0000000000..e67d694ba8
--- /dev/null
+++ b/resources/preloader.mjs
@@ -0,0 +1,364 @@
+import { SW_MESSAGES } from "./shared/sw-messages.mjs";
+import { params } from "./shared/params.mjs";
+
+const PRELOAD_TIMEOUT_MS = 60_000;
+
+export class ResourcePreloader {
+ constructor(customParams = params) {
+ this._params = customParams;
+ this._registration = null;
+ this._sw = null;
+ this._preloadParams = null;
+ this._pendingRequest = null;
+ this._onPreloadProgress = null;
+ this._activePreloadPromise = null;
+ this._activeStopPromise = null;
+ this._nextRequestId = 1;
+ this._boundOnMessage = this.onMessage.bind(this);
+ this._messageListenerAdded = false;
+ }
+
+ _isServiceWorkerSupported() {
+ return Boolean(globalThis.navigator?.serviceWorker);
+ }
+
+ _serializeParams(suitesOrParams = this._params, maybeSuites = undefined) {
+ const targetParams = typeof suitesOrParams?.toSearchParams === "function" ? suitesOrParams : this._params;
+ const suites = Array.isArray(suitesOrParams) ? suitesOrParams : maybeSuites;
+ const searchParamsStr = targetParams.toSearchParams();
+ if (!Array.isArray(suites))
+ return searchParamsStr;
+ const suiteNames = suites
+ .filter((s) => typeof s === "string" ? true : s.enabled !== false)
+ .map((s) => typeof s === "string" ? s : s.name)
+ .join(",");
+ return `${searchParamsStr}|suites:${suiteNames}`;
+ }
+
+ isCached(enabledSuites) {
+ if (!this._params.preload || (!this._registration && !this._sw) || !this._preloadParams)
+ return false;
+ if (enabledSuites === undefined)
+ return this._preloadParams.split("|suites:")[0] === this._params.toSearchParams();
+ return this._preloadParams === this._serializeParams(this._params, enabledSuites);
+ }
+
+ async setup() {
+ if (!this._isServiceWorkerSupported())
+ return;
+
+ if (!this._params.preload) {
+ if (this._registration || this._sw)
+ await this._unregisterOldServiceWorkers();
+ return;
+ }
+
+ if (this._registration && this._sw)
+ return;
+
+ await this._registerServiceWorker();
+ this._setupMessageListener();
+ }
+
+ async _unregisterOldServiceWorkers() {
+ this._removeMessageListener();
+ const activeRegistration = this._registration;
+ this._registration = null;
+ this._sw = null;
+ this._preloadParams = null;
+
+ if (!this._isServiceWorkerSupported())
+ return;
+
+ const expectedScope = new URL("./", window.location.href).href;
+ let unregisteredActive = false;
+ const existingRegistrations = await navigator.serviceWorker?.getRegistrations?.() ?? [];
+ for (const existing of existingRegistrations) {
+ if (existing.scope === expectedScope) {
+ await existing.unregister?.();
+ if (existing === activeRegistration)
+ unregisteredActive = true;
+ }
+ }
+ if (!unregisteredActive && (!activeRegistration?.scope || activeRegistration.scope === expectedScope))
+ await activeRegistration?.unregister?.();
+ }
+
+ async _registerServiceWorker() {
+ if (!this._isServiceWorkerSupported())
+ return;
+
+ this._registration = await navigator.serviceWorker?.register("./sw.mjs", { type: "module" });
+ const readyRegistration = await navigator.serviceWorker?.ready;
+ this._sw = this._registration?.active || readyRegistration?.active || navigator.serviceWorker?.controller || null;
+ }
+
+ _setupMessageListener() {
+ if (!this._isServiceWorkerSupported() || this._messageListenerAdded)
+ return;
+ navigator.serviceWorker?.addEventListener("message", this._boundOnMessage);
+ this._messageListenerAdded = true;
+ }
+
+ _removeMessageListener() {
+ if (!this._isServiceWorkerSupported() || !this._messageListenerAdded)
+ return;
+ navigator.serviceWorker?.removeEventListener("message", this._boundOnMessage);
+ this._messageListenerAdded = false;
+ }
+
+ _isValidMessage(data) {
+ if (!this._pendingRequest)
+ return false;
+
+ if (data.requestId !== undefined && data.requestId !== this._pendingRequest.requestId)
+ return false;
+
+ if (this._pendingRequest.type !== data.type)
+ return false;
+
+ return true;
+ }
+
+ onMessage(event) {
+ const data = event?.data || {};
+
+ if (data.type === SW_MESSAGES.FATAL_ERROR) {
+ globalThis.benchmarkClient?.handleError(new Error(data.message));
+ return;
+ }
+
+ if (!this._isValidMessage(data))
+ return;
+
+ if (data.status === SW_MESSAGES.PRELOAD_PROGRESS) {
+ this._onPreloadProgress?.(data);
+ return;
+ }
+
+ const { resolve, reject, timeoutId } = this._pendingRequest;
+ if (timeoutId)
+ clearTimeout(timeoutId);
+ this._pendingRequest = null;
+
+ if (data.status === SW_MESSAGES.ERROR)
+ reject(new Error(data.message || "Unknown SW Error"));
+ else
+ resolve(data);
+ }
+
+ // Allow preempting in-flight preloads or overriding any pending request with an explicit stop.
+ _isCooperativePreemption(messageData) {
+ return this._pendingRequest?.type === SW_MESSAGES.PRELOAD_SUITES || messageData?.type === SW_MESSAGES.STOP_PRELOADING;
+ }
+
+ isCooperativePreemption(messageData) {
+ return this._isCooperativePreemption(messageData);
+ }
+
+ _postMessage(messageData, timeoutMs = 0) {
+ if (!this._sw)
+ return Promise.resolve(null);
+
+ if (this._pendingRequest) {
+ const logMessage = `Already waiting for ${this._pendingRequest.type}, overriding with ${messageData.type}`;
+ if (this._isCooperativePreemption(messageData)) {
+ console.warn(logMessage);
+ this._resolvePendingRequest(null);
+ } else {
+ this._rejectPendingRequest(logMessage);
+ }
+ }
+
+ const requestId = this._nextRequestId++;
+ const payload = { ...messageData, requestId };
+
+ return new Promise((resolve, reject) => {
+ let timeoutId = null;
+
+ if (timeoutMs > 0) {
+ timeoutId = setTimeout(() => {
+ if (this._pendingRequest?.requestId === requestId)
+ this._rejectPendingRequest(`Service worker message timed out: ${messageData.type}`);
+ }, timeoutMs);
+ }
+
+ this._pendingRequest = { type: messageData.type, requestId, resolve, reject, timeoutId };
+ this._sw?.postMessage(payload);
+ });
+ }
+
+ _resolvePendingRequest(data = null) {
+ const pendingRequest = this._pendingRequest;
+ this._pendingRequest = null;
+ if (pendingRequest?.timeoutId)
+ clearTimeout(pendingRequest.timeoutId);
+
+ pendingRequest?.resolve(data);
+ }
+
+ _rejectPendingRequest(errorMessage) {
+ const pendingRequest = this._pendingRequest;
+ this._pendingRequest = null;
+ if (pendingRequest?.timeoutId)
+ clearTimeout(pendingRequest.timeoutId);
+
+ console.warn(errorMessage);
+ pendingRequest?.reject(new Error(errorMessage));
+ }
+
+ async teardown() {
+ this._preloadParams = null;
+ if (!this._sw && !this._registration)
+ return;
+
+ await this.stopPreloading();
+
+ const response = await this.getFailedRequests();
+ await this._unregisterOldServiceWorkers();
+ if (response?.requests?.length > 0) {
+ console.warn("The following requests failed during the benchmark and bypassed the cache:");
+ console.warn(response.requests.join("\n"));
+ throw new Error("The benchmark had missing resources that were not cached. Check the console for details.");
+ }
+ }
+
+ async stopPreloading() {
+ if (!this._sw)
+ return;
+
+ if (this._activeStopPromise) {
+ await this._activeStopPromise;
+ return;
+ }
+
+ if (this._activePreloadPromise || this._pendingRequest?.type === SW_MESSAGES.PRELOAD_SUITES)
+ this._preloadParams = null;
+
+ const activePreloadPromise = this._activePreloadPromise;
+ this._activeStopPromise = this._postMessage({ type: SW_MESSAGES.STOP_PRELOADING });
+ try {
+ await this._activeStopPromise;
+ if (activePreloadPromise)
+ await activePreloadPromise;
+ } finally {
+ this._activeStopPromise = null;
+ }
+ }
+
+ async preloadSuites(suites, clearCache = true, onProgress) {
+ if (!this._isServiceWorkerSupported())
+ return undefined;
+
+ if (!this._params.preload) {
+ if (this._registration || this._sw)
+ await this._unregisterOldServiceWorkers();
+ return undefined;
+ }
+
+ if (this._activeStopPromise)
+ await this._activeStopPromise;
+
+ if (!this._sw && !this._registration)
+ await this.setup();
+
+ if (!this._sw)
+ return undefined;
+
+ if (this._activePreloadPromise || this._onPreloadProgress)
+ throw new Error("Preload already active");
+
+ this._preloadParams = null;
+
+ const suitesData = suites
+ .filter((s) => s.resources)
+ .map((s) => ({
+ name: s.name,
+ url: new URL(s.url, window.location.href).href,
+ resources: new URL(s.resources, window.location.href).href,
+ }));
+
+ if (suitesData.length === 0)
+ return undefined;
+
+ this._onPreloadProgress = onProgress;
+ const startTime = performance.now();
+ try {
+ this._activePreloadPromise = this._postMessage({ type: SW_MESSAGES.PRELOAD_SUITES, suites: suitesData, clearCache }, PRELOAD_TIMEOUT_MS);
+ const response = await this._activePreloadPromise;
+ if (this._activeStopPromise)
+ await this._activeStopPromise;
+
+ if (response?.status === SW_MESSAGES.PRELOAD_DONE) {
+ const timeTakenMs = performance.now() - startTime;
+ const sizeMB = ((response.transferredSize || 0) / (1024 * 1024)).toFixed(2);
+ const timeSec = (timeTakenMs / 1000).toFixed(2);
+ console.log(`Preloaded ${response.count} files (${sizeMB} MB transferred) in ${timeSec}s`);
+ this._preloadParams = this._serializeParams(this._params, suites);
+ }
+ return response;
+ } finally {
+ this._activePreloadPromise = null;
+ this._onPreloadProgress = null;
+ }
+ }
+
+ async getFailedRequests() {
+ if (!this._sw)
+ return { requests: [] };
+
+ const response = await this._postMessage({ type: SW_MESSAGES.GET_FAILED_REQUESTS });
+ return response || { requests: [] };
+ }
+}
+
+export class PreloadStatusUpdater {
+ constructor() {
+ this._latestData = null;
+ this._rafId = null;
+ this._updateFn = this._updateUI.bind(this);
+
+ this._progressCompletedElement = document.getElementById("preload-progress-completed");
+ this._infoLabelElement = document.getElementById("preload-info-label");
+ this._infoProgressElement = document.getElementById("preload-info-progress");
+ }
+
+ start() {
+ this.stop();
+ this._progressCompletedElement.max = 1;
+ this._progressCompletedElement.value = 0;
+ this._infoLabelElement.textContent = "";
+ this._infoProgressElement.textContent = "";
+ document.body.style.setProperty("--preload-progress", "0%");
+ }
+
+ onProgress(data) {
+ this._latestData = data;
+ if (!this._rafId)
+ this._rafId = requestAnimationFrame(this._updateFn);
+ }
+
+ _updateUI() {
+ this._rafId = null;
+ const data = this._latestData;
+ if (!data)
+ return;
+
+ const { loaded, total, url, suiteName } = data;
+ document.body.style.setProperty("--preload-progress", `${total > 0 ? (loaded / total) * 100 : 100}%`);
+ this._progressCompletedElement.max = total > 0 ? total : 1;
+ this._progressCompletedElement.value = total > 0 ? loaded : 0;
+ const match = url?.match(/(?:suites(?:-experimental)?|resources)\/(.+)$/);
+ const filename = match?.[1] ?? "";
+ this._infoLabelElement.textContent = `${suiteName}: ${filename}`;
+ this._infoProgressElement.textContent = `${loaded} / ${total}`;
+ }
+
+ stop() {
+ if (this._rafId) {
+ cancelAnimationFrame(this._rafId);
+ this._rafId = null;
+ }
+ this._latestData = null;
+ }
+}
diff --git a/resources/shared/params.mjs b/resources/shared/params.mjs
index f9e0a0541a..3acb07d35b 100644
--- a/resources/shared/params.mjs
+++ b/resources/shared/params.mjs
@@ -43,6 +43,8 @@ export class Params {
measurePrepare = false;
// External config url to override internal tests.
config = "";
+ // Use service worker for resource preloading.
+ preload = false;
constructor(searchParams = undefined, warnUnused = false) {
if (searchParams)
@@ -88,6 +90,7 @@ export class Params {
this.layoutMode = this._parseEnumParam(searchParams, "layoutMode", LAYOUT_MODES);
this.measurePrepare = this._parseBooleanParam(searchParams, "measurePrepare");
this.config = this._parseConfig(searchParams);
+ this.preload = this._parseBooleanParam(searchParams, "preload");
if (warnUnused) {
const unused = Array.from(searchParams.keys());
@@ -99,8 +102,9 @@ export class Params {
_parseBooleanParam(searchParams, paramKey) {
if (!searchParams.has(paramKey))
return false;
+ const value = searchParams.get(paramKey)?.toLowerCase();
searchParams.delete(paramKey);
- return true;
+ return value !== "false" && value !== "0";
}
_parserNumberParam(searchParams, paramKey, minValue) {
diff --git a/resources/shared/request-limiter.mjs b/resources/shared/request-limiter.mjs
new file mode 100644
index 0000000000..44c2c48ddf
--- /dev/null
+++ b/resources/shared/request-limiter.mjs
@@ -0,0 +1,38 @@
+export class RequestLimiter {
+ constructor(limit) {
+ this._limit = limit;
+ this._active = 0;
+ this._queue = [];
+ }
+
+ async _processQueue() {
+ while (this._queue.length > 0) {
+ const task = this._queue.shift();
+ try {
+ await task();
+ } catch (e) {
+ // Individual task errors are handled by their respective promises
+ }
+ }
+ this._active--;
+ }
+
+ schedule(fn) {
+ return new Promise((resolve, reject) => {
+ const task = () => fn().then(resolve, reject);
+ task.resolve = resolve;
+ this._queue.push(task);
+
+ if (this._active < this._limit) {
+ this._active++;
+ this._processQueue(); // Intentionally not awaited
+ }
+ });
+ }
+
+ clear() {
+ for (const task of this._queue)
+ task.resolve?.(0);
+ this._queue.length = 0;
+ }
+}
diff --git a/resources/shared/sw-messages.mjs b/resources/shared/sw-messages.mjs
new file mode 100644
index 0000000000..7e1cff97f8
--- /dev/null
+++ b/resources/shared/sw-messages.mjs
@@ -0,0 +1,11 @@
+export const SW_MESSAGES = Object.freeze({
+ PRELOAD_SUITES: "PRELOAD_SUITES",
+ PRELOAD_PROGRESS: "PRELOAD_PROGRESS",
+ PRELOAD_DONE: "PRELOAD_DONE",
+ STOP_PRELOADING: "STOP_PRELOADING",
+ FATAL_ERROR: "FATAL_ERROR",
+ ERROR: "ERROR",
+ SUCCESS: "SUCCESS",
+ GET_FAILED_REQUESTS: "GET_FAILED_REQUESTS",
+ FAILED_REQUESTS: "FAILED_REQUESTS",
+});
diff --git a/resources/suite-runner.mjs b/resources/suite-runner.mjs
index ca87f36f3d..eedd795794 100644
--- a/resources/suite-runner.mjs
+++ b/resources/suite-runner.mjs
@@ -117,7 +117,7 @@ export class SuiteRunner {
const suitePrepare = this.#suiteResults.prepare;
if (suiteTotal === 0)
throw new Error(`Got invalid 0-time total for suite ${this.#suite.name}: ${suiteTotal}`);
- if (this.#params.measurePrepare && suitePrepare === 0)
+ if ((this.#params.measurePrepare || this.#suite.measurePrepare) && suitePrepare === 0)
throw new Error(`Got invalid 0-time prepare time for suite ${this.#suite.name}: ${suitePrepare}`);
}
diff --git a/suites-experimental/javascript-wc-indexeddb/dist/src/speedometer-utils/params.mjs b/suites-experimental/javascript-wc-indexeddb/dist/src/speedometer-utils/params.mjs
index 5e6246320a..4a41279504 100644
--- a/suites-experimental/javascript-wc-indexeddb/dist/src/speedometer-utils/params.mjs
+++ b/suites-experimental/javascript-wc-indexeddb/dist/src/speedometer-utils/params.mjs
@@ -39,6 +39,8 @@ export class Params {
measurePrepare = false;
// External config url to override internal tests.
config = "";
+ // Use service worker for resource preloading.
+ preload = false;
constructor(searchParams = undefined, warnUnused = false) {
if (searchParams)
@@ -74,6 +76,7 @@ export class Params {
this.layoutMode = this._parseEnumParam(searchParams, "layoutMode", LAYOUT_MODES);
this.measurePrepare = this._parseBooleanParam(searchParams, "measurePrepare");
this.config = this._parseConfig(searchParams);
+ this.preload = this._parseBooleanParam(searchParams, "preload");
if (warnUnused) {
const unused = Array.from(searchParams.keys());
@@ -85,8 +88,9 @@ export class Params {
_parseBooleanParam(searchParams, paramKey) {
if (!searchParams.has(paramKey))
return false;
+ const value = searchParams.get(paramKey)?.toLowerCase();
searchParams.delete(paramKey);
- return true;
+ return value !== "false" && value !== "0";
}
_parseIntParam(searchParams, paramKey, minValue) {
@@ -211,6 +215,10 @@ export class Params {
toSearchParams() {
return this.toSearchParamsObject().toString();
}
+
+ isDefault() {
+ return this === defaultParams;
+ }
}
function isValidJsonUrl(url) {
diff --git a/suites/default-suites.mjs b/suites/default-suites.mjs
index 2e9522bc60..7ffede005f 100644
--- a/suites/default-suites.mjs
+++ b/suites/default-suites.mjs
@@ -1006,7 +1006,8 @@ export const DefaultSuites = freezeSuites([
},
{
name: "Perf-Dashboard",
- url: "suites/perf.webkit.org/public/v3/#/charts/?since=1678991819934&paneList=((55-1974-null-null-(5-2.5-500)))",
+ url: "suites/perf.webkit.org/public/v3/index.html#/charts/?since=1678991819934&paneList=((55-1974-null-null-(5-2.5-500)))",
+ resources: "suites/perf.webkit.org/resources.txt",
tags: ["default", "chart", "webcomponents"],
async prepare(page) {
await page.waitForElement("#app-is-ready");
diff --git a/suites/perf.webkit.org/resources.txt b/suites/perf.webkit.org/resources.txt
new file mode 100644
index 0000000000..661dcb374f
--- /dev/null
+++ b/suites/perf.webkit.org/resources.txt
@@ -0,0 +1,144 @@
+public/api/analysis-tasks-platform=55-metric=1006
+public/api/analysis-tasks-platform=55-metric=1407
+public/api/analysis-tasks-platform=55-metric=1648
+public/api/analysis-tasks-platform=55-metric=1649
+public/api/analysis-tasks-platform=55-metric=1974
+public/api/measurement-set-platform=16-metric=1
+public/api/measurement-set-platform=16-metric=13
+public/api/measurement-set-platform=16-metric=17
+public/api/measurement-set-platform=16-metric=1726
+public/api/measurement-set-platform=16-metric=6
+public/api/measurement-set-platform=16-metric=7
+public/api/measurement-set-platform=55-metric=1
+public/api/measurement-set-platform=55-metric=1006
+public/api/measurement-set-platform=55-metric=13
+public/api/measurement-set-platform=55-metric=1407
+public/api/measurement-set-platform=55-metric=1648
+public/api/measurement-set-platform=55-metric=1649
+public/api/measurement-set-platform=55-metric=17
+public/api/measurement-set-platform=55-metric=1726
+public/api/measurement-set-platform=55-metric=1974
+public/api/measurement-set-platform=55-metric=6
+public/api/measurement-set-platform=55-metric=7
+public/common.css
+public/data/manifest.json
+public/data/measurement-set-16-1-1682812800000.json
+public/data/measurement-set-16-1.json
+public/data/measurement-set-16-13-1682812800000.json
+public/data/measurement-set-16-13.json
+public/data/measurement-set-16-17-1682812800000.json
+public/data/measurement-set-16-17.json
+public/data/measurement-set-16-1726-1682812800000.json
+public/data/measurement-set-16-1726.json
+public/data/measurement-set-16-6-1682812800000.json
+public/data/measurement-set-16-6.json
+public/data/measurement-set-16-7-1682812800000.json
+public/data/measurement-set-16-7.json
+public/data/measurement-set-55-1-1682812800000.json
+public/data/measurement-set-55-1.json
+public/data/measurement-set-55-13-1682812800000.json
+public/data/measurement-set-55-13.json
+public/data/measurement-set-55-1407-1682812800000.json
+public/data/measurement-set-55-1407.json
+public/data/measurement-set-55-1648-1682812800000.json
+public/data/measurement-set-55-1648.json
+public/data/measurement-set-55-1649-1682812800000.json
+public/data/measurement-set-55-1649.json
+public/data/measurement-set-55-17-1682812800000.json
+public/data/measurement-set-55-17.json
+public/data/measurement-set-55-1726-1682812800000.json
+public/data/measurement-set-55-1726.json
+public/data/measurement-set-55-1974-1682812800000.json
+public/data/measurement-set-55-1974.json
+public/data/measurement-set-55-6-1682812800000.json
+public/data/measurement-set-55-6.json
+public/data/measurement-set-55-7-1682812800000.json
+public/data/measurement-set-55-7.json
+public/shared/common-component-base.js
+public/shared/common-remote.js
+public/shared/statistics.js
+public/v3/async-task.js
+public/v3/bundled-scripts.js
+public/v3/commit-set-range-bisector.js
+public/v3/components/analysis-results-viewer.js
+public/v3/components/analysis-task-bug-list.js
+public/v3/components/bar-graph-group.js
+public/v3/components/base.js
+public/v3/components/button-base.js
+public/v3/components/chart-pane-base.js
+public/v3/components/chart-revision-range.js
+public/v3/components/chart-status-evaluator.js
+public/v3/components/chart-styles.js
+public/v3/components/close-button.js
+public/v3/components/combo-box.js
+public/v3/components/commit-log-viewer.js
+public/v3/components/custom-analysis-task-configurator.js
+public/v3/components/custom-configuration-test-group-form.js
+public/v3/components/customizable-test-group-form.js
+public/v3/components/dashboard-chart-status-view.js
+public/v3/components/editable-text.js
+public/v3/components/expand-collapse-button.js
+public/v3/components/freshness-indicator.js
+public/v3/components/instant-file-uploader.js
+public/v3/components/interactive-time-series-chart.js
+public/v3/components/minus-button.js
+public/v3/components/mutable-list-view.js
+public/v3/components/owned-commit-viewer.js
+public/v3/components/pane-selector.js
+public/v3/components/plus-button.js
+public/v3/components/ratio-bar-graph.js
+public/v3/components/repetition-type-selection.js
+public/v3/components/results-table.js
+public/v3/components/spinner-icon.js
+public/v3/components/test-group-form.js
+public/v3/components/test-group-results-viewer.js
+public/v3/components/test-group-revision-table.js
+public/v3/components/time-series-chart.js
+public/v3/components/warning-icon.js
+public/v3/index.html
+public/v3/instrumentation.js
+public/v3/lazily-evaluated-function.js
+public/v3/main.js
+public/v3/models/analysis-results.js
+public/v3/models/analysis-task.js
+public/v3/models/bug-tracker.js
+public/v3/models/bug.js
+public/v3/models/build-request.js
+public/v3/models/builder.js
+public/v3/models/commit-log.js
+public/v3/models/commit-set.js
+public/v3/models/data-model.js
+public/v3/models/manifest.js
+public/v3/models/measurement-adaptor.js
+public/v3/models/measurement-cluster.js
+public/v3/models/measurement-set.js
+public/v3/models/metric.js
+public/v3/models/platform-group.js
+public/v3/models/platform.js
+public/v3/models/repository.js
+public/v3/models/test-group.js
+public/v3/models/test.js
+public/v3/models/time-series.js
+public/v3/models/triggerable.js
+public/v3/models/uploaded-file.js
+public/v3/pages/analysis-category-page.js
+public/v3/pages/analysis-category-toolbar.js
+public/v3/pages/analysis-task-page.js
+public/v3/pages/build-request-queue-page.js
+public/v3/pages/chart-pane-status-view.js
+public/v3/pages/chart-pane.js
+public/v3/pages/charts-page.js
+public/v3/pages/charts-toolbar.js
+public/v3/pages/create-analysis-task-page.js
+public/v3/pages/dashboard-page.js
+public/v3/pages/dashboard-toolbar.js
+public/v3/pages/domain-control-toolbar.js
+public/v3/pages/heading.js
+public/v3/pages/page-router.js
+public/v3/pages/page-with-heading.js
+public/v3/pages/page.js
+public/v3/pages/summary-page.js
+public/v3/pages/test-freshness-page.js
+public/v3/pages/toolbar.js
+public/v3/privileged-api.js
+public/v3/remote.js
diff --git a/sw.mjs b/sw.mjs
new file mode 100644
index 0000000000..d288499ae5
--- /dev/null
+++ b/sw.mjs
@@ -0,0 +1,239 @@
+import { SW_MESSAGES } from "./resources/shared/sw-messages.mjs";
+import { RequestLimiter } from "./resources/shared/request-limiter.mjs";
+
+const CACHE_NAME = "speedometer-cache-v4";
+const MAX_CONCURRENT_REQUESTS = 20;
+const WORKER_STATE = {
+ failedRequests: new Set(),
+ abortController: null,
+ requestLimiter: new RequestLimiter(MAX_CONCURRENT_REQUESTS),
+};
+
+function replyToClient(event, status, msg = {}) {
+ const type = event.data?.type;
+ const requestId = event.data?.requestId;
+ event.source?.postMessage({ type, status, requestId, ...msg });
+}
+
+function replyError(event, message) {
+ replyToClient(event, SW_MESSAGES.ERROR, { message });
+}
+
+async function handleStopPreloadingMessage(event) {
+ await stopPreloading(event.source?.id);
+ replyToClient(event, SW_MESSAGES.STOP_PRELOADING);
+}
+
+function abortActivePreload() {
+ WORKER_STATE.abortController?.abort();
+ WORKER_STATE.abortController = null;
+ WORKER_STATE.requestLimiter.clear();
+}
+
+async function stopPreloading(currentClientId) {
+ abortActivePreload();
+ await stopAllOtherClients(currentClientId);
+}
+
+async function stopAllOtherClients(currentClientId) {
+ const allClients = await self.clients.matchAll({ includeUncontrolled: true });
+ for (const client of allClients) {
+ if (currentClientId && client.id !== currentClientId)
+ client.postMessage({ type: SW_MESSAGES.FATAL_ERROR, message: "Aborted Run: Another tab took over." });
+ }
+
+}
+
+async function handlePreloadSuitesMessage(event, { suites = [], clearCache = true }) {
+ abortActivePreload();
+ const abortController = new AbortController();
+ const signal = abortController.signal;
+ WORKER_STATE.abortController = abortController;
+
+ try {
+ await stopAllOtherClients(event.source?.id);
+ signal.throwIfAborted();
+ // debounce on progress callbacks to limit overhead.
+ const MAX_RATE_MS = 100;
+ const onProgress = (payload) => {
+ if (!signal.aborted)
+ replyToClient(event, SW_MESSAGES.PRELOAD_PROGRESS, payload);
+ };
+ const { transferredSize, count } = await preloadSuites(suites, clearCache, throttle(onProgress, MAX_RATE_MS), signal);
+ signal.throwIfAborted();
+ replyToClient(event, SW_MESSAGES.PRELOAD_DONE, { transferredSize, count });
+ } catch (error) {
+ if (error?.name === "AbortError" || signal.aborted)
+ return;
+ console.error("Error during preload:", error);
+ replyError(event, error?.message || "Failed to preload resources.");
+ } finally {
+ if (WORKER_STATE.abortController === abortController)
+ WORKER_STATE.abortController = null;
+ }
+}
+
+function throttle(callback, max_rate_ms) {
+ let lastCallbackTime = 0;
+ return (...args) => {
+ const now = Date.now();
+ if (now - lastCallbackTime > max_rate_ms) {
+ lastCallbackTime = now;
+ callback(...args);
+ }
+ };
+}
+
+async function preloadSuites(suites, clearCache, onProgress, signal) {
+ WORKER_STATE.failedRequests.clear();
+ if (clearCache) {
+ await caches.delete(CACHE_NAME);
+ signal.throwIfAborted();
+ }
+
+ const cache = await caches.open(CACHE_NAME);
+ signal.throwIfAborted();
+ const urlsToCache = await getUrlsToCache(suites, signal);
+ signal.throwIfAborted();
+ const total = urlsToCache.length;
+
+ let loaded = 0;
+ let transferredSize = 0;
+
+ const promises = urlsToCache.map(async (item) => {
+ const size = await fetchAndCache(cache, item.url, signal);
+ signal.throwIfAborted();
+ transferredSize += size;
+ loaded++;
+ if (!signal.aborted)
+ onProgress?.({ loaded, total, url: item.url, suiteName: item.suiteName });
+ });
+
+ await Promise.all(promises);
+ signal.throwIfAborted();
+ return { transferredSize, count: urlsToCache.length };
+}
+
+async function getUrlsToCache(suites, signal) {
+ const urlsToCache = [];
+ for (const suite of suites) {
+ signal.throwIfAborted();
+ if (suite?.resources)
+ urlsToCache.push(...await parseSuiteResources(suite, signal));
+ }
+
+ return urlsToCache;
+}
+
+async function parseSuiteResources(suite, signal) {
+ const response = await fetch(suite.resources, { signal });
+ if (!response.ok) {
+ const errorMsg = `Failed to load resources for suite ${suite.name}: ${suite.resources} returned ${response.status}`;
+ console.error(errorMsg);
+ throw new Error(errorMsg);
+ }
+ const text = await response.text();
+ signal.throwIfAborted();
+ const baseUrl = new URL(suite.resources, self.location.href);
+ return text
+ .trim()
+ .split("\n")
+ .map((resourceUrl) => resourceUrl.trim())
+ .filter(Boolean)
+ .map((resourceUrl) => ({
+ url: new URL(resourceUrl, baseUrl).href,
+ suiteName: suite.name,
+ }));
+}
+
+async function fetchAndCache(cache, url, signal) {
+ signal.throwIfAborted();
+
+ return WORKER_STATE.requestLimiter.schedule(async () => {
+ signal.throwIfAborted();
+ const request = new Request(url, { cache: "no-cache", signal });
+ const existing = await cache.match(request, { ignoreSearch: true });
+ signal.throwIfAborted();
+ if (existing)
+ return getResponseSize(existing);
+
+ try {
+ const response = await fetch(request);
+ signal.throwIfAborted();
+ if (!response.ok) {
+ console.error(`Cache failed for ${url}: HTTP ${response.status}`);
+ WORKER_STATE.failedRequests.add(url);
+ return 0;
+ }
+ const size = getResponseSize(response);
+ await cache.put(request, response);
+ return size;
+ } catch (e) {
+ signal.throwIfAborted();
+ console.error("Cache failed for", url, e);
+ WORKER_STATE.failedRequests.add(url);
+ return 0;
+ }
+ });
+}
+
+function getResponseSize(response) {
+ const contentLength = response.headers.get("content-length");
+ return contentLength ? parseInt(contentLength, 10) : 0;
+}
+
+async function handleGetFailedRequestsMessage(event) {
+ replyToClient(event, SW_MESSAGES.FAILED_REQUESTS, { requests: Array.from(WORKER_STATE.failedRequests) });
+}
+
+const MESSAGE_HANDLERS = Object.freeze({
+ [SW_MESSAGES.PRELOAD_SUITES]: handlePreloadSuitesMessage,
+ [SW_MESSAGES.STOP_PRELOADING]: handleStopPreloadingMessage,
+ [SW_MESSAGES.GET_FAILED_REQUESTS]: handleGetFailedRequestsMessage,
+});
+
+self.addEventListener("install", () => {
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("message", (event) => {
+ const { data } = event;
+ if (!data)
+ return;
+ const handler = MESSAGE_HANDLERS[data.type];
+ if (handler)
+ event.waitUntil(handler(event, data));
+ else
+ console.error("Unknown service worker message type:", data.type);
+});
+
+self.addEventListener("fetch", (event) => {
+ if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin")
+ return;
+
+ event.respondWith(
+ (async () => {
+ let requestToMatch = event.request;
+ const urlObj = new URL(event.request.url);
+ if (urlObj.pathname.endsWith("/")) {
+ urlObj.pathname += "index.html";
+ requestToMatch = urlObj.href;
+ }
+
+ const cachedResponse = await caches.match(requestToMatch, { cacheName: CACHE_NAME, ignoreSearch: true });
+ if (cachedResponse)
+ return cachedResponse;
+
+ try {
+ return await fetch(event.request);
+ } catch (error) {
+ WORKER_STATE.failedRequests.add(event.request.url);
+ return Response.error();
+ }
+ })()
+ );
+});
diff --git a/tests/index.html b/tests/index.html
index e7d5ad9b7d..5c3abd7f4a 100644
--- a/tests/index.html
+++ b/tests/index.html
@@ -29,7 +29,10 @@
const cacheBust = Date.now();
await import("./unittests/benchmark-runner.mjs?t=" + cacheBust);
+ await import("./unittests/benchmark-configurator.mjs?t=" + cacheBust);
await import("./unittests/params.mjs?t=" + cacheBust);
+ await import("./unittests/preloader.mjs?t=" + cacheBust);
+ await import("./unittests/request-limiter.mjs?t=" + cacheBust);
await import("./unittests/suites.mjs?t=" + cacheBust);
globalThis.testResults = undefined;
diff --git a/tests/run-end2end.mjs b/tests/run-end2end.mjs
index d36b3b18d0..81478e64fc 100644
--- a/tests/run-end2end.mjs
+++ b/tests/run-end2end.mjs
@@ -17,13 +17,50 @@ async function testPage(url) {
console.log(`Testing: ${url}`);
await driver.get(`http://localhost:${port}/${url}`);
- await driver.executeAsyncScript((callback) => {
- if (globalThis.benchmarkClient)
- callback();
- else
- globalThis.addEventListener("SpeedometerReady", () => callback(), { once: true });
+ const initResult = await driver.executeAsyncScript((callback) => {
+ if (globalThis.benchmarkClient?.isInitialized) {
+ callback({});
+ return;
+ }
+ if (globalThis.benchmarkClient?.hasFinished?.()) {
+ callback({
+ error: {
+ message: document.getElementById("invalid-score-text")?.textContent || "Benchmark failed during initialization",
+ },
+ });
+ return;
+ }
+ globalThis.addEventListener("SpeedometerReady", () => callback({}), { once: true });
+ globalThis.addEventListener("error", (event) => {
+ callback({
+ error: {
+ message: event.message || "Unknown Runtime Error",
+ source: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ stack: event.error?.stack,
+ },
+ });
+ });
+ globalThis.addEventListener("unhandledrejection", (e) => {
+ const reason = e.reason;
+ const message = reason instanceof Error ? reason.message : typeof reason === "object" && reason !== null ? JSON.stringify(reason) : String(reason);
+ callback({
+ error: {
+ message: `Unhandled Rejection: ${message}`,
+ stack: reason?.stack,
+ },
+ });
+ });
});
+ if (initResult?.error) {
+ const { error } = initResult;
+ const stackTrace = error.stack ? `\nBrowser Stack Trace:\n${error.stack}` : "";
+ const location = error.source ? ` at ${error.source}:${error.lineno}:${error.colno}` : "";
+ throw new Error(`${error.message}${location}${stackTrace}`);
+ }
+
console.log(" - Awaiting Benchmark");
const { error, metrics } = await driver.executeAsyncScript((callback) => {
globalThis.addEventListener(
@@ -35,24 +72,35 @@ async function testPage(url) {
{ once: true }
);
// Install error handlers to report page errors back to selenium.
- globalThis.addEventListener("error", (message, source, lineno, colno, error) =>
+ globalThis.addEventListener("error", (event) => {
callback({
- error: { message, source, lineno, colno, error },
- })
- );
+ error: {
+ message: event.message || "Unknown Runtime Error",
+ source: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ stack: event.error?.stack,
+ },
+ });
+ });
globalThis.addEventListener("unhandledrejection", (e) => {
+ const reason = e.reason;
+ const message = reason instanceof Error ? reason.message : typeof reason === "object" && reason !== null ? JSON.stringify(reason) : String(reason);
callback({
error: {
- message: e.reason.toString(),
- stack: e.reason?.stack,
+ message: `Unhandled Rejection: ${message}`,
+ stack: reason?.stack,
},
});
});
globalThis.benchmarkClient.start();
});
- if (error)
- throw new Error(error.message + (error?.stack ?? ""));
+ if (error) {
+ const stackTrace = error.stack ? `\nBrowser Stack Trace:\n${error.stack}` : "";
+ const location = error.source ? ` at ${error.source}:${error.lineno}:${error.colno}` : "";
+ throw new Error(`${error.message}${location}${stackTrace}`);
+ }
validateMetrics(metrics);
return metrics;
@@ -61,6 +109,7 @@ async function testPage(url) {
function validateMetrics(metrics) {
for (const [name, metric] of Object.entries(metrics))
validateMetric(name, metric);
+
assert(metrics.Geomean.mean > 0);
assert(metrics.Score.mean > 0);
}
@@ -97,6 +146,18 @@ async function testAll() {
assert(metrics.Score.values.length === 1);
}
+async function testPreloadEnabled() {
+ const metrics = await testPage("index.html?iterationCount=1&suites=Perf-Dashboard&preload=true");
+ assert("Perf-Dashboard" in metrics);
+ assert(metrics["Perf-Dashboard"].values.length === 1);
+}
+
+async function testPreloadDisabled() {
+ const metrics = await testPage("index.html?iterationCount=1&suites=Perf-Dashboard&preload=false");
+ assert("Perf-Dashboard" in metrics);
+ assert(metrics["Perf-Dashboard"].values.length === 1);
+}
+
async function testDeveloperMode() {
const params = ["developerMode", "iterationCount=1", "warmupBeforeSync=2", "waitBeforeSync=2", "shuffleSeed=123", "suites=Perf-Dashboard"];
const metrics = await testPage(`index.html?${params.join("&")}`);
@@ -110,6 +171,115 @@ async function testDeveloperMode() {
});
}
+async function testDeveloperModePreload() {
+ const url = "index.html?developerMode=true&iterationCount=1&suites=Perf-Dashboard";
+ console.log(`Testing: ${url} (enabling preload via Developer Mode checkbox)`);
+ await driver.get(`http://localhost:${port}/${url}`);
+
+ const initResult = await driver.executeAsyncScript((callback) => {
+ if (globalThis.benchmarkClient?.isInitialized) {
+ callback({});
+ return;
+ }
+ globalThis.addEventListener("SpeedometerReady", () => callback({}), { once: true });
+ globalThis.addEventListener("error", (event) => {
+ callback({ error: { message: event.message || "Unknown Runtime Error", stack: event.error?.stack } });
+ });
+ globalThis.addEventListener("unhandledrejection", (e) => {
+ const reason = e.reason;
+ callback({ error: { message: `Unhandled Rejection: ${reason?.message || String(reason)}`, stack: reason?.stack } });
+ });
+ });
+
+ if (initResult?.error)
+ throw new Error(`${initResult.error.message}${initResult.error.stack ? `\n${initResult.error.stack}` : ""}`);
+
+ console.log(" - Toggling preload checkbox and awaiting Benchmark");
+ const result = await driver.executeAsyncScript((callback) => {
+ const labels = Array.from(document.querySelectorAll(".developer-mode label"));
+ const preloadLabel = labels.find((label) => label.textContent.includes("Use service worker for resource preloading"));
+ const preloadCheckbox = preloadLabel?.querySelector('input[type="checkbox"]');
+ if (!preloadCheckbox || preloadCheckbox.checked) {
+ callback({ error: { message: `Unexpected initial preload checkbox state: ${preloadCheckbox?.checked}` } });
+ return;
+ }
+
+ const preloadProgressEl = document.getElementById("preload-progress");
+ const preloadInfoEl = document.getElementById("preload-info");
+ const initialProgressHidden = getComputedStyle(preloadProgressEl).display === "none" && getComputedStyle(preloadInfoEl).display === "none";
+
+ preloadCheckbox.click();
+ if (!preloadCheckbox.checked || !window.location.search.includes("preload=true")) {
+ callback({ error: { message: `Preload checkbox click did not update state or URL: checked=${preloadCheckbox.checked}, search=${window.location.search}` } });
+ return;
+ }
+
+ const seenStates = [];
+ let preloadProgressVisibleDuringPreload = false;
+ let startButtonDisabledDuringPreload = false;
+ const observer = new MutationObserver(() => {
+ const state = document.body.getAttribute("data-benchmark-state");
+ seenStates.push(state);
+ if (state === "PRELOADING") {
+ preloadProgressVisibleDuringPreload = getComputedStyle(preloadProgressEl).display !== "none" && getComputedStyle(preloadInfoEl).display !== "none";
+ startButtonDisabledDuringPreload = document.querySelector(".start-tests-button")?.disabled === true;
+ }
+ });
+ observer.observe(document.body, { attributes: true, attributeFilter: ["data-benchmark-state"] });
+
+ let preloadResult = null;
+ const preloader = globalThis.benchmarkClient._resourcePreloader;
+ const origPreloadSuites = preloader.preloadSuites.bind(preloader);
+ preloader.preloadSuites = async (...args) => {
+ preloadResult = await origPreloadSuites(...args);
+ return preloadResult;
+ };
+
+ globalThis.addEventListener(
+ "SpeedometerDone",
+ () => {
+ observer.disconnect();
+ const preloadProgressHiddenAfterDone = getComputedStyle(preloadProgressEl).display === "none" && getComputedStyle(preloadInfoEl).display === "none";
+ callback({
+ metrics: globalThis.benchmarkClient.metrics,
+ initialProgressHidden,
+ seenStates,
+ preloadProgressVisibleDuringPreload,
+ startButtonDisabledDuringPreload,
+ preloadProgressHiddenAfterDone,
+ preloadResult,
+ });
+ },
+ { once: true }
+ );
+ globalThis.addEventListener("error", (event) => {
+ observer.disconnect();
+ callback({ error: { message: event.message || "Unknown Runtime Error", stack: event.error?.stack } });
+ });
+ globalThis.addEventListener("unhandledrejection", (e) => {
+ observer.disconnect();
+ const reason = e.reason;
+ callback({ error: { message: `Unhandled Rejection: ${reason?.message || String(reason)}`, stack: reason?.stack } });
+ });
+
+ globalThis.benchmarkClient.start();
+ });
+
+ if (result?.error)
+ throw new Error(`${result.error.message}${result.error.stack ? `\n${result.error.stack}` : ""}`);
+
+ assert(result.initialProgressHidden, "Expected #preload-progress and #preload-info to be hidden before preloading");
+ assert(result.seenStates.includes("PRELOADING"), `Expected PRELOADING state, saw: ${result.seenStates.join(", ")}`);
+ assert(result.preloadProgressVisibleDuringPreload, "Expected #preload-progress and #preload-info to be visible during PRELOADING");
+ assert(result.startButtonDisabledDuringPreload, "Expected .start-tests-button to be disabled during PRELOADING");
+ assert(result.preloadProgressHiddenAfterDone, "Expected #preload-progress and #preload-info to be hidden after DONE");
+ assert(result.preloadResult?.status === "PRELOAD_DONE" && result.preloadResult?.count > 0, "Expected Service Worker preloadSuites to finish with PRELOAD_DONE");
+
+ validateMetrics(result.metrics);
+ assert("Perf-Dashboard" in result.metrics);
+ assert(result.metrics["Perf-Dashboard"].values.length === 1);
+}
+
async function test() {
try {
await testWithRetries(retry);
@@ -140,7 +310,10 @@ async function tryTests() {
await driver.manage().setTimeouts({ script: 60000 });
await testIterations();
await testAll();
+ await testPreloadEnabled();
+ await testPreloadDisabled();
await testDeveloperMode();
+ await testDeveloperModePreload();
console.log("\nTests complete!");
}
diff --git a/tests/unittests/benchmark-configurator.mjs b/tests/unittests/benchmark-configurator.mjs
new file mode 100644
index 0000000000..82585db5f1
--- /dev/null
+++ b/tests/unittests/benchmark-configurator.mjs
@@ -0,0 +1,42 @@
+import { BenchmarkConfigurator } from "../../resources/benchmark-configurator.mjs";
+
+describe("BenchmarkConfigurator", () => {
+ it("should set measurePrepare to false on processed suites if not present", async () => {
+ const configurator = new BenchmarkConfigurator();
+ await configurator.init();
+ const suites = configurator.suites;
+ expect(suites.length).to.be.greaterThan(0);
+ suites.forEach((suite) => {
+ expect(suite).to.have.property("measurePrepare");
+ expect(suite.measurePrepare).to.be.a("boolean");
+ });
+ });
+
+ it("should validate URLs correctly in _isValidUrl", () => {
+ const configurator = new BenchmarkConfigurator();
+ expect(configurator._isValidUrl("http://example.com")).to.be(true);
+ expect(configurator._isValidUrl("suites/todomvc/index.html")).to.be(true);
+ expect(configurator._isValidUrl("")).to.be(false);
+ expect(configurator._isValidUrl(null)).to.be(false);
+ });
+
+ it("should enable suites by names", async () => {
+ const configurator = new BenchmarkConfigurator();
+ await configurator.init();
+ const firstSuiteName = configurator.suites[0].name;
+ configurator.enableSuites([firstSuiteName], []);
+ expect(configurator.suites[0].enabled).to.be(true);
+ // Assuming there are multiple suites
+ expect(configurator.suites[1].enabled).to.be(false);
+ });
+
+ it("should enable suites by tags", async () => {
+ const configurator = new BenchmarkConfigurator();
+ await configurator.init();
+ configurator.enableSuites([], ["todomvc"]);
+ const todomvcSuites = configurator.suites.filter((suite) => suite.tags.includes("todomvc"));
+ const otherSuites = configurator.suites.filter((suite) => !suite.tags.includes("todomvc"));
+ todomvcSuites.forEach((suite) => expect(suite.enabled).to.be(true));
+ otherSuites.forEach((suite) => expect(suite.enabled).to.be(false));
+ });
+});
diff --git a/tests/unittests/params.mjs b/tests/unittests/params.mjs
index 0b3821445c..b236f01e4c 100644
--- a/tests/unittests/params.mjs
+++ b/tests/unittests/params.mjs
@@ -74,6 +74,22 @@ describe("Params", () => {
);
expect(params.toSearchParams()).to.equal("suites=");
});
+ it("should contain preload when enabled", () => {
+ const params = new Params(
+ new URLSearchParams({
+ preload: "true",
+ })
+ );
+ expect(params.toSearchParams()).to.equal("preload=true");
+ });
+ it("should not contain preload when disabled", () => {
+ const params = new Params(
+ new URLSearchParams({
+ preload: "false",
+ })
+ );
+ expect(params.toSearchParams()).to.equal("");
+ });
});
describe("isDefault", () => {
it("should return true for defaultParams", () => {
@@ -94,6 +110,14 @@ describe("Params", () => {
});
describe("parse input params", () => {
+ it("should parse preload boolean param", () => {
+ expect(new Params(new URLSearchParams("preload")).preload).to.be(true);
+ expect(new Params(new URLSearchParams({ preload: "true" })).preload).to.be(true);
+ expect(new Params(new URLSearchParams({ preload: "1" })).preload).to.be(true);
+ expect(new Params(new URLSearchParams({ preload: "false" })).preload).to.be(false);
+ expect(new Params(new URLSearchParams({ preload: "FALSE" })).preload).to.be(false);
+ expect(new Params(new URLSearchParams({ preload: "0" })).preload).to.be(false);
+ });
it("should parse custom viewport", () => {
const params = new Params(
new URLSearchParams({
diff --git a/tests/unittests/preloader.mjs b/tests/unittests/preloader.mjs
new file mode 100644
index 0000000000..37c90975a8
--- /dev/null
+++ b/tests/unittests/preloader.mjs
@@ -0,0 +1,305 @@
+import { ResourcePreloader } from "../../resources/preloader.mjs";
+import { Params } from "../../resources/shared/params.mjs";
+import { SW_MESSAGES } from "../../resources/shared/sw-messages.mjs";
+
+const TEST_SUITES = [
+ {
+ name: "SuiteA",
+ url: "suites/suite-a/index.html",
+ resources: "suites/suite-a/resources.txt",
+ enabled: true,
+ },
+ {
+ name: "SuiteB",
+ url: "suites/suite-b/index.html",
+ resources: "suites/suite-b/resources.txt",
+ enabled: true,
+ },
+];
+
+function createMutableParams(search = "developerMode=true") {
+ return new Params(new URLSearchParams(search));
+}
+
+function createMockServiceWorkerEnv(scopeUrl) {
+ const listeners = new Map();
+ const activeWorker = {
+ postMessage: sinon.stub(),
+ };
+ const registration = {
+ scope: scopeUrl,
+ active: activeWorker,
+ unregister: sinon.stub().resolves(true),
+ };
+ const serviceWorkerContainer = {
+ controller: activeWorker,
+ ready: Promise.resolve(registration),
+ register: sinon.stub().resolves(registration),
+ getRegistrations: sinon.stub().resolves([registration]),
+ addEventListener: sinon.stub().callsFake((type, fn) => {
+ listeners.set(type, fn);
+ }),
+ removeEventListener: sinon.stub().callsFake((type, fn) => {
+ if (listeners.get(type) === fn)
+ listeners.delete(type);
+ }),
+ emitMessage(data) {
+ listeners.get("message")?.({ data });
+ },
+ };
+ return { serviceWorkerContainer, registration, activeWorker, listeners };
+}
+
+describe("ResourcePreloader", () => {
+ let createdWindow = false;
+ let hadOwnServiceWorker = false;
+ let originalServiceWorkerDescriptor;
+
+ function setNavigatorServiceWorker(value) {
+ Object.defineProperty(globalThis.navigator, "serviceWorker", {
+ value,
+ configurable: true,
+ writable: true,
+ });
+ }
+
+ beforeEach(() => {
+ if (typeof globalThis.window === "undefined") {
+ globalThis.window = {
+ location: {
+ href: "http://localhost:8080/index.html",
+ },
+ };
+ createdWindow = true;
+ }
+ hadOwnServiceWorker = Object.prototype.hasOwnProperty.call(globalThis.navigator, "serviceWorker");
+ originalServiceWorkerDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "serviceWorker");
+ });
+
+ afterEach(() => {
+ if (hadOwnServiceWorker && originalServiceWorkerDescriptor)
+ Object.defineProperty(globalThis.navigator, "serviceWorker", originalServiceWorkerDescriptor);
+ else
+ delete globalThis.navigator.serviceWorker;
+
+ if (createdWindow) {
+ delete globalThis.window;
+ createdWindow = false;
+ }
+ });
+
+ it("should not throw in non-secure contexts where navigator.serviceWorker is undefined", async () => {
+ setNavigatorServiceWorker(undefined);
+ const testParams = createMutableParams("developerMode=true&preload=true");
+ const preloader = new ResourcePreloader(testParams);
+
+ await preloader.setup();
+ expect(preloader._registration).to.be(null);
+ expect(preloader._sw).to.be(null);
+
+ const preloadResult = await preloader.preloadSuites(TEST_SUITES);
+ expect(preloadResult).to.be(undefined);
+ expect(preloader.isCached(TEST_SUITES)).to.be(false);
+
+ await preloader.teardown();
+ });
+
+ it("should not touch navigator.serviceWorker on setup() when params.preload is false", async () => {
+ const expectedScope = new URL("./", window.location.href).href;
+ const { serviceWorkerContainer } = createMockServiceWorkerEnv(expectedScope);
+ setNavigatorServiceWorker(serviceWorkerContainer);
+
+ const testParams = createMutableParams("developerMode=true&preload=false");
+ const preloader = new ResourcePreloader(testParams);
+
+ await preloader.setup();
+ expect(serviceWorkerContainer.register.called).to.be(false);
+ expect(serviceWorkerContainer.getRegistrations.called).to.be(false);
+ expect(serviceWorkerContainer.addEventListener.called).to.be(false);
+ });
+
+ it("should lazily set up Service Worker when params.preload is toggled from false to true, and tear down when toggled back to false", async () => {
+ const expectedScope = new URL("./", window.location.href).href;
+ const { serviceWorkerContainer, registration, activeWorker } = createMockServiceWorkerEnv(expectedScope);
+ setNavigatorServiceWorker(serviceWorkerContainer);
+
+ const testParams = createMutableParams("developerMode=true&preload=false");
+ const preloader = new ResourcePreloader(testParams);
+
+ // Initial setup with preload=false does not register SW.
+ await preloader.setup();
+ expect(serviceWorkerContainer.register.called).to.be(false);
+
+ // Toggle preload to true (as in Developer Mode) and call preloadSuites().
+ testParams.preload = true;
+ activeWorker.postMessage.callsFake((msg) => {
+ if (msg.type === SW_MESSAGES.PRELOAD_SUITES) {
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.PRELOAD_SUITES,
+ status: SW_MESSAGES.PRELOAD_DONE,
+ requestId: msg.requestId,
+ transferredSize: 1024,
+ count: 2,
+ });
+ }
+ });
+
+ await preloader.preloadSuites(TEST_SUITES);
+ expect(serviceWorkerContainer.register.calledOnce).to.be(true);
+ expect(preloader.isCached(TEST_SUITES)).to.be(true);
+
+ // Toggle preload back to false: preloadSuites() should tear down registration and skip preloading.
+ testParams.preload = false;
+ activeWorker.postMessage.resetHistory();
+ await preloader.preloadSuites(TEST_SUITES);
+
+ expect(registration.unregister.calledOnce).to.be(true);
+ expect(activeWorker.postMessage.called).to.be(false);
+ expect(preloader._registration).to.be(null);
+ expect(preloader._sw).to.be(null);
+ expect(preloader.isCached(TEST_SUITES)).to.be(false);
+ });
+
+ it("should only unregister Service Worker registrations matching the current scope", async () => {
+ const expectedScope = new URL("./", window.location.href).href;
+ const otherScope = new URL("/other-benchmark/", window.location.href).href;
+ const { serviceWorkerContainer, registration, activeWorker } = createMockServiceWorkerEnv(expectedScope);
+
+ const otherRegistration = {
+ scope: otherScope,
+ unregister: sinon.stub().resolves(true),
+ };
+ serviceWorkerContainer.getRegistrations.resolves([otherRegistration, registration]);
+ setNavigatorServiceWorker(serviceWorkerContainer);
+
+ activeWorker.postMessage.callsFake((msg) => {
+ if (msg.type === SW_MESSAGES.STOP_PRELOADING) {
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.STOP_PRELOADING,
+ status: SW_MESSAGES.STOP_PRELOADING,
+ requestId: msg.requestId,
+ });
+ } else if (msg.type === SW_MESSAGES.GET_FAILED_REQUESTS) {
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.GET_FAILED_REQUESTS,
+ status: SW_MESSAGES.FAILED_REQUESTS,
+ requestId: msg.requestId,
+ requests: [],
+ });
+ }
+ });
+
+ const testParams = createMutableParams("developerMode=true&preload=true");
+ const preloader = new ResourcePreloader(testParams);
+ await preloader.setup();
+ await preloader.teardown();
+
+ expect(registration.unregister.calledOnce).to.be(true);
+ expect(otherRegistration.unregister.called).to.be(false);
+ });
+
+ it("should ignore stale PRELOAD_PROGRESS and PRELOAD_DONE messages from an earlier requestId", async () => {
+ const expectedScope = new URL("./", window.location.href).href;
+ const { serviceWorkerContainer, activeWorker } = createMockServiceWorkerEnv(expectedScope);
+ setNavigatorServiceWorker(serviceWorkerContainer);
+
+ const testParams = createMutableParams("developerMode=true&preload=true");
+ const preloader = new ResourcePreloader(testParams);
+ await preloader.setup();
+
+ let firstPreloadRequestId = null;
+ activeWorker.postMessage.callsFake((msg) => {
+ if (msg.type === SW_MESSAGES.PRELOAD_SUITES && firstPreloadRequestId === null) {
+ firstPreloadRequestId = msg.requestId;
+ // Do not reply immediately; simulate in-flight preload that gets stopped.
+ } else if (msg.type === SW_MESSAGES.STOP_PRELOADING) {
+ // Before STOP_PRELOADING replies, emit stale PRELOAD_PROGRESS and PRELOAD_DONE from the first request!
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.PRELOAD_SUITES,
+ status: SW_MESSAGES.PRELOAD_PROGRESS,
+ requestId: firstPreloadRequestId,
+ loaded: 1,
+ total: 2,
+ url: "suites/suite-a/app.js",
+ suiteName: "SuiteA",
+ });
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.PRELOAD_SUITES,
+ status: SW_MESSAGES.PRELOAD_DONE,
+ requestId: firstPreloadRequestId,
+ transferredSize: 512,
+ count: 2,
+ });
+ // Now reply to STOP_PRELOADING with its matching requestId.
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.STOP_PRELOADING,
+ status: SW_MESSAGES.STOP_PRELOADING,
+ requestId: msg.requestId,
+ });
+ }
+ });
+
+ const progressSpy = sinon.spy();
+ const preloadPromise = preloader.preloadSuites(TEST_SUITES, true, progressSpy);
+ const stopPromise = preloader.stopPreloading();
+
+ const [preloadResult] = await Promise.all([preloadPromise, stopPromise]);
+ expect(preloadResult).to.be(null);
+
+ // Emit another late message after no request is pending: must not throw.
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.PRELOAD_SUITES,
+ status: SW_MESSAGES.PRELOAD_DONE,
+ requestId: firstPreloadRequestId,
+ transferredSize: 512,
+ count: 2,
+ });
+ });
+
+ it("should return false from isCached(enabledSuites) when stopped early or when enabled suites change", async () => {
+ const expectedScope = new URL("./", window.location.href).href;
+ const { serviceWorkerContainer, activeWorker } = createMockServiceWorkerEnv(expectedScope);
+ setNavigatorServiceWorker(serviceWorkerContainer);
+
+ const testParams = createMutableParams("developerMode=true&preload=true");
+ const preloader = new ResourcePreloader(testParams);
+ await preloader.setup();
+
+ // 1. Preload stopped early -> isCached must be false.
+ activeWorker.postMessage.callsFake((msg) => {
+ if (msg.type === SW_MESSAGES.STOP_PRELOADING) {
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.STOP_PRELOADING,
+ status: SW_MESSAGES.STOP_PRELOADING,
+ requestId: msg.requestId,
+ });
+ }
+ });
+
+ const abortedPreload = preloader.preloadSuites(TEST_SUITES);
+ await preloader.stopPreloading();
+ const abortedResult = await abortedPreload;
+ expect(abortedResult).to.be(null);
+ expect(preloader.isCached(TEST_SUITES)).to.be(false);
+
+ // 2. Full preload succeeds -> isCached is true for the same suites, false if enabled suites change.
+ activeWorker.postMessage.callsFake((msg) => {
+ if (msg.type === SW_MESSAGES.PRELOAD_SUITES) {
+ serviceWorkerContainer.emitMessage({
+ type: SW_MESSAGES.PRELOAD_SUITES,
+ status: SW_MESSAGES.PRELOAD_DONE,
+ requestId: msg.requestId,
+ transferredSize: 2048,
+ count: 2,
+ });
+ }
+ });
+
+ await preloader.preloadSuites(TEST_SUITES);
+ expect(preloader.isCached(TEST_SUITES)).to.be(true);
+
+ // Changing enabled suites (e.g. toggling a suite checkbox in Developer Mode) invalidates cache.
+ const subsetSuites = [TEST_SUITES[0]];
+ expect(preloader.isCached(subsetSuites)).to.be(false);
+ });
+});
diff --git a/tests/unittests/request-limiter.mjs b/tests/unittests/request-limiter.mjs
new file mode 100644
index 0000000000..d8c3cda7aa
--- /dev/null
+++ b/tests/unittests/request-limiter.mjs
@@ -0,0 +1,108 @@
+import { RequestLimiter } from "../../resources/shared/request-limiter.mjs";
+
+describe("RequestLimiter", () => {
+ it("should process requests within the limit concurrently", async () => {
+ const limiter = new RequestLimiter(2);
+ let active = 0;
+ let maxActive = 0;
+
+ const task = async () => {
+ active++;
+ maxActive = Math.max(maxActive, active);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ active--;
+ };
+
+ const p1 = limiter.schedule(task);
+ const p2 = limiter.schedule(task);
+ const p3 = limiter.schedule(task);
+
+ await Promise.all([p1, p2, p3]);
+
+ expect(maxActive).to.be(2);
+ expect(limiter._active).to.be(0);
+ });
+
+ it("should resolve with the correct value", async () => {
+ const limiter = new RequestLimiter(1);
+ const p = limiter.schedule(async () => "result");
+ const result = await p;
+ expect(result).to.be("result");
+ });
+
+ it("should reject if the task throws", async () => {
+ const limiter = new RequestLimiter(1);
+ const p = limiter.schedule(async () => {
+ throw new Error("task failed");
+ });
+
+ let threw = false;
+ try {
+ await p;
+ } catch (e) {
+ threw = true;
+ expect(e.message).to.be("task failed");
+ }
+ expect(threw).to.be(true);
+ // Wait for microtasks to flush so _processQueue can decrement _active
+ await new Promise((r) => setTimeout(r, 0));
+ expect(limiter._active).to.be(0);
+ });
+
+ it("should continue processing queue if a task rejects", async () => {
+ const limiter = new RequestLimiter(1);
+
+ const p1 = limiter.schedule(async () => {
+ throw new Error("failed");
+ });
+ const p2 = limiter.schedule(async () => "success");
+
+ try {
+ await p1;
+ } catch (e) {
+ // expected to throw
+ }
+
+ const res2 = await p2;
+ expect(res2).to.be("success");
+ });
+
+ it("should clear the queue and resolve pending requests with 0", async () => {
+ const limiter = new RequestLimiter(1);
+ let executed = 0;
+
+ const p1 = limiter.schedule(async () => {
+ executed++;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ return 1;
+ });
+
+ const p2 = limiter.schedule(async () => {
+ executed++;
+ return 2;
+ });
+
+ const p3 = limiter.schedule(async () => {
+ executed++;
+ return 3;
+ });
+
+ limiter.clear();
+
+ const res1 = await p1;
+ const res2 = await p2;
+ const res3 = await p3;
+
+ // p1 already started, so it finishes normally
+ expect(res1).to.be(1);
+ // p2 and p3 were cleared, so they resolve with 0
+ expect(res2).to.be(0);
+ expect(res3).to.be(0);
+
+ // only p1 actually ran
+ expect(executed).to.be(1);
+
+ // eventually active count resets when p1 completes
+ expect(limiter._active).to.be(0);
+ });
+});