From c5f5c30b28ff688434d01487ee5998f7dbcad1e1 Mon Sep 17 00:00:00 2001 From: Tom Hudson Date: Sun, 15 Mar 2026 18:07:20 -0400 Subject: [PATCH 01/18] Add internet stability test feature Add a prolonged ping-based stability test with real-time canvas chart, stats (avg/min/max/jitter/packet loss), stability rating, external ping targets, CSV export, and Docker support. Link from main page to stability test. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 1 + Dockerfile.alpine | 1 + docker/entrypoint.sh | 1 + docker/ui.php | 2 +- package.json | 6 +- stability.html | 793 +++++++++++++++++++++++++++++++++++++++++++ stability_worker.js | 216 ++++++++++++ 7 files changed, 1017 insertions(+), 3 deletions(-) create mode 100644 stability.html create mode 100644 stability_worker.js diff --git a/Dockerfile b/Dockerfile index 365f1b699..097d590a1 100755 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ COPY docker/servers.json /servers.json diff --git a/Dockerfile.alpine b/Dockerfile.alpine index e513e88a7..755a9259c 100755 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -34,6 +34,7 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ COPY docker/servers.json /servers.json diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4e63e91c6..c922a0fe6 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,6 +12,7 @@ rm -rf /var/www/html/* # Copy frontend files cp /speedtest/*.js /var/www/html/ +cp /speedtest/stability.html /var/www/html/ # Copy favicon cp /speedtest/favicon.ico /var/www/html/ diff --git a/docker/ui.php b/docker/ui.php index 7310d2af6..62001dd01 100755 --- a/docker/ui.php +++ b/docker/ui.php @@ -481,7 +481,7 @@ function initUI(){ - Source code + Stability Test | Source code
-
Loss
+
Failed Requests
%
@@ -813,7 +813,7 @@

LibreSpeed - Stability Test

ctx.lineJoin = "round"; ctx.stroke(); - // draw lost packet markers + // draw failed request markers for (var i = 0; i < visible.length; i++) { if (!visible[i].lost) continue; var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW; @@ -879,7 +879,7 @@

LibreSpeed - Stability Test

// CSV download function downloadCsv() { if (allPingData.length === 0) return; - var csv = "elapsed_s,ping_ms,lost\n"; + var csv = "elapsed_s,ping_ms,failed\n"; for (var i = 0; i < allPingData.length; i++) { var d = allPingData[i]; csv += d.t.toFixed(3) + "," + d.ping.toFixed(2) + "," + (d.lost ? "1" : "0") + "\n"; diff --git a/stability_worker.js b/stability_worker.js index 46f591699..e96461881 100644 --- a/stability_worker.js +++ b/stability_worker.js @@ -11,7 +11,7 @@ let avgPing = 0; let minPing = -1; let maxPing = 0; let jitter = 0; -let packetLoss = 0; +let packetLoss = 0; // failed request percentage let elapsed = 0; let progress = 0; @@ -121,7 +121,7 @@ function recordPing(instspd) { } prevInstspd = instspd; - // packet loss + // failed request percentage packetLoss = totalSamples > 0 ? parseFloat(((failedSamples / totalSamples) * 100).toFixed(2)) : 0; // record data point From a9197302b0893fb62d51526b8e057058a8df1a86 Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 15:22:06 +0200 Subject: [PATCH 06/18] Fix stability test server list and timeouts --- docker/entrypoint.sh | 1 + stability.html | 53 ++++++++++++++++++++++++++++++++------------ stability_worker.js | 22 ++++++++++++++++-- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bff69085a..57293a3db 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -96,6 +96,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g') sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/index-modern.html sed -i "s/var SPEEDTEST_SERVERS = \\[/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";\\n\\t\\t\\/\\*/" /var/www/html/index-classic.html + sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/stability.html fi # The stability page reads the same local server list as the main UI when present. diff --git a/stability.html b/stability.html index 60b90cc14..28a569352 100644 --- a/stability.html +++ b/stability.html @@ -357,7 +357,8 @@

LibreSpeed - Stability Test

} // Server configuration (same pattern as index.html) - var SPEEDTEST_SERVERS = []; + // Set this to a different URL to load the server list from another location. + var SPEEDTEST_SERVERS = "server-list.json"; // State var worker = null; @@ -394,30 +395,51 @@

LibreSpeed - Stability Test

// Load server list dynamically (Docker exposes server-list.json; older setups may expose servers.json) function loadServers(callback) { + if (Array.isArray(SPEEDTEST_SERVERS)) { + callback(); + return; + } + + var serverListUrl = typeof SPEEDTEST_SERVERS === "string" ? SPEEDTEST_SERVERS : ""; + if (!serverListUrl) { + SPEEDTEST_SERVERS = []; + callback(); + return; + } + var xhr = new XMLHttpRequest(); xhr.onload = function () { + var loadedServers = null; try { var servers = JSON.parse(xhr.responseText); - if (Array.isArray(servers) && servers.length > 0) { - SPEEDTEST_SERVERS = servers; + if (Array.isArray(servers)) { + loadedServers = servers; } } catch (e) {} - if (SPEEDTEST_SERVERS.length > 0 || xhr._fallbackTried) callback(); - else { + + if (loadedServers !== null) { + SPEEDTEST_SERVERS = loadedServers; + callback(); + } else if (!xhr._fallbackTried) { xhr._fallbackTried = true; xhr.open("GET", "servers.json?r=" + Math.random()); xhr.send(); + } else { + SPEEDTEST_SERVERS = []; + callback(); } }; xhr.onerror = function () { - if (xhr._fallbackTried) callback(); - else { + if (xhr._fallbackTried) { + SPEEDTEST_SERVERS = []; + callback(); + } else { xhr._fallbackTried = true; xhr.open("GET", "servers.json?r=" + Math.random()); xhr.send(); } }; - xhr.open("GET", "server-list.json?r=" + Math.random()); + xhr.open("GET", serverListUrl + (serverListUrl.match(/\?/) ? "&" : "?") + "r=" + Math.random()); try { xhr.timeout = 2000; xhr.ontimeout = xhr.onerror; @@ -515,8 +537,10 @@

LibreSpeed - Stability Test

workerSettings.mpot = true; } - worker = new Worker("stability_worker.js?r=" + Math.random()); + var currentWorker = new Worker("stability_worker.js?r=" + Math.random()); + worker = currentWorker; worker.onmessage = function (e) { + if (worker !== currentWorker) return; var data = JSON.parse(e.data); latestData = data; @@ -554,12 +578,13 @@

LibreSpeed - Stability Test

} // request final status if (worker) { - worker.postMessage("status"); + var stoppedWorker = worker; + stoppedWorker.postMessage("status"); setTimeout(function () { - if (worker) { - try { - worker.terminate(); - } catch (e) {} + try { + stoppedWorker.terminate(); + } catch (e) {} + if (worker === stoppedWorker) { worker = null; } }, 500); diff --git a/stability_worker.js b/stability_worker.js index e96461881..0dd47b3a8 100644 --- a/stability_worker.js +++ b/stability_worker.js @@ -26,6 +26,7 @@ let settings = { url_ping_external: "", // external URL to ping (uses fetch no-cors, e.g. "https://www.google.com/generate_204") duration: 60, // seconds ping_interval: 200, // minimum ms between pings to limit sample rate + ping_timeout: 5000, ping_allowPerformanceApi: true, mpot: false }; @@ -202,7 +203,7 @@ function doPing() { true ); try { - xhr.timeout = 5000; + xhr.timeout = settings.ping_timeout; } catch (e) {} xhr.send(); } @@ -210,16 +211,33 @@ function doPing() { // ping an external host using fetch with no-cors (opaque response, but timing still works) function doPingExternal() { const prevT = new Date().getTime(); + const remainingMs = Math.max(1, settings.duration * 1000 - (prevT - startTime)); + const timeoutMs = Math.min(settings.ping_timeout, remainingMs); const url = settings.url_ping_external + (settings.url_ping_external.indexOf("?") >= 0 ? "&" : "?") + "r=" + Math.random(); - fetch(url, { mode: "no-cors", cache: "no-store" }) + + let timeoutId = null; + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const fetchOptions = { mode: "no-cors", cache: "no-store" }; + if (controller) fetchOptions.signal = controller.signal; + + const timeout = new Promise(function (_, reject) { + timeoutId = setTimeout(function () { + if (controller) controller.abort(); + reject(new Error("timeout")); + }, timeoutMs); + }); + + Promise.race([fetch(url, fetchOptions), timeout]) .then(function () { + if (timeoutId) clearTimeout(timeoutId); if (aborted || testState >= 4) return; const instspd = new Date().getTime() - prevT; recordPing(instspd); schedulePing(instspd); }) .catch(function () { + if (timeoutId) clearTimeout(timeoutId); if (aborted || testState >= 4) return; recordLoss(); schedulePing(0); From 94104e9bb326369d9ccfb4a35175bf73066d6d4f Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 17:23:34 +0200 Subject: [PATCH 07/18] Update README for stability test --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00c21e004..9525ea536 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Works with mobile versions too. * Telemetry (optional) * Results sharing (optional) * Multiple Points of Test (optional) +* Connection stability test with latency charting, loss tracking, threshold alerts, and CSV export ![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v7.gif) @@ -40,7 +41,7 @@ Works with mobile versions too. Assuming you have PHP and a web server installed, the installation steps are quite simple. 1. Download the source code and extract it -1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `favicon.ico`, and the `backend` folder. +1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `stability.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `stability_worker.js`, `favicon.ico`, and the `backend` folder. 1. Also copy the contents of `frontend/` into the same web root so the modern UI assets end up in `styling/`, `javascript/`, `images/`, and `fonts/` next to the HTML files. 1. Optionally, copy the results folder too, and set up the database using the config file in it. 1. Be sure your permissions allow read and execute access where needed. @@ -72,6 +73,12 @@ If you want to contribute or develop with LibreSpeed, see [DEVELOPMENT.md](DEVEL LibreSpeed supports both the classic and modern UI. The root `index.html` acts as a lightweight switcher and redirects to `index-classic.html` or `index-modern.html` based on `config.json` (`useNewDesign`) or URL overrides (`?design=new` / `?design=old`). For architecture and deployment details (including Docker behavior), see [DESIGN_SWITCH.md](DESIGN_SWITCH.md). +## Stability test + +LibreSpeed includes a standalone connection stability test at `stability.html`, linked from both the classic and modern interfaces. It repeatedly measures ping over a selected duration and reports current, average, minimum, maximum, jitter, and failed request percentage values with a live chart. + +The stability test can target the local LibreSpeed backend, one of the configured multiple points of test, or built-in external targets such as Google, Cloudflare, and Apple. It also supports optional latency threshold alerts and CSV export of the collected samples. Docker deployments copy `stability.html` and `stability_worker.js` into the web root and reuse the same server list configuration as the main UI. + ## Docker A docker image is available on [GitHub](https://github.com/librespeed/speedtest/pkgs/container/speedtest), check our [docker documentation](doc_docker.md) for more info about it. From 4ec3a755fae94f818bdc5edcc81e9bfba9790dc7 Mon Sep 17 00:00:00 2001 From: valer23 Date: Fri, 17 Apr 2026 10:28:38 +0200 Subject: [PATCH 08/18] feat: sort server list by country name instead of city name Sort the server dropdown by country first, then by city within the same country. This makes it easier to find servers in a specific country when the list is long. Applies to both modern and classic UIs. --- frontend/javascript/index.js | 11 +++++++++++ index-classic.html | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index ba4eb0ead..146ae3d35 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -230,6 +230,17 @@ function populateDropdown(servers) { }); } + // Sort servers by country, then by city within the same country + servers.sort((a, b) => { + const commaA = a.name.lastIndexOf(","); + const commaB = b.name.lastIndexOf(","); + const countryA = commaA >= 0 ? a.name.substring(commaA + 1).trim() : a.name; + const countryB = commaB >= 0 ? b.name.substring(commaB + 1).trim() : b.name; + const cityA = commaA >= 0 ? a.name.substring(0, commaA).trim() : ""; + const cityB = commaB >= 0 ? b.name.substring(0, commaB).trim() : ""; + return countryA.localeCompare(countryB) || cityA.localeCompare(cityB); + }); + // Populate the list to choose from servers.forEach((server) => { const item = document.createElement("li"); diff --git a/index-classic.html b/index-classic.html index 19bf2e20b..7b5592684 100644 --- a/index-classic.html +++ b/index-classic.html @@ -50,6 +50,16 @@ s.selectServer(function (server) { if (server != null) { //at least 1 server is available I("loading").className = "hidden"; //hide loading message + //sort servers by country, then by city + SPEEDTEST_SERVERS.sort(function (a, b) { + var commaA = a.name.lastIndexOf(","); + var commaB = b.name.lastIndexOf(","); + var countryA = commaA >= 0 ? a.name.substring(commaA + 1).trim() : a.name; + var countryB = commaB >= 0 ? b.name.substring(commaB + 1).trim() : b.name; + var cityA = commaA >= 0 ? a.name.substring(0, commaA).trim() : ""; + var cityB = commaB >= 0 ? b.name.substring(0, commaB).trim() : ""; + return countryA.localeCompare(countryB) || cityA.localeCompare(cityB); + }); //populate server list for manual selection for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) { if (SPEEDTEST_SERVERS[i].pingT == -1) continue; From 42d700cf08417496f01331e2b917d5fb1b7047b6 Mon Sep 17 00:00:00 2001 From: valer23 Date: Fri, 17 Apr 2026 10:28:38 +0200 Subject: [PATCH 09/18] fix: avoid mutating original server array and add null guard Address code review findings: - Sort a shallow copy instead of mutating the caller's array - Add null guard on server.name to handle malformed entries - Use original SPEEDTEST_SERVERS index for classic UI option values --- frontend/javascript/index.js | 18 ++++++++++-------- index-classic.html | 26 ++++++++++++++------------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index 146ae3d35..e7f4aa450 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -231,18 +231,20 @@ function populateDropdown(servers) { } // Sort servers by country, then by city within the same country - servers.sort((a, b) => { - const commaA = a.name.lastIndexOf(","); - const commaB = b.name.lastIndexOf(","); - const countryA = commaA >= 0 ? a.name.substring(commaA + 1).trim() : a.name; - const countryB = commaB >= 0 ? b.name.substring(commaB + 1).trim() : b.name; - const cityA = commaA >= 0 ? a.name.substring(0, commaA).trim() : ""; - const cityB = commaB >= 0 ? b.name.substring(0, commaB).trim() : ""; + const sorted = [...servers].sort((a, b) => { + const nameA = a.name || ""; + const nameB = b.name || ""; + const commaA = nameA.lastIndexOf(","); + const commaB = nameB.lastIndexOf(","); + const countryA = commaA >= 0 ? nameA.substring(commaA + 1).trim() : nameA; + const countryB = commaB >= 0 ? nameB.substring(commaB + 1).trim() : nameB; + const cityA = commaA >= 0 ? nameA.substring(0, commaA).trim() : ""; + const cityB = commaB >= 0 ? nameB.substring(0, commaB).trim() : ""; return countryA.localeCompare(countryB) || cityA.localeCompare(cityB); }); // Populate the list to choose from - servers.forEach((server) => { + sorted.forEach((server) => { const item = document.createElement("li"); const link = document.createElement("a"); link.href = "#"; diff --git a/index-classic.html b/index-classic.html index 7b5592684..9a8c0fdd5 100644 --- a/index-classic.html +++ b/index-classic.html @@ -51,22 +51,24 @@ if (server != null) { //at least 1 server is available I("loading").className = "hidden"; //hide loading message //sort servers by country, then by city - SPEEDTEST_SERVERS.sort(function (a, b) { - var commaA = a.name.lastIndexOf(","); - var commaB = b.name.lastIndexOf(","); - var countryA = commaA >= 0 ? a.name.substring(commaA + 1).trim() : a.name; - var countryB = commaB >= 0 ? b.name.substring(commaB + 1).trim() : b.name; - var cityA = commaA >= 0 ? a.name.substring(0, commaA).trim() : ""; - var cityB = commaB >= 0 ? b.name.substring(0, commaB).trim() : ""; + var sortedServers = SPEEDTEST_SERVERS.slice().sort(function (a, b) { + var nameA = a.name || ""; + var nameB = b.name || ""; + var commaA = nameA.lastIndexOf(","); + var commaB = nameB.lastIndexOf(","); + var countryA = commaA >= 0 ? nameA.substring(commaA + 1).trim() : nameA; + var countryB = commaB >= 0 ? nameB.substring(commaB + 1).trim() : nameB; + var cityA = commaA >= 0 ? nameA.substring(0, commaA).trim() : ""; + var cityB = commaB >= 0 ? nameB.substring(0, commaB).trim() : ""; return countryA.localeCompare(countryB) || cityA.localeCompare(cityB); }); //populate server list for manual selection - for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) { - if (SPEEDTEST_SERVERS[i].pingT == -1) continue; + for (var i = 0; i < sortedServers.length; i++) { + if (sortedServers[i].pingT == -1) continue; var option = document.createElement("option"); - option.value = i; - option.textContent = SPEEDTEST_SERVERS[i].name; - if (SPEEDTEST_SERVERS[i] === server) option.selected = true; + option.value = SPEEDTEST_SERVERS.indexOf(sortedServers[i]); + option.textContent = sortedServers[i].name; + if (sortedServers[i] === server) option.selected = true; I("server").appendChild(option); } //show test UI From 69fa7a4edbb8dcdfa25c9e6c4071b886b7d6c7d3 Mon Sep 17 00:00:00 2001 From: valer23 Date: Fri, 17 Apr 2026 10:39:33 +0200 Subject: [PATCH 10/18] fix: handle parenthetical qualifiers and multi-comma server names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse server names more robustly for sorting: - "City, Country, Provider" → use second part as country - "City, Country (1) (Hetzner)" → strip parentheticals from country - "Frankfurt, Germany (FRA01)" → country is "Germany" not "Germany (FRA01)" --- frontend/javascript/index.js | 33 +++++++++++++++++++++++---------- index-classic.html | 30 +++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/frontend/javascript/index.js b/frontend/javascript/index.js index e7f4aa450..9935bd4c3 100644 --- a/frontend/javascript/index.js +++ b/frontend/javascript/index.js @@ -230,17 +230,30 @@ function populateDropdown(servers) { }); } - // Sort servers by country, then by city within the same country + // Sort servers by country, then by city within the same country. + // Name formats: "City, Country", "City, Country (qualifier)", "City, Country, Provider", "Country" + const parseServerName = (name) => { + const parts = (name || "").split(",").map((s) => s.trim()); + let country, city; + if (parts.length >= 3) { + // "City, Country, Provider" — use second part as country + country = parts[1]; + city = parts[0]; + } else if (parts.length === 2) { + country = parts[1]; + city = parts[0]; + } else { + country = parts[0]; + city = ""; + } + // Strip parenthetical qualifiers for sorting: "Germany (1) (Hetzner)" → "Germany" + country = country.replace(/\s*\([^)]*\)\s*/g, "").trim(); + return { country, city }; + }; const sorted = [...servers].sort((a, b) => { - const nameA = a.name || ""; - const nameB = b.name || ""; - const commaA = nameA.lastIndexOf(","); - const commaB = nameB.lastIndexOf(","); - const countryA = commaA >= 0 ? nameA.substring(commaA + 1).trim() : nameA; - const countryB = commaB >= 0 ? nameB.substring(commaB + 1).trim() : nameB; - const cityA = commaA >= 0 ? nameA.substring(0, commaA).trim() : ""; - const cityB = commaB >= 0 ? nameB.substring(0, commaB).trim() : ""; - return countryA.localeCompare(countryB) || cityA.localeCompare(cityB); + const pa = parseServerName(a.name); + const pb = parseServerName(b.name); + return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city); }); // Populate the list to choose from diff --git a/index-classic.html b/index-classic.html index 9a8c0fdd5..036191cfe 100644 --- a/index-classic.html +++ b/index-classic.html @@ -51,16 +51,28 @@ if (server != null) { //at least 1 server is available I("loading").className = "hidden"; //hide loading message //sort servers by country, then by city + //name formats: "City, Country", "City, Country (qualifier)", "City, Country, Provider", "Country" + function parseServerName(name) { + var parts = (name || "").split(","); + for (var p = 0; p < parts.length; p++) parts[p] = parts[p].trim(); + var country, city; + if (parts.length >= 3) { + country = parts[1]; + city = parts[0]; + } else if (parts.length === 2) { + country = parts[1]; + city = parts[0]; + } else { + country = parts[0]; + city = ""; + } + country = country.replace(/\s*\([^)]*\)\s*/g, "").trim(); + return { country: country, city: city }; + } var sortedServers = SPEEDTEST_SERVERS.slice().sort(function (a, b) { - var nameA = a.name || ""; - var nameB = b.name || ""; - var commaA = nameA.lastIndexOf(","); - var commaB = nameB.lastIndexOf(","); - var countryA = commaA >= 0 ? nameA.substring(commaA + 1).trim() : nameA; - var countryB = commaB >= 0 ? nameB.substring(commaB + 1).trim() : nameB; - var cityA = commaA >= 0 ? nameA.substring(0, commaA).trim() : ""; - var cityB = commaB >= 0 ? nameB.substring(0, commaB).trim() : ""; - return countryA.localeCompare(countryB) || cityA.localeCompare(cityB); + var pa = parseServerName(a.name); + var pb = parseServerName(b.name); + return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city); }); //populate server list for manual selection for (var i = 0; i < sortedServers.length; i++) { From 5cd7ce2efaf4a7dbbf27272f00e8d3d810e22500 Mon Sep 17 00:00:00 2001 From: valer23 Date: Fri, 17 Apr 2026 10:42:37 +0200 Subject: [PATCH 11/18] =?UTF-8?q?perf:=20avoid=20O(n=C2=B2)=20indexOf=20lo?= =?UTF-8?q?okup=20in=20classic=20UI=20server=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build an array of {idx, server} pairs before sorting so the original SPEEDTEST_SERVERS index is carried through, eliminating the per-option indexOf call. --- index-classic.html | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/index-classic.html b/index-classic.html index 036191cfe..b8f2d5cef 100644 --- a/index-classic.html +++ b/index-classic.html @@ -69,18 +69,22 @@ country = country.replace(/\s*\([^)]*\)\s*/g, "").trim(); return { country: country, city: city }; } - var sortedServers = SPEEDTEST_SERVERS.slice().sort(function (a, b) { - var pa = parseServerName(a.name); - var pb = parseServerName(b.name); + var indexed = []; + for (var j = 0; j < SPEEDTEST_SERVERS.length; j++) { + indexed.push({ idx: j, server: SPEEDTEST_SERVERS[j] }); + } + indexed.sort(function (a, b) { + var pa = parseServerName(a.server.name); + var pb = parseServerName(b.server.name); return pa.country.localeCompare(pb.country) || pa.city.localeCompare(pb.city); }); //populate server list for manual selection - for (var i = 0; i < sortedServers.length; i++) { - if (sortedServers[i].pingT == -1) continue; + for (var i = 0; i < indexed.length; i++) { + if (indexed[i].server.pingT == -1) continue; var option = document.createElement("option"); - option.value = SPEEDTEST_SERVERS.indexOf(sortedServers[i]); - option.textContent = sortedServers[i].name; - if (sortedServers[i] === server) option.selected = true; + option.value = indexed[i].idx; + option.textContent = indexed[i].server.name; + if (indexed[i].server === server) option.selected = true; I("server").appendChild(option); } //show test UI From 7839a4331c29d87eeeaef1284c259427df218f48 Mon Sep 17 00:00:00 2001 From: Tom Hudson Date: Sun, 15 Mar 2026 18:07:20 -0400 Subject: [PATCH 12/18] Add internet stability test feature Add a prolonged ping-based stability test with real-time canvas chart, stats (avg/min/max/jitter/packet loss), stability rating, external ping targets, CSV export, and Docker support. Link from main page to stability test. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 2 +- Dockerfile.alpine | 2 +- docker/entrypoint.sh | 1 + frontend/index.html | 1 + index-classic.html | 2 +- index-modern.html | 1 + package.json | 6 +- stability.html | 793 +++++++++++++++++++++++++++++++++++++++++++ stability_worker.js | 216 ++++++++++++ 9 files changed, 1019 insertions(+), 5 deletions(-) create mode 100644 stability.html create mode 100644 stability_worker.js diff --git a/Dockerfile b/Dockerfile index f74012946..8aeca244c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,9 @@ COPY index.html /speedtest/ COPY index-classic.html /speedtest/ COPY index-modern.html /speedtest/ COPY config.json /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ -COPY docker/*.php /speedtest/ COPY docker/entrypoint.sh / # Prepare default environment variables diff --git a/Dockerfile.alpine b/Dockerfile.alpine index e492a83f8..47f694bd5 100755 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -35,9 +35,9 @@ COPY index.html /speedtest/ COPY index-classic.html /speedtest/ COPY index-modern.html /speedtest/ COPY config.json /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ -COPY docker/*.php /speedtest/ COPY docker/entrypoint.sh / # Prepare default environment variables diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7ede3a7bf..7ff3b1d42 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -35,6 +35,7 @@ rm -rf /var/www/html/* # Copy frontend files cp /speedtest/*.js /var/www/html/ +cp /speedtest/stability.html /var/www/html/ # Copy design switch files cp /speedtest/config.json /var/www/html/ diff --git a/frontend/index.html b/frontend/index.html index cf7b7b04a..8a804d7a2 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -75,6 +75,7 @@

Upload

diff --git a/index-classic.html b/index-classic.html index b8f2d5cef..d546c494b 100644 --- a/index-classic.html +++ b/index-classic.html @@ -567,7 +567,7 @@

Share results

Try the modern design
- Source code + Stability Test | Source code
-
Loss
+
Failed Requests
%
@@ -796,7 +796,7 @@

LibreSpeed - Stability Test

ctx.lineJoin = "round"; ctx.stroke(); - // draw lost packet markers + // draw failed request markers for (var i = 0; i < visible.length; i++) { if (!visible[i].lost) continue; var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW; @@ -862,7 +862,7 @@

LibreSpeed - Stability Test

// CSV download function downloadCsv() { if (allPingData.length === 0) return; - var csv = "elapsed_s,ping_ms,lost\n"; + var csv = "elapsed_s,ping_ms,failed\n"; for (var i = 0; i < allPingData.length; i++) { var d = allPingData[i]; csv += d.t.toFixed(3) + "," + d.ping.toFixed(2) + "," + (d.lost ? "1" : "0") + "\n"; diff --git a/stability_worker.js b/stability_worker.js index 46f591699..e96461881 100644 --- a/stability_worker.js +++ b/stability_worker.js @@ -11,7 +11,7 @@ let avgPing = 0; let minPing = -1; let maxPing = 0; let jitter = 0; -let packetLoss = 0; +let packetLoss = 0; // failed request percentage let elapsed = 0; let progress = 0; @@ -121,7 +121,7 @@ function recordPing(instspd) { } prevInstspd = instspd; - // packet loss + // failed request percentage packetLoss = totalSamples > 0 ? parseFloat(((failedSamples / totalSamples) * 100).toFixed(2)) : 0; // record data point From 7a995280a2847a5d39dcd5b4976f4c4f0430b8f2 Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 15:22:06 +0200 Subject: [PATCH 15/18] Fix stability test server list and timeouts --- docker/entrypoint.sh | 1 + stability.html | 74 ++++++++++++++++++++++++++++++++++---------- stability_worker.js | 22 +++++++++++-- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 44d9d1fd7..ede428eeb 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -102,6 +102,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g') sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/index-modern.html sed -i "s/var SPEEDTEST_SERVERS = \\[/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";\\n\\t\\t\\/\\*/" /var/www/html/index-classic.html + sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/stability.html fi # Replace title placeholders if TITLE is set diff --git a/stability.html b/stability.html index 76197d40f..28a569352 100644 --- a/stability.html +++ b/stability.html @@ -357,7 +357,8 @@

LibreSpeed - Stability Test

} // Server configuration (same pattern as index.html) - var SPEEDTEST_SERVERS = []; + // Set this to a different URL to load the server list from another location. + var SPEEDTEST_SERVERS = "server-list.json"; // State var worker = null; @@ -385,22 +386,60 @@

LibreSpeed - Stability Test

var CHART_PADDING_TOP = 15; var CHART_PADDING_BOTTOM = 30; - // Load server list dynamically (for Docker frontend/dual modes where servers.json exists) + function joinServerUrl(server, path) { + if (!server) return path; + if (!path) return server; + if (server.charAt(server.length - 1) === "/" || path.charAt(0) === "/") return server + path; + return server + "/" + path; + } + + // Load server list dynamically (Docker exposes server-list.json; older setups may expose servers.json) function loadServers(callback) { + if (Array.isArray(SPEEDTEST_SERVERS)) { + callback(); + return; + } + + var serverListUrl = typeof SPEEDTEST_SERVERS === "string" ? SPEEDTEST_SERVERS : ""; + if (!serverListUrl) { + SPEEDTEST_SERVERS = []; + callback(); + return; + } + var xhr = new XMLHttpRequest(); xhr.onload = function () { + var loadedServers = null; try { var servers = JSON.parse(xhr.responseText); - if (Array.isArray(servers) && servers.length > 0) { - SPEEDTEST_SERVERS = servers; + if (Array.isArray(servers)) { + loadedServers = servers; } } catch (e) {} - callback(); + + if (loadedServers !== null) { + SPEEDTEST_SERVERS = loadedServers; + callback(); + } else if (!xhr._fallbackTried) { + xhr._fallbackTried = true; + xhr.open("GET", "servers.json?r=" + Math.random()); + xhr.send(); + } else { + SPEEDTEST_SERVERS = []; + callback(); + } }; xhr.onerror = function () { - callback(); + if (xhr._fallbackTried) { + SPEEDTEST_SERVERS = []; + callback(); + } else { + xhr._fallbackTried = true; + xhr.open("GET", "servers.json?r=" + Math.random()); + xhr.send(); + } }; - xhr.open("GET", "servers.json?r=" + Math.random()); + xhr.open("GET", serverListUrl + (serverListUrl.match(/\?/) ? "&" : "?") + "r=" + Math.random()); try { xhr.timeout = 2000; xhr.ontimeout = xhr.onerror; @@ -442,8 +481,8 @@

LibreSpeed - Stability Test

} function pingServer(server, callback) { - var url = - server.server + server.pingURL + (server.pingURL.match(/\?/) ? "&" : "?") + "cors=true&r=" + Math.random(); + var baseUrl = joinServerUrl(server.server, server.pingURL); + var url = baseUrl + (baseUrl.match(/\?/) ? "&" : "?") + "cors=true&r=" + Math.random(); var xhr = new XMLHttpRequest(); var t = new Date().getTime(); xhr.onload = function () { @@ -494,12 +533,14 @@

LibreSpeed - Stability Test

}; if (!externalTarget && selectedServer) { - workerSettings.url_ping = selectedServer.server + selectedServer.pingURL; + workerSettings.url_ping = joinServerUrl(selectedServer.server, selectedServer.pingURL); workerSettings.mpot = true; } - worker = new Worker("stability_worker.js?r=" + Math.random()); + var currentWorker = new Worker("stability_worker.js?r=" + Math.random()); + worker = currentWorker; worker.onmessage = function (e) { + if (worker !== currentWorker) return; var data = JSON.parse(e.data); latestData = data; @@ -537,12 +578,13 @@

LibreSpeed - Stability Test

} // request final status if (worker) { - worker.postMessage("status"); + var stoppedWorker = worker; + stoppedWorker.postMessage("status"); setTimeout(function () { - if (worker) { - try { - worker.terminate(); - } catch (e) {} + try { + stoppedWorker.terminate(); + } catch (e) {} + if (worker === stoppedWorker) { worker = null; } }, 500); diff --git a/stability_worker.js b/stability_worker.js index e96461881..0dd47b3a8 100644 --- a/stability_worker.js +++ b/stability_worker.js @@ -26,6 +26,7 @@ let settings = { url_ping_external: "", // external URL to ping (uses fetch no-cors, e.g. "https://www.google.com/generate_204") duration: 60, // seconds ping_interval: 200, // minimum ms between pings to limit sample rate + ping_timeout: 5000, ping_allowPerformanceApi: true, mpot: false }; @@ -202,7 +203,7 @@ function doPing() { true ); try { - xhr.timeout = 5000; + xhr.timeout = settings.ping_timeout; } catch (e) {} xhr.send(); } @@ -210,16 +211,33 @@ function doPing() { // ping an external host using fetch with no-cors (opaque response, but timing still works) function doPingExternal() { const prevT = new Date().getTime(); + const remainingMs = Math.max(1, settings.duration * 1000 - (prevT - startTime)); + const timeoutMs = Math.min(settings.ping_timeout, remainingMs); const url = settings.url_ping_external + (settings.url_ping_external.indexOf("?") >= 0 ? "&" : "?") + "r=" + Math.random(); - fetch(url, { mode: "no-cors", cache: "no-store" }) + + let timeoutId = null; + const controller = typeof AbortController !== "undefined" ? new AbortController() : null; + const fetchOptions = { mode: "no-cors", cache: "no-store" }; + if (controller) fetchOptions.signal = controller.signal; + + const timeout = new Promise(function (_, reject) { + timeoutId = setTimeout(function () { + if (controller) controller.abort(); + reject(new Error("timeout")); + }, timeoutMs); + }); + + Promise.race([fetch(url, fetchOptions), timeout]) .then(function () { + if (timeoutId) clearTimeout(timeoutId); if (aborted || testState >= 4) return; const instspd = new Date().getTime() - prevT; recordPing(instspd); schedulePing(instspd); }) .catch(function () { + if (timeoutId) clearTimeout(timeoutId); if (aborted || testState >= 4) return; recordLoss(); schedulePing(0); From 89e14e9a384a54612e32f745af448a95bf9c7f1a Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 17:23:34 +0200 Subject: [PATCH 16/18] Update README for stability test --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 00c21e004..9525ea536 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Works with mobile versions too. * Telemetry (optional) * Results sharing (optional) * Multiple Points of Test (optional) +* Connection stability test with latency charting, loss tracking, threshold alerts, and CSV export ![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v7.gif) @@ -40,7 +41,7 @@ Works with mobile versions too. Assuming you have PHP and a web server installed, the installation steps are quite simple. 1. Download the source code and extract it -1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `favicon.ico`, and the `backend` folder. +1. Copy the project files to your web server's shared folder (ie. `/var/www/html/speedtest` for Apache). For the current layout, the web root should contain `index.html`, `index-classic.html`, `index-modern.html`, `stability.html`, `design-switch.js`, `config.json`, `speedtest.js`, `speedtest_worker.js`, `stability_worker.js`, `favicon.ico`, and the `backend` folder. 1. Also copy the contents of `frontend/` into the same web root so the modern UI assets end up in `styling/`, `javascript/`, `images/`, and `fonts/` next to the HTML files. 1. Optionally, copy the results folder too, and set up the database using the config file in it. 1. Be sure your permissions allow read and execute access where needed. @@ -72,6 +73,12 @@ If you want to contribute or develop with LibreSpeed, see [DEVELOPMENT.md](DEVEL LibreSpeed supports both the classic and modern UI. The root `index.html` acts as a lightweight switcher and redirects to `index-classic.html` or `index-modern.html` based on `config.json` (`useNewDesign`) or URL overrides (`?design=new` / `?design=old`). For architecture and deployment details (including Docker behavior), see [DESIGN_SWITCH.md](DESIGN_SWITCH.md). +## Stability test + +LibreSpeed includes a standalone connection stability test at `stability.html`, linked from both the classic and modern interfaces. It repeatedly measures ping over a selected duration and reports current, average, minimum, maximum, jitter, and failed request percentage values with a live chart. + +The stability test can target the local LibreSpeed backend, one of the configured multiple points of test, or built-in external targets such as Google, Cloudflare, and Apple. It also supports optional latency threshold alerts and CSV export of the collected samples. Docker deployments copy `stability.html` and `stability_worker.js` into the web root and reuse the same server list configuration as the main UI. + ## Docker A docker image is available on [GitHub](https://github.com/librespeed/speedtest/pkgs/container/speedtest), check our [docker documentation](doc_docker.md) for more info about it. From 48263c47e899c613989020c755b86ec358becae7 Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 19:10:39 +0200 Subject: [PATCH 17/18] remove duplicated lines from rebase --- Dockerfile | 1 - Dockerfile.alpine | 1 - 2 files changed, 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3eb170fe4..8aeca244c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,6 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ -COPY stability.html /speedtest/ COPY index.html /speedtest/ COPY index-classic.html /speedtest/ COPY index-modern.html /speedtest/ diff --git a/Dockerfile.alpine b/Dockerfile.alpine index 27a6315c6..47f694bd5 100755 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -31,7 +31,6 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ -COPY stability.html /speedtest/ COPY index.html /speedtest/ COPY index-classic.html /speedtest/ COPY index-modern.html /speedtest/ From 320b908864b909b978c1a5f8871c7bb3fbe89681 Mon Sep 17 00:00:00 2001 From: Stefan Stidl Date: Sat, 16 May 2026 20:02:07 +0200 Subject: [PATCH 18/18] Add stability feature Playwright coverage, fix Server selection start race --- stability.html | 65 ++++++++++++++++++- tests/e2e/helpers/ui.js | 9 ++- tests/e2e/stability.spec.js | 125 ++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/stability.spec.js diff --git a/stability.html b/stability.html index 28a569352..7f93bffab 100644 --- a/stability.html +++ b/stability.html @@ -63,13 +63,29 @@ 0 0 2em rgba(0, 0, 0, 0.1), inset 0 0 1em rgba(0, 0, 0, 0.1); } + #startBtn.disabled { + color: #a0a0a0; + border-color: #c0c0c0; + cursor: default; + opacity: 0.8; + } + #startBtn.disabled:hover { + box-shadow: + 0 0 0 rgba(0, 0, 0, 0.1), + inset 0 0 0 rgba(0, 0, 0, 0.1); + } #startBtn:before { content: "Start"; } + #startBtn.finding:before { + content: "Finding..."; + } #startBtn.running { background-color: #ff3030; border-color: #ff6060; color: #ffffff; + cursor: pointer; + opacity: 1; } #startBtn.running:before { content: "Abort"; @@ -232,6 +248,10 @@ color: #9090ff; border-color: #7070ff; } + #startBtn.disabled { + color: #707070; + border-color: #505050; + } #startBtn.running { background-color: #ff3030; border-color: #ff6060; @@ -267,13 +287,13 @@

LibreSpeed - Stability Test

- -
+
Reset
@@ -370,6 +390,8 @@

LibreSpeed - Stability Test

var lastBeepTime = 0; var audioCtx = null; var selectedServer = null; + var localServerReady = false; + var serverDiscoveryPending = false; // Dark mode detection for canvas colors var isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; @@ -449,10 +471,23 @@

LibreSpeed - Stability Test

// Server initialization function initServers() { + selectedServer = null; + localServerReady = false; + serverDiscoveryPending = false; + I("server").innerHTML = ""; if (SPEEDTEST_SERVERS.length === 0) { I("serverArea").style.display = "none"; + localServerReady = true; + updateStartButtonState(); } else { I("serverArea").style.display = ""; + serverDiscoveryPending = true; + I("server").disabled = true; + var pendingOpt = document.createElement("option"); + pendingOpt.value = ""; + pendingOpt.textContent = "Finding best server..."; + I("server").appendChild(pendingOpt); + updateStartButtonState(); // ping servers to find best one var completed = 0; var best = null; @@ -464,6 +499,7 @@

LibreSpeed - Stability Test

completed++; if (completed === SPEEDTEST_SERVERS.length) { // populate dropdown + I("server").innerHTML = ""; for (var j = 0; j < SPEEDTEST_SERVERS.length; j++) { if (SPEEDTEST_SERVERS[j].pingT <= 0) continue; var opt = document.createElement("option"); @@ -473,6 +509,10 @@

LibreSpeed - Stability Test

I("server").appendChild(opt); } selectedServer = best; + serverDiscoveryPending = false; + localServerReady = selectedServer !== null || I("server").options.length > 0; + I("server").disabled = !localServerReady; + updateStartButtonState(); } }); })(i); @@ -500,7 +540,24 @@

LibreSpeed - Stability Test

} function onServerChange(idx) { + if (idx === "") return; selectedServer = SPEEDTEST_SERVERS[idx]; + updateStartButtonState(); + } + + function canStartTest() { + return !!I("targetSelect").value || localServerReady; + } + + function updateStartButtonState() { + if (running) return; + + var startBtn = I("startBtn"); + var canStart = canStartTest(); + startBtn.className = canStart ? "" : serverDiscoveryPending ? "disabled finding" : "disabled"; + startBtn.setAttribute("aria-disabled", canStart ? "false" : "true"); + startBtn.title = canStart ? "" : serverDiscoveryPending ? "Finding best server..." : "No reachable local server found"; + I("server").disabled = !localServerReady || serverDiscoveryPending; } // Start/Stop @@ -510,11 +567,14 @@

LibreSpeed - Stability Test

if (worker) worker.postMessage("abort"); stopTest(); } else { + if (!canStartTest()) return; startTest(); } } function startTest() { + if (!canStartTest()) return; + allPingData = []; latestData = null; resetUI(); @@ -572,6 +632,7 @@

LibreSpeed - Stability Test

I("durationSelect").disabled = false; I("targetSelect").disabled = false; I("server").disabled = false; + updateStartButtonState(); if (updater) { clearInterval(updater); updater = null; diff --git a/tests/e2e/helpers/ui.js b/tests/e2e/helpers/ui.js index 8cf8e9777..14ebf5ce3 100644 --- a/tests/e2e/helpers/ui.js +++ b/tests/e2e/helpers/ui.js @@ -1,12 +1,17 @@ function modernStartButton(page) { - return page.locator('#start-button'); + return page.locator("#start-button"); } function classicStartButton(page) { - return page.locator('#startStopBtn'); + return page.locator("#startStopBtn"); +} + +function stabilityStartButton(page) { + return page.locator("#startBtn"); } module.exports = { modernStartButton, classicStartButton, + stabilityStartButton }; diff --git a/tests/e2e/stability.spec.js b/tests/e2e/stability.spec.js new file mode 100644 index 000000000..e066efdd7 --- /dev/null +++ b/tests/e2e/stability.spec.js @@ -0,0 +1,125 @@ +const fs = require("node:fs"); +const { test, expect } = require("@playwright/test"); +const { baseUrls } = require("./helpers/env"); +const { stabilityStartButton } = require("./helpers/ui"); + +async function setShortDuration(page) { + await page.evaluate(() => { + const select = document.querySelector("#durationSelect"); + const option = document.createElement("option"); + option.value = "1"; + option.textContent = "1 Second"; + select.insertBefore(option, select.firstChild); + select.value = "1"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +async function setAlertThreshold(page, value) { + await page.evaluate(threshold => { + const input = document.querySelector("#alertThreshold"); + input.value = String(threshold); + input.dispatchEvent(new Event("input", { bubbles: true })); + }, value); +} + +async function waitForSamples(page) { + await expect.poll(() => page.evaluate(() => window.allPingData.length), { timeout: 10_000 }).toBeGreaterThan(0); +} + +async function waitForLocalServer(page, serverName) { + await expect(page.locator("#serverArea")).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("#server option")).toContainText(serverName, { timeout: 10_000 }); +} + +test.describe("Stability test", () => { + test("keeps the local start control disabled until server discovery completes", async ({ page }) => { + let releaseServerProbe; + const serverProbe = new Promise(resolve => { + releaseServerProbe = resolve; + }); + + await page.route(/\/backend\/empty\.php\?cors=true/, async route => { + await serverProbe; + await route.fulfill({ status: 200, body: "" }); + }); + + await page.goto(`${baseUrls.standalone}/stability.html`); + + await expect(stabilityStartButton(page)).toHaveClass(/disabled/); + await expect(stabilityStartButton(page)).toHaveClass(/finding/); + await expect(stabilityStartButton(page)).toHaveAttribute("aria-disabled", "true"); + await expect(stabilityStartButton(page)).toHaveAttribute("title", "Finding best server..."); + await expect(page.locator("#server")).toBeDisabled(); + + releaseServerProbe(); + + await expect(page.locator("#server option")).toContainText("local", { timeout: 10_000 }); + await expect(stabilityStartButton(page)).not.toHaveClass(/disabled/); + await expect(stabilityStartButton(page)).toHaveAttribute("aria-disabled", "false"); + await expect(stabilityStartButton(page)).toHaveAttribute("title", ""); + await expect(page.locator("#server")).toBeEnabled(); + }); + + test("runs a short local measurement and exports CSV data", async ({ page }) => { + await page.goto(`${baseUrls.standalone}/stability.html`); + + await expect(page).toHaveTitle("LibreSpeed - Stability Test"); + await waitForLocalServer(page, "local"); + + await setShortDuration(page); + await stabilityStartButton(page).click(); + + await expect(stabilityStartButton(page)).toHaveClass(/running/); + await expect(page.locator("#durationSelect")).toBeDisabled(); + await expect(page.locator("#targetSelect")).toBeDisabled(); + await expect(page.locator("#server")).toBeDisabled(); + + await waitForSamples(page); + await expect(page.locator("#statAvg")).not.toHaveText("", { timeout: 5_000 }); + await expect(page.locator("#rating")).not.toHaveText("--", { timeout: 5_000 }); + + await expect(stabilityStartButton(page)).not.toHaveClass(/running/, { timeout: 10_000 }); + await expect(page.locator("#durationSelect")).toBeEnabled(); + + const [download] = await Promise.all([page.waitForEvent("download"), page.locator("#downloadCsvBtn").click()]); + expect(download.suggestedFilename()).toMatch(/^stability_test_.*\.csv$/); + + const csvPath = await download.path(); + const csv = fs.readFileSync(csvPath, "utf8"); + expect(csv).toContain("elapsed_s,ping_ms,failed\n"); + expect(csv.trim().split("\n").length).toBeGreaterThan(1); + }); + + test("supports threshold display, abort, and reset controls", async ({ page }) => { + await page.goto(`${baseUrls.standalone}/stability.html`); + await waitForLocalServer(page, "local"); + + await setAlertThreshold(page, 40); + await expect(page.locator("#thresholdValue")).toHaveText("40 ms"); + + await stabilityStartButton(page).click(); + await expect(stabilityStartButton(page)).toHaveClass(/running/); + await waitForSamples(page); + + await stabilityStartButton(page).click(); + await expect(stabilityStartButton(page)).not.toHaveClass(/running/); + await expect(page.locator("#durationSelect")).toBeEnabled(); + await expect(page.locator("#targetSelect")).toBeEnabled(); + + await page.waitForTimeout(700); + await page.locator("#resetBtn").click(); + + await expect(page.locator("#rating")).toHaveText("--"); + await expect(page.locator("#statAvg")).toHaveText(""); + await expect.poll(() => page.evaluate(() => window.allPingData.length)).toBe(0); + await expect.poll(() => page.evaluate(() => window.latestData)).toBeNull(); + }); + + test("loads the configured dual-mode server list", async ({ page }) => { + await page.goto(`${baseUrls.dual}/stability.html`); + + await expect(page.locator("#serverArea")).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("#server option")).toContainText("Local dual backend", { timeout: 10_000 }); + }); +});