diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..16926cfeb --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,41 @@ +{ + "env": { + "browser": true, + "es6": true, + "worker": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2015, + "sourceType": "script" + }, + "globals": { + "XMLHttpRequest": "readonly", + "Worker": "readonly", + "Blob": "readonly", + "URL": "readonly", + "navigator": "readonly", + "document": "readonly", + "window": "readonly", + "console": "readonly", + "setTimeout": "readonly", + "clearTimeout": "readonly", + "setInterval": "readonly", + "clearInterval": "readonly", + "postMessage": "readonly", + "onmessage": "writable", + "addEventListener": "readonly" + }, + "rules": { + "no-unused-vars": [ + "warn", + { + "args": "none" + } + ], + "no-console": "off", + "no-empty": "warn", + "no-undef": "warn", + "no-const-assign": "error" + } +} \ No newline at end of file diff --git a/.github/agents/release-agent.agent.md b/.github/agents/release-agent.agent.md new file mode 100644 index 000000000..5e9c0aa9c --- /dev/null +++ b/.github/agents/release-agent.agent.md @@ -0,0 +1,104 @@ +--- +# Fill in the fields below to create a basic custom agent for your repository. +# The Copilot CLI can be used for local testing: https://gh.io/customagents/cli +# To make this agent available, merge this file into the default repository branch. +# For format details, see: https://gh.io/customagents/config + +name: Release Agent +description: prepares releases, updates release tags in code, commits release changes, and creates the release tag +--- + +# My Agent + +You are a release automation agent for this repository. + +## Purpose + +Prepare and finalize releases by analyzing repository state, updating release tags in the codebase, committing those changes, and creating the corresponding Git tag for the release. + +## Responsibilities + +- Inspect recent changes relevant to the next release +- Summarize user-facing changes, fixes, and breaking changes +- Draft release notes in clear markdown +- Propose a semantic version bump with justification +- Identify and update version or release tag references in the repository +- Commit the release-tag changes +- Create the release Git tag +- Identify release risks, missing checks, and follow-up items +- Verify that release artifacts and documentation appear consistent + +## Workflow + +1. Determine the likely release scope from recent commits, merged pull requests, and changed files +2. Group changes into: + - Features + - Fixes + - Documentation + - Maintenance + - Breaking changes +3. Recommend the next version: + - patch for fixes or internal changes + - minor for backward-compatible features + - major for breaking changes +4. Identify files that contain the current version or release tag +5. Update those files to the new release version +6. Create a commit for the release changes +7. Create the Git tag for the release +8. Draft release notes with: + - title + - summary + - highlights + - breaking changes, if any + - migration notes, if any + - acknowledgments, if available +9. Flag anything that should block or delay the release: + - failing or missing tests + - missing changelog entries + - undocumented breaking changes + - version inconsistencies + - incomplete release artifacts + +## Output Format + +When asked to prepare a release, respond with these sections: + +### Proposed Version +`` with a brief justification + +### Files Updated +List of changed files and the version changes made + +### Release Summary +A short paragraph describing the release + +### Release Notes +Markdown-ready notes suitable for a GitHub release + +### Commit Message +Proposed or created commit message + +### Release Tag +Proposed or created tag name + +### Risks / Blockers +Bullet list of anything that needs attention before release + +### Recommended Next Actions +Short, concrete next steps + +### Result +State whether the release changes were prepared only, committed, or fully tagged + +## Constraints + +- Prefer evidence from the repository over assumptions +- Be precise and concise +- Do not invent changes that are not supported by repository history +- Clearly label uncertain conclusions +- Only change files directly related to versioning or release tagging unless explicitly instructed otherwise +- Keep version updates consistent across all affected files +- Use semantic versioning unless the repository clearly uses a different scheme +- Do not guess hidden release rules; infer them from the repository +- If required files are missing or version locations are ambiguous, explain the issue clearly +- Ask for explicit confirmation before any action that would publish or mutate release state diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..b023cda46 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "docker" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..08365148b --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,30 @@ +name: Playwright E2E + +# Manual-only by design: +# - Automatic e2e runs that gate Docker image builds live in docker-publish.yml. +# - Keeping this workflow manual avoids running the same e2e suite twice on master pushes. +on: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npm run test:e2e diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..aae25669f --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,31 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '32 18 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v11 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' + stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' + days-before-stale: 60 + days-before-close: 7 + close-issue-message: 'This issue was automatically closed due to inactivity.' + close-pr-message: 'This pull request was automatically closed due to inactivity.' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' diff --git a/.gitignore b/.gitignore index 302804e0f..1e0cf6e31 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ results/idObfuscation_salt.php backend/getIP_serverLocation.php db-dir/ +.vscode/ +node_modules/ +playwright-report/ +test-results/ +results/speedtest_telemetry.db +results/speedtest_telemetry.db-shm +results/speedtest_telemetry.db-wal +docker/test/.env diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..fb8c42634 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +node_modules/ +backend/ +results/ +docker/ +.git/ +*.php +*.md +*.sql +*.mmdb +*.phar +*.ttf +*.ico +*.png +*.gif diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..5c1d57f77 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "ignore" +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 884c64355..000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "recommendations": [ - "github.vscode-github-actions" - ] -} \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..66e4eb474 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,122 @@ +# Development Guide + +This document describes how to use the package manager (npm) for development tasks. + +## Prerequisites + +- Node.js 14.0.0 or higher +- npm (comes with Node.js) + +## Getting Started + +Install development dependencies: + +```bash +npm install +``` + +## Available Scripts + +### Linting + +Check code for potential issues: + +```bash +npm run lint +``` + +Automatically fix linting issues where possible: + +```bash +npm run lint:fix +``` + +### Code Formatting + +Check code formatting (JavaScript files only): + +```bash +npm run format:check +``` + +Format JavaScript files: + +```bash +npm run format +``` + +> **Note**: The existing codebase uses its original formatting style. Prettier and ESLint are provided as optional tools for new code or improvements. + +### Validation + +Run all checks (formatting and linting): + +```bash +npm run validate +``` + +### Testing + +Currently, there are no automated tests configured: + +```bash +npm run test +``` + +### Docker + +Build Docker images: + +```bash +npm run docker:build # Standard Debian-based image +npm run docker:build-alpine # Alpine-based image +``` + +## Development Tools + +The package manager setup includes: + +- **ESLint**: JavaScript linting tool to catch common errors +- **Prettier**: Code formatting tool to maintain consistent style + +These tools are configured but non-intrusive to the existing codebase. + +## Project Structure + +``` +. +├── speedtest.js # Main speedtest library +├── speedtest_worker.js # Web Worker for speed testing +├── index.html # Default UI +├── backend/ # PHP backend files +├── examples/ # Example implementations +├── results/ # Results/telemetry handling +├── docker/ # Docker-related files +└── package.json # npm package configuration +``` + +## Why Use a Package Manager? + +The package manager provides several benefits: + +1. **Standardized tooling**: Common commands across different environments +2. **Development dependencies**: Easy installation of linting and formatting tools +3. **Project metadata**: Version, description, and licensing information +4. **Future extensibility**: Foundation for adding build tools, tests, or bundlers if needed +5. **npm distribution**: Makes the library easy to use in other Node.js projects + +## Contributing + +When making changes: + +1. Run `npm run lint` to check for potential issues +2. Consider running `npm run format` on new files for consistency +3. Test your changes manually in a browser +4. For PHP backend changes, test with the appropriate server setup + +## Notes + +- The core library has **no runtime dependencies** - it's pure vanilla JavaScript +- Development dependencies (ESLint, Prettier) are only needed for development +- The library can still be used standalone without npm (just include the JS files) +- npm setup is completely optional and doesn't change how the library is deployed diff --git a/Dockerfile b/Dockerfile index c55bc1eb0..b7beba52b 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,18 @@ # Pinned to a patched release (CVE-2026-109618/109619). Bump deliberately; do not use a floating tag. FROM php:8.4.27-apache -# Install extensions -RUN apt-get update && apt-get install -y \ - libfreetype6-dev \ - libjpeg62-turbo-dev \ - libpng-dev \ - libpq-dev \ - && docker-php-ext-install -j$(nproc) iconv \ - && docker-php-ext-configure gd --with-freetype=/usr/include/ --with-jpeg=/usr/include/ \ - && docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \ - && docker-php-ext-install -j$(nproc) gd pdo pdo_mysql pdo_pgsql pgsql \ - && rm -rf /var/lib/apt/lists/* - -COPY docker/speedtest-php.ini ${PHP_INI_DIR}/conf.d/99-speedtest.ini +# use docker-php-extension-installer for automatically get the right packages installed +ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ +# Install extensions and cleanup in a single layer to reduce image size +RUN install-php-extensions iconv gd pdo pdo_mysql pdo_pgsql pgsql \ + && rm -f /usr/src/php.tar.xz /usr/src/php.tar.xz.asc \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# RUN sudo apt install mysql-server -y +COPY docker/librespeed-php.ini ${PHP_INI_DIR}/conf.d/99-librespeed.ini +COPY docker/speedtest-php.ini ${PHP_INI_DIR}/conf.d/99-speedtest.ini # Prepare files and folders @@ -30,13 +26,18 @@ COPY results/*.php /speedtest/results/ COPY results/*.ttf /speedtest/results/ COPY *.js /speedtest/ +COPY index.html /speedtest/ +COPY settings.json /speedtest/ +COPY server-list.json /speedtest/ +COPY stability.html /speedtest/ COPY favicon.ico /speedtest/ +COPY manifest.webmanifest /speedtest/ +COPY images/ /speedtest/images/ COPY docker/servers.json /servers.json COPY logo.png /speedtest/ -COPY docker/*.php /speedtest/ COPY docker/entrypoint.sh / # Prepare default environment variables @@ -51,6 +52,19 @@ ENV WEBPORT=80 # https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop STOPSIGNAL SIGWINCH +# Add labels for better metadata +LABEL org.opencontainers.image.title="LibreSpeed" +LABEL org.opencontainers.image.description="A Free and Open Source speed test that you can host on your server(s)" +LABEL org.opencontainers.image.vendor="LibreSpeed" +LABEL org.opencontainers.image.url="https://github.com/librespeed/speedtest" +LABEL org.opencontainers.image.source="https://github.com/librespeed/speedtest" +LABEL org.opencontainers.image.documentation="https://github.com/librespeed/speedtest/blob/master/doc_docker.md" +LABEL org.opencontainers.image.licenses="LGPL-3.0-or-later" + +# Add health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:${WEBPORT}/ || exit 1 + # Final touches -EXPOSE 80 -CMD ["bash", "/entrypoint.sh"] \ No newline at end of file +EXPOSE ${WEBPORT} +CMD ["bash", "/entrypoint.sh"] diff --git a/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 000000000..e7f352d5b --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,83 @@ +FROM alpine:3.24 +RUN apk add --quiet --no-cache \ + bash \ + apache2 \ + ca-certificates \ + php \ + php-apache2 \ + php-ctype \ + php-phar \ + php-gd \ + php-openssl \ + php-pdo \ + php-pdo_mysql \ + php-pdo_pgsql \ + php-pdo_sqlite \ + php-pgsql \ + php-session \ + php-sqlite3 + +RUN ln -sf /dev/stdout /var/log/apache2/access.log && \ + ln -sf /dev/stderr /var/log/apache2/error.log + +# Install the ini into the scan dir PHP itself reports. +COPY docker/librespeed-php.ini /tmp/librespeed-php.ini +RUN set -eu; \ + scan_dir="$(php -r 'echo rtrim(PHP_CONFIG_FILE_SCAN_DIR);')"; \ + if [ -z "$scan_dir" ]; then \ + echo "ERROR: PHP_CONFIG_FILE_SCAN_DIR is empty" >&2; \ + exit 1; \ + fi; \ + install -m 0644 /tmp/librespeed-php.ini "$scan_dir/99-librespeed.ini"; \ + rm /tmp/librespeed-php.ini + +# Prepare files and folders +RUN mkdir -p /speedtest/ + +# Copy sources +COPY backend/ /speedtest/backend + +COPY results/*.php /speedtest/results/ +COPY results/*.ttf /speedtest/results/ + +COPY *.js /speedtest/ +COPY index.html /speedtest/ +COPY settings.json /speedtest/ +COPY server-list.json /speedtest/ +COPY stability.html /speedtest/ +COPY favicon.ico /speedtest/ +COPY manifest.webmanifest /speedtest/ +COPY images/ /speedtest/images/ + +COPY docker/entrypoint.sh / + +# Prepare default environment variables +ENV TITLE=LibreSpeed +ENV MODE=standalone +ENV PASSWORD=password +ENV TELEMETRY=false +ENV ENABLE_ID_OBFUSCATION=false +ENV REDACT_IP_ADDRESSES=false +ENV WEBPORT=8080 + +# https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop +STOPSIGNAL SIGWINCH + +# Add labels for better metadata +LABEL org.opencontainers.image.title="LibreSpeed" +LABEL org.opencontainers.image.description="A Free and Open Source speed test that you can host on your server(s)" +LABEL org.opencontainers.image.vendor="LibreSpeed" +LABEL org.opencontainers.image.url="https://github.com/librespeed/speedtest" +LABEL org.opencontainers.image.source="https://github.com/librespeed/speedtest" +LABEL org.opencontainers.image.documentation="https://github.com/librespeed/speedtest/blob/master/doc_docker.md" +LABEL org.opencontainers.image.licenses="LGPL-3.0-or-later" + +# Add health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:${WEBPORT}/ || exit 1 + +WORKDIR /var/www/html + +# Final touches +EXPOSE ${WEBPORT} +CMD ["bash", "/entrypoint.sh"] diff --git a/README.md b/README.md index fe5f81711..40fd5749d 100755 --- a/README.md +++ b/README.md @@ -20,13 +20,16 @@ No Flash, No Java, No Websocket, No Bullshit. This is a very lightweight speed test implemented in Javascript, using XMLHttpRequest and Web Workers. ## Try it + [Take a speed test](https://librespeed.org) ## Compatibility + All modern browsers are supported: IE11, latest Edge, latest Chrome, latest Firefox, latest Safari. Works with mobile versions too. ## Features + * Download * Upload * Ping @@ -35,56 +38,80 @@ 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_v6.gif) - +![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v7.gif) ## Server requirements + * A reasonably fast web server with Apache 2 (nginx, IIS also supported) * PHP 5.4 or newer (other backends also available) -* MySQL database to store test results (optional, Microsoft SQL Server, PostgreSQL and SQLite also supported) +* MariaDB or MySQL database to store test results (optional, Microsoft SQL Server, PostgreSQL and SQLite also supported) * A fast! internet connection ## Installation -Assuming you have PHP installed, the installation steps are quite simple. -I set this up on a QNAP. -For this example, I am using a folder called **speedtest** in my web share area. -1. Choose one of the example-xxx.html files in `examples` folder as your index.html if the default index.html does not fit. -2. Add: speedtest.js, speedtest_worker.js, and favicon.ico to your speedtest folder. -3. Download all of the backend folder into speedtest/backend. -4. Download all of the results folder into speedtest/results. -5. Be sure your permissions allow execute (755). -6. Visit YOURSITE/speedtest/index.html and voila! +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), keeping the layout as it is in the repository. +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. +1. Visit YOURSITE/speedtest/index.html and voila! ### Installation Video -There is a more in-depth installation video here: -* [Quick start installation guide for Ubuntu Server 19.04](https://fdossena.com/?p=speedtest/quickstart_v5_ubuntu.frag) + +This video shows the installation process of a standalone LibreSpeed server: [Quick start installation guide for Debian 12](https://fdossena.com/?p=speedtest/quickstart_deb12.frag) + +More videos will be added later. ## Android app + A template to build an Android client for your LibreSpeed installation is available [here](https://github.com/librespeed/speedtest-android). ## CLI client + A command line client is available [here](https://github.com/librespeed/speedtest-cli). +## .NET client + +A .NET client library is available in the [`LibreSpeed.NET`](https://github.com/Memphizzz/LibreSpeed.NET) repo ([NuGet](https://www.nuget.org/packages/LibreSpeed.NET)), maintained by [MemphiZ](https://github.com/Memphizzz). + +## Development + +If you want to contribute or develop with LibreSpeed, see [DEVELOPMENT.md](DEVELOPMENT.md) for information about using npm for development tasks, linting, and formatting. + +## Stability test + +LibreSpeed includes a standalone connection stability test at `stability.html`, available alongside the main page. 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. +The image is built every week to include an updated version of the ipinfo-DB used for ISP detection. Also this ensures, that the latest security patches in PHP are installed. Therefore we recommend to use the `latest` image. ## Go backend + A Go implementation is available in the [`speedtest-go`](https://github.com/librespeed/speedtest-go) repo, maintained by [Maddie Zhan](https://github.com/maddie). ## Rust backend + A Rust implementation is available in the [`speedtest-rust`](https://github.com/librespeed/speedtest-rust) repo, maintained by [Sudo Dios](https://github.com/sudodios). ## Node.js backend + A partial Node.js implementation is available in the `node` branch, developed by [dunklesToast](https://github.com/dunklesToast). It's not recommended to use at the moment. ## Donate + [![Donate with Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/fdossena/donate) [Donate with PayPal](https://www.paypal.me/sineisochronic) ## License -Copyright (C) 2016-2022 Federico Dossena + +Copyright (C) 2016-2024 Federico Dossena This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by diff --git a/backend/country_asn.mmdb b/backend/country_asn.mmdb new file mode 100644 index 000000000..520ae5462 Binary files /dev/null and b/backend/country_asn.mmdb differ diff --git a/backend/geoip2.phar b/backend/geoip2.phar new file mode 100644 index 000000000..af650b225 Binary files /dev/null and b/backend/geoip2.phar differ diff --git a/backend/getIP.php b/backend/getIP.php index a668f515c..371db9299 100755 --- a/backend/getIP.php +++ b/backend/getIP.php @@ -2,7 +2,7 @@ /* * This script detects the client's IP address and fetches ISP info from ipinfo.io/ - * Output from this script is a JSON string composed of 2 objects: a string called processedString which contains the combined IP, ISP, Country and distance as it can be presented to the user; and an object called rawIspInfo which contains the raw data from ipinfo.io (will be empty if isp detection is disabled). + * Output from this script is a JSON string composed of 2 objects: a string called processedString which contains the combined IP, ISP, Country and distance as it can be presented to the user; and an object called rawIspInfo which contains the raw data from ipinfo.io (or an empty string if isp detection is disabled or if it failed). * Client side, the output of this script can be treated as JSON or as regular text. If the output is regular text, it will be shown to the user as is. */ @@ -10,399 +10,189 @@ define('API_KEY_FILE', 'getIP_ipInfo_apikey.php'); define('SERVER_LOCATION_CACHE_FILE', 'getIP_serverLocation.php'); +define('OFFLINE_IPINFO_DB_FILE', 'country_asn.mmdb'); require_once 'getIP_util.php'; -/** - * @param string $ip - * - * @return string|null - */ -function getLocalOrPrivateIpInfo($ip) -{ +function getLocalOrPrivateIpInfo($ip){ // ::1/128 is the only localhost ipv6 address. there are no others, no need to strpos this if ('::1' === $ip) { return 'localhost IPv6 access'; } - // simplified IPv6 link-local address (should match fe80::/10) if (stripos($ip, 'fe80:') === 0) { return 'link-local IPv6 access'; } - // fc00::/7 Unique Local IPv6 Unicast Addresses if (preg_match('/^(fc|fd)([0-9a-f]{0,4}:){1,7}[0-9a-f]{1,4}$/i', $ip) === 1) { return 'ULA IPv6 access'; } - // anything within the 127/8 range is localhost ipv4, the ip must start with 127.0 if (strpos($ip, '127.') === 0) { return 'localhost IPv4 access'; } - // 10/8 private IPv4 if (strpos($ip, '10.') === 0) { return 'private IPv4 access'; } - // 172.16/12 private IPv4 if (preg_match('/^172\.(1[6-9]|2\d|3[01])\./', $ip) === 1) { return 'private IPv4 access'; } - // 192.168/16 private IPv4 if (strpos($ip, '192.168.') === 0) { return 'private IPv4 access'; } - // IPv4 link-local if (strpos($ip, '169.254.') === 0) { return 'link-local IPv4 access'; } - return null; } -/** - * @return string - */ -function getIpInfoTokenString() -{ - if ( - !file_exists(API_KEY_FILE) - || !is_readable(API_KEY_FILE) - ) { - return ''; +function getIspInfo_ipinfoApi($ip){ + if (!file_exists(API_KEY_FILE) || !is_readable(API_KEY_FILE)){ + return null; } - require API_KEY_FILE; - - if (empty($IPINFO_APIKEY)) { - return ''; + if(empty($IPINFO_APIKEY)){ + return null; } - - return '?token=' . $IPINFO_APIKEY; -} - -/** - * @param string $ip - * - * @return array|null - */ -function getIspInfo($ip) -{ - $json = file_get_contents('https://ipinfo.io/' . $ip . '/json' . getIpInfoTokenString()); + $json = file_get_contents('https://ipinfo.io/' . $ip . '/json?token=' . $IPINFO_APIKEY); if (!is_string($json)) { return null; } - $data = json_decode($json, true); if (!is_array($data)) { return null; } - - return $data; -} - -/** - * @param array|null $rawIspInfo - * - * @return string - */ -function getIsp($rawIspInfo) -{ - if (is_array($rawIspInfo)) { - /* variant with no token - has json like: - { - "ip": "xxx.xxx.xxx.xxx", - "hostname": "example.com", - "city": "Vienna", - "region": "Vienna", - "country": "AT", - "loc": "48.2085,16.3721", - "org": "ASxxxx T-Mobile Austria GmbH", - "postal": "nnnn", - "timezone": "Europe/Vienna", - "readme": "https://ipinfo.io/missingauth" + $isp=null; + //ISP name, if present, is either in org or asn.name + if (array_key_exists('org', $data) && is_string($data['org']) && !empty($data['org'])) { + // Remove AS##### from ISP name, if present + $isp = preg_replace('/AS\\d+\\s/', '', $data['org']); + } elseif (array_key_exists('asn', $data) && is_array($data['asn']) && !empty($data['asn']) && array_key_exists('name', $data['asn']) && is_string($data['asn']['name'])) { + $isp = $data['asn']['name']; + } else{ + return null; + } + $country=null; + if(array_key_exists('country',$data) && is_string($data['country'])){ + $country = $data['country']; + } + //If requested by the client (and we have the required information), calculate the distance + $distance=null; + if(isset($_GET['distance']) && ($_GET['distance']==='mi' || $_GET['distance']==='km') && array_key_exists('loc', $data) && is_string($data['loc'])){ + $unit = $_GET['distance']; + $clientLoc = $data['loc']; + $serverLoc = null; + if (file_exists(SERVER_LOCATION_CACHE_FILE) && is_readable(SERVER_LOCATION_CACHE_FILE)) { + require SERVER_LOCATION_CACHE_FILE; } - */ - if ( - array_key_exists('org', $rawIspInfo) - && is_string($rawIspInfo['org']) - && !empty($rawIspInfo['org']) - ) { - // Remove AS##### from ISP name, if present - return preg_replace('/AS\\d+\\s/', '', $rawIspInfo['org']); + if (!is_string($serverLoc) || empty($serverLoc)) { + $json = file_get_contents('https://ipinfo.io/json?token=' . $IPINFO_APIKEY); + if (!is_string($json)) { + return null; + } + $sdata = json_decode($json, true); + if (!is_array($sdata) || !array_key_exists('loc', $sdata) || !is_string($sdata['loc']) || empty($sdata['loc'])) { + return null; + } + $serverLoc = $sdata['loc']; + file_put_contents(SERVER_LOCATION_CACHE_FILE, " $processedString, + 'rawIspInfo' => $data ?: '', + ]); } -/** - * Optimized algorithm from http://www.codexworld.com - * - * @param float $latitudeFrom - * @param float $longitudeFrom - * @param float $latitudeTo - * @param float $longitudeTo - * - * @return float [km] - */ -function distance( - $latitudeFrom, - $longitudeFrom, - $latitudeTo, - $longitudeTo -) { - $rad = M_PI / 180; - $theta = $longitudeFrom - $longitudeTo; - $dist = sin($latitudeFrom * $rad) - * sin($latitudeTo * $rad) - + cos($latitudeFrom * $rad) - * cos($latitudeTo * $rad) - * cos($theta * $rad); - - return acos($dist) / $rad * 60 * 1.853; +if (PHP_VERSION_ID >= 80100){ + require_once("geoip2.phar"); } - -/** - * @param array|null $rawIspInfo - * - * @return string|null - */ -function getDistance($rawIspInfo) -{ - if ( - !is_array($rawIspInfo) - || !array_key_exists('loc', $rawIspInfo) - || !isset($_GET['distance']) - || !in_array($_GET['distance'], ['mi', 'km'], true) - ) { +function getIspInfo_ipinfoOfflineDb($ip){ + if (PHP_VERSION_ID < 80100 || !file_exists(OFFLINE_IPINFO_DB_FILE) || !is_readable(OFFLINE_IPINFO_DB_FILE)){ return null; } - - $unit = $_GET['distance']; - $clientLocation = $rawIspInfo['loc']; - $serverLocation = getServerLocation(); - - if (!is_string($serverLocation)) { + $reader = new MaxMind\Db\Reader(OFFLINE_IPINFO_DB_FILE); + $data = $reader->get($ip); + if(!is_array($data)){ return null; } - - return calculateDistance( - $serverLocation, - $clientLocation, - $unit - ); -} - -/** - * @param string $clientLocation - * @param string $serverLocation - * @param string $unit - * - * @return string - */ -function calculateDistance($clientLocation, $serverLocation, $unit) -{ - list($clientLatitude, $clientLongitude) = explode(',', $clientLocation); - list($serverLatitude, $serverLongitude) = explode(',', $serverLocation); - $dist = distance( - $clientLatitude, - $clientLongitude, - $serverLatitude, - $serverLongitude - ); - - if ('mi' === $unit) { - $dist /= 1.609344; - $dist = round($dist, -1); - if ($dist < 15) { - $dist = '<15'; - } - - return $dist . ' mi'; - } - - if ('km' === $unit) { - $dist = round($dist, -1); - if ($dist < 20) { - $dist = '<20'; - } - - return $dist . ' km'; - } - - return null; -} - -/** - * @return void - */ -function sendHeaders() -{ - header('Content-Type: application/json; charset=utf-8'); - - if (isset($_GET['cors'])) { - header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Methods: GET, POST'); - } - - header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, s-maxage=0'); - header('Cache-Control: post-check=0, pre-check=0', false); - header('Pragma: no-cache'); + $processedString = $ip.' - ' . $data['as_name'] . ', ' . $data['country_name']; + return json_encode([ + 'processedString' => $processedString, + 'rawIspInfo' => $data ?: '', + ]); } -/** - * @param string $ip - * @param string|null $ipInfo - * @param string|null $distance - * @param array|null $rawIspInfo - * - * @return void - */ -function sendResponse( - $ip, - $ipInfo = null, - $distance = null, - $rawIspInfo = null -) { - $processedString = $ip; - if (is_string($ipInfo)) { - $processedString .= ' - ' . $ipInfo; - } - - if ( - is_array($rawIspInfo) - && array_key_exists('country', $rawIspInfo) - ) { - $processedString .= ', ' . $rawIspInfo['country']; +function formatResponse_simple($ip,$ispName=null){ + $processedString=$ip; + if(is_string($ispName)){ + $processedString.=' - '.$ispName; } - if (is_string($distance)) { - $processedString .= ' (' . $distance . ')'; - } - - sendHeaders(); - echo json_encode([ + return json_encode([ 'processedString' => $processedString, - 'rawIspInfo' => $rawIspInfo ?: '', + 'rawIspInfo' => '', ]); } -$ip = getClientIp(); - -$localIpInfo = getLocalOrPrivateIpInfo($ip); -// local ip, no need to fetch further information -if (is_string($localIpInfo)) { - sendResponse($ip, $localIpInfo); - exit; +header('Content-Type: application/json; charset=utf-8'); +if (isset($_GET['cors'])) { + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, POST'); } +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, s-maxage=0'); +header('Cache-Control: post-check=0, pre-check=0', false); +header('Pragma: no-cache'); -if (!isset($_GET['isp'])) { - sendResponse($ip); - exit; +$ip = getClientIp(); +//if the user requested the ISP info, we first try to fetch it using ipinfo.io (if there is no api key set it fails without sending data, it can also fail because of rate limiting or invalid responses), then we try with the offline db, if that also fails (or if ISP info was not requested) we just respond with the IP address +if(isset($_GET['isp'])){ + $localIpInfo = getLocalOrPrivateIpInfo($ip); + //local ip, no need to fetch further information + if (is_string($localIpInfo)) { + echo formatResponse_simple($ip,$localIpInfo); + }else{ + $r=getIspInfo_ipinfoApi($ip); + if(!is_null($r)){ + echo $r; + }else{ + $r=getIspInfo_ipinfoOfflineDb($ip); + if(!is_null($r)){ + echo $r; + }else{ + echo formatResponse_simple($ip); + } + } + } +}else{ + echo formatResponse_simple($ip); } - -$rawIspInfo = getIspInfo($ip); -$isp = getIsp($rawIspInfo); -$distance = getDistance($rawIspInfo); - -sendResponse($ip, $isp, $distance, $rawIspInfo); diff --git a/backend/getIP_util.php b/backend/getIP_util.php old mode 100644 new mode 100755 index 5cc6b64b9..23268ea43 --- a/backend/getIP_util.php +++ b/backend/getIP_util.php @@ -1,20 +1,55 @@ by Federico Dossena -> Version 5.2.4 +> Version 6.3.1 > [https://github.com/librespeed/speedtest/](https://github.com/librespeed/speedtest/) ## Introduction + LibreSpeed is a Free and Open Source speed test that you can host on your server(s), and users can run in their browser. __Features:__ + * Download test * Upload test * Ping and Jitter test @@ -20,6 +22,7 @@ __Browser support:__ The test supports any browser that supports XHR Level 2 and Web Workers. JavaScript must be enabled. The following browsers are officially supported: + * Internet Explorer 11 * Microsoft Edge (last 2 versions) * Mozilla Firefox (latest ESR and last 2 versions) @@ -30,53 +33,83 @@ The following browsers are officially supported: Client side, the test can use up to 500MB of RAM on very fast connections. ## Quick start guides -These guides cover a simple single server installation of the speed test. -* [Quick start installation guide for Ubuntu Server 19.04](https://fdossena.com/?p=speedtest/quickstart_v5_ubuntu.frag) +This video shows the installation process of a standalone LibreSpeed server: [Quick start installation guide for Debian 12](https://fdossena.com/?p=speedtest/quickstart_deb12.frag) -More guides will be added later +More videos will be added later. ## Installation ### Single server, PHP + Server side, you'll need: + * Apache 2 (nginx and IIS also supported). A fast internet connection is required (possibly gigabit), and the web server must accept large POST requests (up to 20MB) -* PHP 5.4 or newer, a 64-bit version is strongly recommended +* PHP 5.4 or newer (8.0 required for ISP and distance detection), a 64-bit version is strongly recommended * OpenSSL and its PHP module (this is usually installed automatically by most distros) * If you want to store test results (telemetry), one of the following: - - MySQL/MariaDB and its PHP PDO module - - PostgreSQL and its PHP PDO module - - SQLite 3 and its PHP PDO module -* If you want to enable results sharing: - - FreeType 2 and its PHP module (this is usually installed automatically by most distros) + * MySQL/MariaDB and its PHP PDO module + * PostgreSQL and its PHP PDO module + * SQLite 3 and its PHP PDO module + * MSSQL and its PHP PDO module (Windows only) +* If you want to enable results sharing (these are usually installed automatically by most distros when you install PHP): + * FreeType 2 and its PHP module + * The PHP gd library Let's install the speed test. Put all files on your web server via FTP or by copying them directly. You can install it in the root, or in a subdirectory. +**Web server upload limit:** The upload test sends POST requests up to 20 MB (configurable with `xhr_ul_blob_megabytes`). Without proper configuration, the server will reject these with HTTP 413, causing wildly inaccurate upload speeds. Configure your web server to accept large request bodies: + +Nginx: +``` +client_max_body_size 128m; +``` + +Apache: +``` +LimitRequestBody 134217728 +``` + +IIS: Add the following to `web.config` (value is in bytes): +```xml + + + + + + + +``` + __Important:__ The speed test needs write permissions in the installation folder! #### ipinfo.io -The speed test uses [ipinfo.io](https://ipinfo.io) to detect ISP and distance from server. This is completely optional and can be disabled if you want (see speed test settings), but it is enabled by default, and if you expect more than ~500 tests per day, you will need to sign up to [ipinfo.io](https://ipinfo.io) and edit `backend/getIP_ipInfo_apikey.php` to set your access token. -IpInfo.io has kindly offered free access to their APIs for users of this project; if you're interested, contact me at [info@fdossena.com](mailto:info@fdossena.com) and provide a description of what you intend to do with the project, and you'll get the API key. +The speed test uses the [ipinfo.io](https://ipinfo.io) offline database to detect ISP and country (CC-BY-SA 4.0 license), enabled by default. +It's possible to use the full [ipinfo.io](https://ipinfo.io) API to detect distance from server as well. This is completely optional and can be enabled by obtaining an [access token](https://ipinfo.io) and putting it in `backend/getIP_ipInfo_apikey.php`. When this feature is enabled, the offline database will only be used as fallback. #### Telemetry and results sharing + The test supports storing test results and can generate shareable images that users can embed in forum signatures and such. To use this function, you will need a database. The test supports MySQL, PostgreSQL and SQLite as backends. ##### Creating the database -This step is only required for MySQL and PostgreSQL. If you want to use SQLite, skip to the next step. -Log into your database using phpMyAdmin or a similar software and create a new database. Inside the `results` folder you will find `telemetry_mysql.sql` and `telemetry_postgresql.sql`, which are templates for MySQL and PostgreSQL respectively. Import the one you need, and you will see a `speedtest_users` table in the database. You can delete the templates afterwards. +This step is only required for MySQL, PostgreSQL and MSSQL. If you want to use SQLite, skip to the next step. + +Log into your database using phpMyAdmin or a similar software and create a new database. Inside the `results` folder you will find `telemetry_mysql.sql`, `telemetry_postgresql.sql` and `telemetry_mssql.sql`, which are templates for MySQL, PostgreSQL and MSSQL respectively. Import the one you need, and you will see a `speedtest_users` table in the database. You can delete the templates afterwards. ##### Configuring telemetry -Open `results/telemetry_settings.php` in a text editor. Set `$db_type` to either `mysql`,`postgresql` or `sqlite`. + +Open `results/telemetry_settings.php` in a text editor. Set `$db_type` to either `mysql`,`postgresql`, `mssql` or `sqlite`. If you chose to use SQLite, you might want to change `$Sqlite_db_file` to another path where you want the database to be stored. Just make sure that the file cannot be downloaded by users. Sqlite doesn't require any additional configuration, you can skip the rest of this section. If you chose to use MySQL, you must set your database credentials: + ```php $MySql_username="USERNAME"; //your database username $MySql_password="PASSWORD"; //your database password @@ -85,6 +118,7 @@ $MySql_databasename="DB_NAME"; //the name of the database where you loaded telem ``` If you chose to use PostgreSQL, you must set your database credentials: + ```php $PostgreSql_username="USERNAME"; //your database username $PostgreSql_password="PASSWORD"; //your database password @@ -92,7 +126,19 @@ $PostgreSql_hostname="DB_HOSTNAME"; //database address, usually localhost $PostgreSql_databasename="DB_NAME"; //the name of the database where you loaded telemetry_postgresql.sql ``` +If you chose to use MSSQL, you must set your database credentials: + +```php +$MsSql_server = 'DB_HOSTNAME'; +$MsSql_databasename = 'DB_NAME'; +$MsSql_WindowsAuthentication = true; //true or false +$MsSql_username = 'USERNAME'; //not used if MsSql_WindowsAuthentication is true +$MsSql_password = 'PASSWORD'; //not used if MsSql_WindowsAuthentication is true +$MsSql_TrustServerCertificate = true; //true, false or comment out for driver default +``` + ##### Results sharing + This feature generates an image that can be share by the user containing the download, upload, ping, jitter and ISP (if enabled). By default, the telemetry generates a progressive ID for each test. Even if no sensitive information is leaked, you might not want users to be able to guess other test IDs. To avoid this, you can turn on ID obfuscation, which turns IDs into a reversible hash, much like YouTube video IDs. @@ -104,77 +150,94 @@ __Important:__ ID obfuscation currently only works on 64-bit PHP! While you're editing `results/telemetry_settings.php`, you might want to set `$redact_ip_addresses` to `true`, this way, all IP addresses will be removed from the telemetry for better privacy. This is disabled by default. ##### Seeing the results + A basic front-end for visualizing and searching tests by ID is available in `results/stats.php`. A login is required to access the interface. __Important__: change the default password in `results/telemetry_settings.php`. #### The end -Now that the test is installed, the default page uses telemetry and results sharing. If you want another index page, rename one of the examples to `index.html` and delete the other examples. The best starting point for most people is `example-singleServer-gauges.html`. + +Now that the test is installed, the default page uses telemetry and results sharing. If you want another UI, you can easily customize `index.html` or one of the examples in the `examples` folder (the best starting point for most people is `example-singleServer-gauges.html`). If you're not using telemetry and results sharing, you can delete the `results` folder too. -Details about the examples and how to make custom UIs will be discussed later. If you don't want to make a custom UI, feel free to modify the example and replace "LibreSpeed Example" with the name of your test. +Details about the examples and how to make custom UIs will be discussed later. #### Privacy + Telemetry contains personal information (according to GDPR definition), therefore it is important to treat this data respectfully of national and international laws, especially if you plan to offer the service in the European Union. Default `index.html` and `example-multipleServers-full.html` both contain a privacy policy for the service: you MUST read it, change it if necessary, and add your email address for data deletion requests. __Failure to comply with GDPR regulations can get you in serious trouble.__ ### Multiple servers, PHP + The speed test can automatically choose between multiple test points and use the one with the lowest ping in a list. Note that this is an advanced use case and it is recommended that you already know how to use the speed test with a single server. We must distinguish 2 types of servers: + * __Frontend server__: hosts the UI, the JS files, and optionally telemetry and results sharing stuff. You only need 1 of these, and this is the server that your clients will first connect to. * __Test backends__: the servers used to actually perform the test. There can be 1+ of these, and they only host the backend files. #### Frontend server + This is the server that your users will first connect to. It hosts the UI, the JS files, and optionally telemetry and results sharing stuff. Requirements: + * Apache 2 (nginx and IIS also supported). A fast connection is not mandatory, but is still recommended -* PHP 5.4 or newer +* PHP 5.4 or newer, a 64-bit version is strongly recommended * If you want to store test results (telemetry), one of the following: - - MySQL/MariaDB and its PHP PDO module - - PostgreSQL and its PHP PDO module - - SQLite 3 and its PHP PDO module -* If you want to enable results sharing: - - FreeType 2 and its PHP module (this is usually installed automatically by most distros) - -To install the speed test frontend, copy the following files to your web server: + * MySQL/MariaDB and its PHP PDO module + * PostgreSQL and its PHP PDO module + * SQLite 3 and its PHP PDO module + * MSSQL and its PHP PDO module (Windows only) +* If you want to enable results sharing (these are usually installed automatically by most distros when you install PHP): + * FreeType 2 and its PHP module + * The PHP gd library + +To install the speed test frontend, copy the project files to your web server, keeping the layout they have in the repository: + +* `index.html` +* `settings.json` +* `server-list.json` * `speedtest.js` * `speedtest_worker.js` +* `favicon.ico` +* the `backend` folder * Optionally, the `results` folder -* One of the `multipleServers` examples (the best starting points are `example-multipleServers-pretty.html` if you don't want to use telemetry and results sharing, `example-multipleServers-full.html` if you want to use them). Rename the example you choose to `index.html` -__Important:__ The speed test needs write permissions in the installation folder! +__Important:__ The speed test needs read and execute permissions in the installation folder where applicable! ##### Server list -Edit `index.html`, you will see a list of servers: + +Edit `index.html` and uncomment the list of servers: + ```js var SPEEDTEST_SERVERS=[ - { - "name":"Speed test Demo Server 1", //user friendly name for the server - "server":"//mpotdemo.fdossena.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically - "dlURL":"garbage.php", //path to download test on this server (garbage.php or replacement) - "ulURL":"empty.php", //path to upload test on this server (empty.php or replacement) - "pingURL":"empty.php", //path to ping/jitter test on this server (empty.php or replacement) - "getIpURL":"getIP.php" //path to getIP on this server (getIP.php or replacement) - }, - { - "name":"Speed test Demo Server 2", - "server":"//mpotdemo2.fdossena.com/", - "dlURL":"garbage.php", - "ulURL":"empty.php", - "pingURL":"empty.php", - "getIpURL":"getIP.php" - } - //add other servers here, comma separated + /*{ + name:"Example Server 1", //user friendly name for the server + server:"//test1.mydomain.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically + dlURL:"backend/garbage.php", //path to download test on this server (garbage.php or replacement) + ulURL:"backend/empty.php", //path to upload test on this server (empty.php or replacement) + pingURL:"backend/empty.php", //path to ping/jitter test on this server (empty.php or replacement) + getIpURL:"backend/getIP.php" //path to getIP on this server (getIP.php or replacement) + }, + { + name:"Example Server 2", //user friendly name for the server + server:"//test2.example.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically + dlURL:"garbage.php", //path to download test on this server (garbage.php or replacement) + ulURL:"empty.php", //path to upload test on this server (empty.php or replacement) + pingURL:"empty.php", //path to ping/jitter test on this server (empty.php or replacement) + getIpURL:"getIP.php" //path to getIP on this server (getIP.php or replacement) + }*/ + //add other servers here, comma separated ]; ``` Replace the demo servers with your test points. Each server in the list is an object containing: + * `"name"`: user friendly name for this test point * `"server"`: URL to the server. If your server only supports HTTP or HTTPS, put http:// or https:// at the beginning, respectively; if it supports both, put // at the beginning and it will be replaced automatically * `"dlURL"`: path to the download test on this server (garbage.php or replacement) @@ -186,16 +249,16 @@ None of these parameters can be omitted. __Important__: You can't mix HTTP with HTTPS; if the frontend uses HTTP, you won't be able to connect to HTTPS backends, and viceversa. -__Important__: For HTTPS, all your servers must have valid certificates or the browser will refuse to connect +__Important__: For HTTPS, all your servers must have valid certificates or the browser will refuse to connect. -__Important__: Don't use my demo servers, they're slow! +If your list of servers changes often, you might not want to have it hardcoded in the HTML file. LibreSpeed can load the server list from a JSON file. To do this, remove the server list and replace it with the URL to your server list, like this: -If your list of servers changes often, you might not want to have it hardcoded in the HTML file. LibreSpeed can load the server list from a JSON file. To do this, edit `index.html` and replace the list of servers with this: ```js var SPEEDTEST_SERVERS="your URL here"; ``` The URL doesn't need to be complete, it can just point to a file in the current directory. The URL should point to a JSON file with the same format used above: + ```js [ { @@ -207,57 +270,67 @@ The URL doesn't need to be complete, it can just point to a file in the current __Important:__ The same origin policy applies to which URLs you can and cannot load with this method. If possible, it's best to just point it to a file on the current server. -##### Telemetry and results sharing +##### Telemetry and results sharing in multiple server scenarios + Telemetry is stored on the frontend server. The setup procedure is the same as the single server version. #### Test backends + These are the servers that will actually be used to perform the test. Requirements: + * Apache 2 (nginx and IIS also supported). A fast internet connection is required (possibly gigabit), and the web server must accept large POST requests (up to 20MB) -* PHP 5.4 or newer +* PHP 5.4 or newer (8.0 required for ISP and distance detection) * OpenSSL and its PHP module (this is usually installed automatically by most distros) To install a backend, simply copy all the files in the `backend` folder to your backend server. __Important:__ The speed test needs write permissions in the installation folder! -#### ipinfo.io -The speed test uses [ipinfo.io](https://ipinfo.io) to detect ISP and distance from server. This is completely optional and can be disabled if you want (see speed test settings), but it is enabled by default, and if you expect more than ~500 tests per day, you will need to sign up to [ipinfo.io](https://ipinfo.io) and edit `getIP_ipInfo_apikey.php` to set your access token. +#### ipinfo.io, see single server -IpInfo.io has kindly offered free access to their APIs for users of this project; if you're interested, contact me at [info@fdossena.com](mailto:info@fdossena.com) and provide a description of what you intend to do with the project, and you'll get the API key. +see [above](#ipinfoio) ## Making a custom front-end + This section explains how to use speedtest.js in your webpages. The best way to learn is by looking at the provided examples. __Single server:__ + * `example-singleServer-basic.html`: The most basic configuration possible. Runs the test with the default settings when the page is loaded and displays the results with no fancy graphics. * `example-singleServer-pretty.html`: A more sophisticated example with a nicer layout and a start/stop button. __This is the best starting point for most users__ * `example-singleServer-progressBar.html`: Same as `example-singleServer-pretty.html` but adds a progress indicator * `example-singleServer-customSettings.html`: Same as `example-singleServer-pretty.html` but configures the test so that it only performs download and upload tests, and with a fixed length instead of automatic * `example-singleServer-gauges.html`: The most sophisticated example, with the same functionality as `example-singleServer-pretty.html` but adds gauges. This is also a good starting point, but the gauges may slow down underpowered devices * `example-singleServer-chart.html`: Shows how to use the test with the Chart.js library -* default `index.html`: The most complete example. Based on `example-singleServer-gauges.html`, also enables telemetry and results sharing +* default `index.html`: A full UI that supports both single server and multiple servers installations and even has a dark theme __Multiple servers:__ + * `example-multipleServers-pretty.html`: Same as `example-singleServer-pretty.html` but with multiple test points. Server selection is fully automatic * `example-multipleServers-full.html`: Same as default `index.html` but with multiple test points. Server selection is automatic but the server can be changed afterwards by the user ### Initialization + To use the speed test in your page, first you need to load it: + ```xml ``` After loading, you can initialize the test: + ```js var s=new Speedtest(); ``` ### Event handlers + Now, you can set up event handlers to update your UI: + ```js s.onupdate=function(data){ //update your UI here @@ -271,33 +344,34 @@ s.onend=function(aborted){ ``` The `onupdate` event handler will be called periodically by the test with data coming from the speed test worker thread. The `data` argument is an object containing the following: + * __testState__: an integer between -1 and 5 - * `-1` = Test not started yet - * `0` = Test starting - * `1` = Download test in progress - * `2` = Ping + Jitter test in progress - * `3` = Upload test in progress - * `4` = Test finished - * `5` = Test aborted + * `-1` = Test not started yet + * `0` = Test starting + * `1` = Download test in progress + * `2` = Ping + Jitter test in progress + * `3` = Upload test in progress + * `4` = Test finished + * `5` = Test aborted * __dlStatus__: either - * Empty string (not started or aborted) - * Download speed in Megabit/s as a number with 2 decimals - * The string "Fail" (test failed) + * Empty string (not started or aborted) + * Download speed in Megabit/s as a number with 2 decimals + * The string "Fail" (test failed) * __ulStatus__: either - * Empty string (not started or aborted) - * Upload speed in Megabit/s as a number with 2 decimals - * The string "Fail" (test failed) + * Empty string (not started or aborted) + * Upload speed in Megabit/s as a number with 2 decimals + * The string "Fail" (test failed) * __pingStatus__: either - * Empty string (not started or aborted) - * Estimated ping in milliseconds as a number with 2 decimals - * The string "Fail" (test failed) + * Empty string (not started or aborted) + * Estimated ping in milliseconds as a number with 2 decimals + * The string "Fail" (test failed) * __clientIp__: either - * Empty string (not fetched yet or failed) - * The client's IP address as a string (with ISP info if enabled) + * Empty string (not fetched yet or failed) + * The client's IP address as a string (with ISP info if enabled) * __jitterStatus__: either - * Empty string (not started or aborted) - * Estimated jitter in milliseconds as a number with 2 decimals (lower = stable connection) - * The string "Fail" (test failed) + * Empty string (not started or aborted) + * Estimated jitter in milliseconds as a number with 2 decimals (lower = stable connection) + * The string "Fail" (test failed) * __dlProgress__: the progress of the download test as a number between 0 and 1 * __ulProgress__: the progress of the upload test as a number between 0 and 1 * __pingProgress__: the progress of the ping+jitter test as a number between 0 and 1 @@ -306,131 +380,140 @@ The `onupdate` event handler will be called periodically by the test with data c The `onend` event handler will be called at the end of the test (`onupdate` will be called first), with a boolean telling you if the test was aborted (either manually or because of an error) or if it ended normally. ### Test parameters + Before starting the test, you can change some of the settings from their default values. You might want to do this to better adapt the speed test to a specific scenario, such as a satellite connection. To change a setting, use + ```js s.setParameter("parameter_name",value); ``` For instance, to enable telemetry we can use: + ```js s.setParameter("telemetry_level","basic"); ``` + And now the test results will be stored and we will get our test ID at the end of the test (along with the other data) __Main parameters:__ + * __time_dl_max__: Maximum duration of the download test in seconds. If auto duration is disabled, this is used as the duration of the test. - * Default: `15` - * Recommended: `>=5` + * Default: `15` + * Recommended: `>=5` * __time_ul_max__: Maximum duration of the upload test in seconds. If auto duration is disabled, this is used as the duration of the test. - * Default: `15` - * Recommended: `>=10` + * Default: `15` + * Recommended: `>=10` * __time_auto__: Automatically determine the duration of the download and upload tests, making them faster on faster connections, to avoid wasting data. - * Default: `true` + * Default: `true` * __count_ping__: How many pings to perform in the ping test - * Default: `10` - * Recommended: `>=3, <30` + * Default: `10` + * Recommended: `>=3, <30` * __url_dl__: path to garbage.php or a large file to use for the download test. - * Default: `garbage.php` - * __Important:__ path is relative to js file + * Default: `garbage.php` + * __Important:__ path is relative to js file * __url_ul__: path to an empty file or empty.php to use for the upload test - * Default: `empty.php` - * __Important:__ path is relative to js file + * Default: `empty.php` + * __Important:__ path is relative to js file * __url_ping__: path to an empty file or empty.php to use for the ping test - * Default: `empty.php` - * __Important:__ path is relative to js file + * Default: `empty.php` + * __Important:__ path is relative to js file * __url_getIp__: path to getIP.php or replacement - * Default: `getIP.php` - * __Important:__ path is relative to js file + * Default: `getIP.php` + * __Important:__ path is relative to js file * __url_telemetry__: path to telemetry.php or replacement - * Default: `results/telemetry.php` - * __Important:__ path is relative to js file - * __Note:__ you can ignore this parameter if you're not using the telemetry + * Default: `results/telemetry.php` + * __Important:__ path is relative to js file + * __Note:__ you can ignore this parameter if you're not using the telemetry * __telemetry_level__: The type of telemetry to use. See the telemetry section for more info about this - * Default: `none` - * `basic`: send results only - * `full`: send results and timing information, even for aborted tests - * `debug`: same as full but also sends debug information. Not recommended. + * Default: `none` + * `basic`: send results only + * `full`: send results and timing information, even for aborted tests + * `debug`: same as full but also sends debug information. Not recommended. * __test_order__: the order in which tests will be performed. You can use this to change the order of the test, or to only enable specific tests. Each character represents an operation: - * `I`: get IP - * `D`: download test - * `U`: upload test - * `P`: ping + jitter test - * `_`: delay 1 second - * Default test order: `IP_D_U` - * __Important:__ Tests can only be run once - * __Important:__ On Firefox, it is better to run the upload test last + * `I`: get IP + * `D`: download test + * `U`: upload test + * `P`: ping + jitter test + * `_`: delay 1 second + * Default test order: `IP_D_U` + * __Important:__ Tests can only be run once + * __Important:__ On Firefox, it is better to run the upload test last * __getIp_ispInfo__: if true, the server will try to get ISP info and pass it along with the IP address. This will add `isp=true` to the request to `url_getIp`. getIP.php accomplishes this using ipinfo.io - * Default: `true` -* __getIp_ispInfo_distance__: if true, the server will try to get an estimate of the distance from the client to the speed test server. This will add a `distance` argument to the request to `url_getIp`. `__getIp_ispInfo__` must be enabled in order for this to work. getIP.php accomplishes this using ipinfo.io - * `km`: estimate distance in kilometers - * `mi`: estimate distance in miles - * not set: do not measure distance - * Default: `km` + * Default: `true` +* __getIp_ispInfo_distance__: if true, the server will try to get an estimate of the distance from the client to the speed test server. This will add a `distance` argument to the request to `url_getIp`. `__getIp_ispInfo__` must be enabled in order for this to work. getIP.php accomplishes this using ipinfo.io (API key required) + * `km`: estimate distance in kilometers + * `mi`: estimate distance in miles + * not set: do not measure distance + * Default: `km` __Advanced parameters:__ (Seriously, don't change these unless you know what you're doing) + * __telemetry_extra__: Extra data that you want to be passed to the telemetry. This is a string field, if you want to pass an object, make sure you use ``JSON.stringify``. This string will be added to the database entry for this test. * __enable_quirks__: enables browser-specific optimizations. These optimizations override some of the default settings. They do not override settings that are explicitly set. - * Default: `true` + * Default: `true` * __garbagePhp_chunkSize__: size of chunks sent by garbage.php in megabytes - * Default: `100` - * Recommended: `>=10` - * Maximum: `1024` + * Default: `100` + * Recommended: `>=10` + * Maximum: `1024` * __xhr_dlMultistream__: how many streams should be opened for the download test - * Default: `6` - * Recommended: `>=3` - * Default override: 3 on Edge if enable_quirks is true - * Default override: 5 on Chromium-based if enable_quirks is true + * Default: `6` + * Recommended: `>=3` + * Default override: 3 on Edge if enable_quirks is true + * Default override: 5 on Chromium-based if enable_quirks is true * __xhr_ulMultistream__: how many streams should be opened for the upload test - * Default: `3` - * Recommended: `>=1` + * Default: `3` + * Recommended: `>=1` * __xhr_ul_blob_megabytes__: size in megabytes of the blobs sent during the upload test - * Default: `20` - * Default override: 4 on Chromium-based mobile browsers (limitation introduced around version 65). This will be forced - * Default override: IE11 and Edge currently use a different method for the upload test. This parameter is ignored + * Default: `20` + * Default override: 4 on Chromium-based mobile browsers (limitation introduced around version 65). This will be forced + * Default override: IE11 and Edge currently use a different method for the upload test. This parameter is ignored * __xhr_multistreamDelay__: how long should the multiple streams be delayed (in ms) - * Default: `300` - * Recommended: `>=100`, `<=700` + * Default: `300` + * Recommended: `>=100`, `<=700` * __xhr_ignoreErrors__: how to react to errors in download/upload streams and the ping test - * `0`: Fail test on error (behaviour of previous versions of this test) - * `1`: Restart a stream/ping when it fails - * `2`: Ignore all errors - * Default: `1` - * Recommended: `1` + * `0`: Fail test on error (behaviour of previous versions of this test) + * `1`: Restart a stream/ping when it fails + * `2`: Ignore all errors + * Default: `1` + * Recommended: `1` * __time_dlGraceTime__: How long to wait (in seconds) before actually measuring the download speed. This is a good idea because we want to wait for the TCP window to be at its maximum (or close to it) - * Default: `1.5` - * Recommended: `>=0` + * Default: `1.5` + * Recommended: `>=0` * __time_ulGraceTime__: How long to wait (in seconds) before actually measuring the upload speed. This is a good idea because we want to wait for the buffers to be full (avoids the peak at the beginning of the test) - * Default: `3` - * Recommended: `>=1` + * Default: `3` + * Recommended: `>=1` * __ping_allowPerformanceApi__: toggles use of Performance API to improve accuracy of Ping/Jitter test on browsers that support it. - * Default: `true` - * Default override: `false` on Firefox because its performance API implementation is inaccurate + * Default: `true` + * Default override: `false` on Firefox because its performance API implementation is inaccurate * __useMebibits__: use mebibits/s instead of megabits/s for the speeds - * Default: `false` + * Default: `false` * __overheadCompensationFactor__: compensation for HTTP and network overhead. Default value assumes typical MTUs used over the Internet. You might want to change this if you're using this in your internal network with different MTUs, or if you're using IPv6 instead of IPv4. - * Default: `1.06` probably a decent estimate for all overhead. This was measured empirically by comparing the measured speed and the speed reported by my the network adapter. - * `1048576/925000`: old default value. This is probably too high. - * `1.0513`: HTTP+TCP+IPv6+ETH, over the Internet (empirically tested, not calculated) - * `1.0369`: Alternative value for HTTP+TCP+IPv4+ETH, over the Internet (empirically tested, not calculated) - * `1.081`: Yet another alternative value for over the Internet (empirically tested, not calculated) - * `1514 / 1460`: TCP+IPv4+ETH, ignoring HTTP overhead - * `1514 / 1440`: TCP+IPv6+ETH, ignoring HTTP overhead - * `1`: ignore overheads. This measures the speed at which you actually download and upload files rather than the raw connection speed + * Default: `1.06` probably a decent estimate for all overhead. This was measured empirically by comparing the measured speed and the speed reported by my network adapter. + * `1048576/925000`: old default value. This is probably too high. + * `1.0513`: HTTP+TCP+IPv6+ETH, over the Internet (empirically tested, not calculated) + * `1.0369`: Alternative value for HTTP+TCP+IPv4+ETH, over the Internet (empirically tested, not calculated) + * `1.081`: Yet another alternative value for over the Internet (empirically tested, not calculated) + * `1514 / 1460`: TCP+IPv4+ETH, ignoring HTTP overhead + * `1514 / 1440`: TCP+IPv6+ETH, ignoring HTTP overhead + * `1`: ignore overheads. This measures the speed at which you actually download and upload files rather than the raw connection speed ### Multiple Points of Test + If you want to use more than one test server, this is the time to add all your test points and select the best one. Skip this part if you don't want to use this feature. The best way to do this is to declare an array with all your servers, and give it to the speed test: + ```js var SPEEDTEST_SERVERS=[ - server1, - server2, - ... + server1, + server2, + ... ]; s.addTestPoints(SPEEDTEST_SERVERS); ``` Each server in the list is an object containing: + * `name`: user friendly name for this test point * `server`: URL to the server. If your server only supports HTTP or HTTPS, put `http://` or `https://` at the beginning, respectively; if it supports both, put `//` at the beginning and it will be replaced automatically * `dlURL`: path to the download test on this server (garbage.php or replacement) @@ -441,40 +524,49 @@ Each server in the list is an object containing: None of these parameters can be omitted. Example: + ```js { - name:"Milano, IT", - server:"http://backend1.myspeedtest.net/", - dlURL:"garbage.php", - ulURL:"empty.php", - pingURL:"empty.php", - getIpURL:"getIP.php" + name:"Milano, IT", + server:"http://backend1.myspeedtest.net/", + dlURL:"garbage.php", + ulURL:"empty.php", + pingURL:"empty.php", + getIpURL:"getIP.php" } ``` Now, we can run the server selector: + ```js s.selectServer(function(server){ //do something }) ``` + The `selectServer` function is asynchronous in order to avoid freeing the UI, and it will run a callback function when it is done choosing the server with the lowest ping. The `server` argument is the selected server, and you can display it in the UI if you want. __You cannot start the test until the selection is done!__ You can also set the test point manually (for instance, from a combobox in the UI): + ```js s.setSelectedServer(server) ``` + where `server` is the server that you want to use. ### Running the test + Finally, we can run the test: + ```js s.start(); ``` + During the test, your `onupdate` event handler will be called periodically with data that you can use to update your UI. Your `onend` handler will be called at the end of the test. You can abort the test at any time: + ```js s.abort(); ``` @@ -482,27 +574,32 @@ s.abort(); When the test is finished, you can run it again if you want, or you can just destroy `s`. ## Implementation details + The purpose of this section is to help developers who want to make changes to the inner workings of the speed test. It will be divided into 4 sections: `speedtest.js`, `speedtest_worker.js`, the `backend` files and the `results` files. ### `speedtest.js` + This is the main interface between your webpage and the speed test. It hides the speed test web worker to the page, and provides many convenient functions to control the test. You can think of this as a finite state machine. These are the states (use getState() to see them): + * __0__: here you can change the speed test settings (such as test duration) with the `setParameter("parameter",value)` function. From here you can either start the test using `start()` (goes to state 3) or you can add multiple test points using `addTestPoint(server)` or `addTestPoints(serverList)` (goes to state 1). Additionally, this is the perfect moment to set up callbacks for the `onupdate(data)` and `onend(aborted)` events. * __1__: here you can add test points. You only need to do this if you want to use multiple test points. A server is defined as an object like this: - ``` + + ```jsonc { name: "User friendly name", - server:"http://yourBackend.com/", <---- URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol - dlURL:"garbage.php" <----- path to garbage.php or its replacement on the server - ulURL:"empty.php" <----- path to empty.php or its replacement on the server - pingURL:"empty.php" <----- path to empty.php or its replacement on the server. This is used to ping the server by this selector - getIpURL:"getIP.php" <----- path to getIP.php or its replacement on the server + server:"http://yourBackend.com/", // <---- URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol + dlURL:"garbage.php" // <----- path to garbage.php or its replacement on the server + ulURL:"empty.php" // <----- path to empty.php or its replacement on the server + pingURL:"empty.php" // <----- path to empty.php or its replacement on the server. This is used to ping the server by this selector + getIpURL:"getIP.php" // <----- path to getIP.php or its replacement on the server } ``` + While in state 1, you can only add test points, you cannot change the test settings. When you're done, use selectServer(callback) to select the test point with the lowest ping. This is asynchronous, when it's done, it will call your callback function and move to state 2. Calling setSelectedServer(server) will manually select a server and move to state 2. * __2__: test point selected, ready to start the test. Use `start()` to begin, this will move to state 3 * __3__: test running. Here, your `onupdate` event callback will be called periodically, with data coming from the worker about speed and progress. A data object will be passed to your `onupdate` function, with the following items: @@ -523,46 +620,57 @@ You can think of this as a finite state machine. These are the states (use getSt #### List of functions in the Speedtest class ##### getState() + Returns the state of the test: 0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done ##### setParameter(parameter,value) + Change one of the test settings from their defaults. -- parameter: string with the name of the parameter that you want to set -- value: new value for the parameter + +* parameter: string with the name of the parameter that you want to set +* value: new value for the parameter Invalid values or nonexistant parameters will be ignored by the speed test worker. ##### addTestPoint(server) + Add a test point (multiple points of test) -- server: the server to be added as an object. Must contain the following elements: - ``` + +* server: the server to be added as an object. Must contain the following elements: + + ```jsonc { name: "User friendly name", - server:"http://yourBackend.com/", URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol - dlURL:"garbage.php" path to garbage.php or its replacement on the server - ulURL:"empty.php" path to empty.php or its replacement on the server - pingURL:"empty.php" path to empty.php or its replacement on the server. This is used to ping the server by this selector - getIpURL:"getIP.php" path to getIP.php or its replacement on the server + server:"http://yourBackend.com/", // URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol + dlURL:"garbage.php", // path to garbage.php or its replacement on the server + ulURL:"empty.php", // path to empty.php or its replacement on the server + pingURL:"empty.php", // path to empty.php or its replacement on the server. This is used to ping the server by this selector + getIpURL:"getIP.php", // path to getIP.php or its replacement on the server } ``` Note that this will add `mpot`:`true` to the parameters sent to the speed test worker. ##### addTestPoints(list) + Same as addTestPoint, but you can pass an array of servers ##### loadServerList(url,result) + Loads a list of servers from a JSON file pointed by the `url`. The process is asynchronous and the `result` function will be called when it's done. If the request succeeded, an array containing the list of loaded servers will be passed to the function, otherwise `null` will be passed. ##### getSelectedServer() + Returns the selected server (multiple points of test) ##### setSelectedServer() + Manually selects one of the test points (multiple points of test) ##### selectServer(result) + Automatically selects a server from the list of added test points. The server with the lowest ping will be chosen. (multiple points of test) The selector checks multiple servers in parallel (default: 6 streams) to speed things up if the list of servers is long. @@ -570,6 +678,7 @@ The selector checks multiple servers in parallel (default: 6 streams) to speed t The process is asynchronous and the passed `result` callback function will be called when it's done, then the test can be started. ##### start() + Starts the test. Note (multiple points of test): the selected server will be added to the `telemetry_extra` string. If this string was already set, then `telemetry_extra` will be a JSON string containing both the server and the original string @@ -578,22 +687,28 @@ During the test, the `onupdate(data)` callback function will be called periodica At the end of the test, the `onend(aborted)` function will be called with a boolean telling you if the test was aborted or if it ended normally. ##### abort() + Aborts the test while it's running. ### `speedtest_worker.js` + This is where the actual speed test code is. It receives the settings from the main thread, runs the test, and reports back the results. The worker accepts 3 commands: + * `start`: starts the test. Optionally, test settings can be passed as a JSON string after the word start and a space * `status`: returns the current status as a JSON string. The status string contents are the ones described in the Event handlers section in the section about making a custom front-end. * `abort`: aborts the test #### Parameters + In addition to the parameters listed in the Test settings section in the section about making a custom front-end, there is one additional setting: + * `mpot`: set this to true to run the test with multiple points of test. This will add `cors=true` to all requests (all responses will contain CORS headers) and enable some extra quirks. Default: `false` #### Download test + The download test is performed by transferring large blobs of garbage data using XHR from the server to the client. The test uses multiple streams. If a stream finishes the download, it is restarted. The amount of downloaded data for each stream is tracked using the XHR Level 2 `onprogress` event. @@ -605,17 +720,20 @@ Every 200ms, a timer updates the `dlStatus` string with the current speed and ca See the code for more implementation details. #### Upload test + This works similarly to the download test, but in reverse. A large blob of garbage data is generated and it is sent to the server repeatedly using multiple streams. To keep track of the amount of transferred data, the XHR Level 2 `upload.onprogress` event is used. 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. #### Ping + Jitter test + The Ping/Jitter test __is NOT an ICMP ping__. This is a common misconception. You cannot use ICMP over HTTP, and certainly not in a browser. This test works by creating a persistent HTTP connection to the server, and then repeatedly downloading an empty file, and measuring how long it takes between the request and the response. @@ -627,22 +745,28 @@ Jitter is the variance in ping times. See the code for more implementation details. ### `backend` files + #### `garbage.php` + Uses OpenSSL to generate a stream of incompressible garbage data for the download test. If accepts a `ckSize` GET parameter, which specifies how much garbage data to generate in megabytes (4-1024). #### `empty.php` + An empty file used for the upload and ping test. It only sends headers to create the connection. #### `getIP.php` + Returns client IP, ISP and distance from the server. GET parameters: + * `isp`: if set, fetches ISP info from ipinfo.io * `distance`: if set, calculates distance from server. You can specify `km` or `mi` for the format. If `isp` is set, the output is a JSON string containing: + * `processedString`: string that can be displayed to the user * `rawIspInfo`: info about the client as a JSON string, straight from ipinfo.io @@ -651,18 +775,23 @@ If `isp` is not set, the output is just a string containing the client's IP addr Note: if your server is behind some proxy, firewall, VPN, etc., the client's IP address may not be detected properly. If this happens, you must analyze the traffic coming from the client to find the name of the HTTP header that contains the original IP address. `getIP.php` contains some of these headers but not all of them. #### CORS headers + All these files will send the following CORS headers if the GET parameter `cors=true` is passed to them: -``` + +```header Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST Access-Control-Allow-Headers: Content-Encoding, Content-Type ``` ### `results` files + #### `telemetry.php` + This file stores telemetry information into the database. Data is passed as POST parameters: + * `ispinfo`: ISP info (if enabled, empty string otherwise) * `extra`: the `telemetry_extra` string passed to the worker (if set, empty string otherwise) * `dl`: download speed @@ -672,25 +801,31 @@ Data is passed as POST parameters: * `log`: telemetry log (if `telemetry_level` is set to `full` or higher, empty string otherwise) #### `index.php` + Generates a shareable results image for a given test ID. GET parameters: + * `id`: ID of the test you want to display The looks of this image can be customized by editing the variables in this file. #### `idObfuscation.php` + Contains the implementation of ID obfuscation and deobfuscation. See the code for the implementation details, it's basically a bunch of bitwise operations. #### `stats.php` + Simple UI to display and search test results. Not required to run the test. ## Alternative backends + If for some reason you can't or don't want to use PHP, the speed test can run with other backends, or even no backend (with limited functionality). You will need replacements for `backend/garbage.php` and `backend/empty.php` and optionally `backend/getIP.php`, and the test needs to know where to find them: + ```js //Speed test initialization var s=new Speedtest(); @@ -702,7 +837,8 @@ s.setParameter("url_ping","URL to your empty.php replacement"); s.setParameter("url_getIp","URL to your getIP.php replacement"); ``` -#### Replacement for `garbage.php` +### Replacement for `garbage.php` + A replacement for `garbage.php` must generate incompressible garbage data. A large file (10-100 Mbytes) is a possible replacement. You can get one [here](http://downloads.fdossena.com/geth.php?r=speedtest-bigfile). @@ -712,6 +848,7 @@ A symlink to `/dev/urandom` is also ok. If you want to make your own backend, see the section on the implementation details of `garbage.php`. #### Replacement for `empty.php` + Your replacement must simply respond with a HTTP code 200 and send nothing else. You may want to send additional headers to disable caching. The test assumes that `Connection:keep-alive` is sent by the server. An empty file can be used for this. @@ -719,20 +856,24 @@ An empty file can be used for this. If you want to make your own backend, see the section on the implementation details of `empty.php`. #### Replacement for `getIP.php` + Your replacement can simply respond with the client's IP as plaintext or do something more fancy. If you want to make your own backend, see the section on the implementation details of `getIP.php`. ### No backend + The speed test can run, albeit with limited functionality, using only a web server as backend, with no PHP or other server-side scripting. You will be able to run the download and upload test, but no IP, ISP and distance detection, no telemetry and results sharing, and only a single point of test. To do this, you will need: + * A replacement for `garbage.php`: a large incompressible file, like [this](http://downloads.fdossena.com/geth.php?r=speedtest-bigfile). We'll call this `backend/garbage.dat` * A replacement for `empty.php`: an empty file will do. We'll call this `backend/empty.dat` Now you need to configure the test to use them. Look for `s=new Speedtest()` and right below it, put the following: + ```js s.setParameter("url_dl","backend/garbage.dat"); s.setParameter("url_ul","backend/empty.dat"); @@ -743,39 +884,50 @@ s.setParameter("test_order","P_D_U"); This will point to our static files and set the test to only do ping/jitter, download and upload tests. ## Troubleshooting + These are the most common issues reported by users, and how to fix them. If you still need help, contact me at [info@fdossena.com](mailto:info@fdossena.com). -#### Download test gives very low result +### Download test gives very low result + Are garbage.php and empty.php (or your replacements) reachable? Press F12, select network and start the test. Do you see errors? (cancelled requests are not errors) If a small download starts, open it in a text editor. Does it say it's missing openssl_random_pseudo_bytes()? In this case, install OpenSSL (this is usually included when you install Apache and PHP on most distros). #### Upload test is inaccurate, and/or I see lag spikes + Check your server's maximum POST size, make sure it's at least 20Mbytes, possibly more #### Download and/or upload results are slightly too optimistic + The test was fine tuned to run over a typical IPv4 internet connection. If you're using it under different conditions, see the `overheadCompensationFactor` parameter. -#### All tests are wrong, give extremely high results, browser lags/crashes, ... +#### All tests are wrong, give extremely high results, browser lags/crashes + You're running the test on localhost, therefore it is trying to measure the speed of your loopback interface. The test is meant to be run over an Internet connection, from a different machine. #### Ping test shows double the actual ping + Make sure your server is sending the `Connection:keep-alive` header #### The server is behind a load balancer, proxy, etc. and I get the wrong IP address + Edit getIP.php and replace lines 14-23 with what is more appropriate in your scenario. Example: `$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];` #### The results sharing just generates a blank image + If the image doesn't display and the browser displays a broken image icon, FreeType2 is not installed or configured properly. If the image is blank, this usually happens because PHP can't find the font files inside the `results` folder. You can fix your PHP config or edit `results/index.php` and use absolute paths for the fonts. This is a [known issue with PHP](http://php.net/manual/en/function.imagefttext.php) and no real solution is known. #### My server is behind Cloudflare and I can't reach full speed on some of the tests + This is not a speed test related issue, as it can be replicated in virtually any HTTP file upload/download. Go to your domain's DNS settings and change "DNS and HTTP proxy (CDN)" to "DNS only", and wait for the settings to be applied (can take a few minutes). #### On Windows Server, using IIS, the upload test doesn't work, CORS errors are visible in the console + This is a configuration issue. Make a file called web.config in wwwroot and adapt the following code: + ```xml @@ -799,22 +951,30 @@ This is a configuration issue. Make a file called web.config in wwwroot and adap ``` #### ID obfuscation doesn't work (incorrect output, blank results image) + ID obfuscation only works on 64-bit PHP (requires PHP_INT_SIZE to be 8). Note that older versions of PHP 5 on Windows use PHP_INT_SIZE of 4, even if they're 64 bit. If you're in this situation, update your PHP install. Also, make sure that the web server has write permission on the `results` folder. ## Known bugs and limitations + ### General + * The ping/jitter test is measured by seeing how long it takes for an empty XHR to complete. It is not an actual ICMP ping. Different browsers may also show different results, especially on very fast connections on slow devices. + ### IE specific + * The upload test is not precise on very fast connections with high latency (will probably be fixed by Edge 17) -* On IE11, a same origin policy error is erroneously triggered under unknown conditions. Seems to be related to running the test from unusual URLs like a top level domain (for instance http://abc/speedtest). These are bugs in IE11's implementation of the same origin policy, not in the speed test itself. +* On IE11, a same origin policy error is erroneously triggered under unknown conditions. Seems to be related to running the test from unusual URLs like a top level domain (for instance ). These are bugs in IE11's implementation of the same origin policy, not in the speed test itself. * On IE11, under unknown circumstances, on some systems the test can only be run once, after which speedtest_worker.js will not be loaded by IE until the browser is restarted. This is a rare bug in IE11. + ### Firefox specific + * On some Linux systems with hardware acceleration turned off, the page rendering makes the browser lag, reducing the accuracy of the ping/jitter test, and potentially even the download and upload tests on very fast connections. ## Contributing + Since this is an open source project, you can modify it. If you made some changes that you think should make it into the main project, send a Pull Request on GitHub, or contact me at [info@fdossena.com](mailto:info@fdossena.com). @@ -823,10 +983,10 @@ We don't require you to use a specific coding convention, write the code however Donations are also appreciated: you can donate with [PayPal](https://www.paypal.me/sineisochronic) or [Liberapay](https://liberapay.com/fdossena/donate). ## License + This software is under the GNU LGPL license, Version 3 or newer. To put it short: you are free to use, study, modify, and redistribute this software and modified versions of it, for free or for money. You can also use it in proprietary software but all changes to this software must remain under the same GNU LGPL license. Contact me at [info@fdossena.com](mailto:info@fdossena.com) for other licensing models. - diff --git a/doc_docker.md b/doc_docker.md index a11a8edd5..33d4dec7a 100755 --- a/doc_docker.md +++ b/doc_docker.md @@ -1,17 +1,27 @@ +# Using the docker image + A docker version of LibreSpeed is available here: [GitHub Packages](https://github.com/librespeed/speedtest/pkgs/container/speedtest) -## Downloading docker image -To download LibreSpeed from the docker repo, use this command: +# Alpine Linux variant -``` -docker pull ghcr.io/librespeed/speedtest -``` +An Alpine Linux based docker version of LibreSpeed is also available here: [GitHub Packages](https://github.com/librespeed/speedtest/pkgs/container/speedtest) under all the tags that have the `-alpine` suffix. This variant is significantly smaller but can have slightly different behaviour due to its toolchain being based in [musl](https://en.wikipedia.org/wiki/Musl) libc as mentioned in [here](https://alpinelinux.org/about/). -You will now have a new docker image called `librespeed/speedtest`. +## Quickstart +If you just want to try it, the fastest way is: + +```shell +docker run -p 80:8080 -d --name speedtest --rm ghcr.io/librespeed/speedtest +``` + +Then go with your browser to port 80 of your server and try it out. If port 80 is already in use, adjust the first number in 80:8080 above. +Default is to run in standalone mode. ## Docker Compose -To start the container using [docker compose](https://docs.docker.com/compose/) the following configuration can be used: + +In production environments we would recommend using docker-compose. + +To start the container using [docker compose](https://docs.docker.com/compose/) the following `docker-compose.yml` configuration can be used: ```yml version: '3.7' @@ -27,32 +37,45 @@ services: #ENABLE_ID_OBFUSCATION: "false" #REDACT_IP_ADDRESSES: "false" #PASSWORD: - #EMAIL: + #GDPR_EMAIL: "privacy@example.com" #DISABLE_IPINFO: "false" + #IPINFO_APIKEY: "your api key" #DISTANCE: "km" - #WEBPORT: 80 + #WEBPORT: 8080 ports: - - "80:80" # webport mapping (host:container) + - "80:8080" # webport mapping (host:container) ``` Please adjust the environment variables according to the intended operating mode. ## Standalone mode + If you want to install LibreSpeed on a single server, you need to configure it in standalone mode. To do this, set the `MODE` environment variable to `standalone`. The test can be accessed on port 80. Here's a list of additional environment variables available in this mode: + * __`TITLE`__: Title of your speed test. Default value: `LibreSpeed` +* __`SERVER_LIST_URL`__: When set, the frontend loads its server list from this URL instead of the generated or mounted `server-list.json`. This is useful if you want the containerized frontend to consume a remote shared server list. * __`TELEMETRY`__: Whether to enable telemetry or not. If enabled, you maybe want your data to be persisted. See below. Default value: `false` * __`ENABLE_ID_OBFUSCATION`__: When set to true with telemetry enabled, test IDs are obfuscated, to avoid exposing the database internal sequential IDs. Default value: `false` +* __`OBFUSCATION_SALT`__: The salt string that is used to obfuscate the test IDs. The format should be a 2 byte hex string (e.g. `0x1234abcd`). If not specified, a random one will be generated. * __`REDACT_IP_ADDRESSES`__: When set to true with telemetry enabled, IP addresses and hostnames are redacted from the collected telemetry, for better privacy. Default value: `false` +* __`DB_TYPE`__: When set to one of the supported DB-Backends it will use this instead of the default sqlite database backend. TELEMETRY has to be set to `true`. Also you have to create the database as described in [doc.md](doc.md#creating-the-database). Supported backend types are: + * sqlite - no additional settings required + * mysql, postgresql - set additional env-variables: + * DB_HOSTNAME - Name or IP of the DB server + * DB_PORT (mysql only) - Port where DB is running + * DB_NAME - Name of the telemetry db + * DB_USERNAME, DB_PASSWORD - credentials of the user with read and update permissions to the db + * mssql - not supported in docker image yet (feel free to open a PR with that, has to be done in `entrypoint.sh`) * __`PASSWORD`__: Password to access the stats page. If not set, stats page will not allow accesses. -* __`EMAIL`__: Email address for GDPR requests. Must be specified when telemetry is enabled. -* __`IPINFO_APIKEY`__: API key for ipinfo.io. Optional, but required if you expect to serve a large number of tests -* __`DISABLE_IPINFO`__: If set to true, ISP info and distance will not be fetched from ipinfo.io. Default: value: `false` -* __`DISTANCE`__: When `DISABLE_IPINFO` is set to false, this specifies how the distance from the server is measured. Can be either `km` for kilometers, `mi` for miles, or an empty string to disable distance measurement. Default value: `km` -* __`WEBPORT`__: Allows choosing a custom port for the included web server. Default value: `80`. Note that you will have to expose it through docker with the -p argument +* __`GDPR_EMAIL`__: Email address displayed in the privacy policy for data deletion requests. If not set, the default placeholder text will be shown. This should be set to comply with GDPR requirements when running in production. Must be specified when telemetry is enabled. Note: the old `EMAIL` environment variable is still accepted as a fallback but is deprecated — please migrate to `GDPR_EMAIL`. +* __`DISABLE_IPINFO`__: If set to `true`, ISP info and distance will not be fetched from either [ipinfo.io](https://ipinfo.io) or the offline database. Default: value: `false` +* __`IPINFO_APIKEY`__: API key for [ipinfo.io](https://ipinfo.io). Optional, but required if you want to use the full [ipinfo.io](https://ipinfo.io) APIs (required for distance measurement) +* __`DISTANCE`__: When `DISABLE_IPINFO` is set to false, this specifies how the distance from the server is measured. Can be either `km` for kilometers, `mi` for miles, or an empty string to disable distance measurement. Requires an [ipinfo.io](https://ipinfo.io) API key. Default value: `km` +* __`WEBPORT`__: Allows choosing a custom port for the included web server. Default value: `8080`. Note that you will have to expose it through docker with the -p argument. This is not the port where the service is exposed outside docker! If telemetry is enabled, a stats page will be available at `http://your.server/results/stats.php`, but a password must be specified. @@ -62,42 +85,44 @@ Default DB driver is sqlite. The DB file is written to `/database/db.sql`. So if you want your data to be persisted over image updates, you have to mount a volume with `-v $PWD/db-dir:/database`. +#### Example Standalone Mode with telemetry -###### Example -This command starts LibreSpeed in standalone mode, with the default settings, on port 80: - -``` -docker run -e MODE=standalone -p 80:80 -it ghcr.io/librespeed/speedtest -``` - -This command starts LibreSpeed in standalone mode, with telemetry, ID obfuscation and a stats password, on port 86: +This command starts LibreSpeed in standalone mode, with persisted telemetry, ID obfuscation and a stats password, on port 86: -``` +```shell docker run -e MODE=standalone -e TELEMETRY=true -e ENABLE_ID_OBFUSCATION=true -e PASSWORD="yourPasswordHere" -e WEBPORT=86 -p 86:86 -v $PWD/db-dir/:/database -it ghcr.io/librespeed/speedtest ``` ## Multiple Points of Test + For multiple servers, you need to set up 1+ LibreSpeed backends, and 1 LibreSpeed frontend. ### Backend mode + In backend mode, LibreSpeed provides only a test point with no UI. To do this, set the `MODE` environment variable to `backend`. The following backend files can be accessed on port 80: `garbage.php`, `empty.php`, `getIP.php` Here's a list of additional environment variables available in this mode: -* __`IPINFO_APIKEY`__: API key for ipinfo.io. Optional, but required if you expect to serve a large number of tests -###### Example: +* __`IPINFO_APIKEY`__: API key for [ipinfo.io](https://ipinfo.io). Optional, but required if you want to use the full [ipinfo.io](https://ipinfo.io) APIs (required for distance measurement). If no API key is provided, the offline database will be used instead. + +#### Example Backend mode + This command starts LibreSpeed in backend mode, with the default settings, on port 80: -``` -docker run -e MODE=backend -p 80:80 -it ghcr.io/librespeed/speedtest + +```shell +docker run -e MODE=backend -p 80:8080 -it ghcr.io/librespeed/speedtest ``` ### Frontend mode + In frontend mode, LibreSpeed serves clients the Web UI and a list of servers. To do this: + * Set the `MODE` environment variable to `frontend` * Create a servers.json file with your test points. The syntax is the following: - ``` + + ```jsonc [ { "name": "Friendly name for Server 1", @@ -115,34 +140,36 @@ In frontend mode, LibreSpeed serves clients the Web UI and a list of servers. To "pingURL" :"empty.php", "getIpURL" :"getIP.php" }, - ...more servers... + //...more servers... ] ``` + Note: if a server only supports HTTP or HTTPS, specify the protocol in the server field. If it supports both, just use `//`. * Mount this file to `/servers.json` in the container (example at the end of this file) The test can be accessed on port 80. -Here's a list of additional environment variables available in this mode: -* __`TITLE`__: Title of your speedtest. Default value: `LibreSpeed` -* __`TELEMETRY`__: Whether to enable telemetry or not. Default value: `false` -* __`ENABLE_ID_OBFUSCATION`__: When set to true with telemetry enabled, test IDs are obfuscated, to avoid exposing the database internal sequential IDs. Default value: `false` -* __`REDACT_IP_ADDRESSES`__: When set to true with telemetry enabled, IP addresses and hostnames are redacted from the collected telemetry, for better privacy. Default value: `false` -* __`PASSWORD`__: Password to access the stats page. If not set, stats page will not allow accesses. -* __`EMAIL`__: Email address for GDPR requests. Must be specified when telemetry is enabled. -* __`DISABLE_IPINFO`__: If set to true, ISP info and distance will not be fetched from ipinfo.io. Default: value: `false` -* __`DISTANCE`__: When `DISABLE_IPINFO` is set to false, this specifies how the distance from the server is measured. Can be either `km` for kilometers, `mi` for miles, or an empty string to disable distance measurement. Default value: `km` -* __`WEBPORT`__: Allows choosing a custom port for the included web server. Default value: `80` +The list of environment variables available in this mode is the same as [above in standalone mode](#standalone-mode). -###### Example -This command starts LibreSpeed in frontend mode, with a given `servers.json` file, and with telemetry, ID obfuscation, and a stats password: +If you want the Docker frontend to load its server list from another URL instead of `/servers.json`, set `SERVER_LIST_URL`: + +```shell +docker run -e MODE=frontend -e SERVER_LIST_URL="https://example.com/custom-server-list.json" -p 80:8080 -it ghcr.io/librespeed/speedtest ``` -docker run -e MODE=frontend -e TELEMETRY=true -e ENABLE_ID_OBFUSCATION=true -e PASSWORD="yourPasswordHere" -v $(pwd)/servers.json:/servers.json -p 80:80 -it ghcr.io/librespeed/speedtest + +#### Example Frontend mode + +This command starts LibreSpeed in frontend mode, with a given `servers.json` file, and with telemetry, ID obfuscation, and a stats password and a persistant sqlite database for results: + +```shell +docker run -e MODE=frontend -e TELEMETRY=true -e ENABLE_ID_OBFUSCATION=true -e PASSWORD="yourPasswordHere" -v $PWD/servers.json:/servers.json -v $PWD/db-dir/:/database -p 80:8080 -it ghcr.io/librespeed/speedtest ``` ### Dual mode + In dual mode, LibreSpeed operates as a standalone server that can also connect to other test points. To do this: + * Set the `MODE` environment variable to `dual` * Follow the `servers.json` instructions for the frontend mode -* The first server entry should be the local server, using the server endpoint address that a client can access. \ No newline at end of file +* The first server entry should be the local server, using the server endpoint address that a client can access. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 901bbba84..bd13fef87 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,16 +1,59 @@ #!/bin/bash +echo "Setting up docker env..." +echo "MODE: $MODE" +echo "SERVER_LIST_URL: $SERVER_LIST_URL" +echo "WEBPORT: $WEBPORT" +echo "REDACT_IP_ADDRESSES: $REDACT_IP_ADDRESSES" +echo "DB_TYPE: $DB_TYPE" +echo "ENABLE_ID_OBFUSCATION: $ENABLE_ID_OBFUSCATION" +echo "GDPR_EMAIL: $GDPR_EMAIL" + set -e -set -x +#set -x + +is_alpine() { + [ -f /etc/alpine-release ] +} + +html_escape() { + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" +} + +sed_escape() { + printf '%s\n' "$1" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g' +} # Cleanup rm -rf /var/www/html/* # Copy frontend files cp /speedtest/*.js /var/www/html/ +cp /speedtest/stability.html /var/www/html/ -# Copy favicon +# Copy PWA metadata and icons cp /speedtest/favicon.ico /var/www/html/ +cp /speedtest/manifest.webmanifest /var/www/html/ +cp -r /speedtest/images /var/www/html/ + +# Set custom webroot on alpine +if is_alpine; then + echo "ALPINE IMAGE" + sed -i "s#\"/var/www/localhost/htdocs\"#\"/var/www/html\"#g" /etc/apache2/httpd.conf +else + echo "DEBIAN IMAGE" +fi + + +# Copy servers.json for stability page (frontend/dual modes) +if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]] && [ -f /servers.json ]; then + cp /servers.json /var/www/html/servers.json +fi # Copy logo cp /speedtest/logo.png /var/www/html/ @@ -33,31 +76,82 @@ if [ "$MODE" == "backend" ]; then fi # Set up index.php for frontend-only or standalone modes -if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]]; then - cp /speedtest/frontend.php /var/www/html/index.php -elif [ "$MODE" == "standalone" ]; then - cp /speedtest/standalone.php /var/www/html/index.php +if [[ "$MODE" == "frontend" || "$MODE" == "dual" || "$MODE" == "standalone" ]]; then + cp /speedtest/index.html /var/www/html/ + cp /speedtest/stability.html /var/www/html/ + + # Copy frontend config files + cp /speedtest/settings.json /var/www/html/settings.json 2>/dev/null || true + if [ -f /servers.json ]; then + echo "using mounted /servers.json for server-list.json" + cp /servers.json /var/www/html/server-list.json + elif [ -n "$SERVER_LIST_URL" ]; then + echo "no /servers.json found, relying on SERVER_LIST_URL" + elif [ "$MODE" == "frontend" ]; then + echo "ERROR: /servers.json not found and SERVER_LIST_URL is not set" >&2 + exit 1 + else + echo "no /servers.json found, create one for local host" + # generate config for just the local server + echo '[{"name":"local","server":"/backend", "dlURL": "garbage.php", "ulURL": "empty.php", "pingURL": "empty.php", "getIpURL": "getIP.php", "sponsorName": "", "sponsorURL": "", "id":1 }]' > /var/www/html/server-list.json + fi + if [ ! -z "$SERVER_LIST_URL" ]; then + echo "using SERVER_LIST_URL for frontend server list" + SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g') + sed -i "/var SPEEDTEST_SERVERS = \\[/,/^[[:space:]]*];/c\\\t\tvar SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";" /var/www/html/index.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 + if [ ! -z "$TITLE" ]; then + TITLE_ONE_LINE=${TITLE//$'\r'/} + TITLE_ONE_LINE=${TITLE_ONE_LINE//$'\n'/ } + TITLE_HTML_ESCAPED=$(html_escape "$TITLE_ONE_LINE") + TITLE_ESCAPED=$(sed_escape "$TITLE_HTML_ESCAPED") + sed -i "s/LibreSpeed<\\/title>/<title>$TITLE_ESCAPED<\\/title>/g; s/<h1>LibreSpeed<\\/h1>/<h1>$TITLE_ESCAPED<\\/h1>/g" /var/www/html/index.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 + GDPR_EMAIL="$EMAIL" + echo "GDPR_EMAIL: $GDPR_EMAIL" + fi + + # Replace GDPR email placeholder if GDPR_EMAIL is set + 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') + + sed -i "s/TO BE FILLED BY DEVELOPER/$GDPR_EMAIL_ESCAPED/g; s/PUT@YOUR_EMAIL.HERE/$GDPR_EMAIL_ESCAPED/g" /var/www/html/index.html + fi fi # Apply Telemetry settings when running in standalone or frontend mode and telemetry is enabled -if [[ "$TELEMETRY" == "true" && ( "$MODE" == "frontend" || "$MODE" == "standalone" || "$MODE" == "dual" ) ]]; then +if [[ "$TELEMETRY" == "true" && ("$MODE" == "frontend" || "$MODE" == "standalone" || "$MODE" == "dual") ]]; then cp -r /speedtest/results /var/www/html/results + sed -i 's/telemetry_level": ".*"/telemetry_level": "basic"/' /var/www/html/settings.json if [ "$MODE" == "frontend" ]; then mkdir /var/www/html/backend cp /speedtest/backend/getIP_util.php /var/www/html/backend fi - if [ "$DB_TYPE" == "mysql" ]; then + if [ "$DB_TYPE" == "mysql" ]; then sed -i 's/$db_type = '\''.*'\''/$db_type = '\'$DB_TYPE\''/g' /var/www/html/results/telemetry_settings.php sed -i 's/$MySql_username = '\''.*'\''/$MySql_username = '\'$DB_USERNAME\''/g' /var/www/html/results/telemetry_settings.php sed -i 's/$MySql_password = '\''.*'\''/$MySql_password = '\'$DB_PASSWORD\''/g' /var/www/html/results/telemetry_settings.php sed -i 's/$MySql_hostname = '\''.*'\''/$MySql_hostname = '\'$DB_HOSTNAME\''/g' /var/www/html/results/telemetry_settings.php sed -i 's/$MySql_databasename = '\''.*'\''/$MySql_databasename = '\'$DB_NAME\''/g' /var/www/html/results/telemetry_settings.php - if [ "$DB_PORT" != "" ]; then + if [ "$DB_PORT" != "" ]; then sed -i 's/$MySql_port = '\''.*'\''/$MySql_port = '\'$DB_PORT\''/g' /var/www/html/results/telemetry_settings.php fi - elif [ "$DB_TYPE" == "postgresql" ]; then + elif [ "$DB_TYPE" == "postgresql" ]; then sed -i 's/$db_type = '\''.*'\''/$db_type = '\'$DB_TYPE\''/g' /var/www/html/results/telemetry_settings.php sed -i 's/$PostgreSql_username = '\''.*'\''/$PostgreSql_username = '\'$DB_USERNAME\''/g' /var/www/html/results/telemetry_settings.php sed -i 's/$PostgreSql_password = '\''.*'\''/$PostgreSql_password = '\'$DB_PASSWORD\''/g' /var/www/html/results/telemetry_settings.php @@ -67,11 +161,21 @@ if [[ "$TELEMETRY" == "true" && ( "$MODE" == "frontend" || "$MODE" == "standalon sed -i s/\$db_type\ =\ \'.*\'/\$db_type\ =\ \'sqlite\'\/g /var/www/html/results/telemetry_settings.php fi - sed -i s/\$Sqlite_db_file\ =\ \'.*\'/\$Sqlite_db_file=\'\\\/database\\\/db.sql\'/g /var/www/html/results/telemetry_settings.php + # Override SQLite database path for Docker environment + # In Docker, we use /database/db.sql which is outside the web-accessible directory + sed -i s/\$Sqlite_db_file\ =\ .*\'/\$Sqlite_db_file=\'\\\/database\\\/db.sql\'/g /var/www/html/results/telemetry_settings.php sed -i s/\$stats_password\ =\ \'.*\'/\$stats_password\ =\ \'$PASSWORD\'/g /var/www/html/results/telemetry_settings.php if [ "$ENABLE_ID_OBFUSCATION" == "true" ]; then sed -i s/\$enable_id_obfuscation\ =\ .*\;/\$enable_id_obfuscation\ =\ true\;/g /var/www/html/results/telemetry_settings.php + if [ ! -z "$OBFUSCATION_SALT" ]; then + if [[ "$OBFUSCATION_SALT" =~ ^0x[0-9a-fA-F]+$ ]]; then + echo "<?php" > /var/www/html/results/idObfuscation_salt.php + echo "\$OBFUSCATION_SALT = $OBFUSCATION_SALT;" >> /var/www/html/results/idObfuscation_salt.php + else + echo "WARNING: Invalid OBFUSCATION_SALT format. It must be a hex string (e.g., 0x1234abcd). Using random salt." >&2 + fi + fi fi if [ "$REDACT_IP_ADDRESSES" == "true" ]; then @@ -79,18 +183,34 @@ if [[ "$TELEMETRY" == "true" && ( "$MODE" == "frontend" || "$MODE" == "standalon fi mkdir -p /database/ - chown www-data /database/ + if is_alpine; then + chown -R apache /database/ + else + chown -R www-data /database/ + fi fi -chown -R www-data /var/www/html/* +if is_alpine; then + chown -R apache /var/www/html/* +else + chown -R www-data /var/www/html/* +fi # Allow selection of Apache port for network_mode: host if [ "$WEBPORT" != "80" ]; then - sed -i "s/^Listen 80\$/Listen $WEBPORT/g" /etc/apache2/ports.conf - sed -i "s/*:80>/*:$WEBPORT>/g" /etc/apache2/sites-available/000-default.conf + if is_alpine; then + sed -i "s/^Listen 80\$/Listen $WEBPORT/g" /etc/apache2/httpd.conf + else + sed -i "s/^Listen 80\$/Listen $WEBPORT/g" /etc/apache2/ports.conf + sed -i "s/*:80>/*:$WEBPORT>/g" /etc/apache2/sites-available/000-default.conf + fi fi echo "Done, Starting APACHE" # This runs apache -exec apache2-foreground +if is_alpine; then + exec httpd -DFOREGROUND +else + exec apache2-foreground +fi diff --git a/docker/frontend.php b/docker/frontend.php deleted file mode 100755 index 004f1e6f0..000000000 --- a/docker/frontend.php +++ /dev/null @@ -1,474 +0,0 @@ -<!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" /> -<link rel="apple-touch-icon" href="favicon.ico"> -<meta name="apple-mobile-web-app-capable" content="yes"> -<meta charset="UTF-8" /> -<script type="text/javascript" src="speedtest.js"></script> -<script type="text/javascript"> -function I(i){return document.getElementById(i);} - -//LIST OF TEST SERVERS. See documentation for details if needed -var SPEEDTEST_SERVERS= <?= file_get_contents('/servers.json') ?: '[]' ?>; - -//INITIALIZE SPEED TEST -var s=new Speedtest(); //create speed test object -<?php if(getenv("TELEMETRY")=="true"){ ?> -s.setParameter("telemetry_level","basic"); -<?php } ?> -<?php if(getenv("DISABLE_IPINFO")=="true"){ ?> -s.setParameter("getIp_ispInfo",false); -<?php } ?> -<?php if(getenv("DISTANCE")){ ?> -s.setParameter("getIp_ispInfo_distance","<?=getenv("DISTANCE") ?>"); -<?php } ?> - -//SERVER AUTO SELECTION -function initServers(){ - var noServersAvailable=function(){ - I("message").innerHTML="No servers available"; - } - var runServerSelect=function(){ - s.selectServer(function(server){ - if(server!=null){ //at least 1 server is available - I("loading").className="hidden"; //hide loading message - //populate server list for manual selection - for(var i=0;i<SPEEDTEST_SERVERS.length;i++){ - if(SPEEDTEST_SERVERS[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; - I("server").appendChild(option); - } - //show test UI - I("testWrapper").className="visible"; - initUI(); - }else{ //no servers are available, the test cannot proceed - noServersAvailable(); - } - }); - } - if(typeof SPEEDTEST_SERVERS === "string"){ - //need to fetch list of servers from specified URL - s.loadServerList(SPEEDTEST_SERVERS,function(servers){ - if(servers==null){ //failed to load server list - noServersAvailable(); - }else{ //server list loaded - SPEEDTEST_SERVERS=servers; - runServerSelect(); - } - }); - }else{ - //hardcoded server list - s.addTestPoints(SPEEDTEST_SERVERS); - runServerSelect(); - } -} - -var meterBk=/Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent)?"#EAEAEA":"#80808040"; -var dlColor="#6060AA", - ulColor="#616161"; -var progColor=meterBk; - -//CODE FOR GAUGES -function drawMeter(c,amount,bk,fg,progress,prog){ - var ctx=c.getContext("2d"); - var dp=window.devicePixelRatio||1; - var cw=c.clientWidth*dp, ch=c.clientHeight*dp; - var sizScale=ch*0.0055; - if(c.width==cw&&c.height==ch){ - ctx.clearRect(0,0,cw,ch); - }else{ - c.width=cw; - c.height=ch; - } - ctx.beginPath(); - ctx.strokeStyle=bk; - ctx.lineWidth=12*sizScale; - ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,Math.PI*0.1); - ctx.stroke(); - ctx.beginPath(); - ctx.strokeStyle=fg; - ctx.lineWidth=12*sizScale; - ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,amount*Math.PI*1.2-Math.PI*1.1); - ctx.stroke(); - if(typeof progress !== "undefined"){ - ctx.fillStyle=prog; - ctx.fillRect(c.width*0.3,c.height-16*sizScale,c.width*0.4*progress,4*sizScale); - } -} -function mbpsToAmount(s){ - return 1-(1/(Math.pow(1.3,Math.sqrt(s)))); -} -function format(d){ - d=Number(d); - if(d<10) return d.toFixed(2); - if(d<100) return d.toFixed(1); - return d.toFixed(0); -} - -//UI CODE -var uiData=null; -function startStop(){ - if(s.getState()==3){ - //speed test is running, abort - s.abort(); - data=null; - I("startStopBtn").className=""; - I("server").disabled=false; - initUI(); - }else{ - //test is not running, begin - I("startStopBtn").className="running"; - I("shareArea").style.display="none"; - I("server").disabled=true; - s.onupdate=function(data){ - uiData=data; - }; - s.onend=function(aborted){ - I("startStopBtn").className=""; - I("server").disabled=false; - updateUI(true); - if(!aborted){ - //if testId is present, show sharing panel, otherwise do nothing - try{ - var testId=uiData.testId; - if(testId!=null){ - var shareURL=window.location.href.substring(0,window.location.href.lastIndexOf("/"))+"/results/?id="+testId; - I("resultsImg").src=shareURL; - I("resultsURL").value=shareURL; - I("testId").innerHTML=testId; - I("shareArea").style.display=""; - } - }catch(e){} - } - }; - s.start(); - } -} -//this function reads the data sent back by the test and updates the UI -function updateUI(forced){ - if(!forced&&s.getState()!=3) return; - if(uiData==null) return; - var status=uiData.testState; - I("ip").textContent=uiData.clientIp; - I("dlText").textContent=(status==1&&uiData.dlStatus==0)?"...":format(uiData.dlStatus); - drawMeter(I("dlMeter"),mbpsToAmount(Number(uiData.dlStatus*(status==1?oscillate():1))),meterBk,dlColor,Number(uiData.dlProgress),progColor); - I("ulText").textContent=(status==3&&uiData.ulStatus==0)?"...":format(uiData.ulStatus); - drawMeter(I("ulMeter"),mbpsToAmount(Number(uiData.ulStatus*(status==3?oscillate():1))),meterBk,ulColor,Number(uiData.ulProgress),progColor); - I("pingText").textContent=format(uiData.pingStatus); - I("jitText").textContent=format(uiData.jitterStatus); -} -function oscillate(){ - return 1+0.02*Math.sin(Date.now()/100); -} -//update the UI every frame -window.requestAnimationFrame=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||(function(callback,element){setTimeout(callback,1000/60);}); -function frame(){ - requestAnimationFrame(frame); - updateUI(); -} -frame(); //start frame loop -//function to (re)initialize UI -function initUI(){ - drawMeter(I("dlMeter"),0,meterBk,dlColor,0); - drawMeter(I("ulMeter"),0,meterBk,ulColor,0); - I("dlText").textContent=""; - I("ulText").textContent=""; - I("pingText").textContent=""; - I("jitText").textContent=""; - I("ip").textContent=""; -} -</script> -<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; - } - #loading{ - background-color:#FFFFFF; - color:#404040; - text-align:center; - } - span.loadCircle{ - display:inline-block; - width:2em; - height:2em; - vertical-align:middle; - background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg=='); - background-size:2em 2em; - margin-right:0.5em; - animation: spin 0.6s linear infinite; - } - @keyframes spin{ - 0%{transform:rotate(0deg);} - 100%{transform:rotate(359deg);} - } - #startStopBtn{ - 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); - } - #startStopBtn:hover{ - box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1); - } - #startStopBtn.running{ - background-color:#FF3030; - border-color:#FF6060; - color:#FFFFFF; - } - #startStopBtn:before{ - content:"Start"; - } - #startStopBtn.running:before{ - content:"Abort"; - } - #serverArea{ - margin-top:1em; - } - #server{ - font-size:1em; - padding:0.2em; - } - #test{ - margin-top:2em; - margin-bottom:12em; - } - div.testArea{ - display:inline-block; - width:16em; - height:12.5em; - position:relative; - box-sizing:border-box; - } - div.testArea2{ - display:inline-block; - width:14em; - height:7em; - position:relative; - box-sizing:border-box; - text-align:center; - } - div.testArea div.testName{ - position:absolute; - top:0.1em; left:0; - width:100%; - font-size:1.4em; - z-index:9; - } - div.testArea2 div.testName{ - display:block; - text-align:center; - font-size:1.4em; - } - div.testArea div.meterText{ - position:absolute; - bottom:1.55em; left:0; - width:100%; - font-size:2.5em; - z-index:9; - } - div.testArea2 div.meterText{ - display:inline-block; - font-size:2.5em; - } - div.meterText:empty:before{ - content:"0.00"; - } - div.testArea div.unit{ - position:absolute; - bottom:2em; left:0; - width:100%; - z-index:9; - } - div.testArea2 div.unit{ - display:inline-block; - } - div.testArea canvas{ - position:absolute; - top:0; left:0; width:100%; height:100%; - z-index:1; - } - div.testGroup{ - display:block; - margin: 0 auto; - } - #shareArea{ - width:95%; - max-width:40em; - margin:0 auto; - margin-top:2em; - } - #shareArea > *{ - display:block; - width:100%; - height:auto; - margin: 0.25em 0; - } - #privacyPolicy{ - position:fixed; - top:2em; - bottom:2em; - left:2em; - right:2em; - overflow-y:auto; - width:auto; - height:auto; - box-shadow:0 0 3em 1em #000000; - z-index:999999; - text-align:left; - background-color:#FFFFFF; - padding:1em; - } - a.privacy{ - text-align:center; - font-size:0.8em; - color:#808080; - display:block; - } - @media all and (max-width:40em){ - body{ - font-size:0.8em; - } - } - div.visible{ - animation: fadeIn 0.4s; - display:block; - } - div.hidden{ - animation: fadeOut 0.4s; - display:none; - } - @keyframes fadeIn{ - 0%{ - opacity:0; - } - 100%{ - opacity:1; - } - } - @keyframes fadeOut{ - 0%{ - display:block; - opacity:1; - } - 100%{ - display:block; - opacity:0; - } - } -</style> -<title><?= getenv('TITLE') ?: 'LibreSpeed Example' ?> - - -

-
-

Selecting a server...

-
- - - - \ No newline at end of file diff --git a/docker/librespeed-php.ini b/docker/librespeed-php.ini new file mode 100644 index 000000000..5c657d268 --- /dev/null +++ b/docker/librespeed-php.ini @@ -0,0 +1,11 @@ +; speedtest_worker.js defaults xhr_ul_blob_megabytes to 20. +; PHP's stock post_max_size = 8M rejects those upload chunks before +; empty.php can send its headers. 32M leaves modest headroom. + +post_max_size = 32M + +; LibreSpeed does not use the session upload progress feature, and PHP's advisory +; for CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp) names it as a way to reach the +; vulnerable code path, recommending it be off if it is not needed. Updating PHP is +; the real fix; this only removes a feature nobody here uses. +session.upload_progress.enabled = 0 diff --git a/docker/standalone.php b/docker/standalone.php deleted file mode 100755 index d5eac33fa..000000000 --- a/docker/standalone.php +++ /dev/null @@ -1,918 +0,0 @@ - - - - - - - - - - - - - - - Talview Speedtest - - - -
-
- -
-
- -
-
-
Internet Speed Test
- -
-

- Before you begin your assessment or interview, please ensure your - internet connection meets the minimum requirement for a smooth - experience: -

-
    -
  1. Click "Start" to check your current network performance.
  2. -
  3. - Once the test is complete, review the results displayed on the - screen. -
  4. -
  5. - Ensure all network metrics meet the requirements listed above. -
  6. -
-
- - -
-
-
-
-
Ping
-
-
-
ms
-
-
- -
- -
-
-
Jitter
-
-
-
ms
-
-
- -
-
-
-
-
-
Download
-
-
-
Mbps
-
-
- -
- -
-
-
Upload
-
-
-
Mbps
-
-
- -
-
-
- -
-
- - - -
-
-
- - -
-
- - - - \ No newline at end of file diff --git a/docker/test/README.md b/docker/test/README.md new file mode 100644 index 000000000..cafd486ee --- /dev/null +++ b/docker/test/README.md @@ -0,0 +1,4 @@ +The files in this folder represent a simple end to end test. +At the moment it is interactive, so you need to run `docker compose up` in the folder and watch the output for errors. +Sometimes the mysql container needs more than 15 seconds, so the test_script fails. +Just try it again. \ No newline at end of file diff --git a/docker/test/docker-compose.yml b/docker/test/docker-compose.yml new file mode 100644 index 000000000..ead914a36 --- /dev/null +++ b/docker/test/docker-compose.yml @@ -0,0 +1,208 @@ +## this is a docker compose file used to test all scenarios +## do not use it in production +x-shared: + speedtest-service: &speedtest-service + environment: + - PASSWORD=gimmeTheStats1337 + - GDPR_EMAIL=test@example.com + - ENABLE_ID_OBFUSCATION=true + - MODE=standalone + - REDACT_IP_ADDRESSES=true + - TELEMETRY=true + +services: + ###################### POSTGRESQL ################################ + pg: + image: postgres:alpine + environment: + POSTGRES_PASSWORD: Not4SecureProduction + POSTGRES_USER: librespeed + volumes: + # mount init script from source to create database tables + - ../../results/telemetry_postgresql.sql:/docker-entrypoint-initdb.d/01-init.sql + + speedtest-debian-pg: + build: + context: ../.. + dockerfile: Dockerfile + environment: &env_vars_pg + - PASSWORD=gimmeTheStats1337 + - DB_HOSTNAME=pg + - DB_NAME=librespeed + - DB_PASSWORD=Not4SecureProduction + - DB_TYPE=postgresql + - DB_USERNAME=librespeed + - GDPR_EMAIL=test@example.com + - ENABLE_ID_OBFUSCATION=true + - MODE=standalone + - REDACT_IP_ADDRESSES=true + - TELEMETRY=true + ports: + - 9123:8080 + + speedtest-alpine-pg: + build: + context: ../.. + dockerfile: Dockerfile.alpine + environment: *env_vars_pg + ports: + - 9124:8080 + + ####################### MYSQL ############################## + + mysql: + image: mysql:lts + environment: + MYSQL_ROOT_PASSWORD: Not4SecureProduction + MYSQL_USER: librespeed + MYSQL_PASSWORD: Not4SecureProduction + MYSQL_DATABASE: librespeed + volumes: + # mount init script from source to create database tables + - ../../results/telemetry_mysql.sql:/docker-entrypoint-initdb.d/01-init.sql + + speedtest-debian-mysql: + # check at http://localhost:9125/results/sanitycheck.php + build: + context: ../.. + dockerfile: Dockerfile + environment: &env_vars_mysql + - PASSWORD=gimmeTheStats1337 + - DB_HOSTNAME=mysql + - DB_NAME=librespeed + - DB_PASSWORD=Not4SecureProduction + - DB_TYPE=mysql + - DB_USERNAME=librespeed + - GDPR_EMAIL=test@example.com + - ENABLE_ID_OBFUSCATION=true + - MODE=standalone + - REDACT_IP_ADDRESSES=true + - TELEMETRY=true + ports: + - 9125:8080 + speedtest-alpine-mysql: + build: + context: ../.. + dockerfile: Dockerfile.alpine + environment: *env_vars_mysql + ports: + - 9126:8080 + + ####################### MSSQL ############################## + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + - ACCEPT_EULA=Y + - SA_PASSWORD=Not4SecureProduction1337 + - MSSQL_PID=Express + healthcheck: + test: + ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P $$SA_PASSWORD -C -Q 'SELECT 1' || exit 1"] + interval: 10s + retries: 5 + start_period: 30s + + mssql-init: + image: mcr.microsoft.com/mssql/server:2022-latest + depends_on: + mssql: + condition: service_healthy + environment: + - SA_PASSWORD=Not4SecureProduction1337 + volumes: + - ../../results/telemetry_mssql.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + command: > + bash -c ' + echo "Waiting for MSSQL to be ready..."; + until /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$SA_PASSWORD" -C -Q "SELECT 1"; do + sleep 1; + done; + echo "Running init scripts..."; + for f in /docker-entrypoint-initdb.d/*.sql; do + echo "Executing $$f..."; + /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$SA_PASSWORD" -C -i "$$f"; + done; + echo "Initialization complete!"; + ' + + speedtest-debian-mssql: + build: + context: ../.. + dockerfile: Dockerfile + environment: &env_vars_mssql + - PASSWORD=gimmeTheStats1337 + - DB_HOSTNAME=mssql + - DB_NAME=librespeed + - DB_PASSWORD=Not4SecureProduction1337 + - DB_TYPE=mssql + - DB_USERNAME=sa + - GDPR_EMAIL=test@example.com + - ENABLE_ID_OBFUSCATION=true + - MODE=standalone + - REDACT_IP_ADDRESSES=true + - TELEMETRY=true + ports: + - 9130:8080 + + speedtest-alpine-mssql: + build: + context: ../.. + dockerfile: Dockerfile.alpine + environment: *env_vars_mssql + ports: + - 9131:8080 + + ################ SQLITE #################################### + + speedtest-debian-sqlite: + # check at http://localhost:9125/results/sanitycheck.php + build: + context: ../.. + dockerfile: Dockerfile + environment: &env_vars_sqlite + - PASSWORD=gimmeTheStats1337 + - GDPR_EMAIL=test@example.com + - ENABLE_ID_OBFUSCATION=true + - MODE=standalone + - REDACT_IP_ADDRESSES=true + - TELEMETRY=true + ports: + - 9127:8080 + + speedtest-alpine-sqlite: + build: + context: ../.. + dockerfile: Dockerfile.alpine + environment: *env_vars_sqlite + ports: + - 9128:8080 + + speedtest-alpine-sqlite-dual: + <<: *speedtest-service + build: + context: ../.. + dockerfile: Dockerfile.alpine + environment: + - MODE=dual + + volumes: + - ./servers.json:/servers.json:ro + ports: + - 9129:8080 + + ############## TEST CONTAINER ############################################################### + test-container: + image: alpine + depends_on: + - pg + - mysql + - mssql-init + - speedtest-alpine-mysql + - speedtest-alpine-pg + - speedtest-alpine-mssql + volumes: + - ./test-script.sh:/test-script.sh + command: + - sh + - /test-script.sh diff --git a/docker/test/servers.json b/docker/test/servers.json new file mode 100644 index 000000000..f067463a0 --- /dev/null +++ b/docker/test/servers.json @@ -0,0 +1,409 @@ +[ + { + "name": "Amsterdam, Netherlands (Clouvider)", + "server": "//ams.speedtest.clouvider.net/backend", + "id": 51, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Amsterdam, Netherlands (Sharktech)", + "server": "https://amsspeed.sharktech.net", + "id": 94, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Atlanta, United States (Clouvider)", + "server": "//atl.speedtest.clouvider.net/backend", + "id": 53, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Bangalore, India (DigitalOcean)", + "server": "//in1.backend.librespeed.org/", + "id": 75, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "DigitalOcean", + "sponsorURL": "https://www.digitalocean.com" + }, + { + "name": "Bari, Italy (GARR)", + "server": "https://st-be-ba1.infra.garr.it", + "id": 33, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Bologna, Italy (GARR)", + "server": "https://st-be-bo1.infra.garr.it", + "id": 34, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Chicago, USA (Sharktech)", + "server": "https://chispeed.sharktech.net", + "id": 93, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Denver, USA (Sharktech)", + "server": "https://denspeed.sharktech.net", + "id": 92, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Frankfurt, Germany (Clouvider)", + "server": "//fra.speedtest.clouvider.net/backend", + "id": 50, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Frankfurt, Germany (FRA01)", + "server": "https://speedtest.lumischvps.cloud/", + "id": 86, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "LumischVPS", + "sponsorURL": "https://discord.gg/GxYzPwJmA2" + }, + { + "name": "Ghom, Iran (Amin IDC)", + "server": "https://fastme.ir/", + "id": 77, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech/" + }, + { + "name": "Helsinki, Finland (3) (Hetzner)", + "server": "//finew.openspeed.org/", + "id": 22, + "dlURL": "backend437/garbage.php", + "ulURL": "backend437/empty.php", + "pingURL": "backend437/empty.php", + "getIpURL": "backend437/getIP.php", + "sponsorName": "Daily Health Insurance Group", + "sponsorURL": "//dhig.net/" + }, + { + "name": "Helsinki, Finland (5) (Hetzner)", + "server": "//fast.kabi.tk/", + "id": 24, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "KABI.tk", + "sponsorURL": "//kabi.tk" + }, + { + "name": "Johannesburg, South Africa (Host Africa)", + "server": "//za1.backend.librespeed.org/", + "id": 70, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "HOSTAFRICA", + "sponsorURL": "https://www.hostafrica.co.za" + }, + { + "name": "Las Vegas, USA (Sharktech)", + "server": "https://lasspeed.sharktech.net", + "id": 90, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "London, England (Clouvider)", + "server": "//lon.speedtest.clouvider.net/backend", + "id": 49, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Los Angeles, United States (1) (Clouvider)", + "server": "//la.speedtest.clouvider.net/backend", + "id": 54, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Los Angeles, USA (Sharktech)", + "server": "https://laxspeed.sharktech.net", + "id": 91, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "New York, United States (2) (Clouvider)", + "server": "//nyc.speedtest.clouvider.net/backend", + "id": 52, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Nottingham, England (LayerIP)", + "server": "https://uk1.backend.librespeed.org", + "id": 43, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "fosshost.org", + "sponsorURL": "https://fosshost.org" + }, + { + "name": "Nuremberg, Germany (1) (Hetzner)", + "server": "//de1.backend.librespeed.org", + "id": 28, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Snopyta", + "sponsorURL": "https://snopyta.org" + }, + { + "name": "Nuremberg, Germany (2) (Hetzner)", + "server": "//de4.backend.librespeed.org", + "id": 27, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (3) (Hetzner)", + "server": "//de3.backend.librespeed.org", + "id": 30, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (4) (Hetzner)", + "server": "//de5.backend.librespeed.org", + "id": 31, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (6) (Hetzner)", + "server": "//librespeed.lukas-heinrich.com/", + "id": 46, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "luki9100", + "sponsorURL": "https://lukas-heinrich.com/" + }, + { + "name": "Ohio, USA (Rust backend)", + "server": "https://librespeed-rs.ir/", + "id": 95, + "dlURL": "backend/garbage", + "ulURL": "backend/empty", + "pingURL": "backend/empty", + "getIpURL": "backend/getIP", + "sponsorName": "Sudo Dios", + "sponsorURL": "https://github.com/SudoDios" + }, + { + "name": "Poznan, Poland (INEA)", + "server": "https://speedtest.kamilszczepanski.com", + "id": 74, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Kamil Szczepański", + "sponsorURL": "https://kamilszczepanski.com" + }, + { + "name": "Prague, Czech Republic (CESNET)", + "server": "//speedtest.cesnet.cz", + "id": 79, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "CESNET", + "sponsorURL": "https://www.cesnet.cz" + }, + { + "name": "Prague, Czech Republic (Turris)", + "server": "//librespeed.turris.cz", + "id": 85, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Turris", + "sponsorURL": "https://www.turris.com" + }, + { + "name": "Roma, Italy (GARR)", + "server": "https://st-be-rm2.infra.garr.it", + "id": 35, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Serbia (SOX)", + "server": "https://speedtest2.sox.rs", + "id": 87, + "dlURL": "libre/backend/garbage.php", + "ulURL": "libre/backend/empty.php", + "pingURL": "libre/backend/empty.php", + "getIpURL": "libre/backend/getIP.php", + "sponsorName": "Serbian Open eXchange (SOX)", + "sponsorURL": "https://sox.rs" + }, + { + "name": "Singapore (Salvatore Cahyo)", + "server": "https://speedtest.dsgroupmedia.com", + "id": 68, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Salvatore Cahyo", + "sponsorURL": "https://salvatorecahyo.my.id" + }, + { + "name": "Tehran, Iran (Fanava)", + "server": "https://speedme.ir/", + "id": 76, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech" + }, + { + "name": "Tehran, Iran (Faraso)", + "server": "https://st.bardia.tech", + "id": 80, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech/" + }, + { + "name": "Tokyo, Japan (A573)", + "server": "https://librespeed.a573.net/", + "id": 82, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "A573", + "sponsorURL": "https://mirror.a573.net/" + }, + { + "name": "Vilnius, Lithuania (RackRay)", + "server": "//lt1.backend.librespeed.org/", + "id": 69, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Time4VPS", + "sponsorURL": "https://www.time4vps.com" + }, + { + "name": "Virginia, United States, OVH", + "server": "https://speed.riverside.rocks/", + "id": 78, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Riverside Rocks", + "sponsorURL": "https://riverside.rocks" + } +] diff --git a/docker/test/test-arm-build.sh b/docker/test/test-arm-build.sh new file mode 100755 index 000000000..07231e70a --- /dev/null +++ b/docker/test/test-arm-build.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +# register QEMU handlers for emulation (one-time) +docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + +# create and use a buildx builder (if you don't already have one) +if ! docker buildx inspect mybuilder > /dev/null 2>&1; then + docker buildx create --name mybuilder --use +else + docker buildx use mybuilder +fi +docker buildx inspect --bootstrap + +# change to repo root +cd ../.. + +# build the alpine image for arm64 and amd64 +# Note: We cannot use --load with multiple platforms. +# We will build for arm64 specifically to test the emulation and load it. +echo "Building for linux/arm64..." +docker buildx build --platform linux/arm64 -f Dockerfile.alpine -t local-speedtest:alpine-arm64 --load . + +# To build for both platforms, you typically need to push to a registry: +# docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.alpine -t local-speedtest:alpine --push . diff --git a/docker/test/test-script.sh b/docker/test/test-script.sh new file mode 100644 index 000000000..3e1ca3c01 --- /dev/null +++ b/docker/test/test-script.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +## simple script to call sanitycheck on all containers + +PORT=8080 + +if ! apk add w3m; then + echo "Failed to install w3m package" + exit 1 +fi + +echo sleeping a little to get things settled... +sleep 15 + +for db in sqlite pg mysql mssql; do + for distro in alpine debian; do + hostname=speedtest-$distro-$db + echo $hostname + w3m -dump http://$hostname:$PORT/results/sanitycheck.php|grep -v 'N/A' + done +done diff --git a/images/icon-192.png b/images/icon-192.png new file mode 100644 index 000000000..030bddc15 Binary files /dev/null and b/images/icon-192.png differ diff --git a/images/icon-512.png b/images/icon-512.png new file mode 100644 index 000000000..e3a030524 Binary files /dev/null and b/images/icon-512.png differ diff --git a/index.html b/index.html old mode 100755 new mode 100644 diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 000000000..5716118f7 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "LibreSpeed", + "short_name": "LibreSpeed", + "start_url": "./", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "images/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "images/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..88289738f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1337 @@ +{ + "name": "librespeed-speedtest", + "version": "6.3.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "librespeed-speedtest", + "version": "6.3.1", + "license": "LGPL-3.0-or-later", + "devDependencies": { + "@playwright/test": "^1.55.0", + "eslint": "^8.57.0", + "prettier": "^3.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "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": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..4ca46a19b --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "librespeed-speedtest", + "version": "6.3.1", + "description": "LibreSpeed - A Free and Open Source speed test that you can host on your server(s)", + "main": "speedtest.js", + "scripts": { + "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 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", + "docker:build": "docker build -t librespeed-speedtest .", + "docker:build-alpine": "docker build -t librespeed-speedtest:alpine -f Dockerfile.alpine ." + }, + "repository": { + "type": "git", + "url": "git+https://github.com/librespeed/speedtest.git" + }, + "keywords": [ + "speedtest", + "speed-test", + "internet-speed", + "bandwidth", + "network", + "performance", + "librespeed", + "download", + "upload", + "ping", + "latency" + ], + "author": "Federico Dossena", + "license": "LGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/librespeed/speedtest/issues" + }, + "homepage": "https://github.com/librespeed/speedtest#readme", + "devDependencies": { + "@playwright/test": "^1.55.0", + "eslint": "^8.57.0", + "prettier": "^3.1.1" + }, + "files": [ + "speedtest.js", + "speedtest_worker.js", + "stability_worker.js", + "stability.html", + "index.html", + "favicon.ico", + "manifest.webmanifest", + "images/", + "backend/", + "examples/", + "results/" + ], + "engines": { + "node": ">=14.0.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..5f53bcda6 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,32 @@ +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + timeout: 30_000, + expect: { + timeout: 7_500, + }, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], + use: { + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'off', + }, + globalSetup: require.resolve('./tests/e2e/global-setup.js'), + globalTeardown: require.resolve('./tests/e2e/global-teardown.js'), + webServer: { + command: 'node tests/e2e/static-server.js', + url: 'http://127.0.0.1:18184', + reuseExistingServer: !process.env.CI, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/results/index.php b/results/index.php index 455931597..582121578 100755 --- a/results/index.php +++ b/results/index.php @@ -5,6 +5,25 @@ error_reporting(0); putenv('GDFONTPATH='.realpath('.')); +/** + * Everything is drawn at SS times the final size and scaled down at the end. + * GD has no antialiasing for filled shapes, so circles, rounded corners and + * arcs would otherwise come out stepped; oversampling gives all of them smooth + * edges from one mechanism. + */ +const SS = 3; + +const WIDTH = 800; +const HEIGHT = 480; + +const C_CARD = [14, 7, 32]; // --background-backup-color +const C_PANEL = [23, 16, 42]; +const C_PANEL_EDGE = [42, 32, 64]; +const C_TEXT = [255, 255, 255]; // --primary-text-color +const C_TEXT_DIM = [137, 133, 145]; // --secondary-text-color +const C_DOWNLOAD = [92, 249, 253]; // --theme-green +const C_UPLOAD = [214, 59, 198]; // --theme-pink + /** * @param string $name * @@ -41,6 +60,360 @@ function format($d) return number_format($d, 0, '.', ''); } +/** + * Drops the fractional seconds a database may keep on the timestamp column. + * + * How much precision that column carries is decided by the backend, and the + * three schemas shipped here disagree: MySQL declares `timestamp`, which keeps + * none, PostgreSQL uses `timestamp without time zone DEFAULT now()`, which + * keeps microseconds, and MSSQL uses `datetime`, which keeps milliseconds. + * + * The fraction is matched against the seconds field it belongs to rather than + * the end of the string, so a value carrying a zone offset after it, such as + * "2026-08-10 02:04:13.957789+00", is handled as well. + * + * @param string $timestamp + * + * @return string + */ +function formatTimestamp($timestamp) +{ + return preg_replace('/(:\d{2})\.\d+/', '$1', (string) $timestamp); +} + +/** + * Splits a timestamp into a date and a time meant to be read rather than + * parsed. + * + * The whole value is returned as the date with an empty time when it cannot be + * parsed, which keeps a backend nobody here has seen legible rather than blank. + * + * @param string $timestamp + * + * @return array{0: string, 1: string} + */ +function splitTimestamp($timestamp) +{ + $timestamp = formatTimestamp($timestamp); + $t = strtotime($timestamp); + if (false === $t) { + return [$timestamp, '']; + } + + return [date('j M Y', $t), date('H:i', $t)]; +} + +/** + * Names the address family a result was measured over, without disclosing the + * address itself. + * + * Returns an empty string when the deployment redacts client addresses, since + * the placeholder it stores would otherwise be reported as IPv4. + * + * @param string $ip + * + * @return string + */ +function addressFamily($ip) +{ + if ('' === $ip || '0.0.0.0' === $ip) { + return ''; + } + + return false === strpos($ip, ':') ? 'IPv4' : 'IPv6'; +} + +/** + * @param resource|GdImage $im + * @param int[] $rgb + * @param int $alpha 0 opaque, 127 transparent + * + * @return int + */ +function col($im, $rgb, $alpha = 0) +{ + return imagecolorallocatealpha($im, $rgb[0], $rgb[1], $rgb[2], $alpha); +} + +/** + * The horizontal span of a rounded rectangle on a given row. + * + * Filling and shading both need to respect the same corners, so both ask this + * rather than each approximating the curve on its own. + * + * @return array{0: float, 1: float}|null + */ +function roundedSpan($y, $x1, $y1, $x2, $y2, $r) +{ + if ($y < $y1 || $y > $y2) { + return null; + } + + $inset = 0; + if ($y < $y1 + $r) { + $dy = $r - ($y - $y1); + $inset = $r - sqrt(max(0, $r * $r - $dy * $dy)); + } elseif ($y > $y2 - $r) { + $dy = $r - ($y2 - $y); + $inset = $r - sqrt(max(0, $r * $r - $dy * $dy)); + } + + return [$x1 + $inset, $x2 - $inset]; +} + +/** + * @param resource|GdImage $im + * + * @return void + */ +function roundedRect($im, $x1, $y1, $x2, $y2, $r, $color) +{ + for ($y = (int) $y1; $y <= (int) $y2; ++$y) { + $span = roundedSpan($y, $x1, $y1, $x2, $y2, $r); + if (null !== $span) { + imageline($im, (int) round($span[0]), $y, (int) round($span[1]), $y, $color); + } + } +} + +/** + * A panel: a one-pixel edge with a darker fill inside it. + * + * @param resource|GdImage $im + * + * @return void + */ +function panel($im, $x1, $y1, $x2, $y2, $r, $fill, $edge) +{ + roundedRect($im, $x1, $y1, $x2, $y2, $r, $edge); + roundedRect($im, $x1 + SS, $y1 + SS, $x2 - SS, $y2 - SS, $r - SS, $fill); +} + +/** + * Washes an accent colour into the bottom of a panel. + * + * This is deliberately a plain gradient and not a line chart. A curve inside a + * speed test result reads as measured data, and no per-second samples are + * stored for a shared result, so any curve drawn here would be invented. + * + * @param resource|GdImage $im + * @param int[] $rgb + * + * @return void + */ +function wash($im, $x1, $y1, $x2, $y2, $r, $rgb, $height, $maxAlpha = 88) +{ + $start = $y2 - $height; + for ($y = (int) $start; $y <= (int) $y2; ++$y) { + $span = roundedSpan($y, $x1, $y1, $x2, $y2, $r); + if (null === $span) { + continue; + } + $t = ($y - $start) / max(1, $height); + $alpha = (int) round(127 - (127 - $maxAlpha) * ($t * $t)); + imageline($im, (int) round($span[0]), $y, (int) round($span[1]), $y, col($im, $rgb, $alpha)); + } +} + +/** + * @param string $text + * + * @return float + */ +function textWidth($text, $font, $size) +{ + $bbox = imageftbbox($size, 0, $font, $text); + + return is_array($bbox) ? $bbox[2] - $bbox[0] : 0; +} + +/** + * Draws text, anchored left, centre or right on x. + * + * @param resource|GdImage $im + * + * @return float the width drawn + */ +function text($im, $x, $y, $size, $font, $color, $string, $align = 'left') +{ + $w = textWidth($string, $font, $size); + if ('center' === $align) { + $x -= $w / 2; + } elseif ('right' === $align) { + $x -= $w; + } + imagefttext($im, $size, 0, (int) round($x), (int) round($y), $color, $font, $string); + + return $w; +} + +/** + * The width trackedText() would draw. + * + * @return float + */ +function trackedWidth($string, $font, $size, $tracking) +{ + $total = -$tracking; + foreach (preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY) as $ch) { + $total += textWidth($ch, $font, $size) + $tracking; + } + + return $total; +} + +/** + * The same as text(), with the letters spaced apart. + * + * GD has no notion of tracking, so each character is placed on its own. The + * small uppercase labels are unreadable without it at this size. + * + * @param resource|GdImage $im + * + * @return float + */ +function trackedText($im, $x, $y, $size, $font, $color, $string, $tracking, $align = 'left') +{ + $chars = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY); + $total = trackedWidth($string, $font, $size, $tracking); + + if ('center' === $align) { + $x -= $total / 2; + } elseif ('right' === $align) { + $x -= $total; + } + + foreach ($chars as $ch) { + imagefttext($im, $size, 0, (int) round($x), (int) round($y), $color, $font, $ch); + $x += textWidth($ch, $font, $size) + $tracking; + } + + return $total; +} + +/** + * Fills a polygon on both supported PHP majors. + * + * imagefilledpolygon() took a point count until PHP 8.0, which deprecated it + * and made the parameter optional. The backend already relies on the null + * coalescing operator, so PHP 7 is the floor to keep working here. + * + * @param resource|GdImage $im + * @param int[] $points + * + * @return void + */ +function filledPolygon($im, $points, $color) +{ + if (PHP_VERSION_ID >= 80000) { + imagefilledpolygon($im, $points, $color); + + return; + } + + imagefilledpolygon($im, $points, count($points) / 2, $color); +} + +/** + * A ring with an arrow in it, marking which direction a panel measures. + * + * @param resource|GdImage $im + * @param int[] $rgb + * + * @return void + */ +function directionBadge($im, $cx, $cy, $r, $rgb, $pointsDown) +{ + // ring + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r), (int) (2 * $r), col($im, $rgb, 108)); + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r - 2 * SS), (int) (2 * $r - 2 * SS), col($im, C_PANEL)); + + $color = col($im, $rgb); + $shaft = $r * 0.52; + $head = $r * 0.34; + $dir = $pointsDown ? 1 : -1; + + imagesetthickness($im, (int) max(1, SS * 0.7)); + imageline($im, (int) $cx, (int) ($cy - $dir * $shaft), (int) $cx, (int) ($cy + $dir * $shaft), $color); + imagesetthickness($im, 1); + + $tipY = $cy + $dir * $shaft; + filledPolygon($im, [ + (int) $cx, (int) ($tipY + $dir * $head * 0.5), + (int) ($cx - $head), (int) ($tipY - $dir * $head * 0.55), + (int) ($cx + $head), (int) ($tipY - $dir * $head * 0.55), + ], $color); +} + +/** + * A ring with a trace in it: a spike for ping, a wave for jitter. + * + * @param resource|GdImage $im + * @param int[] $rgb + * + * @return void + */ +function traceBadge($im, $cx, $cy, $r, $rgb, $wave) +{ + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r), (int) (2 * $r), col($im, $rgb, 112)); + imagefilledellipse($im, (int) $cx, (int) $cy, (int) (2 * $r - 2 * SS), (int) (2 * $r - 2 * SS), col($im, C_PANEL)); + + $color = col($im, $rgb); + imagesetthickness($im, (int) max(1, SS * 0.8)); + + $w = $r * 1.05; + $prev = null; + for ($i = 0; $i <= 48; ++$i) { + $t = $i / 48; + $x = $cx - $w + 2 * $w * $t; + if ($wave) { + $y = $cy - sin($t * 2 * M_PI) * $r * 0.42; + } else { + // a flat line with one spike in the middle + $d = abs($t - 0.5); + $y = $cy - ($d < 0.16 ? (0.16 - $d) / 0.16 * $r * 0.62 : 0) + ($d > 0.16 && $d < 0.26 ? $r * 0.2 : 0); + } + if (null !== $prev) { + imageline($im, (int) $prev[0], (int) $prev[1], (int) $x, (int) $y, $color); + } + $prev = [$x, $y]; + } + + imagesetthickness($im, 1); +} + +/** + * Shortens text until it fits, marking it as shortened. + * + * User agents in particular are unbounded: a browser sends a far longer string + * than a command line client, and drawn as-is it would run off the panel. + * + * Characters are dropped rather than bytes. A provider name or user agent may + * well carry multibyte characters, and cutting one in half leaves a sequence + * that renders as a replacement glyph rather than shortened text. The input is + * capped first so that the measuring below is bounded no matter what is stored. + * + * @return string + */ +function fitText($text, $font, $size, $maxWidth) +{ + if (textWidth($text, $font, $size) <= $maxWidth) { + return $text; + } + + $chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($chars)) { + return '…'; + } + $chars = array_slice($chars, 0, 256); + + while (count($chars) > 0 && textWidth(implode('', $chars).'…', $font, $size) > $maxWidth) { + array_pop($chars); + } + + return implode('', $chars).'…'; +} + /** * @param array $speedtest * @@ -48,12 +421,14 @@ function format($d) */ function formatSpeedtestDataForImage($speedtest) { - // format values for the image $speedtest['dl'] = format($speedtest['dl']); $speedtest['ul'] = format($speedtest['ul']); $speedtest['ping'] = format($speedtest['ping']); $speedtest['jitter'] = format($speedtest['jitter']); - $speedtest['timestamp'] = $speedtest['timestamp']; + $speedtest['family'] = addressFamily($speedtest['ip']); + list($speedtest['date'], $speedtest['time']) = splitTimestamp($speedtest['timestamp']); + // The classic design prints the timestamp whole rather than split in two. + $speedtest['timestamp'] = formatTimestamp($speedtest['timestamp']); $ispinfo = json_decode($speedtest['ispinfo'], true)['processedString']; $dash = strpos($ispinfo, '-'); @@ -66,18 +441,19 @@ function formatSpeedtestDataForImage($speedtest) } else { $ispinfo = ''; } - - $speedtest['ispinfo'] = $ispinfo; + $speedtest['ispinfo'] = trim($ispinfo); return $speedtest; } /** + * Draws the result image, matching the Talview page (index.html). + * * @param array $speedtest * * @return void */ -function drawImage($speedtest) +function drawClassicImage($speedtest) { // format values for the image $data = formatSpeedtestDataForImage($speedtest); @@ -221,4 +597,4 @@ function drawImage($speedtest) exit(1); } -drawImage($speedtest); +drawClassicImage($speedtest); diff --git a/results/sanitycheck.php b/results/sanitycheck.php old mode 100644 new mode 100755 diff --git a/results/stats.php b/results/stats.php index b8fbee89d..de9210da5 100755 --- a/results/stats.php +++ b/results/stats.php @@ -86,18 +86,18 @@ $speedtest = getSpeedtestUserById($_GET['id']); $speedtests = []; if (false === $speedtest) { - echo '
There was an error trying to fetch the speedtest result for ID "'.htmlspecialchars($_GET['id'], ENT_HTML5, 'UTF-8').'".
'; + echo '
There was an error trying to fetch the test result for ID "'.htmlspecialchars($_GET['id'], ENT_HTML5, 'UTF-8').'".
'; } elseif (null === $speedtest) { - echo '
Could not find a speedtest result for ID "'.htmlspecialchars($_GET['id'], ENT_HTML5, 'UTF-8').'".
'; + echo '
Could not find a test result for ID "'.htmlspecialchars($_GET['id'], ENT_HTML5, 'UTF-8').'".
'; } else { $speedtests = [$speedtest]; } } else { $speedtests = getLatestSpeedtestUsers(); if (false === $speedtests) { - echo '
There was an error trying to fetch latest speedtest results.
'; + echo '
There was an error trying to fetch latest test results.
'; } elseif (empty($speedtests)) { - echo '
Could not find any speedtest results in database.
'; + echo '
Could not find any test results in database.
'; } } foreach ($speedtests as $speedtest) { diff --git a/results/telemetry_db.php b/results/telemetry_db.php index edfb53de5..77b05ae52 100755 --- a/results/telemetry_db.php +++ b/results/telemetry_db.php @@ -70,7 +70,7 @@ function getPdo($returnErrorMessage = false) if($MsSql_WindowsAuthentication){ return new PDO($dsn, "", "", $pdoOptions); } else { - return new PDO($dsn, $MySql_username, $MySql_password, $pdoOptions); + return new PDO($dsn, $MsSql_username, $MsSql_password, $pdoOptions); } } @@ -104,22 +104,38 @@ function getPdo($returnErrorMessage = false) return false; } + // Check if directory exists and is writable + $db_dir = dirname($Sqlite_db_file); + if (!is_dir($db_dir)) { + if ($returnErrorMessage) { + return "SQLite database directory does not exist: " . $db_dir . ". Please create it and ensure it's writable by the web server."; + } + return false; + } + if (!is_writable($db_dir)) { + if ($returnErrorMessage) { + return "SQLite database directory is not writable: " . $db_dir . ". Please ensure the web server has write permissions (e.g., chmod 755 or 775)."; + } + return false; + } + $pdo = new PDO('sqlite:'.$Sqlite_db_file, null, null, $pdoOptions); + # TODO: Why create table only in sqlite mode? $pdo->exec(' CREATE TABLE IF NOT EXISTS `speedtest_users` ( - `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - `ispinfo` text, - `extra` text, - `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `ip` text NOT NULL, - `ua` text NOT NULL, - `lang` text NOT NULL, - `dl` text, - `ul` text, - `ping` text, - `jitter` text, - `log` longtext + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `ispinfo` text, + `extra` text, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ip` text NOT NULL, + `ua` text NOT NULL, + `lang` text NOT NULL, + `dl` text, + `ul` text, + `ping` text, + `jitter` text, + `log` longtext ); '); diff --git a/results/telemetry_mssql.sql b/results/telemetry_mssql.sql old mode 100644 new mode 100755 index 2fa3b55d0..65c23a14b --- a/results/telemetry_mssql.sql +++ b/results/telemetry_mssql.sql @@ -1,3 +1,9 @@ +CREATE DATABASE librespeed; +GO + +USE librespeed; +GO + SET ANSI_NULLS ON GO diff --git a/results/telemetry_settings.php b/results/telemetry_settings.php index d741e143c..de07325d4 100755 --- a/results/telemetry_settings.php +++ b/results/telemetry_settings.php @@ -10,16 +10,16 @@ $redact_ip_addresses = false; // Sqlite3 settings -$Sqlite_db_file = '../../speedtest_telemetry.sql'; +$Sqlite_db_file = __DIR__ . '/../../speedtest_telemetry.db'; // mssql settings $MsSql_server = 'DB_HOSTNAME'; $MsSql_databasename = 'DB_NAME'; -$MsSql_WindowsAuthentication = true; #true or false -$MsSql_username = 'USERNAME'; #not used if MsSql_WindowsAuthentication is true -$MsSql_password = 'PASSWORD'; #not used if MsSql_WindowsAuthentication is true -$MsSql_TrustServerCertificate = true; #true, false or comment out for driver default -#Download driver from https://docs.microsoft.com/en-us/sql/connect/php/download-drivers-php-sql-server?view=sql-server-ver16 +$MsSql_WindowsAuthentication = true; //true or false +$MsSql_username = 'USERNAME'; //not used if MsSql_WindowsAuthentication is true +$MsSql_password = 'PASSWORD'; //not used if MsSql_WindowsAuthentication is true +$MsSql_TrustServerCertificate = true; //true, false or comment out for driver default +//Download driver from https://docs.microsoft.com/en-us/sql/connect/php/download-drivers-php-sql-server?view=sql-server-ver16 // Mysql settings $MySql_username = 'USERNAME'; diff --git a/server-list.json b/server-list.json new file mode 100644 index 000000000..01a8e8c21 --- /dev/null +++ b/server-list.json @@ -0,0 +1,409 @@ +[ + { + "name": "Amsterdam, Netherlands", + "server": "//ams.speedtest.clouvider.net/backend", + "id": 51, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Amsterdam, Netherlands (Rust backend)", + "server": "https://librespeed-rs.ir/", + "id": 95, + "dlURL": "backend/garbage", + "ulURL": "backend/empty", + "pingURL": "backend/empty", + "getIpURL": "backend/getIP", + "sponsorName": "Sudo Dios", + "sponsorURL": "https://github.com/SudoDios" + }, + { + "name": "Amsterdam, Netherlands", + "server": "https://amsspeed.sharktech.net", + "id": 94, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Atlanta, United States", + "server": "//atl.speedtest.clouvider.net/backend", + "id": 53, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Bangalore, India", + "server": "//in1.backend.librespeed.org/", + "id": 75, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "DigitalOcean", + "sponsorURL": "https://www.digitalocean.com" + }, + { + "name": "Bari, Italy", + "server": "https://st-be-ba1.infra.garr.it", + "id": 33, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Bologna, Italy", + "server": "https://st-be-bo1.infra.garr.it", + "id": 34, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Chicago, USA", + "server": "https://chispeed.sharktech.net", + "id": 93, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Denver, USA", + "server": "https://denspeed.sharktech.net", + "id": 92, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "Frankfurt, Germany", + "server": "//fra.speedtest.clouvider.net/backend", + "id": 50, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Frankfurt, Germany (FRA01)", + "server": "https://speedtest.lumischvps.cloud/", + "id": 86, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "LumischVPS", + "sponsorURL": "https://discord.gg/GxYzPwJmA2" + }, + { + "name": "Ghom, Iran (Amin IDC)", + "server": "https://fastme.ir/", + "id": 77, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech/" + }, + { + "name": "Helsinki, Finland (3) (Hetzner)", + "server": "//finew.openspeed.org/", + "id": 22, + "dlURL": "backend437/garbage.php", + "ulURL": "backend437/empty.php", + "pingURL": "backend437/empty.php", + "getIpURL": "backend437/getIP.php", + "sponsorName": "Daily Health Insurance Group", + "sponsorURL": "//dhig.net/" + }, + { + "name": "Helsinki, Finland (5) (Hetzner)", + "server": "//fast.kabi.tk/", + "id": 24, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "KABI.tk", + "sponsorURL": "//kabi.tk" + }, + { + "name": "Johannesburg, South Africa", + "server": "//za1.backend.librespeed.org/", + "id": 70, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "HOSTAFRICA", + "sponsorURL": "https://www.hostafrica.co.za" + }, + { + "name": "Las Vegas, USA", + "server": "https://lasspeed.sharktech.net", + "id": 90, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "London, England", + "server": "//lon.speedtest.clouvider.net/backend", + "id": 49, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Los Angeles, United States (1)", + "server": "//la.speedtest.clouvider.net/backend", + "id": 54, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Los Angeles, USA", + "server": "https://laxspeed.sharktech.net", + "id": 91, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Sharktech", + "sponsorURL": "https://sharktech.net" + }, + { + "name": "New York, United States (2)", + "server": "//nyc.speedtest.clouvider.net/backend", + "id": 52, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Clouvider", + "sponsorURL": "https://www.clouvider.co.uk/" + }, + { + "name": "Nottingham, England (LayerIP)", + "server": "https://uk1.backend.librespeed.org", + "id": 43, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "fosshost.org", + "sponsorURL": "https://fosshost.org" + }, + { + "name": "Nuremberg, Germany (1) (Hetzner)", + "server": "//de1.backend.librespeed.org", + "id": 28, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Snopyta", + "sponsorURL": "https://snopyta.org" + }, + { + "name": "Nuremberg, Germany (2) (Hetzner)", + "server": "//de4.backend.librespeed.org", + "id": 27, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (3) (Hetzner)", + "server": "//de3.backend.librespeed.org", + "id": 30, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (4) (Hetzner)", + "server": "//de5.backend.librespeed.org", + "id": 31, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "LibreSpeed", + "sponsorURL": "https://librespeed.org" + }, + { + "name": "Nuremberg, Germany (6) (Hetzner)", + "server": "//librespeed.lukas-heinrich.com/", + "id": 46, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "luki9100", + "sponsorURL": "https://lukas-heinrich.com/" + }, + { + "name": "Poznan, Poland (INEA)", + "server": "https://speedtest.kamilszczepanski.com", + "id": 74, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Kamil Szczepa\u0144ski", + "sponsorURL": "https://kamilszczepanski.com" + }, + { + "name": "Prague, Czech Republic", + "server": "//speedtest.cesnet.cz", + "id": 79, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "CESNET", + "sponsorURL": "https://www.cesnet.cz" + }, + { + "name": "Prague, Czech Republic", + "server": "//librespeed.turris.cz", + "id": 85, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Turris", + "sponsorURL": "https://www.turris.com" + }, + { + "name": "Roma, Italy", + "server": "https://st-be-rm2.infra.garr.it", + "id": 35, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Consortium GARR", + "sponsorURL": "//garr.it" + }, + { + "name": "Serbia", + "server": "https://speedtest2.sox.rs", + "id": 87, + "dlURL": "libre/backend/garbage.php", + "ulURL": "libre/backend/empty.php", + "pingURL": "libre/backend/empty.php", + "getIpURL": "libre/backend/getIP.php", + "sponsorName": "Serbian Open eXchange", + "sponsorURL": "https://sox.rs" + }, + { + "name": "Singapore", + "server": "https://speedtest.dsgroupmedia.com", + "id": 68, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Salvatore Cahyo", + "sponsorURL": "https://salvatorecahyo.my.id" + }, + { + "name": "Tehran, Iran (Fanava)", + "server": "https://speedme.ir/", + "id": 76, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech" + }, + { + "name": "Tehran, Iran (Faraso)", + "server": "https://st.bardia.tech", + "id": 80, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "Bardia Moshiri", + "sponsorURL": "https://bardia.tech/" + }, + { + "name": "Tokyo, Japan", + "server": "https://librespeed.a573.net/", + "id": 82, + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "sponsorName": "A573", + "sponsorURL": "https://mirror.a573.net/" + }, + { + "name": "Vilnius, Lithuania (RackRay)", + "server": "//lt1.backend.librespeed.org/", + "id": 69, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Time4VPS", + "sponsorURL": "https://www.time4vps.com" + }, + { + "name": "Virginia, United States, OVH", + "server": "https://speed.riverside.rocks/", + "id": 78, + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "sponsorName": "Riverside Rocks", + "sponsorURL": "https://riverside.rocks" + } +] diff --git a/settings.json b/settings.json new file mode 100644 index 000000000..8d2967c79 --- /dev/null +++ b/settings.json @@ -0,0 +1,9 @@ +{ + "telemetry_level": "off", + "test_order": "ID_U_P", + "time_dl_max": 12, + "time_ul_max": 12, + "time_dlGraceTime": 0, + "time_ulGraceTime": 0, + "time_auto": false +} diff --git a/speedtest.js b/speedtest.js index 386fd2286..2674fc091 100755 --- a/speedtest.js +++ b/speedtest.js @@ -12,7 +12,7 @@ The best way to learn how to use this is to look at the basic example, but here's some documentation. To initialize the test, create a new Speedtest object: - var s=new Speedtest(); + let s=new Speedtest(); Now you can think of this as a finite state machine. These are the states (use getState() to see them): - 0: here you can change the speed test settings (such as test duration) with the setParameter("parameter",value) method. From here you can either start the test using start() (goes to state 3) or you can add multiple test points using addTestPoint(server) or addTestPoints(serverList) (goes to state 1). Additionally, this is the perfect moment to set up callbacks for the onupdate(data) and onend(aborted) events. - 1: here you can add test points. You only need to do this if you want to use multiple test points. @@ -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 v5.3.1 - https://github.com/librespeed/speedtest" + "LibreSpeed by Federico Dossena v6.3.1 - https://github.com/librespeed/speedtest" ); } @@ -66,7 +66,7 @@ Speedtest.prototype = { * - parameter: string with the name of the parameter that you want to set * - value: new value for the parameter * - * Invalid values or nonexistant parameters will be ignored by the speed test worker. + * Invalid values or nonexistent parameters will be ignored by the speed test worker. */ setParameter: function(parameter, value) { if (this._state == 3) @@ -125,7 +125,7 @@ Speedtest.prototype = { * Same as addTestPoint, but you can pass an array of servers */ addTestPoints: function(list) { - for (var i = 0; i < list.length; i++) this.addTestPoint(list[i]); + for (let i = 0; i < list.length; i++) this.addTestPoint(list[i]); }, /** * Load a JSON server list from URL (multiple points of test) @@ -144,11 +144,11 @@ Speedtest.prototype = { if (this._state == 0) this._state = 1; if (this._state != 1) throw "You can't add a server after server selection"; this._settings.mpot = true; - var xhr = new XMLHttpRequest(); + let xhr = new XMLHttpRequest(); xhr.onload = function(){ try{ - var servers=JSON.parse(xhr.responseText); - for(var i=0;i 0 && d < instspd) instspd = d; } catch (e) {} @@ -234,14 +234,18 @@ Speedtest.prototype = { }.bind(this); //this function repeatedly pings a server to get a good estimate of the ping. When it's done, it calls the done function without parameters. At the end of the execution, the server will have a new parameter called pingT, which is either the best ping we got from the server or -1 if something went wrong. - var PINGS = 3, //up to 3 pings are performed, unless the server is down... + const PINGS = 3, //up to 3 pings are performed, unless the server is down... SLOW_THRESHOLD = 500; //...or one of the pings is above this threshold - var checkServer = function(server, done) { - var i = 0; + 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 { - var nextPing = function() { + const nextPing = function() { if (i++ == PINGS) { done(); return; @@ -261,10 +265,10 @@ Speedtest.prototype = { } }.bind(this); //check servers in list, one by one - var i = 0; - var done = function() { - var bestServer = null; - for (var i = 0; i < serverList.length; i++) { + let i = 0; + const done = function() { + let bestServer = null; + for (let i = 0; i < serverList.length; i++) { if ( serverList[i].pingT != -1 && (bestServer == null || serverList[i].pingT < bestServer.pingT) @@ -273,7 +277,7 @@ Speedtest.prototype = { } selected(bestServer); }.bind(this); - var nextServer = function() { + const nextServer = function() { if (i == serverList.length) { done(); return; @@ -284,17 +288,17 @@ Speedtest.prototype = { }.bind(this); //parallel server selection - var CONCURRENCY = 6; - var serverLists = []; - for (var i = 0; i < CONCURRENCY; i++) { + const CONCURRENCY = 6; + let serverLists = []; + for (let i = 0; i < CONCURRENCY; i++) { serverLists[i] = []; } - for (var i = 0; i < this._serverList.length; i++) { + for (let i = 0; i < this._serverList.length; i++) { serverLists[i % CONCURRENCY].push(this._serverList[i]); } - var completed = 0; - var bestServer = null; - for (var i = 0; i < CONCURRENCY; i++) { + let completed = 0; + let bestServer = null; + for (let i = 0; i < CONCURRENCY; i++) { select( serverLists[i], function(server) { @@ -323,14 +327,14 @@ Speedtest.prototype = { this.worker.onmessage = function(e) { if (e.data === this._prevData) return; else this._prevData = e.data; - var data = JSON.parse(e.data); + const data = JSON.parse(e.data); try { if (this.onupdate) this.onupdate(data); } catch (e) { console.error("Speedtest onupdate event threw exception: " + e); } if (data.testState >= 4) { - clearInterval(this.updater); + clearInterval(this.updater); this._state = 4; try { if (this.onend) this.onend(data.testState == 5); diff --git a/speedtest_worker.js b/speedtest_worker.js index a01b4aca1..dc996868b 100755 --- a/speedtest_worker.js +++ b/speedtest_worker.js @@ -6,18 +6,18 @@ */ // data reported to main thread -var testState = -1; // -1=not started, 0=starting, 1=download test, 2=ping+jitter test, 3=upload test, 4=finished, 5=abort -var dlStatus = ""; // download speed in megabit/s with 2 decimal digits -var ulStatus = ""; // upload speed in megabit/s with 2 decimal digits -var pingStatus = ""; // ping in milliseconds with 2 decimal digits -var jitterStatus = ""; // jitter in milliseconds with 2 decimal digits -var clientIp = ""; // client's IP address as reported by getIP.php -var dlProgress = 0; //progress of download test 0-1 -var ulProgress = 0; //progress of upload test 0-1 -var pingProgress = 0; //progress of ping+jitter test 0-1 -var testId = null; //test ID (sent back by telemetry if used, null otherwise) +let testState = -1; // -1=not started, 0=starting, 1=download test, 2=ping+jitter test, 3=upload test, 4=finished, 5=abort +let dlStatus = ""; // download speed in megabit/s with 2 decimal digits +let ulStatus = ""; // upload speed in megabit/s with 2 decimal digits +let pingStatus = ""; // ping in milliseconds with 2 decimal digits +let jitterStatus = ""; // jitter in milliseconds with 2 decimal digits +let clientIp = ""; // client's IP address as reported by getIP.php +let dlProgress = 0; //progress of download test 0-1 +let ulProgress = 0; //progress of upload test 0-1 +let pingProgress = 0; //progress of ping+jitter test 0-1 +let testId = null; //test ID (sent back by telemetry if used, null otherwise) -var log = ""; //telemetry log +let log = ""; //telemetry log function tlog(s) { if (settings.telemetry_level >= 2) { log += Date.now() + ": " + s + "\n"; @@ -36,7 +36,7 @@ function twarn(s) { } // test settings. can be overridden by sending specific values with the start command -var settings = { +let settings = { mpot: false, //set to true when in MPOT mode test_order: "IP_D_U", //order in which tests will be performed as a string. D=Download, U=Upload, P=Ping+Jitter, I=IP, _=1 second delay time_ul_max: 15, // max duration of upload test in seconds @@ -68,9 +68,9 @@ var settings = { forceIE11Workaround: false //when set to true, it will force the IE11 upload test on all browsers. Debug only }; -var xhr = null; // array of currently active xhr requests -var interval = null; // timer used in tests -var test_pointer = 0; //pointer to the next test to run inside settings.test_order +let xhr = null; // array of currently active xhr requests +let interval = null; // timer used in tests +let test_pointer = 0; //pointer to the next test to run inside settings.test_order /* this function is used on URLs passed in the settings to determine whether we need a ? or an & as a separator @@ -88,7 +88,7 @@ function url_sep(url) { example: start {"time_ul_max":"10", "time_dl_max":"10", "count_ping":"50"} */ this.addEventListener("message", function(e) { - var params = e.data.split(" "); + const params = e.data.split(" "); if (params[0] === "status") { // return status postMessage( @@ -111,19 +111,19 @@ this.addEventListener("message", function(e) { testState = 0; try { // parse settings, if present - var s = {}; + let s = {}; try { - var ss = e.data.substring(5); + const ss = e.data.substring(5); if (ss) s = JSON.parse(ss); } catch (e) { twarn("Error parsing custom settings JSON. Please check your syntax"); } //copy custom settings - for (var key in s) { + for (let key in s) { if (typeof settings[key] !== "undefined") settings[key] = s[key]; else twarn("Unknown setting ignored: " + key); } - var ua = navigator.userAgent; + const ua = navigator.userAgent; // quirks for specific browsers. apply only if not overridden. more may be added in future releases if (settings.enable_quirks || (typeof s.enable_quirks !== "undefined" && s.enable_quirks)) { if (/Firefox.(\d+\.\d+)/i.test(ua)) { @@ -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 @@ -172,11 +172,11 @@ this.addEventListener("message", function(e) { // run the tests tverb(JSON.stringify(settings)); test_pointer = 0; - var iRun = false, + let iRun = false, dRun = false, uRun = false, pRun = false; - var runNextTest = function() { + const runNextTest = function() { if (testState == 5) return; if (test_pointer >= settings.test_order.length) { //test is finished @@ -267,7 +267,7 @@ this.addEventListener("message", function(e) { function clearRequests() { tverb("stopping pending XHRs"); if (xhr) { - for (var i = 0; i < xhr.length; i++) { + for (let i = 0; i < xhr.length; i++) { try { xhr[i].onprogress = null; xhr[i].onload = null; @@ -289,18 +289,18 @@ function clearRequests() { } } // gets client's IP using url_getIp, then calls the done function -var ipCalled = false; // used to prevent multiple accidental calls to getIp -var ispInfo = ""; //used for telemetry +let ipCalled = false; // used to prevent multiple accidental calls to getIp +let ispInfo = ""; //used for telemetry function getIp(done) { tverb("getIp"); if (ipCalled) return; else ipCalled = true; // getIp already called? - var startT = new Date().getTime(); + let startT = new Date().getTime(); xhr = new XMLHttpRequest(); xhr.onload = function() { tlog("IP: " + xhr.responseText + ", took " + (new Date().getTime() - startT) + "ms"); try { - var data = JSON.parse(xhr.responseText); + const data = JSON.parse(xhr.responseText); clientIp = data.processedString; ispInfo = data.rawIspInfo; } catch (e) { @@ -317,25 +317,25 @@ function getIp(done) { xhr.send(); } // download test, calls done function when it's over -var dlCalled = false; // used to prevent multiple accidental calls to dlTest +let dlCalled = false; // used to prevent multiple accidental calls to dlTest function dlTest(done) { tverb("dlTest"); if (dlCalled) return; else dlCalled = true; // dlTest already called? - var totLoaded = 0.0, // total number of loaded bytes + let totLoaded = 0.0, // total number of loaded bytes startT = new Date().getTime(), // timestamp when test was started bonusT = 0, //how many milliseconds the test has been shortened by (higher on faster connections) graceTimeDone = false, //set to true after the grace time is past failed = false; // set to true if a stream fails xhr = []; // function to create a download stream. streams are slightly delayed so that they will not end at the same time - var testStream = function(i, delay) { + const testStream = function(i, delay) { setTimeout( function() { if (testState !== 1) return; // delayed stream ended up starting after the end of the download test tverb("dl test stream started " + i + " " + delay); - var prevLoaded = 0; // number of bytes loaded last time onprogress was called - var x = new XMLHttpRequest(); + let prevLoaded = 0; // number of bytes loaded last time onprogress was called + let x = new XMLHttpRequest(); xhr[i] = x; xhr[i].onprogress = function(event) { tverb("dl stream progress event " + i + " " + event.loaded); @@ -345,7 +345,7 @@ function dlTest(done) { } catch (e) {} } // just in case this XHR is still running after the download test // progress event, add number of new loaded bytes to totLoaded - var loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; + const loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; if (isNaN(loadDiff) || !isFinite(loadDiff) || loadDiff < 0) return; // just in case totLoaded += loadDiff; prevLoaded = event.loaded; @@ -380,14 +380,14 @@ function dlTest(done) { ); }.bind(this); // open streams - for (var i = 0; i < settings.xhr_dlMultistream; i++) { + for (let i = 0; i < settings.xhr_dlMultistream; i++) { testStream(i, settings.xhr_multistreamDelay * i); } // every 200ms, update dlStatus interval = setInterval( function() { tverb("DL: " + dlStatus + (graceTimeDone ? "" : " (in grace time)")); - var t = new Date().getTime() - startT; + const t = new Date().getTime() - startT; if (graceTimeDone) dlProgress = (t + bonusT) / (settings.time_dl_max * 1000); if (t < 200) return; if (!graceTimeDone) { @@ -401,10 +401,10 @@ function dlTest(done) { graceTimeDone = true; } } else { - var speed = totLoaded / (t / 1000.0); + const speed = totLoaded / (t / 1000.0); if (settings.time_auto) { //decide how much to shorten the test. Every 200ms, the test is shortened by the bonusT calculated here - var bonus = (5.0 * speed) / 100000; + const bonus = (5.0 * speed) / 100000; bonusT += bonus > 400 ? 400 : bonus; } //update status @@ -424,46 +424,46 @@ function dlTest(done) { ); } // upload test, calls done function when it's over -var ulCalled = false; // used to prevent multiple accidental calls to ulTest +let ulCalled = false; // used to prevent multiple accidental calls to ulTest function ulTest(done) { tverb("ulTest"); if (ulCalled) return; else ulCalled = true; // ulTest already called? // garbage data for upload test - var r = new ArrayBuffer(1048576); - var maxInt = Math.pow(2, 32) - 1; + let r = new ArrayBuffer(1048576); + const maxInt = Math.pow(2, 32) - 1; try { r = new Uint32Array(r); - for (var i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; + for (let i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; } catch (e) {} - var req = []; - var reqsmall = []; - for (var i = 0; i < settings.xhr_ul_blob_megabytes; i++) req.push(r); + let req = []; + let reqsmall = []; + for (let i = 0; i < settings.xhr_ul_blob_megabytes; i++) req.push(r); req = new Blob(req); r = new ArrayBuffer(262144); try { r = new Uint32Array(r); - for (var i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; + for (let i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; } catch (e) {} reqsmall.push(r); reqsmall = new Blob(reqsmall); - var testFunction = function() { - var totLoaded = 0.0, // total number of transmitted bytes + const testFunction = function() { + let totLoaded = 0.0, // total number of transmitted bytes startT = new Date().getTime(), // timestamp when test was started bonusT = 0, //how many milliseconds the test has been shortened by (higher on faster connections) graceTimeDone = false, //set to true after the grace time is past failed = false; // set to true if a stream fails xhr = []; // function to create an upload stream. streams are slightly delayed so that they will not end at the same time - var testStream = function(i, delay) { + const testStream = function(i, delay) { setTimeout( function() { if (testState !== 3) return; // delayed stream ended up starting after the end of the upload test tverb("ul test stream started " + i + " " + delay); - var prevLoaded = 0; // number of bytes transmitted last time onprogress was called - var x = new XMLHttpRequest(); + let prevLoaded = 0; // number of bytes transmitted last time onprogress was called + let x = new XMLHttpRequest(); xhr[i] = x; - var ie11workaround; + let ie11workaround; if (settings.forceIE11Workaround) ie11workaround = true; else { try { @@ -496,24 +496,34 @@ function ulTest(done) { } catch (e) {} } // just in case this XHR is still running after the upload test // progress event, add number of new loaded bytes to totLoaded - var loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; + const loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; if (isNaN(loadDiff) || !isFinite(loadDiff) || loadDiff < 0) return; // just in case totLoaded += loadDiff; prevLoaded = event.loaded; }.bind(this); xhr[i].upload.onload = function() { - // this stream sent all the garbage data, start again - tverb("ul stream finished " + i); - testStream(i, 0); + // body uploaded, but response not yet available. + // xhr.onload below handles the full lifecycle. }.bind(this); xhr[i].upload.onerror = function() { tverb("ul stream failed " + i); - if (settings.xhr_ignoreErrors === 0) failed = true; //abort - try { - xhr[i].abort(); - } catch (e) {} + if (settings.xhr_ignoreErrors === 0) failed = true; + try { x.abort(); } catch (e) {} delete xhr[i]; - if (settings.xhr_ignoreErrors === 1) testStream(i, 0); //restart stream + if (settings.xhr_ignoreErrors === 1) testStream(i, 0); + }.bind(this); + xhr[i].onload = function() { + // response complete — check status on captured x, not xhr[i] + if (x.status >= 200 && x.status < 300) { + tverb("ul stream finished " + i); + testStream(i, 0); + } else { + tverb("ul stream failed with HTTP " + x.status + " " + i); + if (settings.xhr_ignoreErrors === 0) failed = true; + try { x.abort(); } catch (e) {} + delete xhr[i]; + if (settings.xhr_ignoreErrors === 1) testStream(i, 0); + } }.bind(this); // send xhr xhr[i].open("POST", settings.url_ul + url_sep(settings.url_ul) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching @@ -528,14 +538,14 @@ function ulTest(done) { ); }.bind(this); // open streams - for (var i = 0; i < settings.xhr_ulMultistream; i++) { + for (let i = 0; i < settings.xhr_ulMultistream; i++) { testStream(i, settings.xhr_multistreamDelay * i); } // every 200ms, update ulStatus interval = setInterval( function() { tverb("UL: " + ulStatus + (graceTimeDone ? "" : " (in grace time)")); - var t = new Date().getTime() - startT; + const t = new Date().getTime() - startT; if (graceTimeDone) ulProgress = (t + bonusT) / (settings.time_ul_max * 1000); if (t < 200) return; if (!graceTimeDone) { @@ -549,10 +559,10 @@ function ulTest(done) { graceTimeDone = true; } } else { - var speed = totLoaded / (t / 1000.0); + const speed = totLoaded / (t / 1000.0); if (settings.time_auto) { //decide how much to shorten the test. Every 200ms, the test is shortened by the bonusT calculated here - var bonus = (5.0 * speed) / 100000; + const bonus = (5.0 * speed) / 100000; bonusT += bonus > 400 ? 400 : bonus; } //update status @@ -584,20 +594,20 @@ function ulTest(done) { } else testFunction(); } // ping+jitter test, function done is called when it's over -var ptCalled = false; // used to prevent multiple accidental calls to pingTest +let ptCalled = false; // used to prevent multiple accidental calls to pingTest function pingTest(done) { tverb("pingTest"); if (ptCalled) return; else ptCalled = true; // pingTest already called? - var startT = new Date().getTime(); //when the test was started - var prevT = null; // last time a pong was received - var ping = 0.0; // current ping value - var jitter = 0.0; // current jitter value - var i = 0; // counter of pongs received - var prevInstspd = 0; // last ping time, used for jitter calculation + const startT = new Date().getTime(); //when the test was started + let prevT = null; // last time a pong was received + let ping = 0.0; // current ping value + let jitter = 0.0; // current jitter value + let i = 0; // counter of pongs received + let prevInstspd = 0; // last ping time, used for jitter calculation xhr = []; // ping function - var doPing = function() { + const doPing = function() { tverb("ping"); pingProgress = i / settings.count_ping; prevT = new Date().getTime(); @@ -608,13 +618,13 @@ function pingTest(done) { if (i === 0) { prevT = new Date().getTime(); // first pong } else { - var instspd = new Date().getTime() - prevT; + let instspd = new Date().getTime() - prevT; if (settings.ping_allowPerformanceApi) { try { //try to get accurate performance timing using performance api - var p = performance.getEntries(); + let p = performance.getEntries(); p = p[p.length - 1]; - var d = p.responseStart - p.requestStart; + let d = p.responseStart - p.requestStart; if (d <= 0) d = p.duration; if (d > 0 && d < instspd) instspd = d; } catch (e) { @@ -625,7 +635,7 @@ function pingTest(done) { //noticed that some browsers randomly have 0ms ping if (instspd < 1) instspd = prevInstspd; if (instspd < 1) instspd = 1; - var instjitter = Math.abs(instspd - prevInstspd); + const instjitter = Math.abs(instspd - prevInstspd); if (i === 1) ping = instspd; /* first ping, can't tell jitter yet*/ else { if (instspd < ping) ping = instspd; // update ping, if the instant ping is lower @@ -684,10 +694,10 @@ function sendTelemetry(done) { xhr = new XMLHttpRequest(); xhr.onload = function() { try { - var parts = xhr.responseText.split(" "); + const parts = xhr.responseText.split(" "); if (parts[0] == "id") { try { - var id = parts[1]; + let id = parts[1]; done(id); } catch (e) { done(null); @@ -702,12 +712,12 @@ function sendTelemetry(done) { done(null); }; xhr.open("POST", settings.url_telemetry + url_sep(settings.url_telemetry) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); - var telemetryIspInfo = { + const telemetryIspInfo = { processedString: clientIp, rawIspInfo: typeof ispInfo === "object" ? ispInfo : "" }; try { - var fd = new FormData(); + const fd = new FormData(); fd.append("ispinfo", JSON.stringify(telemetryIspInfo)); fd.append("dl", dlStatus); fd.append("ul", ulStatus); @@ -717,7 +727,7 @@ function sendTelemetry(done) { fd.append("extra", settings.telemetry_extra); xhr.send(fd); } catch (ex) { - var postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + "&dl=" + encodeURIComponent(dlStatus) + "&ul=" + encodeURIComponent(ulStatus) + "&ping=" + encodeURIComponent(pingStatus) + "&jitter=" + encodeURIComponent(jitterStatus) + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : ""); + const postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + "&dl=" + encodeURIComponent(dlStatus) + "&ul=" + encodeURIComponent(ulStatus) + "&ping=" + encodeURIComponent(pingStatus) + "&jitter=" + encodeURIComponent(jitterStatus) + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : ""); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xhr.send(postData); } diff --git a/stability.html b/stability.html new file mode 100644 index 000000000..7f93bffab --- /dev/null +++ b/stability.html @@ -0,0 +1,991 @@ + + + + + + + LibreSpeed - Stability Test + + + +

LibreSpeed - Stability Test

+ + +
+ + +
+
Reset
+
+ + + +
--
+ +
+
+
+
Current
+
+
ms
+
+
+
Average
+
+
ms
+
+
+
Jitter
+
+
ms
+
+
+
+
+
Min
+
+
ms
+
+
+
Max
+
+
ms
+
+
+
Failed Requests
+
+
%
+
+
+
Elapsed
+
00:00
+
+
+
+
+ +
+ +
+ +
+ + +
+ + + + + + diff --git a/stability_worker.js b/stability_worker.js new file mode 100644 index 000000000..c51a40a1b --- /dev/null +++ b/stability_worker.js @@ -0,0 +1,249 @@ +/* + 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 + } finally { + try { + performance.clearResourceTimings(); + } catch (e) {} + } + } + + 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); + }); +} diff --git a/tests/PLAYWRIGHT_MODES_PLAN.md b/tests/PLAYWRIGHT_MODES_PLAN.md new file mode 100644 index 000000000..365f7e9d7 --- /dev/null +++ b/tests/PLAYWRIGHT_MODES_PLAN.md @@ -0,0 +1,123 @@ +# Playwright Plan: Test All Runtime and UI Modes + +## Objective +Build a deterministic Playwright test suite that validates LibreSpeed behavior across all supported deployment modes and UI design modes, without asserting real network throughput values. + +## Current Status +- Phase 1 is implemented and passing in Chromium. +- Added regression coverage for the classic standalone "No servers available" issue path. +- Docker image workflow is hard-gated by e2e (`build` depends on `e2e`). +- Standalone Playwright workflow is manual-only to avoid duplicate e2e runs. + +## Modes to Cover + +### Docker runtime modes +- `standalone` +- `backend` +- `frontend` +- `dual` + +### UI +- Talview page (`index.html`); the upstream modern design and design switcher were removed + +## Test Strategy + +### 1. Keep assertions deterministic +Do not assert real bandwidth numbers. Focus on: +- HTTP availability of expected files/endpoints +- correct redirects/switching behavior +- expected UI controls rendered +- expected server-list loading behavior +- ability to initiate/abort flow at UI level + +### 2. Separate test types +- **Mode smoke tests** (fast, always-run): verify each runtime mode serves the right surfaces. +- **UI mode tests**: verify classic/modern pages and switcher rules. +- **Optional flow tests** (later): mock `Speedtest` in browser to simulate state changes and verify UI updates. + +### 3. Use Docker Compose as the environment contract +Run Playwright against containers started with explicit `MODE` values to mirror production entrypoint behavior. + +## Proposed Test Matrix + +### A) `standalone` +Expectations: +- `GET /` responds and serves UI (classic by default unless overridden) +- `GET /backend/empty.php`, `GET /backend/garbage.php`, `GET /backend/getIP.php` available +- `GET /results/telemetry.php` reachable (even if telemetry disabled behavior differs) +- `GET /index.html?design=new` resolves to modern page +- `GET /index.html?design=old` resolves to classic page + +### B) `backend` +Expectations: +- backend endpoints return success (`/backend/empty.php`, `/backend/garbage.php`, `/backend/getIP.php`) +- tests only assert local backend endpoint contracts in this mode + +### C) `frontend` +Expectations: +- UI entrypoint available +- server list loads from `/servers.json` (or `SERVER_LIST_URL` if set) +- backend test endpoints should not be treated as local testpoint contract in this mode +- selecting server and pressing start does not crash UI shell + +### D) `dual` +Expectations: +- combines frontend + local backend availability +- UI can load multi-server list +- backend endpoints available locally + +## Playwright Architecture + +### Files +- `playwright.config.js` +- `tests/e2e/modes.spec.js` (runtime-mode smoke) +- `tests/e2e/classic-standalone-regression.spec.js` (revert regression guard) +- `tests/e2e/helpers/env.js` (base URLs + mode metadata) +- `tests/e2e/helpers/ui.js` (shared selectors, start/abort helpers) + +### Environment boot +- `docker compose -f tests/docker-compose-playwright.yml up -d --build` +- dedicate one service per runtime mode on separate ports +- for `frontend` and `dual`, mount a stable `servers.json` + +### Selector policy +Use role/text selectors anchored on stable labels and IDs already in pages; avoid brittle CSS-path selectors. + +## Phased Rollout + +### Phase 1 (recommended first PR) +- Add Playwright scaffolding and CI job +- Add smoke coverage for 4 Docker runtime modes +- Add design switch tests (`index.html`, `?design=new`, `?design=old`) +- No full speed measurement assertions + +### Phase 2 +- Add deterministic UI flow tests with mocked Speedtest state updates +- Validate button states (`Start` -> running -> abort/end) +- Validate result widgets receive simulated values + +### Phase 3 +- Add telemetry-enabled scenario tests (`TELEMETRY=true`) for link visibility and stats exposure +- Add negative tests (missing/invalid `servers.json` in frontend/dual) + +## Risks and Mitigations +- Flaky speed measurements due to host/network variance + - Mitigation: avoid throughput assertions; use mocked state for UI behavior. +- Divergence between local static run and Docker entrypoint behavior + - Mitigation: run all mode tests against Docker services. +- Selector drift between classic and modern UIs + - Mitigation: maintain per-design helper selectors with minimal coupling. + +## CI Proposal +- Docker workflow runs e2e first, then build/push only if e2e passes +- Standalone Playwright workflow is `workflow_dispatch` only for manual branch runs +- Keep a single automatic e2e path to avoid duplicate runs +- Playwright retries: `1` in CI, `0` locally +- Upload traces/screenshots on failure only +- Browser scope for v1: Chromium only + +## Confirmed Decisions +1. Browser scope for v1: Chromium only. +2. Telemetry checks are deferred to Phase 3. +3. `backend` mode tests assert backend endpoint contracts only. +4. Automatic e2e gating lives in Docker workflow; standalone Playwright workflow is manual. diff --git a/tests/docker-compose-playwright.yml b/tests/docker-compose-playwright.yml new file mode 100644 index 000000000..c5a4e8105 --- /dev/null +++ b/tests/docker-compose-playwright.yml @@ -0,0 +1,79 @@ +services: + backend-testpoint: + build: + context: .. + dockerfile: Dockerfile + environment: + - MODE=backend + - WEBPORT=8080 + + standalone: + build: + context: .. + dockerfile: Dockerfile + environment: + - MODE=standalone + - WEBPORT=8080 + ports: + - "18180:8080" + + standalone-alpine: + build: + context: .. + dockerfile: Dockerfile.alpine + environment: + - MODE=standalone + - WEBPORT=8080 + ports: + - "18187:8080" + + backend: + build: + context: .. + dockerfile: Dockerfile + environment: + - MODE=backend + - WEBPORT=8080 + ports: + - "18181:8080" + + frontend: + build: + context: .. + dockerfile: Dockerfile + depends_on: + - backend-testpoint + environment: + - MODE=frontend + - WEBPORT=8080 + volumes: + - ./e2e/fixtures/servers-frontend.json:/servers.json:ro + ports: + - "18182:8080" + + frontend-remote: + build: + context: .. + dockerfile: Dockerfile + depends_on: + - backend-testpoint + environment: + - MODE=frontend + - WEBPORT=8080 + - SERVER_LIST_URL=http://127.0.0.1:18184/tests/e2e/fixtures/servers-frontend-remote.json + ports: + - "18188:8080" + + dual: + build: + context: .. + dockerfile: Dockerfile + depends_on: + - backend-testpoint + environment: + - MODE=dual + - WEBPORT=8080 + volumes: + - ./e2e/fixtures/servers-dual.json:/servers.json:ro + ports: + - "18183:8080" diff --git a/tests/e2e/classic-standalone-regression.spec.js b/tests/e2e/classic-standalone-regression.spec.js new file mode 100644 index 000000000..5d85bf71c --- /dev/null +++ b/tests/e2e/classic-standalone-regression.spec.js @@ -0,0 +1,16 @@ +const { test, expect } = require('@playwright/test'); +const { baseUrls } = require('./helpers/env'); +const { classicStartButton } = require('./helpers/ui'); + +test.describe('Classic standalone regression coverage', () => { + test('standalone classic does not get stuck on "No servers available"', async ({ page }) => { + await page.goto(`${baseUrls.standalone}/index.html`); + + // In standalone mode, classic UI should show the test wrapper directly. + await expect(page.locator('#testWrapper')).toHaveClass(/visible/, { timeout: 10_000 }); + await expect(page.locator('#loading')).toHaveClass(/hidden/, { timeout: 10_000 }); + + await expect(page.locator('#message')).not.toContainText(/No servers available/i); + await expect(classicStartButton(page)).toBeVisible(); + }); +}); diff --git a/tests/e2e/fixtures/servers-dual.json b/tests/e2e/fixtures/servers-dual.json new file mode 100644 index 000000000..53494e8ec --- /dev/null +++ b/tests/e2e/fixtures/servers-dual.json @@ -0,0 +1,20 @@ +[ + { + "name": "Local dual backend", + "server": "/", + "dlURL": "backend/garbage.php", + "ulURL": "backend/empty.php", + "pingURL": "backend/empty.php", + "getIpURL": "backend/getIP.php", + "id": 1 + }, + { + "name": "External backend testpoint", + "server": "http://backend-testpoint:8080/", + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "id": 2 + } +] diff --git a/tests/e2e/fixtures/servers-frontend-remote.json b/tests/e2e/fixtures/servers-frontend-remote.json new file mode 100644 index 000000000..bdd7e6120 --- /dev/null +++ b/tests/e2e/fixtures/servers-frontend-remote.json @@ -0,0 +1,11 @@ +[ + { + "name": "Remote frontend backend", + "server": "http://127.0.0.1:18181/", + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "id": 1 + } +] diff --git a/tests/e2e/fixtures/servers-frontend.json b/tests/e2e/fixtures/servers-frontend.json new file mode 100644 index 000000000..89da9fb07 --- /dev/null +++ b/tests/e2e/fixtures/servers-frontend.json @@ -0,0 +1,11 @@ +[ + { + "name": "Backend testpoint", + "server": "http://backend-testpoint:8080/", + "dlURL": "garbage.php", + "ulURL": "empty.php", + "pingURL": "empty.php", + "getIpURL": "getIP.php", + "id": 1 + } +] diff --git a/tests/e2e/global-setup.js b/tests/e2e/global-setup.js new file mode 100644 index 000000000..2bc718e49 --- /dev/null +++ b/tests/e2e/global-setup.js @@ -0,0 +1,75 @@ +const { execSync } = require('node:child_process'); +const http = require('node:http'); +const https = require('node:https'); + +const COMPOSE_FILE = 'tests/docker-compose-playwright.yml'; + +function isHttpOk(url, requestTimeoutMs = 10_000) { + return new Promise((resolve) => { + const client = url.startsWith('https://') ? https : http; + let settled = false; + const settle = (value) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + + const req = client.get(url, (res) => { + const status = res.statusCode || 0; + // Drain body so sockets can be reused/closed cleanly. + res.resume(); + settle(status >= 200 && status < 300); + }); + + req.setTimeout(requestTimeoutMs, () => { + req.destroy(new Error(`Request timed out after ${requestTimeoutMs}ms`)); + }); + + req.on('timeout', () => { + req.destroy(new Error(`Request timed out after ${requestTimeoutMs}ms`)); + }); + + req.on('error', () => { + // Connection issues/timeouts are expected while services are starting. + settle(false); + }); + }); +} + +async function waitForReady(name, url, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + if (await isHttpOk(url)) { + return; + } + } catch { + // Retry until timeout. + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Timed out waiting for ${name} at ${url}`); +} + +module.exports = async () => { + try { + execSync(`docker compose -f ${COMPOSE_FILE} up -d --build`, { + stdio: 'inherit', + }); + } catch (error) { + throw new Error( + `Failed to start Docker test stack from ${COMPOSE_FILE}. ` + + `Ensure Docker is running. Original error: ${error.message}` + ); + } + + const timeoutMs = 180_000; + await waitForReady('standalone', 'http://127.0.0.1:18180/index.html', timeoutMs); + await waitForReady('standalone-alpine', 'http://127.0.0.1:18187/index.html', timeoutMs); + await waitForReady('backend', 'http://127.0.0.1:18181/empty.php', timeoutMs); + await waitForReady('frontend', 'http://127.0.0.1:18182/index.html', timeoutMs); + await waitForReady('frontend-remote', 'http://127.0.0.1:18188/index.html', timeoutMs); + await waitForReady('dual', 'http://127.0.0.1:18183/index.html', timeoutMs); +}; diff --git a/tests/e2e/global-teardown.js b/tests/e2e/global-teardown.js new file mode 100644 index 000000000..71547d320 --- /dev/null +++ b/tests/e2e/global-teardown.js @@ -0,0 +1,9 @@ +const { execSync } = require('node:child_process'); + +const COMPOSE_FILE = 'tests/docker-compose-playwright.yml'; + +module.exports = async () => { + execSync(`docker compose -f ${COMPOSE_FILE} down --remove-orphans`, { + stdio: 'inherit', + }); +}; diff --git a/tests/e2e/helpers/env.js b/tests/e2e/helpers/env.js new file mode 100644 index 000000000..389410696 --- /dev/null +++ b/tests/e2e/helpers/env.js @@ -0,0 +1,12 @@ +const baseUrls = { + standalone: 'http://127.0.0.1:18180', + backend: 'http://127.0.0.1:18181', + frontend: 'http://127.0.0.1:18182', + dual: 'http://127.0.0.1:18183', + frontendRemote: 'http://127.0.0.1:18188', + standaloneAlpine: 'http://127.0.0.1:18187', +}; + +module.exports = { + baseUrls, +}; diff --git a/tests/e2e/helpers/ui.js b/tests/e2e/helpers/ui.js new file mode 100644 index 000000000..04e9b023f --- /dev/null +++ b/tests/e2e/helpers/ui.js @@ -0,0 +1,12 @@ +function classicStartButton(page) { + return page.locator("#startStopBtn"); +} + +function stabilityStartButton(page) { + return page.locator("#startBtn"); +} + +module.exports = { + classicStartButton, + stabilityStartButton +}; diff --git a/tests/e2e/modes.spec.js b/tests/e2e/modes.spec.js new file mode 100644 index 000000000..af758bb2d --- /dev/null +++ b/tests/e2e/modes.spec.js @@ -0,0 +1,93 @@ +const { test, expect } = require('@playwright/test'); +const { baseUrls } = require('./helpers/env'); +const { classicStartButton } = require('./helpers/ui'); + +const frontendRemoteServerListUrl = 'http://127.0.0.1:18184/tests/e2e/fixtures/servers-frontend-remote.json'; + +test.describe('Runtime mode smoke coverage', () => { + test('standalone exposes UI and local backend endpoints', async ({ page, request }) => { + const root = await request.get(`${baseUrls.standalone}/`); + expect(root.ok()).toBeTruthy(); + + const index = await request.get(`${baseUrls.standalone}/index.html`); + expect(index.ok()).toBeTruthy(); + await expect(await index.text()).toContain('Talview Speedtest'); + + for (const endpoint of ['/backend/empty.php', '/backend/garbage.php', '/backend/getIP.php']) { + const response = await request.get(`${baseUrls.standalone}${endpoint}`); + expect(response.ok()).toBeTruthy(); + } + + await page.goto(`${baseUrls.standalone}/index.html`); + await expect(classicStartButton(page)).toBeVisible(); + }); + + test('Alpine standalone serves frontend settings', async ({ page, request }) => { + const settings = await request.get(`${baseUrls.standaloneAlpine}/settings.json`); + expect(settings.ok()).toBeTruthy(); + await expect(settings.json()).resolves.toMatchObject({ telemetry_level: 'off', time_dl_max: 12 }); + + await page.goto(`${baseUrls.standaloneAlpine}/index.html`); + await expect(classicStartButton(page)).toBeVisible(); + }); + + test('backend exposes only local backend contract endpoints', async ({ request }) => { + for (const endpoint of ['/empty.php', '/garbage.php', '/getIP.php']) { + const response = await request.get(`${baseUrls.backend}${endpoint}`); + expect(response.ok()).toBeTruthy(); + } + }); + + test('frontend serves UI and server list without local backend contract', async ({ page, request }) => { + const serverList = await request.get(`${baseUrls.frontend}/server-list.json`); + expect(serverList.ok()).toBeTruthy(); + await expect(await serverList.text()).toContain('Backend testpoint'); + + const localBackendEndpoint = await request.get(`${baseUrls.frontend}/backend/empty.php`); + expect(localBackendEndpoint.status()).toBe(404); + + await page.goto(`${baseUrls.frontend}/index.html`); + await expect(classicStartButton(page)).toBeVisible(); + }); + + test('frontend starts with SERVER_LIST_URL without requiring /servers.json', async ({ page, request }) => { + const index = await request.get(`${baseUrls.frontendRemote}/index.html`); + expect(index.ok()).toBeTruthy(); + await expect(await index.text()).toContain(frontendRemoteServerListUrl); + + const stability = await request.get(`${baseUrls.frontendRemote}/stability.html`); + expect(stability.ok()).toBeTruthy(); + await expect(await stability.text()).toContain(frontendRemoteServerListUrl); + + const localBackendEndpoint = await request.get(`${baseUrls.frontendRemote}/backend/empty.php`); + expect(localBackendEndpoint.status()).toBe(404); + + await page.goto(`${baseUrls.frontendRemote}/index.html`); + await expect(classicStartButton(page)).toBeVisible(); + await expect(page.locator('#server')).toContainText('Remote frontend backend', { timeout: 10_000 }); + }); + + test('default entrypoint loads the Talview frontend with SERVER_LIST_URL', async ({ page }) => { + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + + await page.goto(`${baseUrls.frontendRemote}/index.html`); + await expect(classicStartButton(page)).toBeVisible(); + await expect(page.locator('#server')).toContainText('Remote frontend backend', { timeout: 10_000 }); + expect(pageErrors).toEqual([]); + }); + + test('dual combines frontend and local backend availability', async ({ page, request }) => { + const serverList = await request.get(`${baseUrls.dual}/server-list.json`); + expect(serverList.ok()).toBeTruthy(); + await expect(await serverList.text()).toContain('Local dual backend'); + + for (const endpoint of ['/backend/empty.php', '/backend/garbage.php', '/backend/getIP.php']) { + const response = await request.get(`${baseUrls.dual}${endpoint}`); + expect(response.ok()).toBeTruthy(); + } + + await page.goto(`${baseUrls.dual}/index.html`); + await expect(classicStartButton(page)).toBeVisible(); + }); +}); diff --git a/tests/e2e/server-selection.spec.js b/tests/e2e/server-selection.spec.js new file mode 100644 index 000000000..3926288d7 --- /dev/null +++ b/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(); + }); +}); diff --git a/tests/e2e/stability.spec.js b/tests/e2e/stability.spec.js new file mode 100644 index 000000000..210f70840 --- /dev/null +++ b/tests/e2e/stability.spec.js @@ -0,0 +1,164 @@ +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"); + 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 }); + }); + + 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(); + }); +}); diff --git a/tests/e2e/static-repository-assets.spec.js b/tests/e2e/static-repository-assets.spec.js new file mode 100644 index 000000000..10efa311e --- /dev/null +++ b/tests/e2e/static-repository-assets.spec.js @@ -0,0 +1,33 @@ +const { test, expect } = require("@playwright/test"); + +const staticRepositoryUrl = "http://127.0.0.1:18184"; + +const expectedAssets = [ + "/index.html", + "/settings.json", + "/server-list.json", + "/speedtest.js", + "/speedtest_worker.js", + "/logo.png", + "/images/icon-192.png" +]; + +test.describe("Unmodified repository static assets", () => { + test("serves every frontend dependency and returns 404 for a missing path", async ({ request }) => { + for (const path of expectedAssets) { + const response = await request.get(`${staticRepositoryUrl}${path}`); + expect(response.ok(), `${path} should return 2xx`).toBeTruthy(); + } + + const missing = await request.get(`${staticRepositoryUrl}/does-not-exist`); + expect(missing.status()).toBe(404); + }); + + test("returns 400 for malformed percent-encoded paths without stopping the server", async ({ request }) => { + const malformed = await request.get(`${staticRepositoryUrl}/%E0%A4%A`); + expect(malformed.status()).toBe(400); + + const followingRequest = await request.get(`${staticRepositoryUrl}/settings.json`); + expect(followingRequest.status()).toBe(200); + }); +}); diff --git a/tests/e2e/static-server.js b/tests/e2e/static-server.js new file mode 100644 index 000000000..5a0977406 --- /dev/null +++ b/tests/e2e/static-server.js @@ -0,0 +1,57 @@ +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const root = process.cwd(); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".woff2": "font/woff2" +}; + +http + .createServer((request, response) => { + response.setHeader("Access-Control-Allow-Origin", "*"); + + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + let pathname; + try { + const url = new URL(request.url, "http://127.0.0.1"); + pathname = url.pathname === "/" ? "/index.html" : url.pathname; + pathname = decodeURIComponent(pathname); + } catch { + response.writeHead(400); + response.end(); + return; + } + + const file = path.resolve(root, `.${pathname}`); + + if (!file.startsWith(`${root}${path.sep}`)) { + response.writeHead(403); + response.end(); + return; + } + + fs.readFile(file, (error, content) => { + if (error) { + response.writeHead(error.code === "ENOENT" ? 404 : 500); + response.end(); + return; + } + + response.writeHead(200, { + "Content-Type": contentTypes[path.extname(file)] || "application/octet-stream" + }); + response.end(content); + }); + }) + .listen(18184, "127.0.0.1"); diff --git a/tests/e2e/worker-browser-quirks.spec.js b/tests/e2e/worker-browser-quirks.spec.js new file mode 100644 index 000000000..b5ff2d6fe --- /dev/null +++ b/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); + }); +});