6 커밋 bd8501ad13 ... be1e0a0daa

작성자 SHA1 메시지 날짜
  Copilot be1e0a0daa Fix mixed-content server selection (#855) 6 일 전
  Copilot 449e9962ed Keep the modern mobile gauge row in view when a test starts (#854) 1 주 전
  dependabot[bot] 678d8f6bf3 chore(deps-dev): bump js-yaml from 4.3.1 to 4.3.2 (#853) 1 주 전
  Stefan Stidl 0490f3e935 chore(release): bump version to 6.3.0 1 주 전
  Copilot fa2e28626a Prevent stability ping values from freezing on long tests (#851) 1 주 전
  marcel1702 0603db7b37 Don't force the IE11 upload workaround on Safari (#847) 1 주 전

+ 2 - 2
doc.md

@@ -1,7 +1,7 @@
 # LibreSpeed
 
 > by Federico Dossena
-> Version 6.2.1
+> Version 6.3.0
 > [https://github.com/librespeed/speedtest/](https://github.com/librespeed/speedtest/)
 
 ## Introduction
@@ -732,7 +732,7 @@ To keep track of the amount of transferred data, the XHR Level 2 `upload.onprogr
 
 This test has a couple of complications:
 
-* Some browsers don't have a working `upload.onprogress` event. For this, we use a small blobs instead of a large one and we keep track of progress using the `onload` event. This is referred to as IE11 Workaround (but the same bug was also found in some versions of Edge and Safari)
+* Some browsers don't have a working `upload.onprogress` event. For this, we use a small blobs instead of a large one and we keep track of progress using the `onload` event. This is referred to as IE11 Workaround. Browsers that expose no usable `xhr.upload` object, such as IE11, select it automatically through feature detection. Some versions of Edge and the PlayStation 4 browser do have an `xhr.upload` object whose events never fire, which feature detection cannot see, so those two are still matched by user agent. Safari is __not__ affected and uses the regular, more accurate upload test
 * When `mpot` is set to `true`, an empty request must first be sent in order to load the CORS headers before the test can start
 
 See the code for more implementation details.

+ 42 - 0
frontend/javascript/index.js

@@ -16,6 +16,8 @@ const testState = {
   state: INITIALIZING,
   speedtest: null,
   servers: [],
+  initialGaugeScrollPending: false,
+  initialGaugeScrollScheduled: false,
   selectedServerDirty: false,
   testData: null,
   testDataDirty: false,
@@ -81,6 +83,7 @@ function startButtonClickHandler() {
     case READY:
     case FINISHED:
       testState.speedtest.start();
+      testState.initialGaugeScrollPending = true;
       testState.state = RUNNING;
       return;
     case RUNNING:
@@ -92,6 +95,30 @@ function startButtonClickHandler() {
   }
 }
 
+/**
+ * Scroll the initial download gauge into view on narrow viewports when starting a test
+ */
+function scrollInitialDownloadGaugeIntoView() {
+  if (!window.matchMedia("(max-width: 800px)").matches) {
+    return;
+  }
+
+  const downloadGauge = document.querySelector("#download-gauge");
+  if (!downloadGauge) {
+    return;
+  }
+
+  const { top, bottom } = downloadGauge.getBoundingClientRect();
+  if (top >= 0 && bottom <= window.innerHeight) {
+    return;
+  }
+
+  downloadGauge.scrollIntoView({
+    block: "center",
+    inline: "nearest",
+  });
+}
+
 /**
  * Event listener for clicks on the "Copy link" button in the modal
  */
@@ -349,6 +376,21 @@ function startRenderingLoop() {
       )
     );
 
+    if (
+      testState.state === RUNNING &&
+      testState.initialGaugeScrollPending &&
+      !testState.initialGaugeScrollScheduled
+    ) {
+      testState.initialGaugeScrollScheduled = true;
+      requestAnimationFrame(() => {
+        if (testState.state === RUNNING) {
+          scrollInitialDownloadGaugeIntoView();
+        }
+        testState.initialGaugeScrollPending = false;
+        testState.initialGaugeScrollScheduled = false;
+      });
+    }
+
     // Show ping and jitter if data is available
     pingAndJitter.forEach((e) =>
       e.classList.toggle(

+ 5 - 5
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "librespeed-speedtest",
-  "version": "6.2.1",
+  "version": "6.3.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "librespeed-speedtest",
-      "version": "6.2.1",
+      "version": "6.3.0",
       "license": "LGPL-3.0-or-later",
       "devDependencies": {
         "@playwright/test": "^1.55.0",
@@ -811,9 +811,9 @@
       "license": "ISC"
     },
     "node_modules/js-yaml": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
-      "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+      "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
       "dev": true,
       "funding": [
         {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "librespeed-speedtest",
-  "version": "6.2.1",
+  "version": "6.3.0",
   "description": "LibreSpeed - A Free and Open Source speed test that you can host on your server(s)",
   "main": "speedtest.js",
   "scripts": {

+ 6 - 2
speedtest.js

@@ -49,7 +49,7 @@ function Speedtest() {
   this._settings = {}; //settings for the speed test worker
   this._state = 0; //0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done
   console.log(
-    "LibreSpeed by Federico Dossena v6.2.1 - https://github.com/librespeed/speedtest"
+    "LibreSpeed by Federico Dossena v6.3.0 - https://github.com/librespeed/speedtest"
   );
 }
 
@@ -239,7 +239,11 @@ Speedtest.prototype = {
       const checkServer = function(server, done) {
         let i = 0;
         server.pingT = -1;
-        if (server.server.indexOf(location.protocol) == -1) done();
+        if (
+          location.protocol === "https:" &&
+          server.server.substring(0, 7).toLowerCase() === "http://"
+        )
+          done();
         else {
           const nextPing = function() {
             if (i++ == PINGS) {

+ 8 - 8
speedtest_worker.js

@@ -146,22 +146,22 @@ this.addEventListener("message", function(e) {
 				}
 			}
 			if (/Edge.(\d+\.\d+)/i.test(ua)) {
-				//Edge 15 introduced a bug that causes onprogress events to not get fired, we have to use the "small chunks" workaround that reduces accuracy
-				settings.forceIE11Workaround = true;
+				if (typeof s.forceIE11Workaround === "undefined") {
+					//Edge 15 introduced a bug that causes onprogress events to not get fired, we have to use the "small chunks" workaround that reduces accuracy
+					settings.forceIE11Workaround = true;
+				}
 			}
 			if (/PlayStation 4.(\d+\.\d+)/i.test(ua)) {
-				//PS4 browser has the same bug as IE11/Edge
-				settings.forceIE11Workaround = true;
+				if (typeof s.forceIE11Workaround === "undefined") {
+					//PS4 browser has the same bug as IE11/Edge
+					settings.forceIE11Workaround = true;
+				}
 			}
 			if (/Chrome.(\d+)/i.test(ua) && /Android|iPhone|iPad|iPod|Windows Phone/i.test(ua)) {
 				//cheap af
 				//Chrome mobile introduced a limitation somewhere around version 65, we have to limit XHR upload size to 4 megabytes
 				settings.xhr_ul_blob_megabytes = 4;
 			}
-			if (/^((?!chrome|android|crios|fxios).)*safari/i.test(ua)) {
-				//Safari also needs the IE11 workaround but only for the MPOT version
-				settings.forceIE11Workaround = true;
-			}
 			//telemetry_level has to be parsed and not just copied
 			if (typeof s.telemetry_level !== "undefined") settings.telemetry_level = s.telemetry_level === "basic" ? 1 : s.telemetry_level === "full" ? 2 : s.telemetry_level === "debug" ? 3 : 0; // telemetry level
 			//transform test_order to uppercase, just in case

+ 4 - 0
stability_worker.js

@@ -185,6 +185,10 @@ function doPing() {
         if (d > 0 && d < instspd) instspd = d;
       } catch (e) {
         // Performance API not available, use estimate
+      } finally {
+        try {
+          performance.clearResourceTimings();
+        } catch (e) {}
       }
     }
 

+ 99 - 0
tests/e2e/mobile-gauge-visibility.spec.js

@@ -0,0 +1,99 @@
+const { test, expect } = require("@playwright/test");
+const { modernStartButton } = require("./helpers/ui");
+
+test.use({ viewport: { width: 390, height: 560 } });
+
+test.describe("Mobile gauge visibility", () => {
+  test("keeps the active gauge visible after pressing Start", async ({ page }) => {
+    await page.route("**/speedtest.js", async (route) => {
+      await route.fulfill({
+        contentType: "text/javascript; charset=utf-8",
+        body: `
+          class Speedtest {
+            constructor() {
+              this.selectedServer = null;
+              this.onupdate = null;
+              this.onend = null;
+            }
+
+            start() {
+              this.onupdate?.({
+                dlProgress: 0.1,
+                ulProgress: 0,
+                dlStatus: "123.45",
+                ulStatus: "0",
+                pingStatus: "10.5",
+                jitterStatus: "0.9",
+                testState: 1
+              });
+            }
+
+            abort() {
+              this.onend?.(true);
+            }
+
+            setParameter() {}
+            addTestPoints() {}
+            selectServer(callback) {
+              callback(this.selectedServer);
+            }
+            setSelectedServer(server) {
+              this.selectedServer = server;
+            }
+            getSelectedServer() {
+              return this.selectedServer;
+            }
+          }
+
+          window.Speedtest = Speedtest;
+        `,
+      });
+    });
+
+    await page.route("**/settings.json", async (route) => {
+      await route.fulfill({
+        contentType: "application/json; charset=utf-8",
+        body: JSON.stringify({ telemetry_level: "off" }),
+      });
+    });
+
+    await page.route("**/server-list.json", async (route) => {
+      await route.fulfill({
+        contentType: "application/json; charset=utf-8",
+        body: JSON.stringify([
+          {
+            name: "Test Server",
+            server: "http://127.0.0.1:18184/backend/",
+          },
+        ]),
+      });
+    });
+
+    await page.goto("http://127.0.0.1:18184/index-modern.html");
+
+    const startButton = modernStartButton(page);
+    const downloadGauge = page.locator("#download-gauge");
+
+    await expect(startButton).toHaveText("Let's start", { timeout: 10_000 });
+
+    await page.evaluate(() => window.scrollTo(0, 0));
+
+    const gaugeStartsBelowFold = await downloadGauge.evaluate((element) => {
+      const { top, bottom } = element.getBoundingClientRect();
+      return bottom > window.innerHeight || top < 0;
+    });
+    expect(gaugeStartsBelowFold).toBe(true);
+
+    await startButton.click();
+    await expect(startButton).toHaveText("Abort");
+
+    await expect
+      .poll(() =>
+        downloadGauge.evaluate((element) => {
+          const { top, bottom } = element.getBoundingClientRect();
+          return top >= 0 && bottom <= window.innerHeight;
+        })
+      )
+      .toBe(true);
+  });
+});

+ 113 - 0
tests/e2e/server-selection.spec.js

@@ -0,0 +1,113 @@
+const { test, expect } = require('@playwright/test');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const speedtestSource = fs.readFileSync(
+  path.resolve(__dirname, '../../speedtest.js'),
+  'utf8'
+);
+
+function createHarness(protocol, responders = {}) {
+  const requests = [];
+
+  function FakeXMLHttpRequest() {
+    this.responseText = '';
+    this.timeout = 0;
+  }
+
+  FakeXMLHttpRequest.prototype.open = function open(method, url) {
+    this._method = method;
+    this._url = url;
+  };
+
+  FakeXMLHttpRequest.prototype.send = function send() {
+    requests.push(this._url);
+    const baseUrl = this._url.replace(/[?&]cors=true$/, '');
+    const outcome = responders[baseUrl] || 'error';
+
+    if (outcome === 'success') {
+      if (typeof this.onload === 'function') {
+        this.onload();
+      }
+      return;
+    }
+
+    if (typeof this.onerror === 'function') {
+      this.onerror(new Error(`Request failed for ${baseUrl}`));
+    }
+  };
+
+  const context = {
+    console: { log() {} },
+    Date,
+    location: { protocol },
+    navigator: { userAgent: 'Playwright' },
+    performance: { getEntriesByName: () => [] },
+    XMLHttpRequest: FakeXMLHttpRequest
+  };
+
+  vm.createContext(context);
+  vm.runInContext(speedtestSource, context, { filename: 'speedtest.js' });
+
+  return { Speedtest: context.Speedtest, requests };
+}
+
+function createServer(server) {
+  return {
+    name: server,
+    server,
+    dlURL: 'garbage.php',
+    ulURL: 'empty.php',
+    pingURL: 'empty.php',
+    getIpURL: 'getIP.php'
+  };
+}
+
+function selectServer(speedtest) {
+  return new Promise((resolve) => {
+    speedtest.selectServer(resolve);
+  });
+}
+
+test.describe('Speedtest server selection protocol handling', () => {
+  test('allows HTTPS backends to be pinged from an HTTP frontend', async () => {
+    const secureServer = 'https://secure.example/';
+    const { Speedtest, requests } = createHarness('http:', {
+      [`${secureServer}empty.php`]: 'success'
+    });
+
+    const speedtest = new Speedtest();
+    speedtest.addTestPoint(createServer(secureServer));
+
+    const selectedServer = await selectServer(speedtest);
+
+    expect(selectedServer.server).toBe(secureServer);
+    expect(
+      requests.some((url) => url.startsWith(`${secureServer}empty.php`))
+    ).toBeTruthy();
+  });
+
+  test('still skips insecure HTTP backends from an HTTPS frontend', async () => {
+    const insecureServer = 'http://insecure.example/';
+    const secureServer = 'https://secure.example/';
+    const { Speedtest, requests } = createHarness('https:', {
+      [`${insecureServer}empty.php`]: 'success',
+      [`${secureServer}empty.php`]: 'success'
+    });
+
+    const speedtest = new Speedtest();
+    speedtest.addTestPoint(createServer(insecureServer));
+    speedtest.addTestPoint(createServer(secureServer));
+
+    const selectedServer = await selectServer(speedtest);
+
+    expect(selectedServer.server).toBe(secureServer);
+    expect(
+      requests.some((url) => url.startsWith(`${insecureServer}empty.php`))
+    ).toBeFalsy();
+    expect(
+      requests.some((url) => url.startsWith(`${secureServer}empty.php`))
+    ).toBeTruthy();
+  });
+});

+ 39 - 0
tests/e2e/stability.spec.js

@@ -1,8 +1,11 @@
 const fs = require("node:fs");
+const path = require("node:path");
 const { test, expect } = require("@playwright/test");
 const { baseUrls } = require("./helpers/env");
 const { stabilityStartButton } = require("./helpers/ui");
 
+const workerSource = fs.readFileSync(path.join(__dirname, "..", "..", "stability_worker.js"), "utf8");
+
 async function setShortDuration(page) {
   await page.evaluate(() => {
     const select = document.querySelector("#durationSelect");
@@ -122,4 +125,40 @@ test.describe("Stability test", () => {
     await expect(page.locator("#serverArea")).toBeVisible({ timeout: 10_000 });
     await expect(page.locator("#server option")).toContainText("Local dual backend", { timeout: 10_000 });
   });
+
+  test("clears resource timings after measuring a ping", async ({ page }) => {
+    await page.goto(`${baseUrls.standalone}/stability.html`);
+    await page.route(`${baseUrls.backend}/empty.php?cors=true&r=*`, route => route.fulfill({ status: 200, headers: { "Access-Control-Allow-Origin": "*" }, body: "" }));
+
+    await expect(
+      page.evaluate(
+        async ({ source, url }) => {
+          const instrumentedSource = `
+          const clearResourceTimings = performance.clearResourceTimings.bind(performance);
+          performance.clearResourceTimings = () => {
+            postMessage("resource timings cleared");
+            clearResourceTimings();
+          };
+          ${source}
+        `;
+          const worker = new Worker(URL.createObjectURL(new Blob([instrumentedSource], { type: "text/javascript" })));
+          try {
+            await new Promise((resolve, reject) => {
+              const timeout = setTimeout(() => reject(new Error("Resource timings were not cleared")), 10_000);
+              worker.onmessage = event => {
+                if (event.data === "resource timings cleared") {
+                  clearTimeout(timeout);
+                  resolve();
+                }
+              };
+              worker.postMessage(`start ${JSON.stringify({ url_ping: url, duration: 1, mpot: true })}`);
+            });
+          } finally {
+            worker.terminate();
+          }
+        },
+        { source: workerSource, url: `${baseUrls.backend}/empty.php` }
+      )
+    ).resolves.toBeUndefined();
+  });
 });

+ 91 - 0
tests/e2e/worker-browser-quirks.spec.js

@@ -0,0 +1,91 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { test, expect } = require("@playwright/test");
+
+const workerSource = fs.readFileSync(path.join(__dirname, "..", "..", "speedtest_worker.js"), "utf8");
+
+const userAgents = {
+  safariMac:
+    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
+  safariIos:
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
+  chromeIos:
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1",
+  firefoxIos:
+    "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/124.0 Mobile/15E148 Safari/605.1.15",
+  chromeAndroid:
+    "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36",
+  edge16:
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
+  playstation4: "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)"
+};
+
+/*
+  Loads speedtest_worker.js into a page that reports the given user agent, feeds it a "start"
+  command and reports back the settings the worker resolved. The "_" test step is a no-op delay,
+  so the worker settles its settings without performing any network I/O.
+*/
+async function resolveSettings(browser, userAgent, customSettings) {
+  const context = await browser.newContext({ userAgent });
+  try {
+    const page = await context.newPage();
+    await page.goto("about:blank");
+    await page.addScriptTag({ content: workerSource });
+    return await page.evaluate(custom => {
+      window.dispatchEvent(new MessageEvent("message", { data: "start " + JSON.stringify(custom) }));
+      return {
+        userAgent: navigator.userAgent,
+        forceIE11Workaround: settings.forceIE11Workaround,
+        xhr_ul_blob_megabytes: settings.xhr_ul_blob_megabytes
+      };
+    }, Object.assign({ test_order: "_" }, customSettings));
+  } finally {
+    await context.close();
+  }
+}
+
+test.describe("speedtest_worker browser quirks", () => {
+  test("Safari uses the accurate upload test instead of the IE11 workaround", async ({ browser }) => {
+    for (const ua of [userAgents.safariMac, userAgents.safariIos]) {
+      const resolved = await resolveSettings(browser, ua);
+      expect(resolved.userAgent, "context user agent should be applied").toBe(ua);
+      expect(resolved.forceIE11Workaround, `${ua} should not force the IE11 workaround`).toBe(false);
+      expect(resolved.xhr_ul_blob_megabytes).toBe(20);
+    }
+  });
+
+  test("Safari, Chrome and Firefox on iOS resolve to the same upload path", async ({ browser }) => {
+    const safari = await resolveSettings(browser, userAgents.safariIos);
+    const chrome = await resolveSettings(browser, userAgents.chromeIos);
+    const firefox = await resolveSettings(browser, userAgents.firefoxIos);
+    expect(safari.forceIE11Workaround).toBe(chrome.forceIE11Workaround);
+    expect(safari.forceIE11Workaround).toBe(firefox.forceIE11Workaround);
+  });
+
+  test("Safari in MPOT mode also uses the accurate upload test", async ({ browser }) => {
+    const resolved = await resolveSettings(browser, userAgents.safariMac, { mpot: true });
+    expect(resolved.forceIE11Workaround).toBe(false);
+  });
+
+  test("Chrome mobile still caps the upload blob at 4 megabytes", async ({ browser }) => {
+    const resolved = await resolveSettings(browser, userAgents.chromeAndroid);
+    expect(resolved.xhr_ul_blob_megabytes).toBe(4);
+    expect(resolved.forceIE11Workaround).toBe(false);
+  });
+
+  test("Edge and the PlayStation 4 browser keep the IE11 workaround by default", async ({ browser }) => {
+    for (const ua of [userAgents.edge16, userAgents.playstation4]) {
+      const resolved = await resolveSettings(browser, ua);
+      expect(resolved.forceIE11Workaround, `${ua} should keep the IE11 workaround`).toBe(true);
+    }
+  });
+
+  test("an explicitly passed forceIE11Workaround is not overwritten by the quirks", async ({ browser }) => {
+    for (const ua of [userAgents.edge16, userAgents.playstation4]) {
+      const off = await resolveSettings(browser, ua, { forceIE11Workaround: false });
+      expect(off.forceIE11Workaround, `${ua} should honour an explicit false`).toBe(false);
+    }
+    const on = await resolveSettings(browser, userAgents.safariMac, { forceIE11Workaround: true });
+    expect(on.forceIE11Workaround, "an explicit true should still force the workaround").toBe(true);
+  });
+});