Răsfoiți Sursa

Merge pull request #805 from librespeed/internet-stability

add internet stability test
sstidl 2 luni în urmă
părinte
comite
23b7780056
12 a modificat fișierele cu 1401 adăugiri și 13 ștergeri
  1. 1 1
      Dockerfile
  2. 1 1
      Dockerfile.alpine
  3. 8 1
      README.md
  4. 16 4
      docker/entrypoint.sh
  5. 1 0
      frontend/index.html
  6. 1 1
      index-classic.html
  7. 1 1
      index-modern.html
  8. 4 2
      package.json
  9. 991 0
      stability.html
  10. 245 0
      stability_worker.js
  11. 7 2
      tests/e2e/helpers/ui.js
  12. 125 0
      tests/e2e/stability.spec.js

+ 1 - 1
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

+ 1 - 1
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

+ 8 - 1
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.

+ 16 - 4
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/
@@ -52,6 +53,11 @@ else
 fi
 
 
+# Copy servers.json for stability page (frontend/dual modes)
+if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]]; then
+  cp /servers.json /var/www/html/servers.json
+fi
+
 # Set up backend side for standlone modes
 if [[ "$MODE" == "standalone" || "$MODE" == "dual" ]]; then
   cp -r /speedtest/backend/ /var/www/html/backend
@@ -73,14 +79,14 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
   cp /speedtest/index.html /var/www/html/
   cp /speedtest/index-classic.html /var/www/html/
   cp /speedtest/index-modern.html /var/www/html/
-  
+  cp /speedtest/stability.html /var/www/html/
   # Copy frontend assets directly to root-level subdirectories (no frontend/ parent dir)
   mkdir -p /var/www/html/styling /var/www/html/javascript /var/www/html/images /var/www/html/fonts
   cp -a /speedtest/frontend/styling/* /var/www/html/styling/
   cp -a /speedtest/frontend/javascript/* /var/www/html/javascript/
   cp -a /speedtest/frontend/images/* /var/www/html/images/
   cp -a /speedtest/frontend/fonts/* /var/www/html/fonts/ 2>/dev/null || true
-  
+
   # Copy frontend config files
   cp /speedtest/frontend/settings.json /var/www/html/settings.json 2>/dev/null || true
   if [ -f /servers.json ]; then
@@ -96,6 +102,12 @@ 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.
+  if [ -f /var/www/html/server-list.json ]; then
+    cp /var/www/html/server-list.json /var/www/html/servers.json
   fi
 
   # Replace title placeholders if TITLE is set
@@ -117,7 +129,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
     TAGLINE_ESCAPED=$(sed_escape "$TAGLINE_HTML_ESCAPED")
     sed -i "s/<p class=\"tagline\">No Flash, No Java, No Websockets, No Bullsh\\*t<\\/p>/<p class=\"tagline\">$TAGLINE_ESCAPED<\\/p>/g" /var/www/html/index-modern.html
   fi
-  
+
   # Support legacy EMAIL env var as fallback for GDPR_EMAIL
   if [ -z "$GDPR_EMAIL" ] && [ ! -z "$EMAIL" ]; then
     echo "WARNING: EMAIL env var is deprecated, please use GDPR_EMAIL instead" >&2
@@ -129,7 +141,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
   if [ ! -z "$GDPR_EMAIL" ]; then
     # Escape special sed characters: & (replacement), / (delimiter), \ (escape), $ (variable)
     GDPR_EMAIL_ESCAPED=$(printf '%s\n' "$GDPR_EMAIL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g')
-    
+
     for html_file in /var/www/html/index-modern.html /var/www/html/index-classic.html; do
       if [ -f "$html_file" ]; then
         sed -i "s/TO BE FILLED BY DEVELOPER/$GDPR_EMAIL_ESCAPED/g; s/PUT@YOUR_EMAIL.HERE/$GDPR_EMAIL_ESCAPED/g" "$html_file"

+ 1 - 0
frontend/index.html

@@ -75,6 +75,7 @@
     </main>
     <footer>
       <p class="source">
+        <a href="stability.html">stability test</a> |
         <a href="https://github.com/librespeed/speedtest">source code</a>
       </p>
     </footer>

+ 1 - 1
index-classic.html

@@ -567,7 +567,7 @@
 			</div>
 		</div>
 		<a href="index.html?design=new">Try the modern design</a><br>
-		<a href="https://github.com/librespeed/speedtest">Source code</a>
+		<a href="stability.html">Stability Test</a> | <a href="https://github.com/librespeed/speedtest">Source code</a>
 	</div>
 	<div id="privacyPolicy" style="display:none">
 		<h2>Privacy Policy</h2>

+ 1 - 1
index-modern.html

@@ -75,7 +75,7 @@
   </main>
   <footer>
     <p class="source">
-      <a href="https://github.com/librespeed/speedtest">source code</a>
+      <a href="stability.html">stability test</a> | <a href="https://github.com/librespeed/speedtest">source code</a>
     </p>
   </footer>
 

+ 4 - 2
package.json

@@ -7,8 +7,8 @@
     "test": "echo \"No automated tests configured yet\" && exit 0",
     "test:e2e": "playwright test",
     "test:e2e:headed": "playwright test --headed",
-    "lint": "eslint speedtest.js speedtest_worker.js",
-    "lint:fix": "eslint --fix speedtest.js speedtest_worker.js",
+    "lint": "eslint speedtest.js speedtest_worker.js stability_worker.js",
+    "lint:fix": "eslint --fix speedtest.js speedtest_worker.js stability_worker.js",
     "format": "prettier --write \"*.js\"",
     "format:check": "prettier --check \"*.js\"",
     "validate": "npm run format:check && npm run lint",
@@ -46,6 +46,8 @@
   "files": [
     "speedtest.js",
     "speedtest_worker.js",
+    "stability_worker.js",
+    "stability.html",
     "index.html",
     "favicon.ico",
     "backend/",

+ 991 - 0
stability.html

@@ -0,0 +1,991 @@
+<!doctype html>
+<html>
+  <head>
+    <link rel="shortcut icon" href="favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
+    <meta charset="UTF-8" />
+    <title>LibreSpeed - Stability Test</title>
+    <style type="text/css">
+      html,
+      body {
+        border: none;
+        padding: 0;
+        margin: 0;
+        background: #ffffff;
+        color: #202020;
+      }
+      body {
+        text-align: center;
+        font-family: "Roboto", sans-serif;
+      }
+      h1 {
+        color: #404040;
+      }
+      nav {
+        margin-bottom: 1em;
+      }
+      nav a {
+        color: #6060aa;
+        text-decoration: none;
+        font-size: 0.95em;
+      }
+      nav a:hover {
+        text-decoration: underline;
+      }
+      #controls {
+        margin: 1em auto;
+      }
+      #durationSelect,
+      #targetSelect {
+        font-size: 1em;
+        padding: 0.3em 0.5em;
+        margin-right: 1em;
+      }
+      #startBtn {
+        display: inline-block;
+        margin: 0 auto;
+        color: #6060aa;
+        background-color: rgba(0, 0, 0, 0);
+        border: 0.15em solid #6060ff;
+        border-radius: 0.3em;
+        transition: all 0.3s;
+        box-sizing: border-box;
+        width: 8em;
+        height: 3em;
+        line-height: 2.7em;
+        cursor: pointer;
+        box-shadow:
+          0 0 0 rgba(0, 0, 0, 0.1),
+          inset 0 0 0 rgba(0, 0, 0, 0.1);
+      }
+      #startBtn:hover {
+        box-shadow:
+          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";
+      }
+      #resetBtn {
+        display: inline-block;
+        color: #808080;
+        background-color: rgba(0, 0, 0, 0);
+        border: 0.15em solid #a0a0a0;
+        border-radius: 0.3em;
+        transition: all 0.3s;
+        box-sizing: border-box;
+        width: 6em;
+        height: 3em;
+        line-height: 2.7em;
+        cursor: pointer;
+        margin-left: 0.5em;
+        box-shadow:
+          0 0 0 rgba(0, 0, 0, 0.1),
+          inset 0 0 0 rgba(0, 0, 0, 0.1);
+      }
+      #resetBtn:hover {
+        box-shadow:
+          0 0 2em rgba(0, 0, 0, 0.1),
+          inset 0 0 1em rgba(0, 0, 0, 0.1);
+      }
+      #serverArea {
+        margin: 0.8em 0;
+      }
+      #server {
+        font-size: 1em;
+        padding: 0.2em;
+      }
+      #test {
+        margin-top: 2em;
+        margin-bottom: 2em;
+      }
+      div.testGroup {
+        display: block;
+        margin: 0 auto;
+      }
+      div.testArea2 {
+        display: inline-block;
+        width: 10em;
+        height: 7em;
+        position: relative;
+        box-sizing: border-box;
+        text-align: center;
+      }
+      div.testArea2 div.testName {
+        display: block;
+        text-align: center;
+        font-size: 1.4em;
+      }
+      div.testArea2 div.meterText {
+        display: inline-block;
+        font-size: 2.5em;
+      }
+      div.meterText:empty:before {
+        content: "0.00";
+      }
+      div.testArea2 div.unit {
+        display: inline-block;
+      }
+      #rating {
+        font-size: 1.3em;
+        font-weight: bold;
+        margin: 0.5em 0;
+      }
+      #rating.great {
+        color: #2ecc71;
+      }
+      #rating.good {
+        color: #27ae60;
+      }
+      #rating.poor {
+        color: #e67e22;
+      }
+      #rating.bad {
+        color: #e74c3c;
+      }
+      #rating.none {
+        color: #808080;
+      }
+      #chartContainer {
+        margin: 1em auto;
+        width: 95%;
+        max-width: 60em;
+        position: relative;
+      }
+      #pingChart {
+        width: 100%;
+        height: 280px;
+        border: 1px solid #e0e0e0;
+        border-radius: 4px;
+        background: #fafafa;
+      }
+      #optionalControls {
+        margin: 1.5em auto;
+        max-width: 40em;
+      }
+      #optionalControls label {
+        display: block;
+        margin: 0.5em 0;
+      }
+      #alertThreshold {
+        vertical-align: middle;
+        width: 200px;
+      }
+      #downloadCsvBtn {
+        margin-top: 0.8em;
+        font-size: 0.9em;
+        padding: 0.4em 1em;
+        cursor: pointer;
+        border: 1px solid #6060aa;
+        background: none;
+        color: #6060aa;
+        border-radius: 0.3em;
+      }
+      #downloadCsvBtn:hover {
+        background: #6060aa;
+        color: #ffffff;
+      }
+      .footer {
+        margin: 2em 0 1em;
+      }
+      .footer a {
+        color: #808080;
+        font-size: 0.85em;
+      }
+      @media all and (max-width: 40em) {
+        body {
+          font-size: 0.8em;
+        }
+        #pingChart {
+          height: 200px;
+        }
+      }
+      @media all and (prefers-color-scheme: dark) {
+        html,
+        body {
+          background: #202020;
+          color: #f4f4f4;
+          color-scheme: dark;
+        }
+        h1 {
+          color: #e0e0e0;
+        }
+        nav a {
+          color: #9090ff;
+        }
+        #pingChart {
+          border-color: #404040;
+          background: #1a1a1a;
+        }
+        a {
+          color: #9090ff;
+        }
+        #startBtn {
+          color: #9090ff;
+          border-color: #7070ff;
+        }
+        #startBtn.disabled {
+          color: #707070;
+          border-color: #505050;
+        }
+        #startBtn.running {
+          background-color: #ff3030;
+          border-color: #ff6060;
+          color: #ffffff;
+        }
+        #resetBtn {
+          color: #a0a0a0;
+          border-color: #606060;
+        }
+        #downloadCsvBtn {
+          color: #9090ff;
+          border-color: #9090ff;
+        }
+        #downloadCsvBtn:hover {
+          background: #9090ff;
+          color: #202020;
+        }
+        .footer a {
+          color: #a0a0a0;
+        }
+      }
+    </style>
+  </head>
+  <body>
+    <h1>LibreSpeed - Stability Test</h1>
+    <nav><a href="./">&larr; Speed Test</a></nav>
+
+    <div id="controls">
+      <select id="durationSelect">
+        <option value="60">60 Seconds</option>
+        <option value="90">90 Seconds</option>
+        <option value="120">2 Minutes</option>
+        <option value="180">3 Minutes</option>
+        <option value="300">5 Minutes</option>
+      </select>
+      <select id="targetSelect" onchange="updateStartButtonState()">
+        <option value="">Local Server</option>
+        <option value="https://www.google.com/generate_204">Google</option>
+        <option value="https://www.cloudflare.com/cdn-cgi/trace">Cloudflare</option>
+        <option value="https://www.apple.com/library/test/success.html">Apple</option>
+      </select>
+      <div id="startBtn" class="disabled finding" onclick="startStop()" aria-disabled="true" title="Finding best server..."></div>
+      <div id="resetBtn" onclick="resetTest()">Reset</div>
+    </div>
+
+    <div id="serverArea" style="display: none">
+      Server:
+      <select id="server" onchange="onServerChange(this.value)"></select>
+    </div>
+
+    <div id="rating" class="none">--</div>
+
+    <div id="test">
+      <div class="testGroup">
+        <div class="testArea2">
+          <div class="testName">Current</div>
+          <div id="statCurrent" class="meterText" style="color: #aa6060"></div>
+          <div class="unit">ms</div>
+        </div>
+        <div class="testArea2">
+          <div class="testName">Average</div>
+          <div id="statAvg" class="meterText" style="color: #aa6060"></div>
+          <div class="unit">ms</div>
+        </div>
+        <div class="testArea2">
+          <div class="testName">Jitter</div>
+          <div id="statJitter" class="meterText" style="color: #aa6060"></div>
+          <div class="unit">ms</div>
+        </div>
+      </div>
+      <div class="testGroup">
+        <div class="testArea2">
+          <div class="testName">Min</div>
+          <div id="statMin" class="meterText"></div>
+          <div class="unit">ms</div>
+        </div>
+        <div class="testArea2">
+          <div class="testName">Max</div>
+          <div id="statMax" class="meterText"></div>
+          <div class="unit">ms</div>
+        </div>
+        <div class="testArea2">
+          <div class="testName">Failed Requests</div>
+          <div id="statLoss" class="meterText"></div>
+          <div class="unit">%</div>
+        </div>
+        <div class="testArea2">
+          <div class="testName">Elapsed</div>
+          <div id="statElapsed" class="meterText">00:00</div>
+          <div class="unit"></div>
+        </div>
+      </div>
+    </div>
+
+    <div id="chartContainer">
+      <canvas id="pingChart"></canvas>
+    </div>
+
+    <div id="optionalControls">
+      <label>
+        Alert threshold:
+        <input
+          type="range"
+          id="alertThreshold"
+          min="0"
+          max="500"
+          step="10"
+          value="0"
+          oninput="updateThreshold(this.value)"
+        />
+        <span id="thresholdValue">Off</span>
+      </label>
+      <button id="downloadCsvBtn" onclick="downloadCsv()">Download CSV</button>
+    </div>
+
+    <div class="footer">
+      <a href="https://github.com/librespeed/speedtest">Source code</a>
+    </div>
+
+    <script type="text/javascript">
+      function I(i) {
+        return document.getElementById(i);
+      }
+
+      // Server configuration (same pattern as index.html)
+      // Set this to a different URL to load the server list from another location.
+      var SPEEDTEST_SERVERS = "server-list.json";
+
+      // State
+      var worker = null;
+      var updater = null;
+      var running = false;
+      var allPingData = []; // full dataset for chart and CSV
+      var latestData = null;
+      var alertThresholdMs = 0;
+      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;
+      try {
+        window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function (e) {
+          isDark = e.matches;
+        });
+      } catch (e) {}
+
+      // Chart constants
+      var VISIBLE_SECONDS = 60;
+      var CHART_PADDING_LEFT = 50;
+      var CHART_PADDING_RIGHT = 15;
+      var CHART_PADDING_TOP = 15;
+      var CHART_PADDING_BOTTOM = 30;
+
+      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)) {
+              loadedServers = servers;
+            }
+          } catch (e) {}
+
+          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) {
+            SPEEDTEST_SERVERS = [];
+            callback();
+          } else {
+            xhr._fallbackTried = true;
+            xhr.open("GET", "servers.json?r=" + Math.random());
+            xhr.send();
+          }
+        };
+        xhr.open("GET", serverListUrl + (serverListUrl.match(/\?/) ? "&" : "?") + "r=" + Math.random());
+        try {
+          xhr.timeout = 2000;
+          xhr.ontimeout = xhr.onerror;
+        } catch (e) {}
+        xhr.send();
+      }
+
+      // 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;
+          for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) {
+            (function (idx) {
+              pingServer(SPEEDTEST_SERVERS[idx], function (server, rtt) {
+                SPEEDTEST_SERVERS[idx].pingT = rtt;
+                if (rtt > 0 && (best === null || rtt < best.pingT)) best = SPEEDTEST_SERVERS[idx];
+                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");
+                    opt.value = j;
+                    opt.textContent = SPEEDTEST_SERVERS[j].name;
+                    if (SPEEDTEST_SERVERS[j] === best) opt.selected = true;
+                    I("server").appendChild(opt);
+                  }
+                  selectedServer = best;
+                  serverDiscoveryPending = false;
+                  localServerReady = selectedServer !== null || I("server").options.length > 0;
+                  I("server").disabled = !localServerReady;
+                  updateStartButtonState();
+                }
+              });
+            })(i);
+          }
+        }
+      }
+
+      function pingServer(server, callback) {
+        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 () {
+          callback(server, new Date().getTime() - t);
+        };
+        xhr.onerror = function () {
+          callback(server, -1);
+        };
+        xhr.open("GET", url);
+        try {
+          xhr.timeout = 2000;
+          xhr.ontimeout = xhr.onerror;
+        } catch (e) {}
+        xhr.send();
+      }
+
+      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
+      function startStop() {
+        if (running) {
+          // abort
+          if (worker) worker.postMessage("abort");
+          stopTest();
+        } else {
+          if (!canStartTest()) return;
+          startTest();
+        }
+      }
+
+      function startTest() {
+        if (!canStartTest()) return;
+
+        allPingData = [];
+        latestData = null;
+        resetUI();
+
+        running = true;
+        I("startBtn").className = "running";
+        I("durationSelect").disabled = true;
+        I("targetSelect").disabled = true;
+        I("server").disabled = true;
+
+        var externalTarget = I("targetSelect").value;
+        var workerSettings = {
+          duration: parseInt(I("durationSelect").value),
+          ping_allowPerformanceApi: true,
+          url_ping_external: externalTarget
+        };
+
+        if (!externalTarget && selectedServer) {
+          workerSettings.url_ping = joinServerUrl(selectedServer.server, selectedServer.pingURL);
+          workerSettings.mpot = true;
+        }
+
+        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;
+
+          // accumulate ping data
+          if (data.pingData && data.pingData.length > 0) {
+            for (var i = 0; i < data.pingData.length; i++) {
+              allPingData.push(data.pingData[i]);
+            }
+          }
+
+          // check alert threshold
+          if (alertThresholdMs > 0 && data.currentPing > alertThresholdMs) {
+            playBeep();
+          }
+
+          if (data.testState >= 4) {
+            stopTest();
+          }
+        };
+        updater = setInterval(function () {
+          if (worker) worker.postMessage("status");
+        }, 200);
+        worker.postMessage("start " + JSON.stringify(workerSettings));
+      }
+
+      function stopTest() {
+        running = false;
+        I("startBtn").className = "";
+        I("durationSelect").disabled = false;
+        I("targetSelect").disabled = false;
+        I("server").disabled = false;
+        updateStartButtonState();
+        if (updater) {
+          clearInterval(updater);
+          updater = null;
+        }
+        // request final status
+        if (worker) {
+          var stoppedWorker = worker;
+          stoppedWorker.postMessage("status");
+          setTimeout(function () {
+            try {
+              stoppedWorker.terminate();
+            } catch (e) {}
+            if (worker === stoppedWorker) {
+              worker = null;
+            }
+          }, 500);
+        }
+      }
+
+      function resetTest() {
+        if (running) {
+          if (worker) worker.postMessage("abort");
+          stopTest();
+        }
+        allPingData = [];
+        latestData = null;
+        resetUI();
+        drawChart();
+      }
+
+      function resetUI() {
+        I("statCurrent").textContent = "";
+        I("statAvg").textContent = "";
+        I("statMin").textContent = "";
+        I("statMax").textContent = "";
+        I("statJitter").textContent = "";
+        I("statLoss").textContent = "";
+        I("statElapsed").textContent = "";
+        I("rating").textContent = "--";
+        I("rating").className = "none";
+      }
+
+      // Format helpers
+      function fmt(v) {
+        v = Number(v);
+        if (isNaN(v) || v <= 0) return "--";
+        if (v < 10) return v.toFixed(2);
+        if (v < 100) return v.toFixed(1);
+        return v.toFixed(0);
+      }
+
+      function fmtTime(sec) {
+        var m = Math.floor(sec / 60);
+        var s = Math.floor(sec % 60);
+        return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
+      }
+
+      // Rating calculation
+      function getRating(avg, jit, loss) {
+        if (avg <= 0) return { text: "--", cls: "none" };
+        if (avg < 30 && jit < 5 && loss < 0.5) return { text: "Great", cls: "great" };
+        if (avg < 60 && jit < 15 && loss < 2) return { text: "Good", cls: "good" };
+        if (avg < 100 && jit < 30 && loss < 5) return { text: "Poor", cls: "poor" };
+        return { text: "Bad", cls: "bad" };
+      }
+
+      // UI update loop
+      function updateUI() {
+        if (!latestData) return;
+        var d = latestData;
+        I("statCurrent").textContent = fmt(d.currentPing);
+        I("statAvg").textContent = fmt(d.avgPing);
+        I("statMin").textContent = d.minPing > 0 ? fmt(d.minPing) : "--";
+        I("statMax").textContent = fmt(d.maxPing);
+        I("statJitter").textContent = fmt(d.jitter);
+        I("statLoss").textContent = d.totalSamples > 0 ? d.packetLoss.toFixed(1) : "--";
+        I("statElapsed").textContent = fmtTime(d.elapsed);
+
+        var r = getRating(d.avgPing, d.jitter, d.packetLoss);
+        I("rating").textContent = r.text;
+        I("rating").className = r.cls;
+      }
+
+      // Canvas chart rendering
+      function drawChart() {
+        var canvas = I("pingChart");
+        var ctx = canvas.getContext("2d");
+        var dp = window.devicePixelRatio || 1;
+        var cw = canvas.clientWidth * dp;
+        var ch = canvas.clientHeight * dp;
+        if (canvas.width !== cw || canvas.height !== ch) {
+          canvas.width = cw;
+          canvas.height = ch;
+        }
+        ctx.clearRect(0, 0, cw, ch);
+
+        var padL = CHART_PADDING_LEFT * dp;
+        var padR = CHART_PADDING_RIGHT * dp;
+        var padT = CHART_PADDING_TOP * dp;
+        var padB = CHART_PADDING_BOTTOM * dp;
+        var plotW = cw - padL - padR;
+        var plotH = ch - padT - padB;
+
+        // colors based on theme
+        var lineColor = isDark ? "#9090FF" : "#6060AA";
+        var fillColor = isDark ? "rgba(144,144,255,0.15)" : "rgba(96,96,170,0.15)";
+        var gridColor = isDark ? "#404040" : "#E0E0E0";
+        var textColor = isDark ? "#A0A0A0" : "#808080";
+        var lostColor = "#e74c3c";
+        var thresholdColor = "rgba(231,76,60,0.6)";
+
+        // determine visible time window
+        var data = allPingData;
+        var maxTime = 0;
+        if (data.length > 0) maxTime = data[data.length - 1].t;
+        var timeStart, timeEnd;
+        if (maxTime <= VISIBLE_SECONDS) {
+          timeStart = 0;
+          timeEnd = Math.max(VISIBLE_SECONDS, maxTime);
+        } else {
+          timeEnd = maxTime;
+          timeStart = maxTime - VISIBLE_SECONDS;
+        }
+
+        // binary search to find first visible data point
+        var visStart = 0;
+        var lo = 0,
+          hi = data.length - 1;
+        while (lo <= hi) {
+          var mid = (lo + hi) >> 1;
+          if (data[mid].t < timeStart) lo = mid + 1;
+          else hi = mid - 1;
+        }
+        visStart = lo;
+
+        // determine Y-axis scale from visible data only
+        var yMax = 50; // minimum 50ms scale
+        for (var i = visStart; i < data.length && data[i].t <= timeEnd; i++) {
+          if (!data[i].lost && data[i].ping > yMax) yMax = data[i].ping;
+        }
+        yMax = Math.ceil((yMax * 1.2) / 10) * 10; // round up to nearest 10 with 20% headroom
+
+        var fontSize = 11 * dp;
+        ctx.font = fontSize + "px sans-serif";
+        ctx.textBaseline = "middle";
+
+        // grid lines - horizontal
+        var ySteps = getYSteps(yMax);
+        ctx.strokeStyle = gridColor;
+        ctx.lineWidth = dp;
+        ctx.fillStyle = textColor;
+        ctx.textAlign = "right";
+        for (var s = 0; s < ySteps.length; s++) {
+          var yVal = ySteps[s];
+          if (yVal > yMax) continue;
+          var y = padT + plotH - (yVal / yMax) * plotH;
+          ctx.beginPath();
+          ctx.moveTo(padL, y);
+          ctx.lineTo(padL + plotW, y);
+          ctx.stroke();
+          ctx.fillText(yVal + "", padL - 5 * dp, y);
+        }
+
+        // grid lines - vertical (time ticks)
+        ctx.textAlign = "center";
+        ctx.textBaseline = "top";
+        var timeSpan = timeEnd - timeStart;
+        var tickInterval = timeSpan <= 60 ? 10 : timeSpan <= 120 ? 15 : 30;
+        var firstTick = Math.ceil(timeStart / tickInterval) * tickInterval;
+        for (var t = firstTick; t <= timeEnd; t += tickInterval) {
+          var x = padL + ((t - timeStart) / timeSpan) * plotW;
+          ctx.beginPath();
+          ctx.strokeStyle = gridColor;
+          ctx.moveTo(x, padT);
+          ctx.lineTo(x, padT + plotH);
+          ctx.stroke();
+          ctx.fillStyle = textColor;
+          ctx.fillText(fmtTime(t), x, padT + plotH + 4 * dp);
+        }
+
+        // axis labels
+        ctx.fillStyle = textColor;
+        ctx.textAlign = "right";
+        ctx.textBaseline = "top";
+        ctx.fillText("ms", padL - 5 * dp, padT - 2 * dp);
+
+        // plot border
+        ctx.strokeStyle = gridColor;
+        ctx.lineWidth = dp;
+        ctx.strokeRect(padL, padT, plotW, plotH);
+
+        // alert threshold line
+        if (alertThresholdMs > 0 && alertThresholdMs <= yMax) {
+          var threshY = padT + plotH - (alertThresholdMs / yMax) * plotH;
+          ctx.save();
+          ctx.setLineDash([6 * dp, 4 * dp]);
+          ctx.strokeStyle = thresholdColor;
+          ctx.lineWidth = 1.5 * dp;
+          ctx.beginPath();
+          ctx.moveTo(padL, threshY);
+          ctx.lineTo(padL + plotW, threshY);
+          ctx.stroke();
+          ctx.restore();
+        }
+
+        if (data.length < 2) return;
+
+        // filter visible data (using binary search start index)
+        var visible = [];
+        for (var i = visStart; i < data.length && data[i].t <= timeEnd; i++) {
+          visible.push(data[i]);
+        }
+        if (visible.length < 1) return;
+
+        // draw filled area
+        ctx.beginPath();
+        var started = false;
+        for (var i = 0; i < visible.length; i++) {
+          if (visible[i].lost) continue;
+          var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
+          var py = padT + plotH - (visible[i].ping / yMax) * plotH;
+          if (!started) {
+            ctx.moveTo(px, padT + plotH);
+            ctx.lineTo(px, py);
+            started = true;
+          } else {
+            ctx.lineTo(px, py);
+          }
+        }
+        // close the fill path
+        if (started) {
+          // find last non-lost point
+          for (var i = visible.length - 1; i >= 0; i--) {
+            if (!visible[i].lost) {
+              var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
+              ctx.lineTo(px, padT + plotH);
+              break;
+            }
+          }
+          ctx.closePath();
+          ctx.fillStyle = fillColor;
+          ctx.fill();
+        }
+
+        // draw line
+        ctx.beginPath();
+        started = false;
+        for (var i = 0; i < visible.length; i++) {
+          if (visible[i].lost) {
+            started = false;
+            continue;
+          }
+          var px = padL + ((visible[i].t - timeStart) / timeSpan) * plotW;
+          var py = padT + plotH - (visible[i].ping / yMax) * plotH;
+          if (!started) {
+            ctx.moveTo(px, py);
+            started = true;
+          } else {
+            ctx.lineTo(px, py);
+          }
+        }
+        ctx.strokeStyle = lineColor;
+        ctx.lineWidth = 2 * dp;
+        ctx.lineJoin = "round";
+        ctx.stroke();
+
+        // 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;
+          ctx.beginPath();
+          ctx.arc(px, padT + plotH - 10 * dp, 3 * dp, 0, Math.PI * 2);
+          ctx.fillStyle = lostColor;
+          ctx.fill();
+        }
+      }
+
+      function getYSteps(yMax) {
+        // generate sensible Y-axis grid steps
+        if (yMax <= 50) return [10, 20, 30, 40, 50];
+        if (yMax <= 100) return [20, 40, 60, 80, 100];
+        if (yMax <= 200) return [50, 100, 150, 200];
+        if (yMax <= 500) return [100, 200, 300, 400, 500];
+        // for very high values
+        var step = Math.ceil(yMax / 5 / 100) * 100;
+        var steps = [];
+        for (var v = step; v <= yMax; v += step) steps.push(v);
+        return steps;
+      }
+
+      // Animation frame loop
+      window.requestAnimationFrame =
+        window.requestAnimationFrame ||
+        window.webkitRequestAnimationFrame ||
+        window.mozRequestAnimationFrame ||
+        window.msRequestAnimationFrame ||
+        function (callback) {
+          setTimeout(callback, 1000 / 60);
+        };
+      function frame() {
+        requestAnimationFrame(frame);
+        updateUI();
+        drawChart();
+      }
+      frame();
+
+      // Alert threshold
+      function updateThreshold(val) {
+        alertThresholdMs = parseInt(val);
+        I("thresholdValue").textContent = alertThresholdMs > 0 ? alertThresholdMs + " ms" : "Off";
+      }
+
+      function playBeep() {
+        var now = new Date().getTime();
+        if (now - lastBeepTime < 1000) return; // debounce 1s
+        lastBeepTime = now;
+        try {
+          if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+          var osc = audioCtx.createOscillator();
+          var gain = audioCtx.createGain();
+          osc.frequency.value = 800;
+          gain.gain.value = 0.3;
+          osc.connect(gain);
+          gain.connect(audioCtx.destination);
+          osc.start();
+          osc.stop(audioCtx.currentTime + 0.1);
+        } catch (e) {}
+      }
+
+      // CSV download
+      function downloadCsv() {
+        if (allPingData.length === 0) return;
+        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";
+        }
+        var blob = new Blob([csv], { type: "text/csv" });
+        var url = URL.createObjectURL(blob);
+        var a = document.createElement("a");
+        a.href = url;
+        a.download = "stability_test_" + new Date().toISOString().slice(0, 19).replace(/:/g, "-") + ".csv";
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        URL.revokeObjectURL(url);
+      }
+
+      // Init — load server list first (async), then initialize UI
+      loadServers(function () {
+        initServers();
+        drawChart();
+      });
+    </script>
+  </body>
+</html>

+ 245 - 0
stability_worker.js

@@ -0,0 +1,245 @@
+/*
+	LibreSpeed - Stability Test Worker
+	https://github.com/librespeed/speedtest/
+	GNU LGPLv3 License
+*/
+
+// data reported to main thread
+let testState = -1; // -1=idle, 0=starting, 1=running, 4=finished, 5=aborted
+let currentPing = 0;
+let avgPing = 0;
+let minPing = -1;
+let maxPing = 0;
+let jitter = 0;
+let packetLoss = 0; // failed request percentage
+let elapsed = 0;
+let progress = 0;
+
+let pingData = []; // all ping data points {t: elapsedMs, ping: ms, lost: bool}
+let lastReportedIndex = 0; // for delta delivery
+let totalSamples = 0;
+let failedSamples = 0;
+let pingSum = 0;
+
+let settings = {
+  url_ping: "backend/empty.php",
+  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
+};
+
+let xhr = null;
+let startTime = 0;
+let prevInstspd = 0;
+let aborted = false;
+
+function url_sep(url) {
+  return url.match(/\?/) ? "&" : "?";
+}
+
+this.addEventListener("message", function (e) {
+  const params = e.data.split(" ");
+  if (params[0] === "status") {
+    // return current state with delta ping data
+    const newData = pingData.slice(lastReportedIndex);
+    lastReportedIndex = pingData.length;
+    postMessage(
+      JSON.stringify({
+        testState: testState,
+        currentPing: currentPing,
+        avgPing: avgPing,
+        minPing: minPing,
+        maxPing: maxPing,
+        jitter: jitter,
+        packetLoss: packetLoss,
+        elapsed: elapsed,
+        duration: settings.duration,
+        progress: progress,
+        pingData: newData,
+        totalSamples: totalSamples,
+        failedSamples: failedSamples
+      })
+    );
+  }
+  if (params[0] === "start" && testState === -1) {
+    testState = 0;
+    try {
+      let s = {};
+      try {
+        const ss = e.data.substring(6);
+        if (ss) s = JSON.parse(ss);
+      } catch (e) {
+        console.warn("Error parsing settings JSON");
+      }
+      for (let key in s) {
+        if (typeof settings[key] !== "undefined") settings[key] = s[key];
+      }
+    } catch (e) {
+      console.warn("Error applying settings: " + e);
+    }
+    // start the stability test
+    aborted = false;
+    startTime = new Date().getTime();
+    testState = 1;
+    doPing();
+  }
+  if (params[0] === "abort") {
+    if (testState >= 4) return;
+    aborted = true;
+    testState = 5;
+    if (xhr) {
+      try {
+        xhr.abort();
+      } catch (e) {}
+    }
+  }
+});
+
+function recordPing(instspd) {
+  // guard against 0ms pings
+  if (instspd < 1) instspd = prevInstspd;
+  if (instspd < 1) instspd = 1;
+
+  totalSamples++;
+  currentPing = instspd;
+  pingSum += instspd;
+  avgPing = parseFloat((pingSum / (totalSamples - failedSamples)).toFixed(2));
+
+  if (minPing === -1 || instspd < minPing) minPing = instspd;
+  if (instspd > maxPing) maxPing = instspd;
+
+  // jitter calculation (same weighted formula as speedtest_worker.js)
+  if (totalSamples > 1 && prevInstspd > 0) {
+    const instjitter = Math.abs(instspd - prevInstspd);
+    if (totalSamples === 2) {
+      jitter = instjitter;
+    } else {
+      jitter = instjitter > jitter ? jitter * 0.3 + instjitter * 0.7 : jitter * 0.8 + instjitter * 0.2;
+    }
+  }
+  prevInstspd = instspd;
+
+  // failed request percentage
+  packetLoss = totalSamples > 0 ? parseFloat(((failedSamples / totalSamples) * 100).toFixed(2)) : 0;
+
+  // record data point
+  const now = new Date().getTime();
+  elapsed = (now - startTime) / 1000;
+  pingData.push({ t: elapsed, ping: parseFloat(instspd.toFixed(2)), lost: false });
+}
+
+function recordLoss() {
+  const now = new Date().getTime();
+  totalSamples++;
+  failedSamples++;
+  packetLoss = parseFloat(((failedSamples / totalSamples) * 100).toFixed(2));
+  elapsed = (now - startTime) / 1000;
+  pingData.push({ t: elapsed, ping: 0, lost: true });
+}
+
+// pace pings to avoid excessive sample rates on low-latency links
+function schedulePing(rtt) {
+  const delay = Math.max(0, settings.ping_interval - rtt);
+  if (delay > 0) {
+    setTimeout(doPing, delay);
+  } else {
+    doPing();
+  }
+}
+
+function doPing() {
+  if (aborted || testState >= 4) return;
+
+  // check if duration exceeded
+  const now = new Date().getTime();
+  elapsed = (now - startTime) / 1000;
+  progress = Math.min(1, elapsed / settings.duration);
+  if (elapsed >= settings.duration) {
+    testState = 4;
+    progress = 1;
+    return;
+  }
+
+  // external ping mode: use fetch with no-cors
+  if (settings.url_ping_external) {
+    doPingExternal();
+    return;
+  }
+
+  const prevT = new Date().getTime();
+  xhr = new XMLHttpRequest();
+  xhr.onload = function () {
+    if (aborted || testState >= 4) return;
+    const now = new Date().getTime();
+    let instspd = now - prevT;
+
+    if (settings.ping_allowPerformanceApi) {
+      try {
+        let p = performance.getEntries();
+        p = p[p.length - 1];
+        let d = p.responseStart - p.requestStart;
+        if (d <= 0) d = p.duration;
+        if (d > 0 && d < instspd) instspd = d;
+      } catch (e) {
+        // Performance API not available, use estimate
+      }
+    }
+
+    recordPing(instspd);
+    schedulePing(instspd);
+  };
+  xhr.onerror = function () {
+    if (aborted || testState >= 4) return;
+    recordLoss();
+    schedulePing(0);
+  };
+  xhr.ontimeout = xhr.onerror;
+  xhr.open(
+    "GET",
+    settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(),
+    true
+  );
+  try {
+    xhr.timeout = settings.ping_timeout;
+  } catch (e) {}
+  xhr.send();
+}
+
+// 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();
+
+  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);
+    });
+}

+ 7 - 2
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
 };

+ 125 - 0
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 });
+  });
+});