From 97aacc3f3642f520327b74b80f04fd5463a0eaf9 Mon Sep 17 00:00:00 2001 From: Adriaan van Rossum <1079135+adriaanvanrossum@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:28:03 +0200 Subject: [PATCH] Use live support data for BrowserStack --- .github/workflows/browserstack.yml | 13 +- README.md | 2 +- package.json | 1 + test/helpers/browser-matrix.js | 490 +++++++++++++++++++++++++++++ test/helpers/get-browsers.js | 340 ++++++++++++-------- test/helpers/get-browsers.test.js | 463 +++++++++++++++++++++++++++ test/index.js | 26 +- 7 files changed, 1185 insertions(+), 150 deletions(-) create mode 100644 test/helpers/browser-matrix.js create mode 100644 test/helpers/get-browsers.test.js diff --git a/.github/workflows/browserstack.yml b/.github/workflows/browserstack.yml index b569afbd..f9513155 100644 --- a/.github/workflows/browserstack.yml +++ b/.github/workflows/browserstack.yml @@ -3,15 +3,11 @@ name: Browserstack on: pull_request: types: [opened, synchronize, unlabeled] - push: - branches: - - master - - main jobs: build: runs-on: ubuntu-latest - if: "${{ github.event_name != 'pull_request' || github.event.action != 'unlabeled' || github.event.label.name == 'skip: browserstack' }}" + if: "${{ github.event.action != 'unlabeled' || github.event.label.name == 'skip: browserstack' }}" strategy: matrix: @@ -21,7 +17,7 @@ jobs: - name: Check BrowserStack skip label id: browserstack env: - SKIP_BROWSERSTACK: "${{ github.event_name == 'pull_request' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'skip: browserstack') }}" + SKIP_BROWSERSTACK: "${{ github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'skip: browserstack') }}" run: echo "skip=${SKIP_BROWSERSTACK}" >> "$GITHUB_OUTPUT" - name: "BrowserStack Env Setup" @@ -52,6 +48,9 @@ jobs: - name: Run npm ci run: sudo npm ci + - name: Test BrowserStack matrix selection + run: npm run test:matrix + - name: Prettier check (report only, no writes or commits) if: always() run: | @@ -77,7 +76,7 @@ jobs: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - name: "Stop BrowserStackLocal" - if: steps.browserstack.outputs.skip != 'true' + if: always() && steps.browserstack.outputs.skip != 'true' uses: "browserstack/github-actions/setup-local@master" with: local-testing: "stop" diff --git a/README.md b/README.md index e2d265fe..f94d7347 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The most important file of the repository is [`/src/default.js`](src/default.js) ## Device testing is sponsored by BrowserStack -We run our public script with more than [50 browsers on many different (real) devices](https://github.com/simpleanalytics/scripts/blob/main/test/helpers/get-browsers.js). We support Internet Explorer 9 (not sure who is still using that) and up. Including many mobile browsers and less common devices. We get amazing sponsorship from [BrowserStack](https://www.browserstack.com/). Thanks, BrowserStack! +We run our public script on a live, representative matrix of [browsers and real devices](https://github.com/simpleanalytics/scripts/blob/main/test/helpers/get-browsers.js). Simple Analytics supports vendor-maintained browser, OS, and device versions and continues compatibility testing for two years after published end of life. Older browser versions with more than 0.1% global usage remain covered. We get amazing sponsorship from [BrowserStack](https://www.browserstack.com/). Thanks, BrowserStack! BrowserStack Logo diff --git a/package.json b/package.json index e8fe1bba..ff19ee61 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "node compile.js", "pretest": "npm run build -- testing", "test": "node -r dotenv/config ./test/index.js", + "test:matrix": "mocha test/helpers/get-browsers.test.js", "posttest": "npm run build" }, "repository": { diff --git a/test/helpers/browser-matrix.js b/test/helpers/browser-matrix.js new file mode 100644 index 00000000..51e017e5 --- /dev/null +++ b/test/helpers/browser-matrix.js @@ -0,0 +1,490 @@ +const GRACE_YEARS = 2; +const USAGE_THRESHOLD = 0.1; +const MINIMUM_SESSIONS = 10; + +const DESKTOP_BROWSERS = ["chrome", "edge", "firefox", "safari", "opera", "ie"]; +const REQUIRED_BROWSERS = ["chrome", "edge", "firefox", "safari"]; +const REQUIRED_OSES = ["Windows", "OS X", "android", "ios"]; +const MOBILE_BROWSERS = ["android", "iphone", "ipad"]; +const UNSTABLE = /beta|preview|nightly|planned|future/i; + +const parseVersion = (value) => { + const match = `${value || ""}`.match(/\d+(?:\.\d+)*/); + return match ? match[0].split(".").map(Number) : []; +}; + +const compareVersions = (left, right) => { + const leftParts = parseVersion(left); + const rightParts = parseVersion(right); + const length = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < length; index++) { + const difference = (leftParts[index] || 0) - (rightParts[index] || 0); + if (difference) return difference; + } + + return 0; +}; + +const majorVersion = (value) => parseVersion(value)[0]; + +const normalizeName = (value) => + `${value || ""}` + .toLowerCase() + .replace(/\b(apple|google|samsung|iphone)\b/g, "") + .replace(/\b5g\b/g, "") + .replace(/[^a-z0-9]+/g, ""); + +const asDate = (value) => { + if (!value) return null; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const addYears = (date, years) => { + const result = new Date(date.getTime()); + result.setUTCFullYear(result.getUTCFullYear() + years); + return result; +}; + +const isLifecycleEligible = ( + lifecycle, + { now = new Date(), usage = 0 } = {} +) => { + if (!lifecycle) return false; + if (lifecycle.status && !["current", "retired"].includes(lifecycle.status)) + return false; + + const releaseDate = asDate(lifecycle.releaseDate); + if (releaseDate && releaseDate > now) return false; + + const eolDate = asDate(lifecycle.eolFrom); + if (lifecycle.isMaintained === true || (eolDate && eolDate >= now)) + return true; + if (eolDate && addYears(eolDate, GRACE_YEARS) >= now) return true; + + return usage > USAGE_THRESHOLD; +}; + +const usageVersionMatches = (usageVersion, browserVersion) => { + const target = parseFloat(parseVersion(browserVersion).slice(0, 2).join(".")); + if (!Number.isFinite(target)) return false; + + const [startRaw, endRaw] = `${usageVersion}`.split("-"); + const start = parseFloat(startRaw); + const end = parseFloat(endRaw || startRaw); + return Number.isFinite(start) && target >= start && target <= end; +}; + +const getUsage = (agents, browser, browserVersion) => { + const agent = agents?.[browser]; + if (!agent?.usage_global) return 0; + + return Object.entries(agent.usage_global).reduce( + (total, [usageVersion, usage]) => + usageVersionMatches(usageVersion, browserVersion) + ? total + Number(usage || 0) + : total, + 0 + ); +}; + +const findReleaseByMajor = (releases, value) => { + const major = majorVersion(value); + return releases.find(({ name }) => majorVersion(name) === major); +}; + +const findMdnLifecycle = (releases, browserVersion) => { + const entries = Object.entries(releases || {}).map(([name, release]) => ({ + name, + ...release, + })); + const exact = `${browserVersion}`.replace(/\.0$/, ""); + const matching = + entries.find(({ name }) => name === exact) || + entries.find(({ name }) => majorVersion(name) === majorVersion(exact)); + + if (!matching) return null; + if (!["current", "retired"].includes(matching.status)) return matching; + + const nextStable = entries + .filter( + (release) => + ["current", "retired"].includes(release.status) && + asDate(release.release_date) > asDate(matching.release_date) + ) + .sort((left, right) => compareVersions(left.name, right.name))[0]; + + return { + name: matching.name, + releaseDate: matching.release_date, + eolFrom: + matching.status === "retired" + ? nextStable?.release_date || matching.release_date + : null, + isMaintained: matching.status === "current", + status: matching.status, + }; +}; + +const findBrowserLifecycle = (sources, browser, browserVersion) => { + if (sources.eol[browser]) + return findReleaseByMajor(sources.eol[browser], browserVersion); + if (sources.mdn[browser]) + return findMdnLifecycle(sources.mdn[browser], browserVersion); + return null; +}; + +const aggregateReleases = (releases) => { + if (!releases.length) return null; + + const dates = releases.map(({ eolFrom }) => asDate(eolFrom)).filter(Boolean); + const releaseDates = releases + .map(({ releaseDate }) => asDate(releaseDate)) + .filter(Boolean); + + return { + name: releases[0].name, + releaseDate: releaseDates.length + ? new Date(Math.min(...releaseDates)).toISOString().slice(0, 10) + : null, + eolFrom: dates.length + ? new Date(Math.max(...dates)).toISOString().slice(0, 10) + : null, + isMaintained: releases.some(({ isMaintained }) => isMaintained === true), + }; +}; + +const findWindowsLifecycle = (releases, osVersion) => { + const normalized = `${osVersion}`.toLowerCase(); + + if (["10", "11"].includes(normalized)) { + const mainstream = releases.filter(({ name }) => { + return ( + name.startsWith(`${normalized}-`) && + !/-e(?:-|$)|-iot(?:-|$)|-lts(?:-|$)/.test(name) + ); + }); + return aggregateReleases(mainstream); + } + + if (normalized === "xp") + return releases.find(({ label }) => /^xp\b/i.test(label)); + if (normalized === "7") + return releases.find(({ label }) => /^7\b/i.test(label)); + return releases.find(({ name }) => name === normalized); +}; + +const findOsLifecycle = (sources, { os, os_version: osVersion }) => { + if (os === "Windows") + return findWindowsLifecycle(sources.eol.windows, osVersion); + if (os === "OS X") { + const normalized = normalizeName(osVersion); + return sources.eol.macos.find( + ({ name, codename }) => + normalizeName(name) === normalized || + normalizeName(codename) === normalized + ); + } + if (os === "android") + return findReleaseByMajor(sources.eol.android, osVersion); + if (os === "ios") return findReleaseByMajor(sources.eol.ios, osVersion); + return null; +}; + +const findIpadRelease = (releases, device) => { + const normalized = `${device}`.toLowerCase(); + const family = normalized.includes("pro") + ? "pro" + : normalized.includes("mini") + ? "mini" + : normalized.includes("air") + ? "air" + : "ipad"; + const matchesFamily = ({ label }) => { + const value = label.toLowerCase(); + if (family === "ipad") + return value.startsWith("ipad (") && !/pro|mini|air/.test(value); + return value.includes(`ipad ${family}`); + }; + + const ordinal = normalized.match(/(\d+)(?:st|nd|rd|th)/)?.[1]; + if (ordinal) { + const ordinalMatch = releases.find( + (release) => + matchesFamily(release) && + release.label.toLowerCase().includes(`(${ordinal}`) + ); + if (ordinalMatch) return ordinalMatch; + } + + const year = normalized.match(/\b(20\d{2})\b/)?.[1]; + if (!year) return null; + + const yearMatches = releases.filter( + (release) => + matchesFamily(release) && `${release.releaseDate}`.startsWith(year) + ); + const size = normalized.match(/\b(12\.9|13|11)\b/)?.[1]; + const sizeMatch = yearMatches.find(({ label }) => + size ? label.includes(`${size}-inch`) : false + ); + return sizeMatch || yearMatches[0] || null; +}; + +const findDeviceLifecycle = (sources, { browser, device }) => { + if (!device) return null; + + if (browser === "iphone") { + const normalized = normalizeName(device); + return sources.eol.iphone.find( + ({ label }) => normalizeName(label) === normalized + ); + } + + if (browser === "ipad") return findIpadRelease(sources.eol.ipad, device); + + if (browser === "android" && /^google pixel/i.test(device)) { + const normalized = normalizeName(device); + return sources.eol.pixel.find( + ({ label }) => normalizeName(label) === normalized + ); + } + + if (browser === "android" && /^samsung/i.test(device)) { + const normalized = normalizeName(device); + return sources.eol["samsung-mobile"].find( + ({ label }) => normalizeName(label) === normalized + ); + } + + return null; +}; + +const isStableCapability = (capability) => + ![capability.browser_version, capability.os_version, capability.device].some( + (value) => UNSTABLE.test(`${value || ""}`) + ); + +const capabilityKey = ({ browser, browser_version, os, os_version, device }) => + [browser, browser_version, os, os_version, device || ""].join("|"); + +const makeUnique = (capabilities) => { + const seen = new Set(); + return capabilities.filter((capability) => { + const key = capabilityKey(capability); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +const lifecycleOrder = (lifecycle, fallback) => + parseFloat(lifecycle?.name) || + parseFloat(parseVersion(fallback).join(".")) || + 0; + +const enrichDesktop = (all, sources, now) => + all + .filter( + (capability) => + DESKTOP_BROWSERS.includes(capability.browser) && + isStableCapability(capability) + ) + .map((capability) => { + const browserLifecycle = findBrowserLifecycle( + sources, + capability.browser, + capability.browser_version + ); + const osLifecycle = findOsLifecycle(sources, capability); + const usage = getUsage( + sources.usage, + capability.browser, + capability.browser_version + ); + return { + capability, + browserLifecycle, + osLifecycle, + usage, + browserOrder: majorVersion(capability.browser_version), + osOrder: lifecycleOrder(osLifecycle, capability.os_version), + eligible: + isLifecycleEligible(browserLifecycle, { now, usage }) && + isLifecycleEligible(osLifecycle, { now }), + }; + }) + .filter(({ eligible }) => eligible); + +const enrichMobile = (all, sources, now) => + all + .filter( + (capability) => + MOBILE_BROWSERS.includes(capability.browser) && + isStableCapability(capability) + ) + .map((capability) => { + const osLifecycle = findOsLifecycle(sources, capability); + const deviceLifecycle = findDeviceLifecycle(sources, capability); + return { + capability, + osLifecycle, + deviceLifecycle, + osOrder: lifecycleOrder(osLifecycle, capability.os_version), + deviceOrder: asDate(deviceLifecycle?.releaseDate)?.getTime() || 0, + eligible: + isLifecycleEligible(osLifecycle, { now }) && + isLifecycleEligible(deviceLifecycle, { now }), + }; + }) + .filter(({ eligible }) => eligible); + +const boundaries = (items, getValue) => { + const sorted = [...items].sort( + (left, right) => getValue(left) - getValue(right) + ); + if (!sorted.length) return []; + return sorted[0] === sorted[sorted.length - 1] + ? [sorted[0]] + : [sorted[0], sorted[sorted.length - 1]]; +}; + +const selectDesktopBoundaries = (eligible) => { + const selected = []; + const eligibleFamilies = []; + + for (const browser of DESKTOP_BROWSERS) { + const family = eligible.filter( + ({ capability }) => capability.browser === browser + ); + if (!family.length) continue; + eligibleFamilies.push(browser); + + const versions = new Map(); + for (const candidate of family) { + const existing = versions.get(candidate.browserOrder); + if (!existing || candidate.osOrder > existing.osOrder) + versions.set(candidate.browserOrder, candidate); + } + + selected.push( + ...boundaries([...versions.values()], ({ browserOrder }) => browserOrder) + ); + } + + return { selected, eligibleFamilies }; +}; + +const addDesktopOsBoundaries = (selected, eligible) => { + const result = [...selected]; + + for (const os of ["Windows", "OS X"]) { + const byVersion = new Map(); + for (const candidate of eligible.filter( + ({ capability }) => capability.os === os + )) { + const key = candidate.capability.os_version; + const existing = byVersion.get(key); + if (!existing || candidate.browserOrder > existing.browserOrder) + byVersion.set(key, candidate); + } + + for (const boundary of boundaries( + [...byVersion.values()], + ({ osOrder }) => osOrder + )) { + const alreadyCovered = result.some( + ({ capability }) => + capability.os === os && + capability.os_version === boundary.capability.os_version + ); + if (!alreadyCovered) result.push(boundary); + } + } + + return result; +}; + +const selectMobileBoundaries = (eligible) => { + const selected = []; + + for (const browser of MOBILE_BROWSERS) { + const byOsVersion = new Map(); + for (const candidate of eligible.filter( + ({ capability }) => capability.browser === browser + )) { + const key = majorVersion(candidate.capability.os_version); + const existing = byOsVersion.get(key); + if (!existing || candidate.deviceOrder > existing.deviceOrder) + byOsVersion.set(key, candidate); + } + selected.push( + ...boundaries([...byOsVersion.values()], ({ osOrder }) => osOrder) + ); + } + + return selected; +}; + +const validateMatrix = ( + matrix, + { eligibleFamilies = [], minimum = MINIMUM_SESSIONS } = {} +) => { + if (matrix.length < minimum) + throw new Error( + `BrowserStack matrix has ${matrix.length} sessions; expected at least ${minimum}` + ); + + for (const browser of REQUIRED_BROWSERS) { + if (!matrix.some((capability) => capability.browser === browser)) + throw new Error( + `BrowserStack matrix is missing required browser: ${browser}` + ); + } + + for (const os of REQUIRED_OSES) { + if (!matrix.some((capability) => capability.os === os)) + throw new Error(`BrowserStack matrix is missing required OS: ${os}`); + } + + for (const browser of eligibleFamilies) { + if (!matrix.some((capability) => capability.browser === browser)) + throw new Error( + `BrowserStack matrix dropped eligible browser: ${browser}` + ); + } + + return matrix; +}; + +const selectBrowsers = (all, sources, { now = new Date(), minimum } = {}) => { + const desktop = enrichDesktop(all, sources, now); + const mobile = enrichMobile(all, sources, now); + const { selected, eligibleFamilies } = selectDesktopBoundaries(desktop); + const withOsBoundaries = addDesktopOsBoundaries(selected, desktop); + const mobileBoundaries = selectMobileBoundaries(mobile); + const matrix = makeUnique( + [...withOsBoundaries, ...mobileBoundaries].map( + ({ capability }) => capability + ) + ); + + return validateMatrix(matrix, { eligibleFamilies, minimum }); +}; + +module.exports = { + GRACE_YEARS, + MINIMUM_SESSIONS, + REQUIRED_BROWSERS, + REQUIRED_OSES, + USAGE_THRESHOLD, + compareVersions, + findDeviceLifecycle, + findMdnLifecycle, + findOsLifecycle, + getUsage, + isLifecycleEligible, + selectBrowsers, + validateMatrix, +}; diff --git a/test/helpers/get-browsers.js b/test/helpers/get-browsers.js index fba8d423..2b24254e 100644 --- a/test/helpers/get-browsers.js +++ b/test/helpers/get-browsers.js @@ -1,160 +1,222 @@ const request = require("request"); -const { promisify } = require("util"); -const requestPromise = promisify(request); -const { makeUnique, version } = require("."); +const { selectBrowsers } = require("./browser-matrix"); const { BROWSERSTACK_USERNAME, BROWSERSTACK_ACCESS_KEY, } = require("../constants/browserstack"); -module.exports = async () => { - const url = - `https://${BROWSERSTACK_USERNAME}:${BROWSERSTACK_ACCESS_KEY}` + - `@api.browserstack.com` + - `/automate/browsers.json`; +const REQUEST_TIMEOUT = 10000; +const REQUEST_ATTEMPTS = 3; +const EOL_API = "https://endoflife.date/api/v1/products"; +const MDN_API = + "https://raw.githubusercontent.com/mdn/browser-compat-data/main/browsers"; +const CANIUSE_API = + "https://raw.githubusercontent.com/Fyrd/caniuse/main/data.json"; +const BROWSERSTACK_API = "https://api.browserstack.com/automate/browsers.json"; - const { body: all } = await requestPromise({ - method: "GET", - json: true, - url, - }); +const EOL_PRODUCTS = [ + "chrome", + "firefox", + "windows", + "macos", + "android", + "ios", + "iphone", + "ipad", + "pixel", + "samsung-mobile", +]; +const MDN_BROWSERS = ["edge", "safari", "opera"]; +const USAGE_BROWSERS = ["chrome", "edge", "firefox", "safari", "opera", "ie"]; - const ie = all.filter(({ browser, browser_version }) => { - return browser === "ie" && version(browser_version) >= 9; - }); +// Microsoft retired the IE 11 desktop application on June 15, 2022. IE 9 and +// IE 10 stopped receiving support when Microsoft moved to the latest-version +// support policy on January 12, 2016. These immutable dates use the same +// lifecycle and usage policy as every other browser. +// https://learn.microsoft.com/en-us/lifecycle/products/internet-explorer-11 +const IE_RELEASES = [ + { + name: "11", + releaseDate: "2013-10-17", + eolFrom: "2022-06-15", + isMaintained: false, + }, + { + name: "10", + releaseDate: "2012-09-04", + eolFrom: "2016-01-12", + isMaintained: false, + }, + { + name: "9", + releaseDate: "2011-03-14", + eolFrom: "2016-01-12", + isMaintained: false, + }, +]; - const edge = makeUnique( - all - .filter(({ browser, browser_version }) => { - return ( - browser === "edge" && - !browser_version.includes("beta") && - !browser_version.includes("preview") - ); - }) - .map((item) => ({ - ...item, - browser_main_version: version(item.browser_version), - })) - .sort((left, right) => - version(left.browser_version) > version(right.browser_version) ? -1 : 1 - ) - .slice(0, 2), - ["os", "browser_main_version"] - ); +const sleep = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); - const chrome = makeUnique( - all - .filter(({ browser, browser_version }) => { - return ( - browser === "chrome" && - !browser_version.includes("beta") && - !browser_version.includes("preview") - ); - }) - .sort((left, right) => - version(left.browser_version) > version(right.browser_version) ? -1 : 1 - ), - ["os", "os_version"] - ); +const withRetries = async ( + source, + load, + { attempts = REQUEST_ATTEMPTS, wait = sleep } = {} +) => { + let lastError; - const ios = makeUnique( - all - .filter(({ browser }) => browser === "iphone") - .sort((left, right) => - version(left.browser_version) > version(right.browser_version) ? -1 : 1 - ), - ["os", "os_version"] - ); + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await load(); + } catch (error) { + lastError = error; + if (attempt < attempts) await wait(attempt * 250); + } + } - const android = makeUnique( - all - .filter(({ browser }) => browser === "android") - .map((item) => ({ - ...item, - os_main_version: parseInt(item.os_version.split(".")[0], 10), - })) - .sort((left, right) => - version(left.os_version) > version(right.os_version) ? -1 : 1 - ), - ["os", "os_main_version"] + const reason = lastError?.code || lastError?.message || "unknown error"; + throw new Error( + `Unable to load ${source} after ${attempts} attempts: ${reason}` ); +}; - const safari = makeUnique( - all - .filter(({ browser, os, browser_version }) => { - return ( - browser === "safari" && - os === "OS X" && - !browser_version.includes("beta") && - !browser_version.includes("preview") - ); - }) - .map((item) => ({ - ...item, - browser_main_version: version(item.browser_version), - })) - .sort((left, right) => - left.browser_main_version > right.browser_main_version ? -1 : 1 - ) - .slice(0, 4), - ["os", "browser_main_version"] - ).slice(0, 5); - - const firefox = makeUnique( - all - .filter(({ browser, browser_version }) => { - return ( - browser === "firefox" && - !browser_version.includes("beta") && - !browser_version.includes("preview") - ); - }) - .sort((left, right) => - version(left.browser_version) > version(right.browser_version) ? -1 : 1 - ), - ["os"] - ); +const requestJson = (options) => + new Promise((resolve, reject) => { + request( + { method: "GET", json: true, timeout: REQUEST_TIMEOUT, ...options }, + (error, response, body) => { + if (error) return reject(error); + if ( + !response || + response.statusCode < 200 || + response.statusCode >= 300 + ) + return reject(new Error(`HTTP ${response?.statusCode || "unknown"}`)); + return resolve(body); + } + ); + }); + +const requireArray = (source, value) => { + if (!Array.isArray(value) || !value.length) + throw new Error(`${source} returned an unexpected response`); + return value; +}; + +const parseEolPayload = (product, payload) => + requireArray(`endoflife.date/${product}`, payload?.result?.releases); - const opera = makeUnique( - all - .filter(({ browser, browser_version }) => { - return ( - version(browser_version) > 12.15 && - browser === "opera" && - !browser_version.includes("beta") && - !browser_version.includes("preview") - ); - }) - .sort((left, right) => - version(left.browser_version) > version(right.browser_version) ? -1 : 1 - ), - ["os"] +const parseMdnPayload = (browser, payload) => { + const releases = payload?.browsers?.[browser]?.releases; + if ( + !releases || + typeof releases !== "object" || + Array.isArray(releases) || + !Object.keys(releases).length + ) + throw new Error(`MDN/${browser} returned an unexpected response`); + return releases; +}; + +const parseUsagePayload = (payload) => { + if (!payload?.agents || typeof payload.agents !== "object") + throw new Error("Can I Use returned an unexpected response"); + for (const browser of USAGE_BROWSERS) { + if ( + !payload.agents[browser]?.usage_global || + typeof payload.agents[browser].usage_global !== "object" + ) + throw new Error(`Can I Use returned no global usage data for ${browser}`); + } + return payload.agents; +}; + +const parseBrowserStackPayload = (payload) => + requireArray("BrowserStack", payload); + +const loadJson = (source, options, parse, dependencies) => + withRetries( + source, + async () => parse(await dependencies.requestJson(options)), + dependencies ); - const ipads = makeUnique( - all - .filter(({ browser }) => { - return browser === "ipad"; - }) - .sort((left, right) => - version(left.os_version) > version(right.os_version) ? -1 : 1 - ), - ["os_version"] +const loadSources = async ({ + requestJson: load = requestJson, + attempts = REQUEST_ATTEMPTS, + wait = sleep, +} = {}) => { + if (!BROWSERSTACK_USERNAME || !BROWSERSTACK_ACCESS_KEY) + throw new Error("BrowserStack credentials are required to load browsers"); + + const dependencies = { requestJson: load, attempts, wait }; + const eolRequests = EOL_PRODUCTS.map(async (product) => [ + product, + await loadJson( + `endoflife.date/${product}`, + { url: `${EOL_API}/${product}` }, + (payload) => parseEolPayload(product, payload), + dependencies + ), + ]); + const mdnRequests = MDN_BROWSERS.map(async (browser) => [ + browser, + await loadJson( + `MDN/${browser}`, + { url: `${MDN_API}/${browser}.json` }, + (payload) => parseMdnPayload(browser, payload), + dependencies + ), + ]); + const browserStackRequest = loadJson( + "BrowserStack", + { + url: BROWSERSTACK_API, + auth: { + user: BROWSERSTACK_USERNAME, + pass: BROWSERSTACK_ACCESS_KEY, + sendImmediately: true, + }, + }, + parseBrowserStackPayload, + dependencies ); + const usageRequest = loadJson( + "Can I Use", + { url: CANIUSE_API }, + parseUsagePayload, + dependencies + ); + + const [eolEntries, mdnEntries, all, usage] = await Promise.all([ + Promise.all(eolRequests), + Promise.all(mdnRequests), + browserStackRequest, + usageRequest, + ]); - const browsers = [ - ...android, - ...chrome, - ...opera, - ...edge, - ...firefox, - ...ie, - ...ipads, - ...ios, - ...safari, - ]; - - return browsers; + return { + all, + sources: { + eol: { ...Object.fromEntries(eolEntries), ie: IE_RELEASES }, + mdn: Object.fromEntries(mdnEntries), + usage, + }, + }; }; + +const getBrowsers = async ({ now = new Date(), load = loadSources } = {}) => { + const { all, sources } = await load(); + return selectBrowsers(all, sources, { now }); +}; + +module.exports = getBrowsers; +module.exports.IE_RELEASES = IE_RELEASES; +module.exports.loadSources = loadSources; +module.exports.parseBrowserStackPayload = parseBrowserStackPayload; +module.exports.parseEolPayload = parseEolPayload; +module.exports.parseMdnPayload = parseMdnPayload; +module.exports.parseUsagePayload = parseUsagePayload; +module.exports.requestJson = requestJson; +module.exports.withRetries = withRetries; diff --git a/test/helpers/get-browsers.test.js b/test/helpers/get-browsers.test.js new file mode 100644 index 00000000..3efb1c1a --- /dev/null +++ b/test/helpers/get-browsers.test.js @@ -0,0 +1,463 @@ +const { expect } = require("chai"); + +const { + findDeviceLifecycle, + getUsage, + isLifecycleEligible, + selectBrowsers, + validateMatrix, +} = require("./browser-matrix"); +const { + IE_RELEASES, + parseBrowserStackPayload, + parseEolPayload, + parseMdnPayload, + parseUsagePayload, + withRetries, +} = require("./get-browsers"); + +const NOW = new Date("2026-08-03T00:00:00.000Z"); + +const maintained = (name, releaseDate = "2025-01-01") => ({ + name, + releaseDate, + eolFrom: null, + isMaintained: true, +}); +const retired = (name, eolFrom, releaseDate = "2020-01-01") => ({ + name, + releaseDate, + eolFrom, + isMaintained: false, +}); + +const sources = () => ({ + eol: { + chrome: [ + maintained("151", "2026-07-28"), + retired("120", "2025-01-01", "2023-12-01"), + retired("100", "2022-04-01", "2022-03-01"), + ], + firefox: [ + maintained("153", "2026-07-21"), + retired("120", "2025-01-01", "2023-11-01"), + ], + ie: IE_RELEASES, + windows: [ + maintained("11-26h1-w", "2026-02-10"), + { + ...maintained("10-22h2", "2022-10-18"), + eolFrom: "2025-10-14", + }, + retired("8.1", "2023-01-10", "2013-10-17"), + retired("8", "2016-01-12", "2012-10-26"), + { ...retired("7-sp1", "2020-01-14"), label: "7 SP1" }, + ], + macos: [ + { ...maintained("26", "2025-09-15"), codename: "Tahoe" }, + { ...maintained("14", "2023-09-26"), codename: "Sonoma" }, + { + ...retired("13", "2025-09-15", "2022-10-24"), + codename: "Ventura", + }, + ], + android: [ + maintained("17", "2026-06-16"), + retired("12", "2025-03-01", "2021-10-04"), + retired("9", "2022-01-01", "2018-08-06"), + ], + ios: [ + maintained("26", "2025-09-15"), + maintained("15", "2021-09-20"), + retired("11", "2018-01-01", "2017-09-19"), + ], + iphone: [ + { ...maintained("15-pro-max", "2023-09-22"), label: "15 Pro Max" }, + { ...maintained("13-pro-max", "2021-09-24"), label: "13 Pro Max" }, + ], + ipad: [ + { + ...maintained("pro-8-13", "2025-10-22"), + label: "iPad Pro 13-inch (M5)", + }, + { + ...maintained("9", "2021-09-24"), + label: "iPad (9th generation)", + }, + ], + pixel: [{ ...maintained("9", "2024-08-22"), label: "Pixel 9" }], + "samsung-mobile": [ + { + ...maintained("galaxy-s22-ultra", "2022-02-25"), + label: "Galaxy S22 Ultra", + }, + { + ...retired("galaxy-note-9", "2022-07-01", "2018-08-24"), + label: "Galaxy Note 9", + }, + ], + }, + mdn: { + edge: { + 130: { + release_date: "2024-08-01", + status: "retired", + }, + 151: { + release_date: "2026-07-01", + status: "current", + }, + 152: { + release_date: "2026-08-20", + status: "beta", + }, + }, + safari: { + 17: { + release_date: "2023-09-18", + status: "retired", + }, + 26: { + release_date: "2025-09-15", + status: "current", + }, + 27: { + release_date: "2026-09-15", + status: "beta", + }, + }, + opera: {}, + }, + usage: { + chrome: { usage_global: { 100: 0, 120: 0.05, 151: 20 } }, + edge: { usage_global: { 130: 0, 151: 5 } }, + firefox: { usage_global: { 120: 0, 153: 3 } }, + safari: { usage_global: { 17: 0.5, 26: 15 } }, + ie: { usage_global: { 9: 0, 10: 0, 11: 0.217074 } }, + opera: { usage_global: {} }, + }, +}); + +const capabilities = () => [ + { + browser: "chrome", + browser_version: "100.0", + os: "Windows", + os_version: "10", + }, + { + browser: "chrome", + browser_version: "120.0", + os: "Windows", + os_version: "10", + }, + { + browser: "chrome", + browser_version: "151.0", + os: "Windows", + os_version: "11", + }, + { + browser: "chrome", + browser_version: "151.0", + os: "Windows", + os_version: "11", + }, + { + browser: "edge", + browser_version: "130.0", + os: "Windows", + os_version: "10", + }, + { + browser: "edge", + browser_version: "151.0", + os: "Windows", + os_version: "11", + }, + { + browser: "edge", + browser_version: "152 beta", + os: "Windows", + os_version: "11", + }, + { + browser: "firefox", + browser_version: "120.0", + os: "OS X", + os_version: "Ventura", + }, + { + browser: "firefox", + browser_version: "153.0", + os: "OS X", + os_version: "Tahoe", + }, + { + browser: "safari", + browser_version: "17.0", + os: "OS X", + os_version: "Sonoma", + }, + { + browser: "safari", + browser_version: "26.0", + os: "OS X", + os_version: "Tahoe", + }, + { + browser: "safari", + browser_version: "27.0", + os: "OS X", + os_version: "Golden Gate", + }, + { + browser: "ie", + browser_version: "9.0", + os: "Windows", + os_version: "7", + }, + { + browser: "ie", + browser_version: "10.0", + os: "Windows", + os_version: "8.1", + }, + { + browser: "ie", + browser_version: "11.0", + os: "Windows", + os_version: "10", + }, + { + browser: "android", + browser_version: "android", + os: "android", + os_version: "9.0", + device: "Samsung Galaxy Note 9", + }, + { + browser: "android", + browser_version: "android", + os: "android", + os_version: "12.0", + device: "Samsung Galaxy S22 Ultra", + }, + { + browser: "android", + browser_version: "android", + os: "android", + os_version: "17.0", + device: "Google Pixel 9", + }, + { + browser: "iphone", + browser_version: "15", + os: "ios", + os_version: "15", + device: "iPhone 13 Pro Max", + }, + { + browser: "iphone", + browser_version: "26", + os: "ios", + os_version: "26", + device: "iPhone 15 Pro Max", + }, + { + browser: "iphone", + browser_version: "27 Beta", + os: "ios", + os_version: "27 Beta", + device: "iPhone 15 Pro Max", + }, + { + browser: "ipad", + browser_version: "15", + os: "ios", + os_version: "15", + device: "iPad 9th", + }, + { + browser: "ipad", + browser_version: "26", + os: "ios", + os_version: "26", + device: "iPad Pro 13 2025", + }, +]; + +describe("BrowserStack support matrix", () => { + it("uses supported, two-year grace, and usage eligibility rules", () => { + expect( + isLifecycleEligible(maintained("1"), { now: NOW, usage: 0 }) + ).to.equal(true); + expect( + isLifecycleEligible(retired("1", "2025-01-01"), { + now: NOW, + usage: 0, + }) + ).to.equal(true); + expect( + isLifecycleEligible(retired("1", "2022-01-01"), { + now: NOW, + usage: 0, + }) + ).to.equal(false); + expect( + isLifecycleEligible(retired("1", "2022-01-01"), { + now: NOW, + usage: 0.11, + }) + ).to.equal(true); + expect( + isLifecycleEligible(retired("1", "2022-01-01"), { + now: NOW, + usage: 0.1, + }) + ).to.equal(false); + }); + + it("matches exact and ranged global usage versions", () => { + const agents = { + safari: { usage_global: { "15.2-15.3": 0.12, 15.4: 0.04 } }, + }; + expect(getUsage(agents, "safari", "15.3")).to.equal(0.12); + expect(getUsage(agents, "safari", "15.4")).to.equal(0.04); + }); + + it("selects oldest and newest eligible boundaries without duplicates", () => { + const matrix = selectBrowsers(capabilities(), sources(), { now: NOW }); + const versions = (browser) => + matrix + .filter((capability) => capability.browser === browser) + .map((capability) => capability.browser_version); + + expect(matrix).to.have.length(15); + expect(versions("chrome")).to.have.members(["120.0", "151.0"]); + expect(versions("edge")).to.have.members(["130.0", "151.0"]); + expect(versions("firefox")).to.have.members(["120.0", "153.0"]); + expect(versions("safari")).to.have.members(["17.0", "26.0"]); + expect(versions("ie")).to.deep.equal(["11.0"]); + expect(new Set(matrix.map(JSON.stringify)).size).to.equal(matrix.length); + + const desktopOsVersions = matrix + .filter(({ device }) => !device) + .map(({ os, os_version: osVersion }) => `${os} ${osVersion}`); + expect(desktopOsVersions).to.include.members([ + "Windows 10", + "Windows 11", + "OS X Ventura", + "OS X Tahoe", + ]); + }); + + it("includes IE 11 through usage and excludes IE 9 and IE 10", () => { + const matrix = selectBrowsers(capabilities(), sources(), { now: NOW }); + const internetExplorer = matrix.filter(({ browser }) => browser === "ie"); + expect(internetExplorer).to.deep.equal([ + { + browser: "ie", + browser_version: "11.0", + os: "Windows", + os_version: "10", + }, + ]); + }); + + it("filters expired devices and keeps both mobile OS boundaries", () => { + const matrix = selectBrowsers(capabilities(), sources(), { now: NOW }); + const mobile = matrix.filter(({ device }) => device); + + expect(mobile.map(({ device }) => device)).to.have.members([ + "Samsung Galaxy S22 Ultra", + "Google Pixel 9", + "iPhone 13 Pro Max", + "iPhone 15 Pro Max", + "iPad 9th", + "iPad Pro 13 2025", + ]); + expect( + mobile.some(({ device }) => device === "Samsung Galaxy Note 9") + ).to.equal(false); + }); + + it("maps supported device names to live lifecycle records", () => { + const data = sources(); + expect( + findDeviceLifecycle(data, { + browser: "android", + device: "Samsung Galaxy S22 Ultra", + }).name + ).to.equal("galaxy-s22-ultra"); + expect( + findDeviceLifecycle(data, { + browser: "ipad", + device: "iPad Pro 13 2025", + }).name + ).to.equal("pro-8-13"); + }); + + it("fails when a required browser has no lifecycle-mapped capability", () => { + const data = sources(); + data.mdn.safari = {}; + expect(() => selectBrowsers(capabilities(), data, { now: NOW })).to.throw( + "missing required browser: safari" + ); + }); + + it("fails when fewer than ten sessions are selected", () => { + expect(() => validateMatrix(new Array(9).fill({}))).to.throw( + "expected at least 10" + ); + }); + + it("retries timeouts twice before succeeding", async () => { + let calls = 0; + const result = await withRetries( + "fixture API", + async () => { + calls++; + if (calls < 3) { + const error = new Error("timed out"); + error.code = "ETIMEDOUT"; + throw error; + } + return { ok: true }; + }, + { wait: async () => {} } + ); + + expect(result).to.deep.equal({ ok: true }); + expect(calls).to.equal(3); + }); + + it("reports the source after three failed attempts", async () => { + let error; + try { + await withRetries( + "fixture API", + async () => { + throw new Error("invalid payload"); + }, + { wait: async () => {} } + ); + } catch (caught) { + error = caught; + } + + expect(error.message).to.include("fixture API after 3 attempts"); + }); + + it("rejects malformed API responses", () => { + expect(() => parseBrowserStackPayload({})).to.throw("BrowserStack"); + expect(() => parseEolPayload("chrome", {})).to.throw( + "endoflife.date/chrome" + ); + expect(() => parseMdnPayload("edge", {})).to.throw("MDN/edge"); + expect(() => parseUsagePayload({})).to.throw("Can I Use"); + expect(() => parseUsagePayload({ agents: {} })).to.throw( + "global usage data for chrome" + ); + }); +}); diff --git a/test/index.js b/test/index.js index 8bafc945..4371c86e 100644 --- a/test/index.js +++ b/test/index.js @@ -7,6 +7,11 @@ const { promisify } = require("util"); const { DEBUG, CI } = require("./constants"); const { version, navigate } = require("./helpers"); const getBrowsers = require("./helpers/get-browsers"); +const { + MINIMUM_SESSIONS, + REQUIRED_BROWSERS, + REQUIRED_OSES, +} = require("./helpers/browser-matrix"); const server = require("./helpers/server"); const { @@ -219,12 +224,27 @@ const getDeviceName = ({ if (CI) suiteInstance.addTest( new Mocha.Test( - `Having more than 20 browsers to test: ${browsers.length}`, + `Having at least ${MINIMUM_SESSIONS} browsers to test: ${browsers.length}`, async function () { expect( browsers, - "Should have more than 20 browsers" - ).to.have.lengthOf.at.least(20); + `Should have at least ${MINIMUM_SESSIONS} browsers` + ).to.have.lengthOf.at.least(MINIMUM_SESSIONS); + } + ) + ); + + if (CI) + suiteInstance.addTest( + new Mocha.Test( + "Having all required browser and OS families", + async function () { + expect(browsers.map(({ browser }) => browser)).to.include.members( + REQUIRED_BROWSERS + ); + expect(browsers.map(({ os }) => os)).to.include.members( + REQUIRED_OSES + ); } ) );