`.
+
+`src/components/views/Main.jsx` defines a `Switch` with these routes:
+
+- `/` renders Home.
+- `/contact` renders Contact.
+- `/dashboard/
` renders DashboardContainer, one route per `dashboards` entry.
+- A catch-all renders NotFound: `` plus a `robots: noindex` meta tag.
+
+Every route component is loaded with `src/lib/lazyLoad.jsx`, a `React.lazy` wrapper, inside
+`src/lib/LazyBoundary.jsx`. LazyBoundary is an error boundary that shows a Spinner while a chunk
+loads, or an Error titled "Error" with the message "Load error" if loading throws. Each route is
+also wrapped in two higher-order components ("decorators"):
+
+- `decorators/withTracker.jsx` sends Google Analytics page views through react-ga.
+- `decorators/withNextButton.jsx` appends `views/NextButton.jsx` when `location.pathname` matches a
+ `nextButtons.from`.
+
+**The container/view split.** Files in `src/components/containers/` hold state and fetching, then
+render a presentational file from `src/components/views/` with props they have already formatted.
+Examples: `ChartContainer` renders `views/Chart`, and `DataTableContainer` renders
+`views/DataTable`. That boundary is the seam the data-shaping tests use. They render a container
+with its view replaced by a stub, then check the props the stub received.
+
+**react-refetch's `connect()`** is the whole data layer. It is a higher-order component, used in
+three containers, that calls `window.fetch` for a URL computed from props. It passes the result in
+as a `dataFetch` prop with `pending`, `rejected` (plus `reason`), or `fulfilled` (plus `value`).
+
+- `DashboardContainer.jsx` fetches `${source}/index.json`.
+ - Pending: renders `views/Spinner`.
+ - Rejected: renders `views/Error` with `id="dashboard-fetch-error"`.
+ - Fulfilled: renders `views/Dashboard`.
+ - If the dashboard has more than one region ("category"), the active region is the first of
+ `sessionStorage.preferredRegion`, component state, `defaultCategory`, and `categories[0]`.
+ Changing `#region-selector` writes `preferredRegion`.
+- `MetricOverviewContainer.jsx` and `SummaryMetricContainer.jsx` both fetch
+ `${dashboardSource}/${activeRegion}/${slug}/index.json`.
+ - Pending: render nothing.
+ - Rejected: render an Error with `id="metric-overview-fetch-error"` or
+ `id="summary-metric-fetch-error"`.
+ - `MetricOverviewContainer` also defines `shouldComponentUpdate`, so that when its URL changes
+ (a region change) it keeps the old metric on screen until new data arrives instead of flashing
+ empty. It currently returns `nextProps.dataFetch.fulfilled`, which also blocks the rejected
+ render, so its error never appears. Milestone 3 fixes this.
+
+Because react-refetch calls `fetch`, stubbing `global.fetch` drives it in jsdom. The same stub
+keeps working if react-refetch is replaced by any other code that calls `fetch`.
+
+**Data shaping**, all client-side:
+
+- `ChartContainer.formatData` sorts populations by their maximum y value, highest first. It drops
+ points whose y is `null` and turns parseable x strings into `Date`s. ChartContainer renders
+ nothing until it has measured `#application > main`, and it re-measures on `resize`.
+- `DataTableContainer.formatData` sorts the active date's rows by `value`, descending. It sorts in
+ place, so it mutates its input.
+- `SummaryMetricContainer.formatData` covers two cases:
+ - One population: the data is a true/false split, so it emits "True" (y) and "False" (100 − y).
+ - More than one: populations under 5% at the active date are summed into "Other", which sorts
+ last. The rest sort by value, descending.
+
+**`src/lib/utils.js`** exports four pure helpers: `bumpSort`, `isFloat`, `prettifyNumber` (which
+uses `import.meta.env.VITE_VALUE_DECIMAL_PLACES`, `3` in the checked-in `.env`), and `getPageTitle`
+(which uses `VITE_SITE_TITLE`, "Firefox Public Data Report").
+
+**Metric descriptions** are rendered as inline Markdown with `markdown-it('zero')`. Only `link` and
+`entity` rules are enabled, plus `markdown-it-sup`, and the output goes in via
+`dangerouslySetInnerHTML`. `src/tests/jest/MetricOverview.test.jsx` guards that this escapes raw
+HTML. Its assertions must not get weaker.
+
+**The gitignored compiled CSS.** Styles are written in Stylus under
+`src/components/views/styl/` and compiled to `src/components/views/css/` by `npm run build:css`.
+That output is gitignored. Every view does a side-effect import like `import './css/Dashboard.css'`.
+Vitest and Vite fail to resolve those imports until `npm run build:css` has run.
+
+**Unit tests today.**
+
+- Vitest, configured in the `test` block of `vite.config.mjs`: `jsdom`, `globals: true`,
+ `include: ['src/tests/jest/**/*.test.jsx']`, `setupFiles: ['./src/setupTests.js']`. The `jest`
+ directory name is a leftover from before Vitest.
+- `src/setupTests.js` configures Enzyme's React 16 adapter and puts `React` and `shallow` on
+ `global`.
+- `eslint.config.js` declares those two globals for test files.
+- There are two files: `Dashboard.test.jsx` (whether `#dashboard-description` renders) and
+ `MetricOverview.test.jsx` (description sanitization).
+- The Vite React plugin uses the classic JSX runtime, so `React` must be in scope wherever JSX
+ appears. The `React` global covers that in tests.
+
+**Playwright today.**
+
+- `playwright.config.js` starts `npm start` on `:3000` and has two Chromium projects: `chromium`
+ runs everything except `jsDisabled.spec.js`, and `chromium-no-js` runs only that spec.
+- The specs live in `tests/playwright/specs/`, are written in CommonJS, and all run against live
+ data.
+- Helpers are in `tests/playwright/utils.js`.
+ - `flagForUpdate` deliberately throws when an element count differs from what a test assumes, to
+ force a human to look.
+ - `changeRegionAndWaitForMetrics` waits for the refetches a region change triggers.
+- Use `.first()` on `path.mg-line1`. metrics-graphics also puts that class on invisible hover
+ paths.
+
+**CI.** `.github/workflows/ci.yml` runs two jobs on pushes to `main` and on pull requests:
+
+- `lint-and-test`: `npm ci`, `npm run build:css`, `npm run lint`, `npm run test:jest`.
+- `e2e`: installs Chromium, runs `build:css`, then `npm run test:playwright`.
+
+Any workflow change must be checked with Zizmor (`AGENTS.md`).
+
+**Documents to keep in sync.** These describe the test setup and go stale with it:
+
+- `AGENTS.md`: the src tree, the Build and Test commands, the ESLint paragraph, Testing Guidelines.
+- `CONTRIBUTING.md`: Testing guidelines.
+- `docs/architecture/frontend.md`: the file layout.
+- `.github/dependabot.yml`: a comment that ties the React major-version ignore to Enzyme.
+- `public/contribute.json`: the keyword list.
+
+## Plan of Work
+
+### Milestone 1: move the unit harness from Enzyme to React Testing Library
+
+At the end of this milestone, the two existing unit tests run on RTL with the same assertions, and
+Enzyme is gone from the dependency tree.
+
+1. Install exact-pinned dev dependencies: `@testing-library/react@12.1.5`,
+ `@testing-library/dom@8.20.1`, `@testing-library/jest-dom@6.9.1`, and
+ `@testing-library/user-event@14.6.8`. Check that `package.json` has no `^` or `~`, and that
+ `npm ls @testing-library/dom` shows a single 8.x copy.
+2. Remove `enzyme`, `enzyme-adapter-react-16`, and the `overrides.enzyme` block. Remove
+ `react-test-renderer` too, since only the Enzyme adapter used it.
+3. Rewrite `src/setupTests.js`:
+ - Import `@testing-library/jest-dom/vitest`.
+ - Keep `global.React`, because the classic JSX runtime needs it.
+ - Call RTL's `cleanup` after each test, because RTL 12 only auto-cleans when the runner exposes
+ an `afterEach` global, and relying on that is fragile.
+ - Make `console.error` throw unless a test has replaced it.
+4. Port `Dashboard.test.jsx` and `MetricOverview.test.jsx` to `render` and `container.querySelector`
+ / `screen` queries. MetricOverview's `.html()` assertions become assertions on
+ `.metric-description`'s `innerHTML`, with the same expected strings. MetricOverview without a
+ `type` lazy-loads nothing, so it renders synchronously.
+5. In `eslint.config.js`, drop the `shallow` global. Give `src/setupTests.js` its own block with
+ the Vitest globals.
+6. Update the documents listed under "Documents to keep in sync".
+
+Acceptance: `npm run test:jest` passes 2 files, 4 tests. `npm run lint` is clean.
+`npm ls enzyme react-test-renderer` prints `(empty)`.
+
+### Milestone 2: unit-test pure logic and data shaping
+
+Add these test files to `src/tests/jest/`:
+
+- `utils.test.jsx` covers `bumpSort` (bumped value first, rest sorted, input unchanged, missing
+ value), `isFloat`, `prettifyNumber` (integers get thousands separators, floats get exactly 3
+ decimal places), and `getPageTitle` (with and without a subtitle). The `.jsx` extension matches
+ the `include` glob.
+- `ChartContainer.test.jsx` mocks `../../components/views/Chart` with `vi.mock` as a stub that
+ records its props. Before rendering, it gives `#application > main` a non-zero `offsetWidth`
+ (jsdom reports 0), so the container renders. It checks legend order by max y, that null y points
+ are dropped, that x strings become `Date`s, `showLegend` for one population versus several,
+ annotation markers, and that width follows the parent width.
+- `DataTableContainer.test.jsx` checks that rows render in descending value order through the real
+ `views/DataTable`.
+- `SummaryMetricContainer.test.jsx` mocks `views/SummaryMetric` and stubs `fetch`. It checks the
+ True/False split, the "Other" bucket (summed and sorted last), and that populations missing the
+ active date are skipped.
+- `withNextButton.test.jsx` checks each `nextButtons` entry renders its text linking to its `to`,
+ and that `/contact` gets no button. It uses `MemoryRouter` because NextButton renders a `Link`.
+- `CustomizableDate.test.jsx` checks that the date selector shows dates formatted as `longDate` in
+ UTC (for example "January 1, 2026"), that the first date is selected by default, and that
+ changing the selection passes the new `activeDate` to children.
+
+Acceptance: `npm run test:jest` passes, and the plan records the new file and test counts.
+
+### Milestone 3: routing and fetch behavior in jsdom
+
+1. `DashboardContainer.test.jsx`:
+ - Stubs `global.fetch` with `vi.fn` returning Responses built inline.
+ - Pending: a `.spinner` renders.
+ - Rejected: `#dashboard-fetch-error` renders, with a spy on `console.error`.
+ - Fulfilled: `#dashboard-title` shows the title.
+ - Region priority with `sessionStorage` set or unset.
+ - Changing `#region-selector` writes `preferredRegion` and requests the new region's metric URLs.
+ - Single-category dashboards show no selector.
+ - It mocks `views/Chart` so jsdom never runs metrics-graphics.
+2. Fix and test `MetricOverviewContainer`'s rejected state.
+ - Write `MetricOverviewContainer.test.jsx` first. It checks:
+ - The exact requested URL.
+ - The title renders when fulfilled.
+ - On first load, an HTTP 500 shows `#metric-overview-fetch-error`.
+ - After a successful load, changing `activeRegion` keeps the old title on screen while the new
+ request is pending.
+ - When that new request then fails with HTTP 500, `#metric-overview-fetch-error` replaces the
+ old content.
+ - Run it and confirm the two error cases fail against the current code.
+ - Change `shouldComponentUpdate` in `src/components/containers/MetricOverviewContainer.jsx` to
+ return `nextProps.dataFetch.fulfilled || nextProps.dataFetch.rejected`. Update its comment so
+ it says rejected states also render.
+ - Leave the separate `#313` comment in `render()` alone. It describes a hosting problem the fix
+ doesn't touch.
+ - Confirm all five cases pass.
+3. `Routes.test.jsx` renders `views/Application` inside `MemoryRouter` with `initialEntries`. It
+ checks `/` (Home and the Proceed button), `/contact`, each `/dashboard/` (the dashboard's
+ `index.json` is requested from its `source`), an unknown path (`#not-found`), and
+ `/dashboard/hardware/extra` (`#not-found`). It stubs `fetch` and mocks `react-ga` so no
+ analytics run. One local `renderAt(path)` function holds the router setup, so a router upgrade
+ changes one place.
+4. `LazyBoundary.test.jsx` checks that a lazily loaded child whose import rejects shows "Load
+ error". React logs caught boundary errors to `console.error`, so the test spies on it.
+
+Acceptance: `npm run test:jest` passes. Breaking the catch-all route in `Main.jsx` fails
+`Routes.test.jsx`.
+
+### Milestone 4: Playwright specs on recorded fixtures
+
+1. Add `scripts/recordPlaywrightFixtures.js`, a Node script using global `fetch`. For each
+ dashboard in `src/config.json`, it saves `index.json`, plus every metric and summary metric for
+ the default region and for one other region when the dashboard supports regions. It writes them
+ under `tests/playwright/fixtures//…`, mirroring the URL path, and prints what it
+ wrote. Re-running it overwrites the files.
+2. Add helpers to `tests/playwright/utils.js`:
+ - `useFixtureData(page)` routes `https://data.firefox.com/datasets/desktop/**` to the matching
+ file. It answers 404 when no fixture exists, and the request URL appears in the test failure.
+ - `failOnPageErrors(page)` collects `pageerror` and `console` error events and makes the test
+ fail if any occurred.
+3. Add specs in `tests/playwright/specs/fixtures/`, asserting values read from the fixture files:
+ - A line chart renders.
+ - The data table's rows are in order.
+ - Summary metrics render.
+ - A region change refetches and shows the new region.
+ - A metric request answered with HTTP 500 shows `#metric-overview-fetch-error`. This depends
+ on the Milestone 3 fix.
+ - Description links render as anchors.
+ - The next-button flow walks Home → User Activity → Usage Behavior → Hardware.
+4. Update the Playwright parts of `AGENTS.md` and `CONTRIBUTING.md`, including how to re-record
+ fixtures and that the fixture specs never touch the network.
+
+Acceptance: `npx playwright test specs/fixtures` passes. It still passes with data.firefox.com
+blocked, which proves the specs use the fixtures. `npm run test:playwright` passes overall.
+
+### Milestone 5: production build in CI
+
+1. Add a "Build" step (`npm run build`) after the unit tests in the `lint-and-test` job of
+ `.github/workflows/ci.yml`. `build:version.json` needs git, and `actions/checkout` provides it.
+2. Run Zizmor on the workflow.
+3. Push the branch and open the pull request. Confirm both CI jobs are green.
+4. Update the CI description in `AGENTS.md`.
+
+Acceptance: both checks are green on the pull request, and the job log shows `vite build` output.
+
+### Milestone 6: every spec on the recording, the same specs on live data
+
+At the end of this milestone, the required "End-to-end tests" check runs every Playwright spec on
+the recording. That includes the dashboard rendering, title and region specs. A new "Live data
+tests" check runs the same specs against data.firefox.com, and its failures show on the pull
+request without blocking merge. There's one command each for re-recording, for running on the
+recording, and for running on live data, all documented.
+
+1. In `tests/playwright/fixtureData.js`:
+ - Add an option `liveData: [false, { option: true }]`. When it is true, the `page` fixture adds
+ no routes and no error checks, so it behaves like plain `@playwright/test`.
+ - In recorded mode, a request for `///index.json` whose region has no
+ recording gets the dashboard's default-region file for the same slug. Read the default region
+ from the recorded `index.json`, the way the recorder does. A missing slug or dashboard still
+ counts as unrecorded.
+2. Switch every spec except `jsDisabled.spec.js` from `require('@playwright/test')` to
+ `require('../fixtureData')`, or `'../../fixtureData'` for files in subfolders. Leave the
+ assertions alone, apart from step 3. The hardcoded titles match the 2026-10-21 recording. If a
+ recorded-mode run surfaces a console error, investigate it. Don't allow-list it.
+3. In the three `dashboards/*.spec.js` files, change each "Charts render" line assertion from
+ `page.locator('#… path.mg-line1').first()` to
+ `page.locator('#… path.mg-main-line').filter({ visible: true }).first()`. Add a comment that a
+ one-point population draws a zero-size line.
+4. In `playwright.config.js`, add a `live-data` project: Desktop Chrome, `use: { liveData: true }`,
+ and `testIgnore` for `**/fixtures/**` (those specs read expected values from the recording) and
+ `**/jsDisabled.spec.js`. Leave `chromium` and `chromium-no-js` as they are.
+5. In `package.json`, add these scripts and leave `test:playwright` running everything:
+
+ "record:fixtures": "node scripts/recordPlaywrightFixtures.js",
+ "test:playwright:recorded": "playwright test --project=chromium --project=chromium-no-js",
+ "test:playwright:live": "playwright test --project=live-data",
+
+6. In `.github/workflows/ci.yml`:
+ - The `e2e` job keeps the name "End-to-end tests" and runs `npm run test:playwright:recorded`.
+ - Add a job `live-data`, named "Live data tests", with the same checkout, Node, `npm ci`,
+ browser install and `build:css` steps, running `npm run test:playwright:live`.
+ - Run Zizmor.
+7. Docs. In `CONTRIBUTING.md`, add two numbered how-tos, and put a condensed version in
+ `AGENTS.md`.
+ - "Running the tests against live data":
+ 1. Run `npx playwright install chromium` once.
+ 2. Run `npm run test:playwright:live`.
+ 3. To test the deployed site, run
+ `PLAYWRIGHT_BASE_URL=https://data.firefox.com npm run test:playwright:live`.
+ 4. If port 3000 is taken, start `npx vite --port 3001 --strictPort` and set
+ `PLAYWRIGHT_BASE_URL=http://localhost:3001`.
+ 5. Failures after upstream changes mean the recording is stale, not that the code is broken.
+ - "Updating the recorded data":
+ 1. Run `npm run record:fixtures`.
+ 2. Run `git diff --stat tests/playwright/fixtures` and skim it for new or removed metrics,
+ regions or titles, or odd populations.
+ 3. Run `npm run test:playwright:recorded`. `fixtures/` failures mean the data's shape changed.
+ `dashboards/` failures mean titles or ordering changed, so update the hardcoded
+ expectations.
+ 4. Run `npm run test:playwright:live`. It should now agree.
+ 5. Commit the recording and the spec updates together ("Re-record Playwright fixtures").
+ - Also update: all specs use `fixtureData.js`, which check runs which mode, that only "End-to-end
+ tests" is required, the region fallback, and that a red live check is fixed by re-recording in
+ a separate pull request.
+
+Acceptance:
+
+- `npx playwright test --list` shows `chromium` with every spec except `jsDisabled`, and `live-data`
+ without `fixtures/` or `jsDisabled`.
+- `npm run test:playwright:recorded` passes with retries off. It still passes with data.firefox.com
+ mapped to a dead address (`--host-resolver-rules`).
+- `npm run test:playwright:live` passes, including "Charts render".
+- Following "Updating the recorded data" exactly gives an empty diff, apart from upstream changes
+ since 2026-10-21, and both modes pass.
+- Breaking ChartContainer's sort fails a recorded-mode test.
+- On #460, the two required checks are green and "Live data tests" reports.
+
+### Milestone 7: test what the charts draw
+
+A reviewer found that the tests covered data preparation and loading states but not the rendered
+charts. At the end of this milestone, the tests check:
+
+- metrics-graphics configuration.
+- Summary-bar proportions, labels and interaction.
+- That region and date changes change what is drawn.
+- That every population appears with the right legend name.
+- Explicit boundary behavior.
+
+Three bugs are fixed, each test first. The Playwright specs run in Milestone 6's recorded mode, so
+Milestone 6 lands first. Resizing is out of scope.
+
+Controlled data in Playwright: register a `page.route()` for one metric URL inside the spec, so it
+takes precedence over the recording, and fulfil it with a small JSON body built in the spec. Match
+on `decodeURIComponent(url.pathname).endsWith(...)`. `descriptions.spec.js` is the pattern.
+
+1. Chart configuration. Add `src/tests/jest/Chart.test.jsx`. It renders the real `views/Chart`,
+ with `vi.mock('react-metrics-graphics', () => ({ default: vi.fn(() => null) }))`, and asserts on
+ the props passed to that mock.
+ - Bounds: data y from 2 to 9 gives `min_y` 0 and `max_y` 9.9.
+ - Suggested bounds: a `suggestedYMin` below the data is used, and one that would crop data is
+ ignored. A `suggestedYMax` above the data is used, which fails until the fix. One that would
+ crop data is ignored.
+ - Units: `%` gives `yax_units: '%'` and `yax_units_append: true`, with no `y_label` and no
+ `left`. Any other unit gives `y_label` and `left: 80`.
+ - `utc_time: true`. `x_mouseover({ x: new Date('2026-01-04') })` returns
+ `'January 4, 2026: '`, which needs UTC formatting when the local time zone is behind UTC.
+ - `y_mouseover` puts no space before `%` and a space before other units.
+ - `legend` and `legend_target` are set only when `showLegend` is true. `markers` are forwarded.
+ 12 colors are generated when `numPopulations` is over 11.
+ - Fix: in `views/Chart.jsx`, assign `props.suggestedYMax`.
+2. Summary-bar proportions and values. Use Vitest with the real `views/SummaryMetric`, rendered
+ inside ``, with `HTMLElement.prototype.offsetWidth` spied to 490
+ so the bar is 450px wide. Use a small controlled dataset through `SummaryMetricContainer` and
+ its hoisted `fetch` stub.
+ - Assert the `rect.bar` count.
+ - Assert widths proportional to values, within rounding.
+ - Assert each `text.bar-label` reads `Name (value%)`, formatted by `prettifyNumber`.
+ - Assert "Other" is the final segment.
+ - Include a single-population True/False example.
+ - Arrows: a segment under 4% gets opacity 0, which fails until the fix.
+ - Fix: in `views/SummaryMetric.jsx`, use `this.arrowIgnoreThreshold`.
+3. Summary-bar hover and click. Add `tests/playwright/specs/fixtures/summary-bars.spec.js`. On
+ `/dashboard/hardware`, override one summary metric with controlled data.
+ - Hovering bar 2 makes its label (name and percentage) visible and hides bar 1's label.
+ - Clicking the last bar does the same for it.
+ - This fails until the fix.
+ - Fix: in `views/SummaryMetric.jsx`, change the handler to `(event, item) => showBarItem(item)`.
+4. Region changes change the plot. Add `fixtures/region-data.spec.js`.
+ - Override one `user-activity` metric with deliberately different Worldwide and Germany
+ responses: different population names and very different values.
+ - After switching to Germany, the legend shows Germany's names.
+ - Hovering the chart shows Germany's value in `.mg-active-datapoint`.
+5. Every population in a representative chart. Add `fixtures/chart-series.spec.js`, with a
+ controlled metric of 3 populations whose maximum y values differ.
+ - Exactly 3 `path.mg-main-line` elements are visible.
+ - The `.mg-line{N}-legend-color` spans read the 3 names in descending max-y order.
+ - Each span's color equals the stroke of `path.mg-main-line.mg-line{N}`.
+6. Date changes through the UI.
+ - In `CustomizableDate.test.jsx`, replace the dummy-child case with the real
+ `DataTableContainer`. Selecting the older date shows that date's rows.
+ - Add `fixtures/dates.spec.js`:
+ - On hardware, override a summary metric whose leader differs between two dates. Selecting
+ the older date changes the visible label and percentage.
+ - On usage-behavior, selecting the older date in the Top Add-ons table shows that date's full
+ ranking from the recording.
+7. Boundary cases, each with its expected behavior stated in the test title.
+ - Summary: exactly 5% gets its own segment, and 4.99% goes into "Other". A 0% population is
+ omitted, and no zero-sized "Other" segment appears.
+ - Chart:
+ - Zero-valued points are kept, and `min_y` is 0.
+ - A constant 5 gives `min_y` 5 and `max_y` 5.5.
+ - A constant 0 gives `[0, 0]`, pinned as today's degenerate range with a comment.
+ - Empty and all-null populations, documented only: ChartContainer keeps the legend entry and
+ passes an empty series. An all-empty chart's bounds are pinned as they are.
+ - In Playwright:
+ - A one-point population renders a visible dot (`circle.mg-line-rollover-circle`, `r` above
+ 0).
+ - Record what an all-null chart shows. If metrics-graphics throws, allow that exact error
+ with `allowedErrors` and say why in a comment.
+8. Titles that match their assertions.
+ - `fixtures/usage-behavior.spec.js` checks the complete ranking: every row's rank, name and
+ formatted value, in order.
+ - `Routes.test.jsx` parameterizes the pageview test across `/`, `/contact`, each
+ `/dashboard/`, and a Not Found path.
+9. Docs. Update `AGENTS.md` and `CONTRIBUTING.md` to cover:
+ - The controlled-response pattern.
+ - The `react-metrics-graphics` mock.
+ - The `offsetWidth` spy for SummaryMetric.
+ - What "Charts render" checks.
+
+Acceptance:
+
+- `npm run lint` is clean and `npm run test:jest` passes, with new counts recorded.
+- Each bug's test fails before its fix and passes after, with the failure output recorded here.
+- `npm run test:playwright:recorded` passes with retries off, on a warm and a cold server, and with
+ data.firefox.com unreachable. `npm run test:playwright:live` passes.
+- Each of these deliberate breakages fails a test, then is reverted:
+ - Make ChartContainer drop the last population.
+ - Swap two legend names.
+ - Stop MetricOverviewContainer refetching on a region change.
+ - Set SummaryMetric's "Other" threshold to 4.
+- On a dev server, hovering GPU Vendor's second segment shows its label. "Other (3.470%)" shows
+ its label without an arrow.
+
+## Concrete Steps
+
+Run everything from the repository root, `/Users/shobson/Sites/ensemble`.
+
+ npm ci
+ npm run build:css
+ npm run lint
+ npm run test:jest
+ npm run test:playwright:recorded
+ npm run test:playwright:live
+ npm run record:fixtures
+ npm run build
+
+Baseline before any change:
+
+ $ npm run test:jest
+ Test Files 2 passed (2)
+ Tests 4 passed (4)
+
+## Validation and Acceptance
+
+The work is done when all of the following hold:
+
+- `npm run lint` is clean.
+- `npm run test:jest` passes, with the file and test counts recorded in Outcomes.
+- `npx playwright test specs/fixtures` passes with no requests reaching data.firefox.com.
+- `npm run test:playwright` passes. If a live spec fails, re-run it once and say whether upstream
+ data caused it.
+- `npm run build` succeeds.
+- Both CI jobs are green on the pull request.
+
+Prove the tests catch real breakage by making each of these temporary changes, confirming a test
+fails, and reverting:
+
+- Make `bumpSort` stop bumping.
+- Delete the catch-all `` in `Main.jsx`.
+- Turn on raw HTML in MetricOverview's markdown parser. That means passing `{ html: true }` to
+ `markdownIt('zero', …)` and adding `'html_inline'` to the `.enable([...])` list.
+- Revert `MetricOverviewContainer.shouldComponentUpdate` to return only `fulfilled`.
+
+## Idempotence and Recovery
+
+Every step is additive and can be re-run. The fixture recorder overwrites its own output, so
+re-running it after upstream data changes is the intended way to refresh. If fixture-based
+assertions then fail, update the expected values from the new files. If a dependency install goes
+wrong, `git checkout package.json package-lock.json && npm ci` restores the previous state.
+
+## Artifacts and Notes
+
+None yet.
+
+## Interfaces and Dependencies
+
+New dev dependencies, all exact-pinned:
+
+- `@testing-library/react` 12.1.5: the last line that supports React 16.
+- `@testing-library/dom` 8.20.1: the version RTL 12 uses, pinned so user-event shares it.
+- `@testing-library/jest-dom` 6.9.1: DOM matchers, registered for Vitest. It is the newest release
+ without a `@testing-library/dom` 10 peer dependency.
+- `@testing-library/user-event` 14.6.8: realistic select changes and clicks.
+
+Removed: `enzyme`, `enzyme-adapter-react-16`, `react-test-renderer`, and the `cheerio` override.
+
+Playwright fixture support (see the Decision Log for why this isn't in `utils.js`):
+
+ tests/playwright/fixtureData.js exports:
+ test // @playwright/test's test, extended: serves fixtures/, fails on errors and unrecorded requests
+ // option: allowedErrors (RegExp or null)
+ expect // @playwright/test's expect
+ readFixture(relativePath) // parsed JSON from tests/playwright/fixtures/
+ Options: allowedErrors (RegExp or null), liveData (boolean; true turns off routing and checks)
+
+New script: `scripts/recordPlaywrightFixtures.js`, run with `node scripts/recordPlaywrightFixtures.js`.
+
+## Revision notes
+
+- 2026-10-21: An adversarial review found that `MetricOverviewContainer` never renders its
+ rejected state. The rejected-fetch assertions in Milestones 3 and 4 could not have passed. Added
+ the fix and its tests to Milestone 3, linked the Milestone 4 spec to it, and recorded the finding
+ in Surprises and the Decision Log. Also marked issue #313 out of scope.
+- 2026-10-22: The first CI run failed on a live-data spec. The user decided upstream data must not
+ block merge, but wanted the rendering tests kept in the required check, plus documented ways to
+ update the recording and to run against live data. Added Milestone 6. It runs every spec on the
+ recording in the required check, and the same specs on live data in a new non-required check. It
+ also adds the region fallback, the "Charts render" assertion change, the npm scripts and the
+ how-tos. Replaced the "NULL" Surprises entry with the exact failure mechanism.
+- 2026-10-22: Review feedback on #460 asked for tests of what the charts draw. Added Milestone 7
+ with 8 items. The tests expose three bugs, and the user chose to fix all of them test first.
+ Corrected the `"NULL"` Surprises entry: one-point populations render as a dot, not as nothing.
+ Removed the out-of-scope line about drawing single points, and made resizing out of scope.
diff --git a/eslint.config.js b/eslint.config.js
index 68abcfa9..4c78f629 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -73,15 +73,27 @@ module.exports = [
},
rules: {
...vitest.configs.recommended.rules,
+ // React Testing Library's `screen` export shadows window.screen;
+ // importing it under that name is the library's documented idiom.
+ 'no-shadow': ['error', { builtinGlobals: true, allow: ['screen'] }],
},
languageOptions: {
globals: {
...globals.browser,
...globals.vitest,
- // In src/setupTests.js, these globals are defined in such a way
- // that they are available to all Vitest tests.
+ // src/setupTests.js puts React on the global object for every
+ // Vitest test.
React: 'readonly',
- shallow: 'readonly',
+ },
+ },
+ },
+ {
+ // Defines the React global the block above declares, so it gets the
+ // Vitest globals without that declaration.
+ files: ['src/setupTests.js'],
+ languageOptions: {
+ globals: {
+ ...globals.vitest,
},
},
},
diff --git a/package-lock.json b/package-lock.json
index 9f615a21..b67c23cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,16 +31,17 @@
"devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.64.0",
+ "@testing-library/dom": "8.20.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "12.1.5",
+ "@testing-library/user-event": "14.6.8",
"@vitejs/plugin-react": "6.1.2",
"@vitest/eslint-plugin": "1.6.27",
- "enzyme": "3.11.0",
- "enzyme-adapter-react-16": "1.15.8",
"eslint": "9.39.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"globals": "17.13.0",
"jsdom": "30.1.2",
- "react-test-renderer": "16.13.1",
"source-map-explorer": "2.5.3",
"stylint": "2.0.0",
"vite": "8.3.4",
@@ -89,6 +90,31 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
@@ -975,6 +1001,110 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@testing-library/dom": {
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
+ "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.1.3",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
+ "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "deep-equal": "^2.0.5"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "12.1.5",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz",
+ "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@testing-library/dom": "^8.0.0",
+ "@types/react-dom": "<18.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "react": "<18.0.0",
+ "react-dom": "<18.0.0"
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.8",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz",
+ "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1013,6 +1143,45 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/react": {
+ "version": "17.0.93",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.93.tgz",
+ "integrity": "sha512-KM4Ty/ZTLZupiYxZVAlP+InNJS3De6uBMdq0ePa6/04+eG9Y7ftnWfst1xTLQ5rwAhgHwQ4momt/O4KepdGBTw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "^0.16",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "17.0.26",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz",
+ "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^17.0.0"
+ }
+ },
+ "node_modules/@types/scheduler": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
+ "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@typescript-eslint/project-service": {
"version": "8.71.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz",
@@ -1289,31 +1458,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/airbnb-prop-types": {
- "version": "2.16.0",
- "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz",
- "integrity": "sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==",
- "deprecated": "This package has been renamed to 'prop-types-tools'",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array.prototype.find": "^2.1.1",
- "function.prototype.name": "^1.1.2",
- "is-regex": "^1.1.0",
- "object-is": "^1.1.2",
- "object.assign": "^4.1.0",
- "object.entries": "^1.1.2",
- "prop-types": "^15.7.2",
- "prop-types-exact": "^1.2.0",
- "react-is": "^16.13.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- },
- "peerDependencies": {
- "react": "^0.14 || ^15.0.0 || ^16.0.0-alpha"
- }
- },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -1440,47 +1584,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/array.prototype.filter": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.4.tgz",
- "integrity": "sha512-r+mCJ7zXgXElgR4IRC+fkvNCeoaavWBs6EdCso5Tbcf+iEMKzBU/His60lt34WEZ9vlb8wDkZvQGcVI5GwkfoQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.7",
- "define-properties": "^1.2.1",
- "es-abstract": "^1.23.2",
- "es-array-method-boxes-properly": "^1.0.0",
- "es-object-atoms": "^1.0.0",
- "is-string": "^1.0.7"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/array.prototype.find": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.3.tgz",
- "integrity": "sha512-fO/ORdOELvjbbeIfZfzrXFMhYHGofRGqd+am9zm3tZ4GlJINj/pA2eITyfd65Vg6+ZbHd/Cys7stpoRSWtQFdA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind": "^1.0.7",
- "define-properties": "^1.2.1",
- "es-abstract": "^1.23.2",
- "es-object-atoms": "^1.0.0",
- "es-shim-unscopables": "^1.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
@@ -1682,13 +1785,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/boolbase": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
- "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@@ -1998,18 +2094,20 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
- "node_modules/css-what": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
- "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">= 6"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
},
"node_modules/d3": {
"version": "4.13.0",
@@ -2659,6 +2757,39 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/deep-equal": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
+ "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.0",
+ "call-bind": "^1.0.5",
+ "es-get-iterator": "^1.1.3",
+ "get-intrinsic": "^1.2.2",
+ "is-arguments": "^1.1.1",
+ "is-array-buffer": "^3.0.2",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "isarray": "^2.0.5",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.5.1",
+ "side-channel": "^1.0.4",
+ "which-boxed-primitive": "^1.0.2",
+ "which-collection": "^1.0.1",
+ "which-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2725,13 +2856,6 @@
"node": ">=8"
}
},
- "node_modules/discontinuous-range": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
- "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/distinct-colors": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/distinct-colors/-/distinct-colors-3.0.0.tgz",
@@ -2756,334 +2880,63 @@
"node": ">=0.10.0"
}
},
- "node_modules/domelementtype": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
- "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fb55"
- }
- ],
- "license": "BSD-2-Clause"
- },
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/duplexer": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
- "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "license": "MIT"
- },
- "node_modules/ejs": {
- "version": "3.1.10",
- "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
- "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "jake": "^10.8.5"
- },
- "bin": {
- "ejs": "bin/cli.js"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/emoji-regex": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
- "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "license": "MIT"
- },
- "node_modules/enzyme": {
- "version": "3.11.0",
- "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz",
- "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array.prototype.flat": "^1.2.3",
- "cheerio": "^1.0.0-rc.3",
- "enzyme-shallow-equal": "^1.0.1",
- "function.prototype.name": "^1.1.2",
- "has": "^1.0.3",
- "html-element-map": "^1.2.0",
- "is-boolean-object": "^1.0.1",
- "is-callable": "^1.1.5",
- "is-number-object": "^1.0.4",
- "is-regex": "^1.0.5",
- "is-string": "^1.0.5",
- "is-subset": "^0.1.1",
- "lodash.escape": "^4.0.1",
- "lodash.isequal": "^4.5.0",
- "object-inspect": "^1.7.0",
- "object-is": "^1.0.2",
- "object.assign": "^4.1.0",
- "object.entries": "^1.1.1",
- "object.values": "^1.1.1",
- "raf": "^3.4.1",
- "rst-selector-parser": "^2.2.3",
- "string.prototype.trim": "^1.2.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/enzyme-adapter-react-16": {
- "version": "1.15.8",
- "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.8.tgz",
- "integrity": "sha512-uYGC31eGZBp5nGsr4nKhZKvxGQjyHGjS06BJsUlWgE29/hvnpgCsT1BJvnnyny7N3GIIVyxZ4O9GChr6hy2WQA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "enzyme-adapter-utils": "^1.14.2",
- "enzyme-shallow-equal": "^1.0.7",
- "hasown": "^2.0.0",
- "object.assign": "^4.1.5",
- "object.values": "^1.1.7",
- "prop-types": "^15.8.1",
- "react-is": "^16.13.1",
- "react-test-renderer": "^16.0.0-0",
- "semver": "^5.7.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- },
- "peerDependencies": {
- "enzyme": "^3.0.0",
- "react": "^16.0.0-0",
- "react-dom": "^16.0.0-0"
- }
- },
- "node_modules/enzyme-adapter-react-16/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver"
- }
- },
- "node_modules/enzyme-adapter-utils": {
- "version": "1.14.2",
- "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.2.tgz",
- "integrity": "sha512-1ZC++RlsYRaiOWE5NRaF5OgsMt7F5rn/VuaJIgc7eW/fmgg8eS1/Ut7EugSPPi7VMdWMLcymRnMF+mJUJ4B8KA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "airbnb-prop-types": "^2.16.0",
- "function.prototype.name": "^1.1.6",
- "hasown": "^2.0.0",
- "object.assign": "^4.1.5",
- "object.fromentries": "^2.0.7",
- "prop-types": "^15.8.1",
- "semver": "^6.3.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- },
- "peerDependencies": {
- "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0"
- }
- },
- "node_modules/enzyme-adapter-utils/node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/enzyme-shallow-equal": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.7.tgz",
- "integrity": "sha512-/um0GFqUXnpM9SvKtje+9Tjoz3f1fpBC3eXRFrNs8kpYn69JljciYP7KZTqM/YQbUY9KUjvKB4jo/q+L6WGGvg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "hasown": "^2.0.0",
- "object-is": "^1.1.5"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/enzyme/node_modules/cheerio": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz",
- "integrity": "sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cheerio-select": "^1.5.0",
- "dom-serializer": "^1.3.2",
- "domhandler": "^4.2.0",
- "htmlparser2": "^6.1.0",
- "parse5": "^6.0.1",
- "parse5-htmlparser2-tree-adapter": "^6.0.1",
- "tslib": "^2.2.0"
- },
- "engines": {
- "node": ">= 6"
- },
- "funding": {
- "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
- }
- },
- "node_modules/enzyme/node_modules/cheerio-select": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.6.0.tgz",
- "integrity": "sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "css-select": "^4.3.0",
- "css-what": "^6.0.1",
- "domelementtype": "^2.2.0",
- "domhandler": "^4.3.1",
- "domutils": "^2.8.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
- },
- "node_modules/enzyme/node_modules/css-select": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
- "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0",
- "css-what": "^6.0.1",
- "domhandler": "^4.3.1",
- "domutils": "^2.8.0",
- "nth-check": "^2.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/fb55"
- }
- },
- "node_modules/enzyme/node_modules/dom-serializer": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
- "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "domelementtype": "^2.0.1",
- "domhandler": "^4.2.0",
- "entities": "^2.0.0"
- },
- "funding": {
- "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
- }
- },
- "node_modules/enzyme/node_modules/domhandler": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
- "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "domelementtype": "^2.2.0"
- },
- "engines": {
- "node": ">= 4"
- },
- "funding": {
- "url": "https://github.com/fb55/domhandler?sponsor=1"
- }
- },
- "node_modules/enzyme/node_modules/domutils": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
- "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "dom-serializer": "^1.0.1",
- "domelementtype": "^2.2.0",
- "domhandler": "^4.2.0"
- },
- "funding": {
- "url": "https://github.com/fb55/domutils?sponsor=1"
- }
- },
- "node_modules/enzyme/node_modules/entities": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
- "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
- "dev": true,
- "license": "BSD-2-Clause",
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
},
- "node_modules/enzyme/node_modules/htmlparser2": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
- "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
- "funding": [
- "https://github.com/fb55/htmlparser2?sponsor=1",
- {
- "type": "github",
- "url": "https://github.com/sponsors/fb55"
- }
- ],
"license": "MIT",
"dependencies": {
- "domelementtype": "^2.0.1",
- "domhandler": "^4.0.0",
- "domutils": "^2.5.2",
- "entities": "^2.0.0"
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
}
},
- "node_modules/enzyme/node_modules/parse5": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
- "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "node_modules/duplexer": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
+ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"dev": true,
"license": "MIT"
},
- "node_modules/enzyme/node_modules/parse5-htmlparser2-tree-adapter": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz",
- "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==",
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "license": "MIT"
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"dev": true,
- "license": "MIT",
+ "license": "Apache-2.0",
"dependencies": {
- "parse5": "^6.0.1"
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
}
},
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "license": "MIT"
+ },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -3182,13 +3035,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/es-array-method-boxes-properly": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz",
- "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3209,6 +3055,27 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-get-iterator": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
+ "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "has-symbols": "^1.0.3",
+ "is-arguments": "^1.1.1",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.7",
+ "isarray": "^2.0.5",
+ "stop-iteration-iterator": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/es-iterator-helpers": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz",
@@ -4154,16 +4021,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/has": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz",
- "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4.0"
- }
- },
"node_modules/has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
@@ -4301,23 +4158,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/html-element-map": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.4.1.tgz",
- "integrity": "sha512-D+8Bp441S8PgDebxxF9jNZ9odEtaX13Mu1vgTvTe8yWRrCFEhyn5m8wzB0uF7u0KOwIRAn66+Zx3wXDYqjL1yw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "array.prototype.filter": "^1.0.4",
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
@@ -4368,6 +4208,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -4430,6 +4280,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4810,13 +4677,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-subset": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
- "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/is-symbol": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
@@ -5552,28 +5412,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.escape": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
- "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lodash.flattendeep": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
- "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lodash.isequal": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
- "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
- "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5603,6 +5441,16 @@
"node": "20 || >=22"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
@@ -5726,6 +5574,16 @@
"jquery": ">=1.11.1"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -5774,13 +5632,6 @@
"mkdirp": "bin/cmd.js"
}
},
- "node_modules/moo": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
- "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/mout": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/mout/-/mout-1.2.4.tgz",
@@ -5819,29 +5670,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/nearley": {
- "version": "2.20.1",
- "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
- "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "commander": "^2.19.0",
- "moo": "^0.5.0",
- "railroad-diagrams": "^1.0.0",
- "randexp": "0.4.6"
- },
- "bin": {
- "nearley-railroad": "bin/nearley-railroad.js",
- "nearley-test": "bin/nearley-test.js",
- "nearley-unparse": "bin/nearley-unparse.js",
- "nearleyc": "bin/nearleyc.js"
- },
- "funding": {
- "type": "individual",
- "url": "https://nearley.js.org/#give-to-nearley"
- }
- },
"node_modules/node-exports-info": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
@@ -5997,19 +5825,6 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
- "node_modules/nth-check": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
- "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "boolbase": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/fb55/nth-check?sponsor=1"
- }
- },
"node_modules/number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
@@ -6397,13 +6212,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/performance-now": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
- "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6589,36 +6397,60 @@
"node": ">= 0.8.0"
}
},
- "node_modules/prop-types": {
- "version": "15.8.1",
- "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
- "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "loose-envify": "^1.4.0",
- "object-assign": "^4.1.1",
- "react-is": "^16.13.1"
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
- "node_modules/prop-types-exact": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.7.tgz",
- "integrity": "sha512-A4RaV6mg3jocQqBYmqi2ojJ2VnV4AKTEHhl3xHsud08/u87gcVJc8DUOtgnPegoOCQv/shUqEk4eZGYibjnHzQ==",
+ "node_modules/pretty-format/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/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.3",
- "es-errors": "^1.3.0",
- "hasown": "^2.0.2",
- "isarray": "^2.0.5",
- "object.assign": "^4.1.7",
- "own-keys": "^1.0.0"
- },
"engines": {
- "node": ">= 0.8"
+ "node": ">=10"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
}
},
"node_modules/punycode": {
@@ -6640,37 +6472,6 @@
"node": ">=6"
}
},
- "node_modules/raf": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
- "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "performance-now": "^2.1.0"
- }
- },
- "node_modules/railroad-diagrams": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
- "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/randexp": {
- "version": "0.4.6",
- "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
- "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "discontinuous-range": "1.0.0",
- "ret": "~0.1.10"
- },
- "engines": {
- "node": ">=0.12"
- }
- },
"node_modules/react": {
"version": "16.14.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz",
@@ -6820,22 +6621,6 @@
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/react-test-renderer": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.13.1.tgz",
- "integrity": "sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "object-assign": "^4.1.1",
- "prop-types": "^15.6.2",
- "react-is": "^16.8.6",
- "scheduler": "^0.19.1"
- },
- "peerDependencies": {
- "react": "^16.13.1"
- }
- },
"node_modules/read-package-json-fast": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-6.0.0.tgz",
@@ -6931,6 +6716,20 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -7042,16 +6841,6 @@
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==",
"license": "MIT"
},
- "node_modules/ret": {
- "version": "0.1.15",
- "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
- "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12"
- }
- },
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
@@ -7100,17 +6889,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.13"
}
},
- "node_modules/rst-selector-parser": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
- "integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "lodash.flattendeep": "^4.4.0",
- "nearley": "^2.7.10"
- }
- },
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@@ -7765,6 +7543,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "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",
@@ -8249,13 +8040,6 @@
"typescript": ">=4.8.4"
}
},
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD"
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index 9135cf97..cc53e014 100644
--- a/package.json
+++ b/package.json
@@ -19,8 +19,11 @@
"lint:styl": "stylint src/components/views/styl",
"test:jest": "vitest run",
"test:playwright": "playwright test",
+ "test:playwright:recorded": "playwright test --project=chromium --project=chromium-no-js",
+ "test:playwright:live": "playwright test --project=live-data",
"posttest": "npm audit || true",
- "size": "source-map-explorer build/assets/index-*.js --no-border-checks"
+ "size": "source-map-explorer build/assets/index-*.js --no-border-checks",
+ "record:fixtures": "node scripts/recordPlaywrightFixtures.js"
},
"dependencies": {
"d3-scale": "4.0.2",
@@ -46,24 +49,20 @@
"devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.64.0",
+ "@testing-library/dom": "8.20.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "12.1.5",
+ "@testing-library/user-event": "14.6.8",
"@vitejs/plugin-react": "6.1.2",
"@vitest/eslint-plugin": "1.6.27",
- "enzyme": "3.11.0",
- "enzyme-adapter-react-16": "1.15.8",
"eslint": "9.39.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"globals": "17.13.0",
"jsdom": "30.1.2",
- "react-test-renderer": "16.13.1",
"source-map-explorer": "2.5.3",
"stylint": "2.0.0",
"vite": "8.3.4",
"vitest": "5.0.3"
- },
- "overrides": {
- "enzyme": {
- "cheerio": "1.0.0-rc.10"
- }
}
}
diff --git a/playwright.config.js b/playwright.config.js
index c721a42a..b0645eaf 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -7,9 +7,9 @@ module.exports = defineConfig({
// Several regionSelector specs navigate many times in a single test
// (once per dashboard, sometimes nested); the 30s default is too tight.
timeout: 60000,
- // The dev server's first request for any given route recompiles and
- // dependency-scans on demand (a documented Vite dev-server characteristic),
- // which can outrun the 5s default under concurrent load.
+ // The dev server compiles each module on its first request (a documented
+ // Vite dev-server characteristic), which can outrun the 5s default under
+ // concurrent load.
expect: {
timeout: 10000,
},
@@ -51,5 +51,15 @@ module.exports = defineConfig({
use: { ...devices['Desktop Chrome'], javaScriptEnabled: false },
testMatch: '**/jsDisabled.spec.js',
},
+ // The same specs as `chromium`, against the real data.firefox.com
+ // instead of tests/playwright/fixtures/ (see fixtureData.js). CI runs
+ // it as a separate, non-required check: upstream data changes weekly
+ // and shouldn't block a merge. The fixtures/ specs are left out because
+ // they read their expected values from the recording.
+ {
+ name: 'live-data',
+ use: { ...devices['Desktop Chrome'], liveData: true },
+ testIgnore: ['**/fixtures/**', '**/jsDisabled.spec.js'],
+ },
],
});
diff --git a/public/contribute.json b/public/contribute.json
index 151eae4f..2dbac662 100644
--- a/public/contribute.json
+++ b/public/contribute.json
@@ -17,7 +17,6 @@
"create-react-app",
"css",
"d3",
- "enzyme",
"javascript",
"jest",
"metrics-graphics",
diff --git a/scripts/recordPlaywrightFixtures.js b/scripts/recordPlaywrightFixtures.js
new file mode 100644
index 00000000..2069d21c
--- /dev/null
+++ b/scripts/recordPlaywrightFixtures.js
@@ -0,0 +1,113 @@
+// Records the transposer JSON the Playwright fixture specs serve in place of
+// data.firefox.com. Run it again to refresh the recording; it replaces the
+// whole fixtures directory.
+//
+// node scripts/recordPlaywrightFixtures.js
+
+const path = require('path');
+const fs = require('fs');
+
+const { dashboards } = require(path.join(__dirname, '..', 'src', 'config.json'));
+
+
+const fixturesDirectory = path.join(__dirname, '..', 'tests', 'playwright', 'fixtures');
+const datasetsPrefix = 'https://data.firefox.com/datasets/desktop/';
+
+// Besides the default region, record this one so region changes can be tested.
+const secondRegion = 'Germany';
+
+// Keep only the most recent dates. The full history makes the hardware
+// fixtures several megabytes, and the specs only need recent data.
+const numDatesToKeep = 12;
+
+async function fetchJSON(url) {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText} for ${url}`);
+ }
+ return response.json();
+}
+
+function write(url, json) {
+ const relativePath = decodeURIComponent(url.slice(datasetsPrefix.length));
+ const filename = path.join(fixturesDirectory, relativePath);
+
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
+ fs.writeFileSync(filename, JSON.stringify(json, null, 4) + '\n');
+
+ // eslint-disable-next-line no-console
+ console.log(`Wrote ${path.relative(process.cwd(), filename)}`);
+}
+
+function isRecent(date, oldestDateToKeep) {
+ return !Number.isNaN(Date.parse(date)) && date >= oldestDateToKeep;
+}
+
+function trimMetric(metric, oldestDateToKeep) {
+ if (metric.data.populations) {
+ const populations = {};
+ Object.entries(metric.data.populations).forEach(([populationName, points]) => {
+ const recentPoints = points.filter(point => (
+ Number.isNaN(Date.parse(point.x)) || isRecent(point.x, oldestDateToKeep)
+ ));
+ if (recentPoints.length) populations[populationName] = recentPoints;
+ });
+ metric.data.populations = populations;
+ }
+
+ if (metric.data.dates) {
+ metric.data.dates = Object.fromEntries(
+ Object.entries(metric.data.dates).filter(([date]) => isRecent(date, oldestDateToKeep))
+ );
+ }
+
+ if (metric.annotations) {
+ metric.annotations = metric.annotations.filter(annotation => isRecent(annotation.date, oldestDateToKeep));
+ }
+
+ return metric;
+}
+
+async function recordDashboard(dashboard) {
+ const indexURL = `${dashboard.source}/index.json`;
+ const index = await fetchJSON(indexURL);
+
+ index.dates = index.dates.slice().sort().reverse().slice(0, numDatesToKeep);
+ const oldestDateToKeep = index.dates.at(-1);
+ write(indexURL, index);
+
+ const defaultRegion = index.categories.length > 1
+ ? index.defaultCategory || index.categories[0]
+ : index.categories[0];
+ const regions = [defaultRegion];
+ if (dashboard.supportsRegions && index.categories.includes(secondRegion)) {
+ regions.push(secondRegion);
+ }
+
+ const slugs = new Set([...index.metrics, ...(index.summaryMetrics || [])]);
+
+ for (const region of regions) {
+ for (const slug of slugs) {
+ const metricURL = `${dashboard.source}/${region}/${slug}/index.json`;
+ const metric = await fetchJSON(encodeURI(metricURL));
+ write(metricURL, trimMetric(metric, oldestDateToKeep));
+ }
+ }
+}
+
+async function main() {
+ fs.rmSync(fixturesDirectory, { recursive: true, force: true });
+
+ for (const dashboard of dashboards) {
+ if (!dashboard.source.startsWith(datasetsPrefix)) {
+ throw new Error(`Unexpected source for ${dashboard.key}: ${dashboard.source}`);
+ }
+ await recordDashboard(dashboard);
+ }
+}
+
+main().catch(error => {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/src/components/containers/MetricOverviewContainer.jsx b/src/components/containers/MetricOverviewContainer.jsx
index 112ce6e2..cba43c6d 100644
--- a/src/components/containers/MetricOverviewContainer.jsx
+++ b/src/components/containers/MetricOverviewContainer.jsx
@@ -6,15 +6,15 @@ import MetricOverview from '../views/MetricOverview';
class MetricOverviewContainer extends React.Component {
- // If the data is changing, only re-render once the new data is received.
- // Don't show a spinner while new data is being loaded, as that would cause
- // the scrollbar to move.
+ // If the data is changing, only re-render once the request settles, with
+ // either the new data or an error. Don't show a spinner while new data is
+ // being loaded, as that would cause the scrollbar to move.
//
// We do want the spinner to be shown on the initial render, but we don't
// need to do anything special here to handle that case because
// shouldComponentUpdate is not run for the initial render.
shouldComponentUpdate(nextProps) {
- return nextProps.dataFetch.fulfilled;
+ return nextProps.dataFetch.fulfilled || nextProps.dataFetch.rejected;
}
render() {
diff --git a/src/components/views/Chart.jsx b/src/components/views/Chart.jsx
index 5e6a588c..b0f96e47 100644
--- a/src/components/views/Chart.jsx
+++ b/src/components/views/Chart.jsx
@@ -110,7 +110,7 @@ export default props => {
minYToShow = props.suggestedYMin;
}
if (props.suggestedYMax > maxVisibleY) {
- maxYToShow = props.suggestedMax;
+ maxYToShow = props.suggestedYMax;
}
if (props.showLegend) {
diff --git a/src/components/views/SummaryMetric.jsx b/src/components/views/SummaryMetric.jsx
index 9262aa79..c30408b7 100644
--- a/src/components/views/SummaryMetric.jsx
+++ b/src/components/views/SummaryMetric.jsx
@@ -128,7 +128,7 @@ export default class extends React.Component {
.attr('cy', this.size.barYPosition + this.size.barHeight - 3)
.style('fill', () => this.colors[i])
.style('opacity', () => {
- if (d.value < this.ignoreThreshold) return 0;
+ if (d.value < this.arrowIgnoreThreshold) return 0;
});
// append text labels
@@ -156,7 +156,8 @@ export default class extends React.Component {
})
// Show the currently hovered or clicked bar's arrow and label.
- .on('mouseenter click', showBarItem);
+ // d3-selection passes the event first and the bar's datum second.
+ .on('mouseenter click', (mouseEvent, item) => showBarItem(item));
};
render() {
diff --git a/src/setupTests.js b/src/setupTests.js
index ca2c637f..2bfcccd9 100644
--- a/src/setupTests.js
+++ b/src/setupTests.js
@@ -1,10 +1,29 @@
import React from 'react';
-import { configure, shallow } from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
+import { cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
-configure({ adapter: new Adapter() });
-
-// Make React and shallow available to individual test files
+// The classic JSX runtime needs React in scope wherever JSX appears.
global.React = React;
-global.shallow = shallow;
+
+// React reports deprecations and misuse through console.error, so treat any
+// unexpected call as a failure. A test that expects an error replaces
+// console.error with its own spy.
+// eslint-disable-next-line no-console
+const originalConsoleError = console.error;
+
+beforeEach(() => {
+ // eslint-disable-next-line no-console
+ console.error = (...args) => {
+ originalConsoleError(...args);
+ throw new Error(`Unexpected console.error: ${args.join(' ')}`);
+ };
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ sessionStorage.clear();
+ // eslint-disable-next-line no-console
+ console.error = originalConsoleError;
+});
diff --git a/src/tests/jest/Chart.test.jsx b/src/tests/jest/Chart.test.jsx
new file mode 100644
index 00000000..4e53c925
--- /dev/null
+++ b/src/tests/jest/Chart.test.jsx
@@ -0,0 +1,128 @@
+import { render } from '@testing-library/react';
+import MetricsGraphics from 'react-metrics-graphics';
+
+import Chart from '../../components/views/Chart';
+
+
+// metrics-graphics needs real layout; these tests check the options the view
+// computes and hands to it.
+vi.mock('react-metrics-graphics', () => ({ default: vi.fn(() => null) }));
+
+const twoPopulations = [
+ [{ x: new Date('2026-01-04'), y: 5 }, { x: new Date('2026-01-11'), y: 4 }, { x: new Date('2026-01-18'), y: 9 }],
+ [{ x: new Date('2026-01-04'), y: 7 }, { x: new Date('2026-01-11'), y: 2 }, { x: new Date('2026-01-18'), y: 8 }],
+];
+
+function optionsFor(props) {
+ MetricsGraphics.mockClear();
+ render();
+ return MetricsGraphics.mock.calls.at(-1)[0];
+}
+
+it('The y range starts at 0 for positive data and ends 10% above the highest value', () => {
+ const options = optionsFor({ data: twoPopulations });
+
+ expect(options.min_y).toBe(0);
+ expect(options.max_y).toBeCloseTo(9.9);
+});
+
+it('A suggested minimum below the data is used, and one that would crop data is ignored', () => {
+ const highData = [[{ x: new Date('2026-01-04'), y: 100 }, { x: new Date('2026-01-11'), y: 101 }]];
+
+ expect(optionsFor({ data: highData }).min_y).toBe(92);
+ expect(optionsFor({ data: highData, suggestedYMin: 50 }).min_y).toBe(50);
+ expect(optionsFor({ data: highData, suggestedYMin: 95 }).min_y).toBe(92);
+});
+
+it('A suggested maximum above the data is used, and one that would crop data is ignored', () => {
+ expect(optionsFor({ data: twoPopulations, suggestedYMax: 100 }).max_y).toBe(100);
+ expect(optionsFor({ data: twoPopulations, suggestedYMax: 9.5 }).max_y).toBeCloseTo(9.9);
+});
+
+it('A % unit is appended to the y-axis ticks; other units become a y-axis label', () => {
+ const percentOptions = optionsFor({ data: twoPopulations, yUnit: '%' });
+ expect(percentOptions).toMatchObject({ yax_units: '%', yax_units_append: true });
+ expect(percentOptions).not.toHaveProperty('y_label');
+ expect(percentOptions).not.toHaveProperty('left');
+
+ const hoursOptions = optionsFor({ data: twoPopulations, yUnit: 'hours' });
+ expect(hoursOptions).toMatchObject({ y_label: 'hours', left: 80 });
+ expect(hoursOptions).not.toHaveProperty('yax_units');
+});
+
+it('Dates are shown in UTC, and hovered values carry their unit', () => {
+ const percentOptions = optionsFor({ data: twoPopulations, yUnit: '%' });
+
+ expect(percentOptions.utc_time).toBe(true);
+ expect(percentOptions.x_mouseover({ x: new Date('2026-01-04') })).toBe('January 4, 2026: ');
+ expect(percentOptions.y_mouseover({ y: 12.5 })).toBe('12.500%');
+
+ const hoursOptions = optionsFor({ data: twoPopulations, yUnit: 'hours' });
+ expect(hoursOptions.y_mouseover({ y: 1500 })).toBe('1,500 hours');
+
+ const unitlessOptions = optionsFor({ data: twoPopulations });
+ expect(unitlessOptions.y_mouseover({ y: 1500 })).toBe('1,500');
+});
+
+it('The legend and its target are passed only when the legend is shown', () => {
+ const shown = optionsFor({ data: twoPopulations, showLegend: true, legend: ['High', 'Low'], legendTarget: '#metric-overview-1 .legend' });
+ expect(shown).toMatchObject({ legend: ['High', 'Low'], legend_target: '#metric-overview-1 .legend' });
+
+ const hidden = optionsFor({ data: twoPopulations, showLegend: false, legend: ['High', 'Low'], legendTarget: '#metric-overview-1 .legend' });
+ expect(hidden).not.toHaveProperty('legend');
+ expect(hidden).not.toHaveProperty('legend_target');
+});
+
+it('Annotation markers are forwarded unchanged', () => {
+ const markers = [{ x: new Date('2026-01-11'), label: 'Firefox 150' }];
+
+ expect(optionsFor({ data: twoPopulations, markers }).markers).toBe(markers);
+ expect(optionsFor({ data: twoPopulations })).not.toHaveProperty('markers');
+});
+
+it('Charts with more than 11 populations get one generated color per population', () => {
+ expect(optionsFor({ data: twoPopulations, numPopulations: 11 })).not.toHaveProperty('colors');
+
+ const colors = optionsFor({ data: twoPopulations, numPopulations: 12 }).colors;
+ expect(colors).toHaveLength(12);
+ expect(new Set(colors).size).toBe(12);
+ colors.forEach(color => expect(color).toMatch(/^#[0-9a-f]{6}$/i));
+});
+
+it('The data and size are passed through', () => {
+ const options = optionsFor({ data: twoPopulations });
+
+ expect(options).toMatchObject({ data: twoPopulations, width: 600, height: 400, x_accessor: 'x', y_accessor: 'y' });
+});
+
+it('Zero values are part of the range, which then starts at 0', () => {
+ const options = optionsFor({ data: [[{ x: new Date('2026-01-04'), y: 0 }, { x: new Date('2026-01-11'), y: 10 }]] });
+
+ expect(options.min_y).toBe(0);
+ expect(options.max_y).toBe(11);
+});
+
+it('A constant series sits on the bottom of a range that ends 10% above it', () => {
+ const options = optionsFor({ data: [[{ x: new Date('2026-01-04'), y: 5 }, { x: new Date('2026-01-11'), y: 5 }]] });
+
+ expect(options.min_y).toBe(5);
+ expect(options.max_y).toBe(5.5);
+});
+
+it('A series that is constantly 0 gets an empty range, [0, 0]', () => {
+ // Pinned as current behavior: the 10% padding is relative, so it adds
+ // nothing to 0. No metric has had an all-zero series so far.
+ const options = optionsFor({ data: [[{ x: new Date('2026-01-04'), y: 0 }, { x: new Date('2026-01-11'), y: 0 }]] });
+
+ expect(options.min_y).toBe(0);
+ expect(options.max_y).toBe(0);
+});
+
+it('A chart with no plottable points gets non-finite bounds', () => {
+ // Pinned as current behavior. metrics-graphics then throws while drawing;
+ // the fixtures/boundaries.spec.js Playwright spec covers what the page shows.
+ const options = optionsFor({ data: [[], []] });
+
+ expect(options.min_y).toBe(Infinity);
+ expect(options.max_y).toBe(-Infinity);
+});
diff --git a/src/tests/jest/ChartContainer.test.jsx b/src/tests/jest/ChartContainer.test.jsx
new file mode 100644
index 00000000..07ca17b9
--- /dev/null
+++ b/src/tests/jest/ChartContainer.test.jsx
@@ -0,0 +1,135 @@
+import { render } from '@testing-library/react';
+
+import Chart from '../../components/views/Chart';
+import ChartContainer from '../../components/containers/ChartContainer';
+
+
+// The real view hands the data to metrics-graphics, which needs real layout.
+vi.mock('../../components/views/Chart', () => ({ default: vi.fn(() => null) }));
+
+// ChartContainer measures #application > main to size the chart.
+function renderChart(props) {
+ render(
+
+
+
+
+
+ );
+
+ return Chart.mock.calls.at(-1)[0];
+}
+
+beforeEach(() => {
+ Chart.mockClear();
+});
+
+it('Populations are ordered by their highest y value, highest first', () => {
+ const chartProps = renderChart({
+ data: {
+ populations: {
+ Low: [{ x: '2026-01-01', y: 1 }, { x: '2026-01-08', y: 2 }],
+ High: [{ x: '2026-01-01', y: 9 }, { x: '2026-01-08', y: 3 }],
+ Middle: [{ x: '2026-01-01', y: 5 }, { x: '2026-01-08', y: 4 }],
+ },
+ },
+ });
+
+ expect(chartProps.legend).toEqual(['High', 'Middle', 'Low']);
+ expect(chartProps.data[0].map(point => point.y)).toEqual([9, 3]);
+});
+
+it('Points with a null y value are dropped and date strings become Dates', () => {
+ const chartProps = renderChart({
+ data: {
+ populations: {
+ MAU: [{ x: '2026-01-01', y: null }, { x: '2026-01-08', y: 250 }],
+ },
+ },
+ });
+
+ expect(chartProps.data).toEqual([[{ x: new Date('2026-01-08'), y: 250 }]]);
+});
+
+it('Non-date x values are passed through unchanged', () => {
+ const chartProps = renderChart({
+ data: {
+ populations: {
+ Memory: [{ x: '4GB', y: 40 }, { x: '8GB', y: 60 }],
+ },
+ },
+ });
+
+ expect(chartProps.data[0].map(point => point.x)).toEqual(['4GB', '8GB']);
+});
+
+it('The legend is shown only when there is more than one population', () => {
+ const singleProps = renderChart({ data: { populations: { Only: [{ x: '2026-01-01', y: 1 }] } } });
+ expect(singleProps.showLegend).toBe(false);
+
+ const multipleProps = renderChart({
+ data: {
+ populations: {
+ First: [{ x: '2026-01-01', y: 1 }],
+ Second: [{ x: '2026-01-01', y: 2 }],
+ },
+ },
+ });
+ expect(multipleProps.showLegend).toBe(true);
+});
+
+it('Annotations become markers with Date x values', () => {
+ const chartProps = renderChart({
+ data: { populations: { Only: [{ x: '2026-01-01', y: 1 }] } },
+ annotations: [{ date: '2026-01-05', label: 'Firefox 150' }],
+ });
+
+ expect(chartProps.markers).toEqual([{ x: new Date('2026-01-05'), label: 'Firefox 150' }]);
+});
+
+it('Axis units and suggested bounds are passed to the chart', () => {
+ const chartProps = renderChart({
+ data: { populations: { Only: [{ x: '2026-01-01', y: 1 }] } },
+ axes: { x: { unit: 'Date' }, y: { unit: '%', suggestedMin: 0, suggestedMax: 100 } },
+ });
+
+ expect(chartProps).toMatchObject({ xUnit: 'Date', yUnit: '%', suggestedYMin: 0, suggestedYMax: 100 });
+});
+
+it('The chart fills a wide parent, and is square when the parent is 400px or narrower', () => {
+ const offsetWidth = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get');
+ const data = { populations: { Only: [{ x: '2026-01-01', y: 1 }] } };
+
+ offsetWidth.mockReturnValue(900);
+ expect(renderChart({ data })).toMatchObject({ width: 900, height: 400 });
+
+ offsetWidth.mockReturnValue(320);
+ expect(renderChart({ data })).toMatchObject({ width: 320, height: 320 });
+
+ offsetWidth.mockReturnValue(100);
+ expect(renderChart({ data })).toMatchObject({ width: 264, height: 400 });
+});
+
+it('Points with a y value of 0 are kept', () => {
+ const chartProps = renderChart({
+ data: { populations: { Share: [{ x: '2026-01-01', y: 0 }, { x: '2026-01-08', y: 4 }] } },
+ });
+
+ expect(chartProps.data[0].map(point => point.y)).toEqual([0, 4]);
+});
+
+it('A population whose points are all null keeps its legend entry and passes an empty series', () => {
+ // Pinned as current behavior: nothing is drawn for it, but it still
+ // appears in the legend.
+ const chartProps = renderChart({
+ data: {
+ populations: {
+ Present: [{ x: '2026-01-01', y: 3 }],
+ Missing: [{ x: '2026-01-01', y: null }, { x: '2026-01-08', y: null }],
+ },
+ },
+ });
+
+ expect(chartProps.legend).toEqual(['Present', 'Missing']);
+ expect(chartProps.data).toEqual([[{ x: new Date('2026-01-01'), y: 3 }], []]);
+});
diff --git a/src/tests/jest/CustomizableDate.test.jsx b/src/tests/jest/CustomizableDate.test.jsx
new file mode 100644
index 00000000..9a528e48
--- /dev/null
+++ b/src/tests/jest/CustomizableDate.test.jsx
@@ -0,0 +1,63 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import CustomizableDateContainer from '../../components/containers/CustomizableDateContainer';
+import DataTableContainer from '../../components/containers/DataTableContainer';
+
+
+const tableData = {
+ dates: {
+ '2026-01-04': {
+ rows: [
+ { name: 'uBlock Origin', value: 2 },
+ { name: 'Dark Reader', value: 1 },
+ ],
+ },
+ '2025-12-28': {
+ rows: [
+ { name: 'Dark Reader', value: 3 },
+ { name: 'uBlock Origin', value: 1 },
+ { name: 'Bitwarden', value: 0.5 },
+ ],
+ },
+ },
+};
+
+function renderTableWithDates(dates) {
+ return render(
+
+
+
+ );
+}
+
+function tableRows(container) {
+ return [...container.querySelectorAll('tbody tr')].map(row => (
+ [...row.querySelectorAll('td')].map(cell => cell.textContent)
+ ));
+}
+
+it('Dates are listed as long dates in UTC, and the table starts on the first one', () => {
+ const { container } = renderTableWithDates(['2026-01-04', '2025-12-28']);
+
+ const selector = screen.getByLabelText('Date');
+ expect([...selector.options].map(option => option.textContent)).toEqual(['January 4, 2026', 'December 28, 2025']);
+ expect(selector).toHaveValue('2026-01-04');
+ expect(tableRows(container)).toEqual([
+ ['1', 'uBlock Origin', '2%'],
+ ['2', 'Dark Reader', '1%'],
+ ]);
+});
+
+it('Choosing another date shows that date\'s rows in the table', async () => {
+ const user = userEvent.setup();
+ const { container } = renderTableWithDates(['2026-01-04', '2025-12-28']);
+
+ await user.selectOptions(screen.getByLabelText('Date'), '2025-12-28');
+
+ expect(tableRows(container)).toEqual([
+ ['1', 'Dark Reader', '3%'],
+ ['2', 'uBlock Origin', '1%'],
+ ['3', 'Bitwarden', '0.500%'],
+ ]);
+});
diff --git a/src/tests/jest/Dashboard.test.jsx b/src/tests/jest/Dashboard.test.jsx
index 60a42597..b1719cbb 100644
--- a/src/tests/jest/Dashboard.test.jsx
+++ b/src/tests/jest/Dashboard.test.jsx
@@ -1,3 +1,5 @@
+import { render } from '@testing-library/react';
+
import Dashboard from '../../components/views/Dashboard';
@@ -11,46 +13,26 @@ beforeAll(() => {
});
it('When a description prop is present, p#dashboard-description is rendered', () => {
- const d = shallow(
+ const { container } = render(
);
- expect(d.find('#dashboard-description').exists()).toEqual(true);
+ expect(container.querySelector('#dashboard-description')).toHaveTextContent('Example description');
});
it('When a description prop is not present, an empty string, undefined, or null, p#dashboard-description is not rendered', () => {
- const d1 = shallow(
-
- );
-
- const d2 = shallow(
-
- );
-
- const d3 = shallow(
-
- );
-
- const d4 = shallow(
-
- );
-
- expect(d1.find('#dashboard-description').exists()).toEqual(false);
- expect(d2.find('#dashboard-description').exists()).toEqual(false);
- expect(d3.find('#dashboard-description').exists()).toEqual(false);
- expect(d4.find('#dashboard-description').exists()).toEqual(false);
+ [{}, { description: '' }, { description: undefined }, { description: null }].forEach(descriptionProps => {
+ const { container, unmount } = render(
+
+ );
+
+ expect(container.querySelector('#dashboard-description')).toBeNull();
+ unmount();
+ });
});
diff --git a/src/tests/jest/DashboardContainer.test.jsx b/src/tests/jest/DashboardContainer.test.jsx
new file mode 100644
index 00000000..ae918567
--- /dev/null
+++ b/src/tests/jest/DashboardContainer.test.jsx
@@ -0,0 +1,114 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import DashboardContainer from '../../components/containers/DashboardContainer';
+
+
+// react-refetch reads window.fetch when connect() runs at import time, so the
+// stub has to exist before the container module loads.
+const fetchMock = vi.hoisted(() => {
+ const mock = vi.fn();
+ window.fetch = mock;
+ return mock;
+});
+
+const source = 'https://data.example/user-activity';
+
+function jsonResponse(body, httpStatus = 200) {
+ return Promise.resolve(new Response(JSON.stringify(body), {
+ status: httpStatus,
+ headers: { 'Content-Type': 'application/json' },
+ }));
+}
+
+// Answers the dashboard index with `dashboard` and leaves every metric request
+// pending, so only the dashboard's own markup renders.
+function serveDashboard(dashboard) {
+ fetchMock.mockImplementation(request => {
+ if (request.url === `${source}/index.json`) return jsonResponse(dashboard);
+ return new Promise(() => {});
+ });
+}
+
+function requestedUrls() {
+ return fetchMock.mock.calls.map(([request]) => request.url);
+}
+
+const multiRegionDashboard = {
+ title: 'User Activity',
+ metrics: ['daily-usage'],
+ dates: ['2026-01-04'],
+ categories: ['Worldwide', 'Germany', 'Brazil'],
+ defaultCategory: 'Worldwide',
+};
+
+beforeEach(() => {
+ fetchMock.mockReset();
+});
+
+it('Requests the dashboard index from its source and shows a spinner while waiting', async () => {
+ fetchMock.mockImplementation(() => new Promise(() => {}));
+
+ const { container } = render();
+
+ expect(container.querySelector('.spinner')).toBeInTheDocument();
+ await waitFor(() => expect(requestedUrls()).toEqual([`${source}/index.json`]));
+});
+
+it('When the request fails, #dashboard-fetch-error is rendered', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ fetchMock.mockImplementation(() => jsonResponse({ error: 'Internal Server Error' }, 500));
+
+ const { container } = render();
+
+ await waitFor(() => expect(container.querySelector('#dashboard-fetch-error')).toBeInTheDocument());
+});
+
+it('When the request succeeds, the dashboard renders and metrics load for the default region', async () => {
+ serveDashboard(multiRegionDashboard);
+
+ const { container } = render();
+
+ await waitFor(() => expect(container.querySelector('#dashboard-title')).toHaveTextContent('User Activity'));
+ expect(screen.getByLabelText('Region')).toHaveValue('Worldwide');
+ await waitFor(() => expect(requestedUrls()).toContain(`${source}/Worldwide/daily-usage/index.json`));
+});
+
+it('A preferred region saved in sessionStorage is used instead of the default', async () => {
+ sessionStorage.setItem('preferredRegion', 'Brazil');
+ serveDashboard(multiRegionDashboard);
+
+ render();
+
+ await waitFor(() => expect(screen.getByLabelText('Region')).toHaveValue('Brazil'));
+ await waitFor(() => expect(requestedUrls()).toContain(`${source}/Brazil/daily-usage/index.json`));
+});
+
+it('Choosing a region saves it to sessionStorage and loads metrics for it', async () => {
+ const user = userEvent.setup();
+ serveDashboard(multiRegionDashboard);
+ render();
+ await screen.findByLabelText('Region');
+
+ await user.selectOptions(screen.getByLabelText('Region'), 'Germany');
+
+ expect(sessionStorage.getItem('preferredRegion')).toBe('Germany');
+ expect(screen.getByLabelText('Region')).toHaveValue('Germany');
+ await waitFor(() => expect(requestedUrls()).toContain(`${source}/Germany/daily-usage/index.json`));
+});
+
+it('A dashboard with one region shows no region selector and ignores the saved preference', async () => {
+ sessionStorage.setItem('preferredRegion', 'Brazil');
+ serveDashboard({
+ title: 'Hardware',
+ metrics: ['gpu-vendor'],
+ dates: ['2026-01-04'],
+ categories: ['default'],
+ });
+
+ const { container } = render();
+
+ await waitFor(() => expect(container.querySelector('#dashboard-title')).toHaveTextContent('Hardware'));
+ expect(container.querySelector('#region-selector')).toBeNull();
+ await waitFor(() => expect(requestedUrls()).toContain(`${source}/default/gpu-vendor/index.json`));
+});
diff --git a/src/tests/jest/DataTableContainer.test.jsx b/src/tests/jest/DataTableContainer.test.jsx
new file mode 100644
index 00000000..5d1c8984
--- /dev/null
+++ b/src/tests/jest/DataTableContainer.test.jsx
@@ -0,0 +1,38 @@
+import { render } from '@testing-library/react';
+
+import DataTableContainer from '../../components/containers/DataTableContainer';
+
+
+it('Rows for the active date are ranked by value, highest first', () => {
+ const { container } = render(
+
+ );
+
+ const rows = [...container.querySelectorAll('tbody tr')].map(row => (
+ [...row.querySelectorAll('td')].map(cell => cell.textContent)
+ ));
+
+ expect(rows).toEqual([
+ ['1', 'English (US)', '40%'],
+ ['2', 'French', '12.250%'],
+ ['3', 'German', '10.500%'],
+ ]);
+ expect([...container.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['Rank', 'Language', 'Share']);
+});
diff --git a/src/tests/jest/LazyBoundary.test.jsx b/src/tests/jest/LazyBoundary.test.jsx
new file mode 100644
index 00000000..b92fc5b9
--- /dev/null
+++ b/src/tests/jest/LazyBoundary.test.jsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react';
+
+import LazyBoundary from '../../lib/LazyBoundary';
+import lazyLoad from '../../lib/lazyLoad';
+
+
+it('Shows a spinner while a lazily loaded component is loading', () => {
+ const NeverLoads = lazyLoad(new Promise(() => {}));
+
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.querySelector('.spinner')).toBeInTheDocument();
+});
+
+it('Shows the loaded component once it arrives', async () => {
+ const Loads = lazyLoad(Promise.resolve({ default: () => Loaded
}));
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText('Loaded')).toBeInTheDocument();
+});
+
+it('Shows "Load error" when loading fails', async () => {
+ // React logs errors caught by an error boundary.
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const FailsToLoad = lazyLoad(Promise.reject(new Error('Failed to fetch dynamically imported module')));
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText('Load error')).toBeInTheDocument();
+});
diff --git a/src/tests/jest/MetricOverview.test.jsx b/src/tests/jest/MetricOverview.test.jsx
index 78821bed..351b43a6 100644
--- a/src/tests/jest/MetricOverview.test.jsx
+++ b/src/tests/jest/MetricOverview.test.jsx
@@ -1,34 +1,36 @@
+import { render } from '@testing-library/react';
+
import MetricOverview from '../../components/views/MetricOverview';
it('Markdown links (and only Markdown links) are parsed in single-paragraph descriptions', () => {
- const mo1 = shallow(
+ const { container: mo1 } = render(
);
- const mo2 = shallow(
+ const { container: mo2 } = render(
);
- const mo3 = shallow(
+ const { container: mo3 } = render(
);
- // We only need to use .html() because the description is set using
- // dangerouslySetInnerHTML.
- expect(mo1.find('.metric-description').html()).toContain('This is a test of the Markdown parser.');
- expect(mo2.find('.metric-description').html()).toContain('This is a test of the Markdown parser.');
- expect(mo3.find('.metric-description').html()).toContain('This is a <script>alert(1)</script> test of **the** Markdown <strong>parser</strong>.');
+ // innerHTML, not text, because the description is set using
+ // dangerouslySetInnerHTML and the assertions are about the markup.
+ expect(mo1.querySelector('.metric-description').innerHTML).toContain('This is a test of the Markdown parser.');
+ expect(mo2.querySelector('.metric-description').innerHTML).toContain('This is a test of the Markdown parser.');
+ expect(mo3.querySelector('.metric-description').innerHTML).toContain('This is a <script>alert(1)</script> test of **the** Markdown <strong>parser</strong>.');
});
it('Markdown links (and only Markdown links) are parsed in multi-paragraph descriptions', () => {
- const mo1 = shallow(
+ const { container: mo1 } = render(
);
- const mo2 = shallow(
+ const { container: mo2 } = render(
);
- const mo3 = shallow(
+ const { container: mo3 } = render(
);
- // We only need to use .html() because the description is set using
- // dangerouslySetInnerHTML.
- expect(mo1.find('.metric-description').html()).toContain('This is a test of the Markdown parser.');
- expect(mo2.find('.metric-description').html()).toContain('This is a test of the Markdown parser.');
- expect(mo3.find('.metric-description').html()).toContain('This is a <script>alert(1)</script> test of **the** Markdown <strong>parser</strong>.');
+ // innerHTML, not text, because the description is set using
+ // dangerouslySetInnerHTML and the assertions are about the markup.
+ expect(mo1.querySelector('.metric-description').innerHTML).toContain('This is a test of the Markdown parser.');
+ expect(mo2.querySelector('.metric-description').innerHTML).toContain('This is a test of the Markdown parser.');
+ expect(mo3.querySelector('.metric-description').innerHTML).toContain('This is a <script>alert(1)</script> test of **the** Markdown <strong>parser</strong>.');
});
diff --git a/src/tests/jest/MetricOverviewContainer.test.jsx b/src/tests/jest/MetricOverviewContainer.test.jsx
new file mode 100644
index 00000000..23acc6ad
--- /dev/null
+++ b/src/tests/jest/MetricOverviewContainer.test.jsx
@@ -0,0 +1,97 @@
+import { render, screen, waitFor } from '@testing-library/react';
+
+import MetricOverviewContainer from '../../components/containers/MetricOverviewContainer';
+
+
+// react-refetch reads window.fetch when connect() runs at import time, so the
+// stub has to exist before the container module loads.
+const fetchMock = vi.hoisted(() => {
+ const mock = vi.fn();
+ window.fetch = mock;
+ return mock;
+});
+
+const pendingRequests = {};
+
+// Each URL gets a request the test settles itself, so it can look at the page
+// while a request is still in flight.
+function settle(url, body, httpStatus = 200) {
+ pendingRequests[url](new Response(JSON.stringify(body), {
+ status: httpStatus,
+ headers: { 'Content-Type': 'application/json' },
+ }));
+}
+
+function renderForRegion(activeRegion, rerender) {
+ const element = (
+
+ );
+
+ return rerender ? rerender(element) : render(element);
+}
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockImplementation(request => new Promise(resolve => {
+ pendingRequests[request.url] = resolve;
+ }));
+});
+
+it('Requests the metric for the dashboard source, region, and slug', async () => {
+ renderForRegion('Worldwide');
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ expect(fetchMock.mock.calls[0][0].url).toBe('https://data.example/user-activity/Worldwide/daily-usage/index.json');
+});
+
+it('When the request succeeds, the metric title is rendered', async () => {
+ renderForRegion('Worldwide');
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+
+ settle('https://data.example/user-activity/Worldwide/daily-usage/index.json', { title: 'Daily Usage' });
+
+ expect(await screen.findByText('Daily Usage')).toBeInTheDocument();
+});
+
+it('When the first request fails, #metric-overview-fetch-error is rendered', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { container } = renderForRegion('Worldwide');
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+
+ settle('https://data.example/user-activity/Worldwide/daily-usage/index.json', { error: 'Internal Server Error' }, 500);
+
+ await waitFor(() => expect(container.querySelector('#metric-overview-fetch-error')).toBeInTheDocument());
+});
+
+it('While a new region loads, the previous region\'s metric stays on screen', async () => {
+ const { rerender, container } = renderForRegion('Worldwide');
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ settle('https://data.example/user-activity/Worldwide/daily-usage/index.json', { title: 'Daily Usage' });
+ await screen.findByText('Daily Usage');
+
+ renderForRegion('Germany', rerender);
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+
+ expect(screen.getByText('Daily Usage')).toBeInTheDocument();
+ expect(container.querySelector('#metric-overview-fetch-error')).toBeNull();
+});
+
+it('When the request for a new region fails, #metric-overview-fetch-error replaces the previous metric', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { rerender, container } = renderForRegion('Worldwide');
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ settle('https://data.example/user-activity/Worldwide/daily-usage/index.json', { title: 'Daily Usage' });
+ await screen.findByText('Daily Usage');
+
+ renderForRegion('Germany', rerender);
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+ settle('https://data.example/user-activity/Germany/daily-usage/index.json', { error: 'Internal Server Error' }, 500);
+
+ await waitFor(() => expect(container.querySelector('#metric-overview-fetch-error')).toBeInTheDocument());
+ expect(screen.queryByText('Daily Usage')).toBeNull();
+});
diff --git a/src/tests/jest/Routes.test.jsx b/src/tests/jest/Routes.test.jsx
new file mode 100644
index 00000000..3d34936a
--- /dev/null
+++ b/src/tests/jest/Routes.test.jsx
@@ -0,0 +1,87 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import reactGA from 'react-ga';
+
+import Application from '../../components/views/Application';
+import { dashboards } from '../../config.json';
+
+
+// react-refetch reads window.fetch when connect() runs at import time, so the
+// stub has to exist before any container module loads.
+const fetchMock = vi.hoisted(() => {
+ const mock = vi.fn();
+ window.fetch = mock;
+ return mock;
+});
+
+vi.mock('react-ga', () => ({
+ default: { initialize: vi.fn(), set: vi.fn(), pageview: vi.fn() },
+}));
+
+function renderAt(path) {
+ return render(
+
+
+
+ );
+}
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockImplementation(() => new Promise(() => {}));
+ reactGA.pageview.mockClear();
+});
+
+it('/ shows the introduction and a button to the first dashboard', async () => {
+ const { container } = renderAt('/');
+
+ expect(await screen.findByRole('link', { name: 'Proceed to User Activity' })).toHaveAttribute('href', '/dashboard/user-activity');
+ expect(container.querySelector('#introduction')).toBeInTheDocument();
+});
+
+it('/contact shows the contact page', async () => {
+ const { container } = renderAt('/contact');
+
+ await screen.findByText(/questions/i, { selector: '#contact' });
+ expect(container.querySelector('#not-found')).toBeNull();
+});
+
+it.each(dashboards)('/dashboard/$key requests its index from $source', async ({ key, source }) => {
+ const { container } = renderAt(`/dashboard/${key}`);
+
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ expect(fetchMock.mock.calls[0][0].url).toBe(`${source}/index.json`);
+ expect(container.querySelector('#not-found')).toBeNull();
+});
+
+it.each([
+ '/not-a-page',
+ '/dashboard/not-a-dashboard',
+ '/dashboard/hardware/extra',
+ '/contact/extra',
+])('%s shows Not Found', async path => {
+ const { container } = renderAt(path);
+
+ await vi.waitFor(() => expect(container.querySelector('#not-found')).toBeInTheDocument());
+});
+
+it.each([
+ '/',
+ '/contact',
+ ...dashboards.map(dashboard => `/dashboard/${dashboard.key}`),
+ '/not-a-page',
+])('%s sends a pageview for its path', async path => {
+ renderAt(path);
+
+ await vi.waitFor(() => expect(reactGA.pageview).toHaveBeenCalledWith(path));
+});
+
+it('The header links to every dashboard in config.json and the contact page', () => {
+ const { container } = renderAt('/');
+
+ const links = [...container.querySelectorAll('#main-navigation a')].map(link => [link.textContent, link.getAttribute('href')]);
+ expect(links).toEqual([
+ ...dashboards.map(dashboard => [dashboard.menuTitle, `/dashboard/${dashboard.key}`]),
+ ['Contact', '/contact'],
+ ]);
+});
diff --git a/src/tests/jest/SummaryMetric.test.jsx b/src/tests/jest/SummaryMetric.test.jsx
new file mode 100644
index 00000000..b25ec085
--- /dev/null
+++ b/src/tests/jest/SummaryMetric.test.jsx
@@ -0,0 +1,87 @@
+import { render, waitFor } from '@testing-library/react';
+
+import SummaryMetricContainer from '../../components/containers/SummaryMetricContainer';
+
+
+// react-refetch reads window.fetch when connect() runs at import time, so the
+// stub has to exist before the container module loads.
+const fetchMock = vi.hoisted(() => {
+ const mock = vi.fn();
+ window.fetch = mock;
+ return mock;
+});
+
+const activeDate = '2026-01-04';
+
+// Renders the real summary bar for one population per [name, value] pair, all
+// on activeDate. SummaryMetric sizes itself from #application > main, which
+// jsdom reports as 0px wide, so the width is stubbed: at 490px the bar is
+// drawn at its 450px maximum, leaving 410px for the segments.
+async function renderSummaryBar(populationValues) {
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(490);
+ const populations = Object.fromEntries(populationValues.map(([populationName, value]) => [populationName, [{ x: activeDate, y: value }]]));
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ title: 'GPU Vendor', data: { populations } }), {
+ headers: { 'Content-Type': 'application/json' },
+ }));
+
+ const { container } = render(
+
+
+
+
+
+ );
+
+ await waitFor(() => expect(container.querySelector('rect.bar')).toBeInTheDocument());
+
+ return {
+ widths: [...container.querySelectorAll('rect.bar')].map(rect => Number(rect.getAttribute('width'))),
+ labels: [...container.querySelectorAll('text.bar-label')].map(label => label.textContent),
+ visibleLabels: [...container.querySelectorAll('text.bar-label')].filter(label => label.style.display !== 'none').map(label => label.textContent),
+ arrowOpacities: [...container.querySelectorAll('circle.bar-arrow')].map(arrow => arrow.style.opacity),
+ };
+}
+
+beforeEach(() => {
+ fetchMock.mockReset();
+});
+
+it('Each population gets a segment sized by its share, largest first, with small ones combined into a final "Other"', async () => {
+ const bar = await renderSummaryBar([['AMD', 30], ['Intel', 50], ['VIA', 2], ['NVIDIA', 15], ['Matrox', 3]]);
+
+ expect(bar.labels).toEqual(['Intel (50%)', 'AMD (30%)', 'NVIDIA (15%)', 'Other (5%)']);
+ expect(bar.widths).toEqual([205, 123, 61.5, 20.5]);
+});
+
+it('Only the largest segment\'s label shows until a segment is hovered or clicked', async () => {
+ const bar = await renderSummaryBar([['AMD', 30], ['Intel', 70]]);
+
+ expect(bar.visibleLabels).toEqual(['Intel (70%)']);
+});
+
+it('A single population is shown as a True/False split', async () => {
+ const bar = await renderSummaryBar([['Has Flash', 80]]);
+
+ expect(bar.labels).toEqual(['True (80%)', 'False (20%)']);
+ expect(bar.widths).toEqual([328, 82]);
+});
+
+it('Segments under 4% get no arrow, since the arrow would be wider than the segment', async () => {
+ const bar = await renderSummaryBar([['Intel', 60], ['AMD', 37], ['VIA', 3]]);
+
+ expect(bar.labels).toEqual(['Intel (60%)', 'AMD (37%)', 'Other (3%)']);
+ expect(bar.arrowOpacities).toEqual(['', '', '0']);
+});
+
+it('A population at exactly 5% gets its own segment; one just under 5% goes into "Other"', async () => {
+ const bar = await renderSummaryBar([['Intel', 60], ['AMD', 30.01], ['VIA', 5], ['Matrox', 4.99]]);
+
+ expect(bar.labels).toEqual(['Intel (60%)', 'AMD (30.010%)', 'VIA (5%)', 'Other (4.990%)']);
+});
+
+it('A population at 0% is left out, and no empty "Other" segment is drawn', async () => {
+ const bar = await renderSummaryBar([['Intel', 70], ['AMD', 30], ['VIA', 0]]);
+
+ expect(bar.labels).toEqual(['Intel (70%)', 'AMD (30%)']);
+ expect(bar.widths).toEqual([287, 123]);
+});
diff --git a/src/tests/jest/SummaryMetricContainer.test.jsx b/src/tests/jest/SummaryMetricContainer.test.jsx
new file mode 100644
index 00000000..8a04b697
--- /dev/null
+++ b/src/tests/jest/SummaryMetricContainer.test.jsx
@@ -0,0 +1,121 @@
+import { render, waitFor } from '@testing-library/react';
+
+import SummaryMetric from '../../components/views/SummaryMetric';
+import SummaryMetricContainer from '../../components/containers/SummaryMetricContainer';
+
+
+// react-refetch reads window.fetch when connect() runs at import time, so the
+// stub has to exist before the container module loads.
+const fetchMock = vi.hoisted(() => {
+ const mock = vi.fn();
+ window.fetch = mock;
+ return mock;
+});
+
+// The real view draws with d3 into a measured parent node.
+vi.mock('../../components/views/SummaryMetric', () => ({ default: vi.fn(() => null) }));
+
+function respondWith(body, httpStatus = 200) {
+ fetchMock.mockResolvedValue(new Response(JSON.stringify(body), {
+ status: httpStatus,
+ headers: { 'Content-Type': 'application/json' },
+ }));
+}
+
+function renderSummaryMetric() {
+ return render(
+
+ );
+}
+
+async function dataPassedToView() {
+ await waitFor(() => expect(SummaryMetric).toHaveBeenCalled());
+ return SummaryMetric.mock.calls.at(-1)[0].data;
+}
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ SummaryMetric.mockClear();
+});
+
+it('Requests the summary metric for the active region', async () => {
+ respondWith({ title: 'GPU Vendor', data: { populations: { Intel: [{ x: '2026-01-04', y: 100 }] } } });
+
+ renderSummaryMetric();
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled());
+ expect(fetchMock.mock.calls[0][0].url).toBe('https://data.example/hardware/Worldwide/gpu-vendor/index.json');
+});
+
+it('A single population becomes a True/False split at the active date', async () => {
+ respondWith({
+ title: 'Has Flash',
+ data: {
+ populations: {
+ 'Has Flash': [
+ { x: '2026-01-04', y: 30 },
+ { x: '2025-12-28', y: 90 },
+ ],
+ },
+ },
+ });
+
+ renderSummaryMetric();
+
+ expect(await dataPassedToView()).toEqual([
+ { id: 1, name: 'False', value: 70 },
+ { id: 0, name: 'True', value: 30 },
+ ]);
+});
+
+it('Populations under 5% are summed into "Other", which comes last; the rest are sorted by value', async () => {
+ respondWith({
+ title: 'GPU Vendor',
+ data: {
+ populations: {
+ Intel: [{ x: '2026-01-04', y: 40 }],
+ NVIDIA: [{ x: '2026-01-04', y: 50 }],
+ Matrox: [{ x: '2026-01-04', y: 3 }],
+ VIA: [{ x: '2026-01-04', y: 1 }],
+ AMD: [{ x: '2026-01-04', y: 6 }],
+ },
+ },
+ });
+
+ renderSummaryMetric();
+
+ const data = await dataPassedToView();
+ expect(data.map(slice => slice.name)).toEqual(['NVIDIA', 'Intel', 'AMD', 'Other']);
+ expect(data.at(-1).value).toBe(4);
+});
+
+it('Populations with no data point at the active date are left out', async () => {
+ respondWith({
+ title: 'GPU Vendor',
+ data: {
+ populations: {
+ Intel: [{ x: '2026-01-04', y: 60 }],
+ NVIDIA: [{ x: '2026-01-04', y: 40 }],
+ '3dfx': [{ x: '1999-01-04', y: 100 }],
+ },
+ },
+ });
+
+ renderSummaryMetric();
+
+ expect((await dataPassedToView()).map(slice => slice.name)).toEqual(['Intel', 'NVIDIA']);
+});
+
+it('When the request fails, #summary-metric-fetch-error is rendered', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ respondWith({ error: 'Internal Server Error' }, 500);
+
+ const { container } = renderSummaryMetric();
+
+ await waitFor(() => expect(container.querySelector('#summary-metric-fetch-error')).toBeInTheDocument());
+});
diff --git a/src/tests/jest/utils.test.jsx b/src/tests/jest/utils.test.jsx
new file mode 100644
index 00000000..27b908de
--- /dev/null
+++ b/src/tests/jest/utils.test.jsx
@@ -0,0 +1,35 @@
+import { bumpSort, isFloat, prettifyNumber, getPageTitle } from '../../lib/utils';
+
+
+it('bumpSort returns a sorted copy with the bumped value first, leaving the input alone', () => {
+ const groups = ['group B', 'control', 'group A'];
+
+ expect(bumpSort(groups, 'control')).toEqual(['control', 'group A', 'group B']);
+ expect(groups).toEqual(['group B', 'control', 'group A']);
+});
+
+it('bumpSort returns a sorted copy when the value to bump is missing', () => {
+ expect(bumpSort(['Germany', 'Brazil', 'India'], 'All')).toEqual(['Brazil', 'Germany', 'India']);
+});
+
+it('isFloat is true only for numbers with a fractional part', () => {
+ expect(isFloat(3.14)).toBe(true);
+ expect(isFloat(5)).toBe(false);
+ expect(isFloat('3.14')).toBe(false);
+ expect(isFloat(NaN)).toBe(false);
+});
+
+it('prettifyNumber adds thousands separators to integers', () => {
+ expect(prettifyNumber(5)).toBe('5');
+ expect(prettifyNumber(5000000)).toBe('5,000,000');
+});
+
+it('prettifyNumber gives floats exactly VITE_VALUE_DECIMAL_PLACES (3) decimal places', () => {
+ expect(prettifyNumber(3.2)).toBe('3.200');
+ expect(prettifyNumber(1234.56789)).toBe('1,234.568');
+});
+
+it('getPageTitle appends the site title to a subtitle, or uses the site title alone', () => {
+ expect(getPageTitle('Hardware')).toBe('Hardware | Firefox Public Data Report');
+ expect(getPageTitle()).toBe('Firefox Public Data Report');
+});
diff --git a/src/tests/jest/withNextButton.test.jsx b/src/tests/jest/withNextButton.test.jsx
new file mode 100644
index 00000000..11a59fdc
--- /dev/null
+++ b/src/tests/jest/withNextButton.test.jsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route } from 'react-router-dom';
+
+import withNextButton from '../../components/decorators/withNextButton';
+import { nextButtons } from '../../config.json';
+
+
+const Page = () => Page content
;
+const PageWithNextButton = withNextButton(Page);
+
+function renderAt(path) {
+ return render(
+
+
+
+ );
+}
+
+it.each(nextButtons)('$from shows "$text" linking to $to', ({ from, to, text }) => {
+ renderAt(from);
+
+ expect(screen.getByText('Page content')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: text })).toHaveAttribute('href', to);
+});
+
+it('Pages without a nextButtons entry get no button', () => {
+ const { container } = renderAt('/contact');
+
+ expect(screen.getByText('Page content')).toBeInTheDocument();
+ expect(container.querySelector('.next-button')).toBeNull();
+});
diff --git a/tests/playwright/fixtureData.js b/tests/playwright/fixtureData.js
new file mode 100644
index 00000000..0e7b85f9
--- /dev/null
+++ b/tests/playwright/fixtureData.js
@@ -0,0 +1,96 @@
+const path = require('path');
+const fs = require('fs');
+const base = require('@playwright/test');
+
+
+// Recorded by scripts/recordPlaywrightFixtures.js, mirroring the URL paths
+// under datasetsPrefix.
+const fixturesDirectory = path.join(__dirname, 'fixtures');
+const datasetsPrefix = 'https://data.firefox.com/datasets/desktop/';
+
+function fixturePath(url) {
+ return path.join(fixturesDirectory, decodeURIComponent(new URL(url).pathname.slice(new URL(datasetsPrefix).pathname.length)));
+}
+
+// Only a few regions are recorded. A metric request for any other region gets
+// the dashboard's default-region recording of the same metric, so specs can
+// choose any region the selector offers. A missing metric or dashboard is
+// still reported as unrecorded.
+function regionFallbackPath(filename) {
+ const [dashboardKey, , slug, basename] = path.relative(fixturesDirectory, filename).split(path.sep);
+ if (!basename) return null;
+
+ const indexFilename = path.join(fixturesDirectory, dashboardKey, 'index.json');
+ if (!fs.existsSync(indexFilename)) return null;
+
+ const index = JSON.parse(fs.readFileSync(indexFilename, 'utf8'));
+ const defaultRegion = index.defaultCategory || index.categories[0];
+ const fallback = path.join(fixturesDirectory, dashboardKey, defaultRegion, slug, basename);
+ return fs.existsSync(fallback) ? fallback : null;
+}
+
+// Read a recorded file by its path under fixtures/, for example
+// readFixture('hardware/index.json').
+function readFixture(relativePath) {
+ return JSON.parse(fs.readFileSync(path.join(fixturesDirectory, relativePath), 'utf8'));
+}
+
+// A `test` whose page answers every data.firefox.com dataset request from the
+// recording, answers other third-party requests (Google Analytics) with an
+// empty response, and fails the test on any page error, console error, or
+// request with no recording. A spec that expects errors matches them with
+// test.use({ allowedErrors: /pattern/ }). It is a single RegExp because
+// test.use() reads an array as a [value, options] pair.
+//
+// With the liveData option (the live-data project in playwright.config.js),
+// the page is left alone and requests go to the real data.firefox.com.
+const test = base.test.extend({
+ allowedErrors: [null, { option: true }],
+ liveData: [false, { option: true }],
+
+ page: async ({ page, baseURL, allowedErrors, liveData }, use) => {
+ if (liveData) {
+ await use(page);
+ return;
+ }
+
+ const errors = [];
+ const unrecordedURLs = [];
+
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('console', message => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+
+ await page.route('**/*', async route => {
+ const url = route.request().url();
+
+ if (url.startsWith(datasetsPrefix)) {
+ const filename = fixturePath(url);
+ const recordedFilename = fs.existsSync(filename) ? filename : regionFallbackPath(filename);
+ if (recordedFilename) {
+ await route.fulfill({ path: recordedFilename, contentType: 'application/json' });
+ } else {
+ unrecordedURLs.push(url);
+ await route.fulfill({ status: 404, json: { error: 'Not recorded' } });
+ }
+ } else if (new URL(url).origin === new URL(baseURL).origin) {
+ await route.fallback();
+ } else {
+ await route.fulfill({ status: 200, body: '' });
+ }
+ });
+
+ await use(page);
+
+ base.expect(unrecordedURLs, 'Requests with no recording in tests/playwright/fixtures').toEqual([]);
+ const unexpectedErrors = errors.filter(error => !(allowedErrors && allowedErrors.test(error)));
+ base.expect(unexpectedErrors, 'Page and console errors').toEqual([]);
+ },
+});
+
+module.exports = {
+ test,
+ expect: base.expect,
+ readFixture,
+};
diff --git a/tests/playwright/fixtures/hardware/default/browserArch/index.json b/tests/playwright/fixtures/hardware/default/browserArch/index.json
new file mode 100644
index 00000000..5812955f
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/browserArch/index.json
@@ -0,0 +1,216 @@
+{
+ "title": "Browsers by Architecture",
+ "description": "In 2017, 64-bit Firefox updates fully unthrottled on Win7+ for 2GB+ users.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 0.008296933042547298
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.008423250256591934
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.008484966160246413
+ },
+ {
+ "x": "2026-09-21",
+ "y": 0.01077037180300741
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.010858671795928407
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.00884593864468939
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.01037308943637849
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.008906128613556087
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.008975406379692494
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.00899472500263709
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.00873075111694832
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.008786071564822548
+ }
+ ],
+ "64-bit ARM": [
+ {
+ "x": "2026-10-12",
+ "y": 5.488557880343609
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.535873164717091
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.559448391354251
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.557440714070061
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.5672151656967594
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.560685420130773
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.620689275067849
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.733160775518718
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.74282764256018
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.723645511494701
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.666923613888226
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.638307594817448
+ }
+ ],
+ "32-bit": [
+ {
+ "x": "2026-10-12",
+ "y": 6.337461473541692
+ },
+ {
+ "x": "2026-10-05",
+ "y": 6.336316933161011
+ },
+ {
+ "x": "2026-09-28",
+ "y": 6.334263606136054
+ },
+ {
+ "x": "2026-09-21",
+ "y": 6.41266480551006
+ },
+ {
+ "x": "2026-09-14",
+ "y": 6.466460742831688
+ },
+ {
+ "x": "2026-09-07",
+ "y": 6.493991433622118
+ },
+ {
+ "x": "2026-08-31",
+ "y": 6.529146596139596
+ },
+ {
+ "x": "2026-08-24",
+ "y": 6.49513804463403
+ },
+ {
+ "x": "2026-08-17",
+ "y": 6.607418489150421
+ },
+ {
+ "x": "2026-08-10",
+ "y": 6.676404323742008
+ },
+ {
+ "x": "2026-08-03",
+ "y": 6.76044558451568
+ },
+ {
+ "x": "2026-07-27",
+ "y": 6.792176201808682
+ }
+ ],
+ "64-bit": [
+ {
+ "x": "2026-10-12",
+ "y": 88.16568371307216
+ },
+ {
+ "x": "2026-10-05",
+ "y": 88.11938665186531
+ },
+ {
+ "x": "2026-09-28",
+ "y": 88.09780303634945
+ },
+ {
+ "x": "2026-09-21",
+ "y": 88.01912410861688
+ },
+ {
+ "x": "2026-09-14",
+ "y": 87.95546541967563
+ },
+ {
+ "x": "2026-09-07",
+ "y": 87.93647720760241
+ },
+ {
+ "x": "2026-08-31",
+ "y": 87.83979103935617
+ },
+ {
+ "x": "2026-08-24",
+ "y": 87.7627950512337
+ },
+ {
+ "x": "2026-08-17",
+ "y": 87.6407784619097
+ },
+ {
+ "x": "2026-08-10",
+ "y": 87.59095543976065
+ },
+ {
+ "x": "2026-08-03",
+ "y": 87.56390005047915
+ },
+ {
+ "x": "2026-07-27",
+ "y": 87.56073013180905
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/cpuCores/index.json b/tests/playwright/fixtures/hardware/default/cpuCores/index.json
new file mode 100644
index 00000000..0d044816
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/cpuCores/index.json
@@ -0,0 +1,466 @@
+{
+ "title": "CPU Cores",
+ "description": "Nearly 34% of users have machines with two physical cores, and an additional 34% of users have machines with four physical cores.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "2": [
+ {
+ "x": "2026-10-12",
+ "y": 24.139333347731707
+ },
+ {
+ "x": "2026-10-05",
+ "y": 24.28323570921359
+ },
+ {
+ "x": "2026-09-28",
+ "y": 24.38020359417343
+ },
+ {
+ "x": "2026-09-21",
+ "y": 24.39245740988242
+ },
+ {
+ "x": "2026-09-14",
+ "y": 24.38979204121346
+ },
+ {
+ "x": "2026-09-07",
+ "y": 24.486121364206877
+ },
+ {
+ "x": "2026-08-31",
+ "y": 24.524146437726834
+ },
+ {
+ "x": "2026-08-24",
+ "y": 24.434024962970817
+ },
+ {
+ "x": "2026-08-17",
+ "y": 24.69741777942881
+ },
+ {
+ "x": "2026-08-10",
+ "y": 24.80065812395757
+ },
+ {
+ "x": "2026-08-03",
+ "y": 24.968217891538718
+ },
+ {
+ "x": "2026-07-27",
+ "y": 25.081937155552936
+ }
+ ],
+ "4": [
+ {
+ "x": "2026-10-12",
+ "y": 29.92900982700436
+ },
+ {
+ "x": "2026-10-05",
+ "y": 29.92774256262044
+ },
+ {
+ "x": "2026-09-28",
+ "y": 29.960118483629035
+ },
+ {
+ "x": "2026-09-21",
+ "y": 29.96645678725364
+ },
+ {
+ "x": "2026-09-14",
+ "y": 29.975789613908116
+ },
+ {
+ "x": "2026-09-07",
+ "y": 30.03418709209021
+ },
+ {
+ "x": "2026-08-31",
+ "y": 30.043174236774593
+ },
+ {
+ "x": "2026-08-24",
+ "y": 29.99025214786884
+ },
+ {
+ "x": "2026-08-17",
+ "y": 29.986859258177446
+ },
+ {
+ "x": "2026-08-10",
+ "y": 30.00372463385336
+ },
+ {
+ "x": "2026-08-03",
+ "y": 30.089615134559892
+ },
+ {
+ "x": "2026-07-27",
+ "y": 30.14220359076593
+ }
+ ],
+ "6": [
+ {
+ "x": "2026-10-12",
+ "y": 14.98951929093811
+ },
+ {
+ "x": "2026-10-05",
+ "y": 14.969121281285307
+ },
+ {
+ "x": "2026-09-28",
+ "y": 14.926778239439553
+ },
+ {
+ "x": "2026-09-21",
+ "y": 14.946941268183586
+ },
+ {
+ "x": "2026-09-14",
+ "y": 14.91563354736153
+ },
+ {
+ "x": "2026-09-07",
+ "y": 14.87585168051051
+ },
+ {
+ "x": "2026-08-31",
+ "y": 14.850867037756936
+ },
+ {
+ "x": "2026-08-24",
+ "y": 14.80916547939255
+ },
+ {
+ "x": "2026-08-17",
+ "y": 14.712954586805186
+ },
+ {
+ "x": "2026-08-10",
+ "y": 14.707258126083595
+ },
+ {
+ "x": "2026-08-03",
+ "y": 14.67747521861613
+ },
+ {
+ "x": "2026-07-27",
+ "y": 14.647412958146955
+ }
+ ],
+ "8": [
+ {
+ "x": "2026-10-12",
+ "y": 12.120157824394553
+ },
+ {
+ "x": "2026-10-05",
+ "y": 12.08000373864767
+ },
+ {
+ "x": "2026-09-28",
+ "y": 12.074521012113806
+ },
+ {
+ "x": "2026-09-21",
+ "y": 12.065750790990993
+ },
+ {
+ "x": "2026-09-14",
+ "y": 12.093347299255827
+ },
+ {
+ "x": "2026-09-07",
+ "y": 12.092641208720012
+ },
+ {
+ "x": "2026-08-31",
+ "y": 12.11286798296463
+ },
+ {
+ "x": "2026-08-24",
+ "y": 12.253945055495336
+ },
+ {
+ "x": "2026-08-17",
+ "y": 12.215718464622281
+ },
+ {
+ "x": "2026-08-10",
+ "y": 12.203030047808078
+ },
+ {
+ "x": "2026-08-03",
+ "y": 12.085983562232695
+ },
+ {
+ "x": "2026-07-27",
+ "y": 12.040829179932969
+ }
+ ],
+ "10": [
+ {
+ "x": "2026-10-12",
+ "y": 7.542291054594286
+ },
+ {
+ "x": "2026-10-05",
+ "y": 7.517425349858005
+ },
+ {
+ "x": "2026-09-28",
+ "y": 7.503871135076369
+ },
+ {
+ "x": "2026-09-21",
+ "y": 7.458984661857453
+ },
+ {
+ "x": "2026-09-14",
+ "y": 7.448593474673469
+ },
+ {
+ "x": "2026-09-07",
+ "y": 7.407767908453
+ },
+ {
+ "x": "2026-08-31",
+ "y": 7.402978423350028
+ },
+ {
+ "x": "2026-08-24",
+ "y": 7.432538187700066
+ },
+ {
+ "x": "2026-08-17",
+ "y": 7.377278799938192
+ },
+ {
+ "x": "2026-08-10",
+ "y": 7.327974583434586
+ },
+ {
+ "x": "2026-08-03",
+ "y": 7.300418954434877
+ },
+ {
+ "x": "2026-07-27",
+ "y": 7.281889877115863
+ }
+ ],
+ "12": [
+ {
+ "x": "2026-10-12",
+ "y": 3.802299946176327
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.783256684302459
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.76687817779909
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.76817691843945
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.77898399346977
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.7662035277842976
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.7716561856578146
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.7862172528121527
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.7590038034817654
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.732970699720472
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.708597527004611
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.6969874384578376
+ }
+ ],
+ "14": [
+ {
+ "x": "2026-10-12",
+ "y": 2.5434150852401025
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.5293598930991226
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.5101091874830375
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.5108981842235427
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.508773794091073
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.485072810206291
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.4748484211724584
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.472459032976689
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.4502301085141824
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.4328324485670847
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.4182347099358124
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.3973485413222946
+ }
+ ],
+ "16": [
+ {
+ "x": "2026-10-12",
+ "y": 2.0323164805447766
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.0175735372831625
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.003319670844894
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.007263471672597
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.0104637909596543
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.0014862717580026
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.0002106855057455
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.015240318265314
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.004061980059587
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.994758677103422
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.9754973625871357
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.9642945276061925
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 2.9016571433757745
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.892281243690243
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.874200499440788
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.8830705074963117
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.8786224450670987
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.850668136270799
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.8192505890909554
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.806157562518237
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.7964752189725486
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.796792659471828
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.775959639090134
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.74709673109902
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/cpuSpeed/index.json b/tests/playwright/fixtures/hardware/default/cpuSpeed/index.json
new file mode 100644
index 00000000..921209b3
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/cpuSpeed/index.json
@@ -0,0 +1,518 @@
+{
+ "title": "CPU Speeds",
+ "description": "About 20% of users have processors with clock speeds between 2.3 GHz and 2.69 GHz.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "NULL": [
+ {
+ "x": "2026-10-12",
+ "y": 48.12679140977553
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 8.70501726049331
+ },
+ {
+ "x": "2026-10-05",
+ "y": 8.338386674550954
+ },
+ {
+ "x": "2026-09-28",
+ "y": 8.28565438044515
+ },
+ {
+ "x": "2026-09-21",
+ "y": 8.041841480197654
+ },
+ {
+ "x": "2026-09-14",
+ "y": 7.984378403537466
+ },
+ {
+ "x": "2026-09-07",
+ "y": 7.888892819466152
+ },
+ {
+ "x": "2026-08-31",
+ "y": 7.849801200650872
+ },
+ {
+ "x": "2026-08-24",
+ "y": 7.85963154042809
+ },
+ {
+ "x": "2026-08-17",
+ "y": 7.876248605246927
+ },
+ {
+ "x": "2026-08-10",
+ "y": 7.845511360895202
+ },
+ {
+ "x": "2026-08-03",
+ "y": 7.78392345508364
+ },
+ {
+ "x": "2026-07-27",
+ "y": 7.762763376044285
+ }
+ ],
+ "Less than 1.4 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 3.6231343143415926
+ },
+ {
+ "x": "2026-10-05",
+ "y": 8.641142070504664
+ },
+ {
+ "x": "2026-09-28",
+ "y": 8.67736596830428
+ },
+ {
+ "x": "2026-09-21",
+ "y": 8.683643176582871
+ },
+ {
+ "x": "2026-09-14",
+ "y": 8.683968580049438
+ },
+ {
+ "x": "2026-09-07",
+ "y": 8.684961391607633
+ },
+ {
+ "x": "2026-08-31",
+ "y": 8.74801975382865
+ },
+ {
+ "x": "2026-08-24",
+ "y": 8.840854150038448
+ },
+ {
+ "x": "2026-08-17",
+ "y": 8.847478922210943
+ },
+ {
+ "x": "2026-08-10",
+ "y": 8.841178170709325
+ },
+ {
+ "x": "2026-08-03",
+ "y": 8.810413232089052
+ },
+ {
+ "x": "2026-07-27",
+ "y": 8.803771175225704
+ }
+ ],
+ "1.7 GHz to 1.99 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 1.6637331276837517
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.993784797182516
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.006489572540524
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.989243923202185
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.9862612404107134
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.027809645363281
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.056573841872728
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.072301695565122
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.080400419916484
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.066204818511352
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.072115930517668
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.081181137066403
+ }
+ ],
+ "2.0 GHz to 2.29 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 5.577063682660267
+ },
+ {
+ "x": "2026-10-05",
+ "y": 10.347797697044854
+ },
+ {
+ "x": "2026-09-28",
+ "y": 10.355722563310536
+ },
+ {
+ "x": "2026-09-21",
+ "y": 10.350755814101689
+ },
+ {
+ "x": "2026-09-14",
+ "y": 10.35216500079547
+ },
+ {
+ "x": "2026-09-07",
+ "y": 10.375323975548508
+ },
+ {
+ "x": "2026-08-31",
+ "y": 10.410221110243983
+ },
+ {
+ "x": "2026-08-24",
+ "y": 10.401629373983097
+ },
+ {
+ "x": "2026-08-17",
+ "y": 10.378148167721953
+ },
+ {
+ "x": "2026-08-10",
+ "y": 10.37916092447635
+ },
+ {
+ "x": "2026-08-03",
+ "y": 10.40716405118203
+ },
+ {
+ "x": "2026-07-27",
+ "y": 10.436878215327585
+ }
+ ],
+ "2.3 GHz to 2.69 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 12.238432086218966
+ },
+ {
+ "x": "2026-10-05",
+ "y": 23.871338439519153
+ },
+ {
+ "x": "2026-09-28",
+ "y": 23.862444114876084
+ },
+ {
+ "x": "2026-09-21",
+ "y": 23.87745020771498
+ },
+ {
+ "x": "2026-09-14",
+ "y": 23.901780180236653
+ },
+ {
+ "x": "2026-09-07",
+ "y": 23.945593000870804
+ },
+ {
+ "x": "2026-08-31",
+ "y": 23.911098585218056
+ },
+ {
+ "x": "2026-08-24",
+ "y": 23.910337410884342
+ },
+ {
+ "x": "2026-08-17",
+ "y": 23.885818991553506
+ },
+ {
+ "x": "2026-08-10",
+ "y": 23.866299471266274
+ },
+ {
+ "x": "2026-08-03",
+ "y": 23.88149919932489
+ },
+ {
+ "x": "2026-07-27",
+ "y": 23.92153745559806
+ }
+ ],
+ "2.7 GHz to 2.99 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 5.787656669571629
+ },
+ {
+ "x": "2026-10-05",
+ "y": 11.350442650768954
+ },
+ {
+ "x": "2026-09-28",
+ "y": 11.353749869338968
+ },
+ {
+ "x": "2026-09-21",
+ "y": 11.39746776685927
+ },
+ {
+ "x": "2026-09-14",
+ "y": 11.431099760089072
+ },
+ {
+ "x": "2026-09-07",
+ "y": 11.456950745291515
+ },
+ {
+ "x": "2026-08-31",
+ "y": 11.471609140187528
+ },
+ {
+ "x": "2026-08-24",
+ "y": 11.48823817651663
+ },
+ {
+ "x": "2026-08-17",
+ "y": 11.48157946576397
+ },
+ {
+ "x": "2026-08-10",
+ "y": 11.490332826382703
+ },
+ {
+ "x": "2026-08-03",
+ "y": 11.50566962594536
+ },
+ {
+ "x": "2026-07-27",
+ "y": 11.529109517845592
+ }
+ ],
+ "3.0 GHz to 3.29 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 6.449429968045842
+ },
+ {
+ "x": "2026-10-05",
+ "y": 12.704603854307928
+ },
+ {
+ "x": "2026-09-28",
+ "y": 12.697019328696015
+ },
+ {
+ "x": "2026-09-21",
+ "y": 12.767424677665629
+ },
+ {
+ "x": "2026-09-14",
+ "y": 12.78078220817523
+ },
+ {
+ "x": "2026-09-07",
+ "y": 12.75367890262977
+ },
+ {
+ "x": "2026-08-31",
+ "y": 12.726155881068552
+ },
+ {
+ "x": "2026-08-24",
+ "y": 12.64440465235309
+ },
+ {
+ "x": "2026-08-17",
+ "y": 12.616564726638282
+ },
+ {
+ "x": "2026-08-10",
+ "y": 12.629979351530876
+ },
+ {
+ "x": "2026-08-03",
+ "y": 12.683114052908648
+ },
+ {
+ "x": "2026-07-27",
+ "y": 12.66120322980429
+ }
+ ],
+ "3.3 GHz to 3.69 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 6.564262042024825
+ },
+ {
+ "x": "2026-10-05",
+ "y": 14.02008071639345
+ },
+ {
+ "x": "2026-09-28",
+ "y": 14.02937153744081
+ },
+ {
+ "x": "2026-09-21",
+ "y": 14.11386088502359
+ },
+ {
+ "x": "2026-09-14",
+ "y": 14.09488756008596
+ },
+ {
+ "x": "2026-09-07",
+ "y": 14.078265561274977
+ },
+ {
+ "x": "2026-08-31",
+ "y": 14.043643096950756
+ },
+ {
+ "x": "2026-08-24",
+ "y": 13.997003838137019
+ },
+ {
+ "x": "2026-08-17",
+ "y": 14.046341654198333
+ },
+ {
+ "x": "2026-08-10",
+ "y": 14.080008004561886
+ },
+ {
+ "x": "2026-08-03",
+ "y": 14.06883333025601
+ },
+ {
+ "x": "2026-07-27",
+ "y": 14.03296567797853
+ }
+ ],
+ "3.7 GHz to 3.99 GHz": [
+ {
+ "x": "2026-10-12",
+ "y": 1.264479439184287
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.674082891226179
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.66544311407512
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.709851044606234
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.712259206408918
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.709188487454649
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.696958780029011
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.695649887304671
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.697292511825597
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.710392230222736
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.690455918054328
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.669610439287255
+ }
+ ],
+ "1.5 GHz to 1.69 GHz": [
+ {
+ "x": "2026-10-05",
+ "y": 1.0583402085013514
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.0667395509725117
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.068461024045901
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.0724178602110814
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.079335470492709
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.0859186099498606
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.0899492747894945
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.0901265349240057
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.0909328414432922
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.0968112046383764
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.1009797758222974
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/cpuVendor/index.json b/tests/playwright/fixtures/hardware/default/cpuVendor/index.json
new file mode 100644
index 00000000..0a160a7c
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/cpuVendor/index.json
@@ -0,0 +1,216 @@
+{
+ "title": "CPU Vendor",
+ "description": "Intel leads the share of CPUs found in our Release users, with about 82% of profiles, while AMD follows behind with about 14%.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "Apple": [
+ {
+ "x": "2026-10-12",
+ "y": 5.118192872004815
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.179975064389205
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.209402748160138
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.204281255819255
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.20949634223881
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.20849209767522
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.272007882785372
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.383633422236448
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.395731305512673
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.381324676195475
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.330372322494414
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.305741810103878
+ }
+ ],
+ "AMD": [
+ {
+ "x": "2026-10-12",
+ "y": 17.182857488782872
+ },
+ {
+ "x": "2026-10-05",
+ "y": 17.16489090313219
+ },
+ {
+ "x": "2026-09-28",
+ "y": 17.177459649325495
+ },
+ {
+ "x": "2026-09-21",
+ "y": 17.133676497579422
+ },
+ {
+ "x": "2026-09-14",
+ "y": 17.097844977225748
+ },
+ {
+ "x": "2026-09-07",
+ "y": 17.11505276018236
+ },
+ {
+ "x": "2026-08-31",
+ "y": 17.100524959784995
+ },
+ {
+ "x": "2026-08-24",
+ "y": 17.167074263596174
+ },
+ {
+ "x": "2026-08-17",
+ "y": 17.1248035291232
+ },
+ {
+ "x": "2026-08-10",
+ "y": 17.11413781955526
+ },
+ {
+ "x": "2026-08-03",
+ "y": 16.997112144673814
+ },
+ {
+ "x": "2026-07-27",
+ "y": 16.953156547290476
+ }
+ ],
+ "Intel": [
+ {
+ "x": "2026-10-12",
+ "y": 77.22427799148323
+ },
+ {
+ "x": "2026-10-05",
+ "y": 77.20184542252979
+ },
+ {
+ "x": "2026-09-28",
+ "y": 77.1699949541184
+ },
+ {
+ "x": "2026-09-21",
+ "y": 77.20883130177288
+ },
+ {
+ "x": "2026-09-14",
+ "y": 77.22845957728619
+ },
+ {
+ "x": "2026-09-07",
+ "y": 77.22242439540578
+ },
+ {
+ "x": "2026-08-31",
+ "y": 77.17526631888884
+ },
+ {
+ "x": "2026-08-24",
+ "y": 77.00301438752275
+ },
+ {
+ "x": "2026-08-17",
+ "y": 77.03852430040479
+ },
+ {
+ "x": "2026-08-10",
+ "y": 77.0682422352293
+ },
+ {
+ "x": "2026-08-03",
+ "y": 77.24244311845624
+ },
+ {
+ "x": "2026-07-27",
+ "y": 77.31582863475819
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 0.474671647729082
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.4532886099488085
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.44314264839597955
+ },
+ {
+ "x": "2026-09-21",
+ "y": 0.4532109448284529
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.4641991032492462
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.45403074673665106
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.45220083854079174
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.4462779266446312
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.4409408649593453
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.4362952690199593
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.4300724143755263
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.4252730078474539
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/gpuModel/index.json b/tests/playwright/fixtures/hardware/default/gpuModel/index.json
new file mode 100644
index 00000000..b4601e79
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/gpuModel/index.json
@@ -0,0 +1,911 @@
+{
+ "title": "GPU Model",
+ "description": [
+ "Share of primary GPU models",
+ "Note: this report only includes the primary GPU for each machine. Any supplemental GPUs are not included and this report is not representative of accelerated graphics."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "GFX9-RAVEN": [
+ {
+ "x": "2026-10-12",
+ "y": 2.183931515225801
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.1835549125335474
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.1919593528871655
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.177650109982196
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.173214123518668
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.1827146045043313
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.18810139220899
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.1688337407505225
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.1589394544128164
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.1642795087750235
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.172576839731234
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.18534662267389
+ }
+ ],
+ "GFX9-RENOIR": [
+ {
+ "x": "2026-10-12",
+ "y": 4.152065023403072
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.159308639813482
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.15954945959233
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.151701406676782
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.137393090948953
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.13024963769526
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.125021500112692
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.11228311330073
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.077148368227003
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.065888888087706
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.043244379546721
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.039931260109003
+ }
+ ],
+ "gen11-icelake-gt1": [
+ {
+ "x": "2026-10-12",
+ "y": 1.2738877586020343
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.2754838610890444
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.2805027986289947
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.2723943472298933
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.2706401366376225
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.2745760781836155
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.2790287918139496
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.2699114883595013
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.2613576280602212
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.26490643367405
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.2704201083387074
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.281184570429511
+ }
+ ],
+ "gen12-alderlake-gt1": [
+ {
+ "x": "2026-10-12",
+ "y": 3.755396658584945
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.757164039111915
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.736917654353068
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.750307315088667
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.7206125905489635
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.668726162666823
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.6225280763220895
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.534642935060249
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.517754289252983
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.496848017915708
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.5137274569076253
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.482979973570807
+ }
+ ],
+ "gen12-alderlake-gt2": [
+ {
+ "x": "2026-10-12",
+ "y": 3.8312811054683658
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.812827739915408
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.808201912508528
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.801273920389846
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.805623143473464
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.800411247559204
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.806870961124538
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.809846802724991
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.797282456368276
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.794288148914586
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.793585989993284
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.799815012890059
+ }
+ ],
+ "gen12-arl": [
+ {
+ "x": "2026-10-12",
+ "y": 1.2164680388152755
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.1959055696516936
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.175984013222658
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.1574000071796833
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.1434151721025703
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.1107169406880542
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.0879342996765953
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.0635876733140717
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.0284638638661323
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.000024791246185
+ }
+ ],
+ "gen12-mtl": [
+ {
+ "x": "2026-10-12",
+ "y": 1.3619671748351287
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.357164643619622
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.3527693415958268
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.352255832694511
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.3555090731474477
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.3449875244019733
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.3477372938233652
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.3500315965358018
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.3328762216039733
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.3188404030261227
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.3102870987175967
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.3072144881173815
+ }
+ ],
+ "gen12-raptorlake": [
+ {
+ "x": "2026-10-12",
+ "y": 5.4470151087739955
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.3979930941969485
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.357768752936135
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.325816746349606
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.316136894499409
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.267779144806492
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.226364556084114
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.196138295443553
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.137369390949939
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.09021151653368
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.058325950523213
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.028592193291594
+ }
+ ],
+ "gen12-tigerlake-gt2": [
+ {
+ "x": "2026-10-12",
+ "y": 5.218824079722772
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.205302110532374
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.210461172594879
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.204387960241898
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.211803756995289
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.226997955385604
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.247946994882193
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.256141876587982
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.241469826314812
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.251613865524699
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.250496453210674
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.276869097107485
+ }
+ ],
+ "Sandy Bridge (GT1)": [
+ {
+ "x": "2026-10-12",
+ "y": 1.556875537386616
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.5752765049808943
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.5873136862370294
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.5861358367794274
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.58358951700348
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.5922989132625287
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.6103220437839005
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.6035956386822985
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.6344099689946663
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.6511843413880227
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.665726148437437
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.66246027720849
+ }
+ ],
+ "Ivy Bridge (GT1)": [
+ {
+ "x": "2026-10-12",
+ "y": 1.8608961805619524
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.881844949552337
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.890528440864417
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.893248102357115
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.890552847841227
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.9020111881309296
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.9125307821645674
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.901888038154394
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.939603075421257
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.9586673430303405
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.973273213874988
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.9705367558852604
+ }
+ ],
+ "Ivy Bridge (GT2)": [
+ {
+ "x": "2026-10-12",
+ "y": 1.447767348759731
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.4562349871590272
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.4655982230384093
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.4645986525280823
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.4692711502610114
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.4832965782514171
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.493177193581711
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.5091484364235246
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.5284725317325831
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.533595966913982
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.5385673099081643
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.5459469359425964
+ }
+ ],
+ "Haswell (GT2)": [
+ {
+ "x": "2026-10-12",
+ "y": 4.120874185750916
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.142908877139222
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.151016801580083
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.15911397660851
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.159540371799735
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.183255084080197
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.190644073009079
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.179542807707103
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.232305008289574
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.253472577127723
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.286737989053355
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.296177161096203
+ }
+ ],
+ "Coffee Lake (GT2)": [
+ {
+ "x": "2026-10-12",
+ "y": 11.13656205915638
+ },
+ {
+ "x": "2026-10-05",
+ "y": 11.12722901007246
+ },
+ {
+ "x": "2026-09-28",
+ "y": 11.131354396470448
+ },
+ {
+ "x": "2026-09-21",
+ "y": 11.149359659561403
+ },
+ {
+ "x": "2026-09-14",
+ "y": 11.138356582890212
+ },
+ {
+ "x": "2026-09-07",
+ "y": 11.100081528997942
+ },
+ {
+ "x": "2026-08-31",
+ "y": 11.10064770367704
+ },
+ {
+ "x": "2026-08-24",
+ "y": 10.994611279929131
+ },
+ {
+ "x": "2026-08-17",
+ "y": 10.925175663420832
+ },
+ {
+ "x": "2026-08-10",
+ "y": 10.92731507681681
+ },
+ {
+ "x": "2026-08-03",
+ "y": 10.999321072616816
+ },
+ {
+ "x": "2026-07-27",
+ "y": 11.002301948915923
+ }
+ ],
+ "Kaby Lake (GT1.5)": [
+ {
+ "x": "2026-10-12",
+ "y": 1.9430086468233347
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.9347717583166228
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.9333030119136505
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.92219802845287
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.9220476600630765
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.9488189139811596
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.95981456486441
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.9754593108910394
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.9829991561140963
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.978215073389894
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.9763330307238043
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.9817804698458377
+ }
+ ],
+ "Kaby Lake (GT2)": [
+ {
+ "x": "2026-10-12",
+ "y": 3.9084995433617826
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.921370918522558
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.928602084630932
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.931070535070089
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.93910975834363
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.9646016262986445
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.980695169270448
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.972680670659704
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.9946791564977886
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.009044642009573
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.032916921835675
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.045368335394238
+ }
+ ],
+ "Skylake (GT2)": [
+ {
+ "x": "2026-10-12",
+ "y": 3.939105225448204
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.941287288534817
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.942872825468045
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.948763144576964
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.948373337468872
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.973718035829809
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.967802900970949
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.943844046156861
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.9696329582340826
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.983523968228847
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.015748410194348
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.039441565691342
+ }
+ ],
+ "gen9lp-geminilake": [
+ {
+ "x": "2026-10-12",
+ "y": 1.1206825739011175
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.1321931144379922
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.1365411782201753
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.1233807741478694
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.1194370538908036
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.1200387711456559
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.1256543885569
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.125721176235911
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.1268976929800698
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.1338965914378096
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.145721285162142
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.1498382568683447
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/gpuVendor/index.json b/tests/playwright/fixtures/hardware/default/gpuVendor/index.json
new file mode 100644
index 00000000..6f56767d
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/gpuVendor/index.json
@@ -0,0 +1,269 @@
+{
+ "title": "GPU Vendor",
+ "description": [
+ "Intel makes up the largest part of the desktop GPU market, accounting for over 65% of our release Desktop population. AMD and Nvidia come in virtually tied for 2^nd^.",
+ "Note: this report only includes the primary GPU for each machine. Any supplemental GPUs are not included and this report is not representative of accelerated graphics."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "AMD": [
+ {
+ "x": "2026-10-12",
+ "y": 14.229359654279945
+ },
+ {
+ "x": "2026-10-05",
+ "y": 14.22567063238116
+ },
+ {
+ "x": "2026-09-28",
+ "y": 14.228335250392387
+ },
+ {
+ "x": "2026-09-21",
+ "y": 14.20514313115304
+ },
+ {
+ "x": "2026-09-14",
+ "y": 14.188572330409954
+ },
+ {
+ "x": "2026-09-07",
+ "y": 14.17680170008413
+ },
+ {
+ "x": "2026-08-31",
+ "y": 14.13770717321406
+ },
+ {
+ "x": "2026-08-24",
+ "y": 14.1338409772721
+ },
+ {
+ "x": "2026-08-17",
+ "y": 14.076659965530267
+ },
+ {
+ "x": "2026-08-10",
+ "y": 14.05352281378191
+ },
+ {
+ "x": "2026-08-03",
+ "y": 13.991200036765713
+ },
+ {
+ "x": "2026-07-27",
+ "y": 13.967721691586457
+ }
+ ],
+ "Apple": [
+ {
+ "x": "2026-10-12",
+ "y": 5.08368424321491
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.14514778104319
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.175047044037677
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.171607684117443
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.176428485410834
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.176085234688744
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.240208340858228
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.352289421463997
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.365595138364868
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.351280622113836
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.3010382517829955
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.2762803633693975
+ }
+ ],
+ "Intel": [
+ {
+ "x": "2026-10-12",
+ "y": 64.04267907002232
+ },
+ {
+ "x": "2026-10-05",
+ "y": 64.06120467823865
+ },
+ {
+ "x": "2026-09-28",
+ "y": 64.08112849229967
+ },
+ {
+ "x": "2026-09-21",
+ "y": 64.03437498218948
+ },
+ {
+ "x": "2026-09-14",
+ "y": 63.99828354121393
+ },
+ {
+ "x": "2026-09-07",
+ "y": 63.98354297637308
+ },
+ {
+ "x": "2026-08-31",
+ "y": 63.97545888225203
+ },
+ {
+ "x": "2026-08-24",
+ "y": 63.69965058138956
+ },
+ {
+ "x": "2026-08-17",
+ "y": 63.73189214508789
+ },
+ {
+ "x": "2026-08-10",
+ "y": 63.73818987031911
+ },
+ {
+ "x": "2026-08-03",
+ "y": 63.92446544050656
+ },
+ {
+ "x": "2026-07-27",
+ "y": 63.97011239073785
+ }
+ ],
+ "NVIDIA": [
+ {
+ "x": "2026-10-12",
+ "y": 13.174075375844396
+ },
+ {
+ "x": "2026-10-05",
+ "y": 13.139053081190731
+ },
+ {
+ "x": "2026-09-28",
+ "y": 13.125308056941828
+ },
+ {
+ "x": "2026-09-21",
+ "y": 13.180345387480724
+ },
+ {
+ "x": "2026-09-14",
+ "y": 13.21273177699268
+ },
+ {
+ "x": "2026-09-07",
+ "y": 13.285458046340684
+ },
+ {
+ "x": "2026-08-31",
+ "y": 13.28569089767208
+ },
+ {
+ "x": "2026-08-24",
+ "y": 13.48116913689063
+ },
+ {
+ "x": "2026-08-17",
+ "y": 13.4852533597227
+ },
+ {
+ "x": "2026-08-10",
+ "y": 13.504697773894767
+ },
+ {
+ "x": "2026-08-03",
+ "y": 13.418488192866088
+ },
+ {
+ "x": "2026-07-27",
+ "y": 13.423123726209907
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 3.470201656638423
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.428923827146269
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.390181156328444
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.408528815059327
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.423983865972597
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.378112042513367
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.360934706003594
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.333049882983715
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.340599391294273
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.3523089198903775
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.364808078078639
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.3627618280963874
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/hasFlash/index.json b/tests/playwright/fixtures/hardware/default/hasFlash/index.json
new file mode 100644
index 00000000..b1748518
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/hasFlash/index.json
@@ -0,0 +1,66 @@
+{
+ "title": "Has Flash",
+ "description": "Since Adobe stopped supporting Flash Player beginning December 31, 2020, Flash availability on Firefox is now below 1%.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-10-12",
+ "y": 0.2644453037456201
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.2643077093034035
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.26806836980071275
+ },
+ {
+ "x": "2026-09-21",
+ "y": 0.27037884465244394
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.27453317187705256
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.2759915738732553
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.27925795303120565
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.27432403921744986
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.2788545082232424
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.2814447594910889
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.28874683574766796
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.2929372181127731
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/osArch/index.json b/tests/playwright/fixtures/hardware/default/osArch/index.json
new file mode 100644
index 00000000..be2ef022
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/osArch/index.json
@@ -0,0 +1,166 @@
+{
+ "title": "Operating Systems by Architecture",
+ "description": "We see 64-bit systems accounting for over 85% of our users. Note that this metric likely undercounts non-Windows OSs, as it relies on the environment.system.isWow64 Telemetry field.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "64-bit ARM": [
+ {
+ "x": "2026-10-12",
+ "y": 5.488557880343609
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.535873164717091
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.559448391354251
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.557440714070061
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.5672151656967594
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.560685420130773
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.620689275067849
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.733160775518718
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.74282764256018
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.723645511494701
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.666923613888226
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.638307594817448
+ }
+ ],
+ "32-bit": [
+ {
+ "x": "2026-10-12",
+ "y": 6.34575840658424
+ },
+ {
+ "x": "2026-10-05",
+ "y": 6.344740183417603
+ },
+ {
+ "x": "2026-09-28",
+ "y": 6.3427485722963
+ },
+ {
+ "x": "2026-09-21",
+ "y": 6.423435177313068
+ },
+ {
+ "x": "2026-09-14",
+ "y": 6.477319414627618
+ },
+ {
+ "x": "2026-09-07",
+ "y": 6.502837372266808
+ },
+ {
+ "x": "2026-08-31",
+ "y": 6.539519685575974
+ },
+ {
+ "x": "2026-08-24",
+ "y": 6.504044173247586
+ },
+ {
+ "x": "2026-08-17",
+ "y": 6.616393895530114
+ },
+ {
+ "x": "2026-08-10",
+ "y": 6.685399048744645
+ },
+ {
+ "x": "2026-08-03",
+ "y": 6.769176335632628
+ },
+ {
+ "x": "2026-07-27",
+ "y": 6.800962273373504
+ }
+ ],
+ "64-bit": [
+ {
+ "x": "2026-10-12",
+ "y": 88.16568371307216
+ },
+ {
+ "x": "2026-10-05",
+ "y": 88.11938665186531
+ },
+ {
+ "x": "2026-09-28",
+ "y": 88.09780303634945
+ },
+ {
+ "x": "2026-09-21",
+ "y": 88.01912410861688
+ },
+ {
+ "x": "2026-09-14",
+ "y": 87.95546541967563
+ },
+ {
+ "x": "2026-09-07",
+ "y": 87.93647720760241
+ },
+ {
+ "x": "2026-08-31",
+ "y": 87.83979103935617
+ },
+ {
+ "x": "2026-08-24",
+ "y": 87.7627950512337
+ },
+ {
+ "x": "2026-08-17",
+ "y": 87.6407784619097
+ },
+ {
+ "x": "2026-08-10",
+ "y": 87.59095543976065
+ },
+ {
+ "x": "2026-08-03",
+ "y": 87.56390005047915
+ },
+ {
+ "x": "2026-07-27",
+ "y": 87.56073013180905
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/osName/index.json b/tests/playwright/fixtures/hardware/default/osName/index.json
new file mode 100644
index 00000000..6404c947
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/osName/index.json
@@ -0,0 +1,566 @@
+{
+ "title": "Operating System",
+ "description": "Windows 10 and Windows 11 are the most popular operating systems, accounting for around 75% of our users.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "macOS Sequoia": [
+ {
+ "x": "2026-10-12",
+ "y": 1.3523182459959462
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.3768128048549808
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.3944879983077927
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.4060145370548596
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.4170189332581595
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.4374491952322797
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.4779988592894664
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.5303541892757413
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.5448151669466776
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.5517461697524644
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.5504260580747158
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.5587826152759459
+ }
+ ],
+ "macOS Tahoe": [
+ {
+ "x": "2026-10-12",
+ "y": 2.80627023896583
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.9061907335412065
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.996022223432461
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.1021541649746744
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.267411231502872
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.576053935386919
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.595669833974414
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.646151979067596
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.63678597122931
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.60963146232894
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.558556860278904
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.5218235618541547
+ }
+ ],
+ "macOS Other": [
+ {
+ "x": "2026-10-12",
+ "y": 3.9514172259110256
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.931252294513631
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.892923559139833
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.778847360703791
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.624034435752673
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.314991327342466
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.357678058543117
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.409954438366948
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.40388488465596
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.3973485186096775
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.406705732816093
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.435854680632214
+ }
+ ],
+ "Linux-6.x": [
+ {
+ "x": "2026-10-12",
+ "y": 2.5478819091251115
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.607954198776018
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.643804509224445
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.7016408083036163
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.6915166331913447
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.6425048404659575
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.620522300391775
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.603135575578378
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.701079261954695
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.7439449332528216
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.7866224696807254
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.880238590858278
+ }
+ ],
+ "Linux-7.x": [
+ {
+ "x": "2026-10-12",
+ "y": 2.774065404466197
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.745644115085912
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.698601610473519
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.630756890969497
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.5766877689540575
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.535036315038908
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.4889669151655642
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.474145895076351
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.4295489635876932
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.336144730031506
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.222207419133583
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.0945073911669287
+ }
+ ],
+ "Linux Other": [
+ {
+ "x": "2026-10-12",
+ "y": 1.2104978152459933
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.2354916904603004
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.2243008794938097
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.243380906596851
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.2294339999794612
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.1905109877214177
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.1865991054150613
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.1596246779459416
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.1938700042998842
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.2055375318281318
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.2199916363163426
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.2270210665254635
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 0.0015328620624078804
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.00154365361070627
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.001557097132848691
+ },
+ {
+ "x": "2026-09-21",
+ "y": 0.001476077846567221
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.0014907883652668598
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.0015106997298381009
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.001674253031836528
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.0015871062695802273
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.0013738614089249881
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.0013334122292132462
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.0012696259011349498
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.001238908536068809
+ }
+ ],
+ "Windows 10": [
+ {
+ "x": "2026-10-12",
+ "y": 29.091633389251562
+ },
+ {
+ "x": "2026-10-05",
+ "y": 29.129181237821207
+ },
+ {
+ "x": "2026-09-28",
+ "y": 29.16277012382587
+ },
+ {
+ "x": "2026-09-21",
+ "y": 29.331320729841654
+ },
+ {
+ "x": "2026-09-14",
+ "y": 29.42558609882419
+ },
+ {
+ "x": "2026-09-07",
+ "y": 29.659459094004525
+ },
+ {
+ "x": "2026-08-31",
+ "y": 29.737748714100874
+ },
+ {
+ "x": "2026-08-24",
+ "y": 29.765959701592386
+ },
+ {
+ "x": "2026-08-17",
+ "y": 29.97609810655537
+ },
+ {
+ "x": "2026-08-10",
+ "y": 30.158034344833524
+ },
+ {
+ "x": "2026-08-03",
+ "y": 30.39294424108932
+ },
+ {
+ "x": "2026-07-27",
+ "y": 30.48796453086477
+ }
+ ],
+ "Windows 11": [
+ {
+ "x": "2026-10-12",
+ "y": 50.71104053271123
+ },
+ {
+ "x": "2026-10-05",
+ "y": 50.46169608321852
+ },
+ {
+ "x": "2026-09-28",
+ "y": 50.35514490621192
+ },
+ {
+ "x": "2026-09-21",
+ "y": 50.08344666937938
+ },
+ {
+ "x": "2026-09-14",
+ "y": 49.96347017303482
+ },
+ {
+ "x": "2026-09-07",
+ "y": 49.82203486426218
+ },
+ {
+ "x": "2026-08-31",
+ "y": 49.69887190009278
+ },
+ {
+ "x": "2026-08-24",
+ "y": 49.61024408615985
+ },
+ {
+ "x": "2026-08-17",
+ "y": 49.21271425716485
+ },
+ {
+ "x": "2026-08-10",
+ "y": 49.06309531977697
+ },
+ {
+ "x": "2026-08-03",
+ "y": 48.85836031334441
+ },
+ {
+ "x": "2026-07-27",
+ "y": 48.75894194386565
+ }
+ ],
+ "Windows 7": [
+ {
+ "x": "2026-10-12",
+ "y": 4.505889361617293
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.534852410273536
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.547953575680288
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.633326214906281
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.711415299777824
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.715488721590853
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.718862238647542
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.682663583474582
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.769658725686703
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.7963683463287285
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.850761464123007
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.8728647833215
+ }
+ ],
+ "Windows Other": [
+ {
+ "x": "2026-10-12",
+ "y": 1.0474530146474097
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.069380777843982
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.0824335170772097
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.0876356394228228
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.0919346373593277
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.1049600192246598
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.115407821347565
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.1161787671926477
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.130170796509927
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.1368152310280248
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.1521541792417676
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.160761927099027
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/ram/index.json b/tests/playwright/fixtures/hardware/default/ram/index.json
new file mode 100644
index 00000000..df726487
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/ram/index.json
@@ -0,0 +1,766 @@
+{
+ "title": "Memory",
+ "description": "The most popular memory sizes are 8GB and 16GB, with almost one-third and one-fifth of our Release users, respectively.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "12GB": [
+ {
+ "x": "2026-10-12",
+ "y": 1.905645440951915
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.894739730036822
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.8993171288189805
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.898749308146731
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.9028124197392149
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.920410055775462
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.926339903314834
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.9487139640152145
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.9652597770558846
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.9786016235552966
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.9709652219429539
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.9679195501399307
+ }
+ ],
+ "14GB": [
+ {
+ "x": "2026-10-12",
+ "y": 1.1109100667536467
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.099859425386195
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.0975752617376948
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.0953937284373967
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.0980648473095633
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.0975545948266092
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.0991748462997586
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.1029507846434112
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.0991412990922282
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.0981564976592528
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.0887152664719957
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.0864879389640476
+ }
+ ],
+ "15GB": [
+ {
+ "x": "2026-10-12",
+ "y": 5.940613879256578
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.920340897175133
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.880672258656241
+ },
+ {
+ "x": "2026-09-21",
+ "y": 5.853625938652765
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.813725247517721
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.752762545554551
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.719100169761632
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.682595354081611
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.617854765110613
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.55441552165428
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.49331563154196
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.458820835283323
+ }
+ ],
+ "16GB": [
+ {
+ "x": "2026-10-12",
+ "y": 24.78980700579179
+ },
+ {
+ "x": "2026-10-05",
+ "y": 24.676123370457304
+ },
+ {
+ "x": "2026-09-28",
+ "y": 24.624653620144993
+ },
+ {
+ "x": "2026-09-21",
+ "y": 24.617392873396227
+ },
+ {
+ "x": "2026-09-14",
+ "y": 24.668064056038446
+ },
+ {
+ "x": "2026-09-07",
+ "y": 24.69416055153533
+ },
+ {
+ "x": "2026-08-31",
+ "y": 24.731644623259445
+ },
+ {
+ "x": "2026-08-24",
+ "y": 24.861754596128545
+ },
+ {
+ "x": "2026-08-17",
+ "y": 24.749828624289876
+ },
+ {
+ "x": "2026-08-10",
+ "y": 24.702324779597646
+ },
+ {
+ "x": "2026-08-03",
+ "y": 24.640091461712377
+ },
+ {
+ "x": "2026-07-27",
+ "y": 24.61956200080629
+ }
+ ],
+ "2GB": [
+ {
+ "x": "2026-10-12",
+ "y": 1.2913622224336057
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.310226446926565
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.321083544486219
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.329218686589997
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.337159147415929
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.333424038084629
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.3316265080496215
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.3200122812907347
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.3571380099892996
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.369025021615216
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.389294131118339
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.4005040257741395
+ }
+ ],
+ "24GB": [
+ {
+ "x": "2026-10-12",
+ "y": 1.1750251119715636
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.1729882506619773
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.1713436796929542
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.1673057344150801
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.1648806589569505
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.164324099203346
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.168464830599217
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.1854668301440048
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.1849385321957602
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.1818576173687099
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.170758160515873
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.163634067391379
+ }
+ ],
+ "3GB": [
+ {
+ "x": "2026-10-12",
+ "y": 1.9913866901202473
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.006102007088484
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.0142653692285193
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.0168329952906174
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.0278995700144047
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.030305971816574
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.0372314357749257
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.015723819495798
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.0518999990718885
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.0639929710312614
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.086679000280718
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.0987862566216267
+ }
+ ],
+ "31GB": [
+ {
+ "x": "2026-10-12",
+ "y": 3.376647148468592
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.362173056407245
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.3531622387863926
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.3507255049644953
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.3447626260001306
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.3098267028836315
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.294717651958221
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.3231983207373172
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.3034758437362526
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.280752537348737
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.236913077651684
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.2188613636404906
+ }
+ ],
+ "32GB": [
+ {
+ "x": "2026-10-12",
+ "y": 7.498290629648585
+ },
+ {
+ "x": "2026-10-05",
+ "y": 7.476647651356358
+ },
+ {
+ "x": "2026-09-28",
+ "y": 7.474556543380245
+ },
+ {
+ "x": "2026-09-21",
+ "y": 7.478024626415325
+ },
+ {
+ "x": "2026-09-14",
+ "y": 7.503428707239804
+ },
+ {
+ "x": "2026-09-07",
+ "y": 7.525136010693255
+ },
+ {
+ "x": "2026-08-31",
+ "y": 7.548862017493621
+ },
+ {
+ "x": "2026-08-24",
+ "y": 7.670439665822643
+ },
+ {
+ "x": "2026-08-17",
+ "y": 7.665794272299478
+ },
+ {
+ "x": "2026-08-10",
+ "y": 7.672654675632748
+ },
+ {
+ "x": "2026-08-03",
+ "y": 7.60763986053353
+ },
+ {
+ "x": "2026-07-27",
+ "y": 7.604731467812799
+ }
+ ],
+ "4GB": [
+ {
+ "x": "2026-10-12",
+ "y": 10.090289176433153
+ },
+ {
+ "x": "2026-10-05",
+ "y": 10.182400900676626
+ },
+ {
+ "x": "2026-09-28",
+ "y": 10.221836474161128
+ },
+ {
+ "x": "2026-09-21",
+ "y": 10.242826831179368
+ },
+ {
+ "x": "2026-09-14",
+ "y": 10.260271211715752
+ },
+ {
+ "x": "2026-09-07",
+ "y": 10.315810109477628
+ },
+ {
+ "x": "2026-08-31",
+ "y": 10.341302893310289
+ },
+ {
+ "x": "2026-08-24",
+ "y": 10.251734106820267
+ },
+ {
+ "x": "2026-08-17",
+ "y": 10.411645306950753
+ },
+ {
+ "x": "2026-08-10",
+ "y": 10.490702948166078
+ },
+ {
+ "x": "2026-08-03",
+ "y": 10.60153751143815
+ },
+ {
+ "x": "2026-07-27",
+ "y": 10.649458780375888
+ }
+ ],
+ "6GB": [
+ {
+ "x": "2026-10-12",
+ "y": 2.436528850964225
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.4424469634996586
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.4517435643255276
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.447654642294891
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.443732851640787
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.4526762182661366
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.4527763586875326
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.4429906215499986
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.4450962053545156
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.4462223232869196
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.4508285313280838
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.459901959509461
+ }
+ ],
+ "64GB": [
+ {
+ "x": "2026-10-12",
+ "y": 1.3809368543570466
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.38030450116107
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.385725248027124
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.386054881997061
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.3888788188594021
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.392487689958401
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.3994545540133092
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.4236702718603398
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.4257422965870041
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.4294783081799443
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.4193956897656397
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.4204631999321984
+ }
+ ],
+ "7GB": [
+ {
+ "x": "2026-10-12",
+ "y": 3.160996453310736
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.1843290477611905
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.1797739089942905
+ },
+ {
+ "x": "2026-09-21",
+ "y": 3.1671862762733496
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.135481549191653
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.1117324561563056
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.07188341264486
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.029495588150129
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.022652530789095
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.0078602002671806
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.0079998962186782
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.9753988712341926
+ }
+ ],
+ "8GB": [
+ {
+ "x": "2026-10-12",
+ "y": 29.4779645513413
+ },
+ {
+ "x": "2026-10-05",
+ "y": 29.53115644350578
+ },
+ {
+ "x": "2026-09-28",
+ "y": 29.574549104288035
+ },
+ {
+ "x": "2026-09-21",
+ "y": 29.6097777767766
+ },
+ {
+ "x": "2026-09-14",
+ "y": 29.57024005839413
+ },
+ {
+ "x": "2026-09-07",
+ "y": 29.56397431187349
+ },
+ {
+ "x": "2026-08-31",
+ "y": 29.532046970874166
+ },
+ {
+ "x": "2026-08-24",
+ "y": 29.35618072564224
+ },
+ {
+ "x": "2026-08-17",
+ "y": 29.309525447034456
+ },
+ {
+ "x": "2026-08-10",
+ "y": 29.333723550769538
+ },
+ {
+ "x": "2026-08-03",
+ "y": 29.47736890909873
+ },
+ {
+ "x": "2026-07-27",
+ "y": 29.52879072127751
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 4.37359591819702
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.36016130789959
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.34974205527166
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.339230195170096
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.340598229966113
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.33541464389465
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.3453738239585675
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.385073069617743
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.390007090442898
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.390231423867198
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.358497650381291
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.346678961236725
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/default/resolution/index.json b/tests/playwright/fixtures/hardware/default/resolution/index.json
new file mode 100644
index 00000000..36aa69f9
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/default/resolution/index.json
@@ -0,0 +1,716 @@
+{
+ "title": "Display Resolution",
+ "description": "Two display resolutions, 1920x1080px and 1366x768px, stand out as the most highly-used, with the former showing an upward trend.",
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "1024x768": [
+ {
+ "x": "2026-10-12",
+ "y": 1.1529627011448729
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.1512525449525384
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.148413102571594
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.1532300660916186
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.1549751419520118
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.16033893323047
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.1379920388748381
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.1372362343515008
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.164010256969198
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.171023062251897
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.169549343983007
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.1690280422316135
+ }
+ ],
+ "1280x1024": [
+ {
+ "x": "2026-10-12",
+ "y": 1.4173687217196462
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.42914174885342
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.4422852744018895
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.4463310247436294
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.442214783035639
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.4301798621979935
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.4177066851502353
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.3975081820450792
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.3999437238541863
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.402717142882842
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.42517257975121
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.4373750809256707
+ }
+ ],
+ "1366x768": [
+ {
+ "x": "2026-10-12",
+ "y": 13.456379791136127
+ },
+ {
+ "x": "2026-10-05",
+ "y": 13.534966602443813
+ },
+ {
+ "x": "2026-09-28",
+ "y": 13.60444299301312
+ },
+ {
+ "x": "2026-09-21",
+ "y": 13.582054511173785
+ },
+ {
+ "x": "2026-09-14",
+ "y": 13.545497279383314
+ },
+ {
+ "x": "2026-09-07",
+ "y": 13.64451841918292
+ },
+ {
+ "x": "2026-08-31",
+ "y": 13.682609322310206
+ },
+ {
+ "x": "2026-08-24",
+ "y": 13.687662806457547
+ },
+ {
+ "x": "2026-08-17",
+ "y": 13.954983989343155
+ },
+ {
+ "x": "2026-08-10",
+ "y": 14.06641463348094
+ },
+ {
+ "x": "2026-08-03",
+ "y": 14.125519639085711
+ },
+ {
+ "x": "2026-07-27",
+ "y": 14.12653215892018
+ }
+ ],
+ "1440x900": [
+ {
+ "x": "2026-10-12",
+ "y": 2.030886736805841
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.0440589464101593
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.052963541980472
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.069134252735336
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.070978096155222
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.065411552224115
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.059206440113078
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.037389707348029
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.057069598830455
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.0600410531142974
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.0688038100717785
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.060923891235092
+ }
+ ],
+ "1600x900": [
+ {
+ "x": "2026-10-12",
+ "y": 4.073582158448031
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.092437465753559
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.094841656817951
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.117162201299705
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.125990001558883
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.1298773122660934
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.130504418814778
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.092474840770823
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.16406317946531
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.170147786305256
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.186942444891142
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.18395093856171
+ }
+ ],
+ "1680x1050": [
+ {
+ "x": "2026-10-12",
+ "y": 1.1732279069053342
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.177696435693018
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.1853433300029081
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.1874347615723126
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.188165959140035
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.1868211143302947
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.1865921726902915
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.1735698240303563
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.165461003360368
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.1621073198551717
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.1740860913975124
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.1853209274724237
+ }
+ ],
+ "1920x1080": [
+ {
+ "x": "2026-10-12",
+ "y": 51.04179091844946
+ },
+ {
+ "x": "2026-10-05",
+ "y": 50.93943321025179
+ },
+ {
+ "x": "2026-09-28",
+ "y": 50.89110426559187
+ },
+ {
+ "x": "2026-09-21",
+ "y": 50.85571145965612
+ },
+ {
+ "x": "2026-09-14",
+ "y": 50.81817865975803
+ },
+ {
+ "x": "2026-09-07",
+ "y": 50.74482075855837
+ },
+ {
+ "x": "2026-08-31",
+ "y": 50.71975548557047
+ },
+ {
+ "x": "2026-08-24",
+ "y": 50.53667108791695
+ },
+ {
+ "x": "2026-08-17",
+ "y": 50.20683433170008
+ },
+ {
+ "x": "2026-08-10",
+ "y": 50.15798472517287
+ },
+ {
+ "x": "2026-08-03",
+ "y": 50.2727899047014
+ },
+ {
+ "x": "2026-07-27",
+ "y": 50.32541477896652
+ }
+ ],
+ "1920x1200": [
+ {
+ "x": "2026-10-12",
+ "y": 5.011559523141262
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.975938766424965
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.944589974782808
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.906350651751884
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.890922161402636
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.841991207693336
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.80197434295621
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.779039398448259
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.707536575330176
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.642698859060018
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.602086159356588
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.578338350928188
+ }
+ ],
+ "2560x1440": [
+ {
+ "x": "2026-10-12",
+ "y": 4.40267692221496
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.394425601924434
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.3804095895061055
+ },
+ {
+ "x": "2026-09-21",
+ "y": 4.388180425627513
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.404533377178454
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.417666894681042
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.43423749005414
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.5029134633811445
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.485623634136003
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.473887941634493
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.428726021253722
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.419453604104145
+ }
+ ],
+ "2560x1600": [
+ {
+ "x": "2026-10-12",
+ "y": 1.2757422516257484
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.260033208730566
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.2438361314324187
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.2571203713029362
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.273940562301797
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.2706148779854902
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.2697909360585782
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.2817356996782254
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.2729992957884806
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.2744865591675612
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.2564625156128053
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.2544861373287652
+ }
+ ],
+ "2880x1800": [
+ {
+ "x": "2026-10-12",
+ "y": 2.167829507175681
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.184913394140387
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.1948217007063358
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.206535674680166
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.222472891756223
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.235273260374268
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.255107043696895
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.297413613518162
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.295516472396928
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.2960568760992985
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.2773541453211963
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.2762436271177657
+ }
+ ],
+ "3440x1440": [
+ {
+ "x": "2026-10-12",
+ "y": 1.0390889728546662
+ },
+ {
+ "x": "2026-10-05",
+ "y": 1.0402714066891037
+ },
+ {
+ "x": "2026-09-28",
+ "y": 1.043665061595993
+ },
+ {
+ "x": "2026-09-21",
+ "y": 1.0389504763010198
+ },
+ {
+ "x": "2026-09-14",
+ "y": 1.040971384130768
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.043716337769076
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.047336263426348
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.064738909515279
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.0625413016513996
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.0577759434816083
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.0424190674297396
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.0357330383379926
+ }
+ ],
+ "3840x2160": [
+ {
+ "x": "2026-10-12",
+ "y": 2.126765499250759
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.123459539153217
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.1212390299641295
+ },
+ {
+ "x": "2026-09-21",
+ "y": 2.118272486223125
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.1256742563143307
+ },
+ {
+ "x": "2026-09-07",
+ "y": 2.132871784465996
+ },
+ {
+ "x": "2026-08-31",
+ "y": 2.1349143943679096
+ },
+ {
+ "x": "2026-08-24",
+ "y": 2.164983704930091
+ },
+ {
+ "x": "2026-08-17",
+ "y": 2.168756934407573
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.1660069048266664
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.13755525037852
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.135887486490074
+ }
+ ],
+ "Other": [
+ {
+ "x": "2026-10-12",
+ "y": 9.630138389127618
+ },
+ {
+ "x": "2026-10-05",
+ "y": 9.651971128579023
+ },
+ {
+ "x": "2026-09-28",
+ "y": 9.652044347632401
+ },
+ {
+ "x": "2026-09-21",
+ "y": 9.673531636840847
+ },
+ {
+ "x": "2026-09-14",
+ "y": 9.695485445932665
+ },
+ {
+ "x": "2026-09-07",
+ "y": 9.695897685040539
+ },
+ {
+ "x": "2026-08-31",
+ "y": 9.72227296591602
+ },
+ {
+ "x": "2026-08-24",
+ "y": 9.846662527608549
+ },
+ {
+ "x": "2026-08-17",
+ "y": 9.894659702766686
+ },
+ {
+ "x": "2026-08-10",
+ "y": 9.898651192667083
+ },
+ {
+ "x": "2026-08-03",
+ "y": 9.832533026765669
+ },
+ {
+ "x": "2026-07-27",
+ "y": 9.811311937379859
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/hardware/index.json b/tests/playwright/fixtures/hardware/index.json
new file mode 100644
index 00000000..4d446622
--- /dev/null
+++ b/tests/playwright/fixtures/hardware/index.json
@@ -0,0 +1,79 @@
+{
+ "title": "Hardware Across the Web",
+ "description": "Hardware Across the Web is a public weekly report of the hardware used by a representative sample of the population from Firefox's release channel on desktop. This information can be used by developers to improve the Firefox experience for users.",
+ "metaDescription": "The hardware of Firefox users, including graphics, processors, operating systems, and plugins. Use our public data to improve your Firefox support!",
+ "categories": [
+ "default"
+ ],
+ "metrics": [
+ "resolution",
+ "gpuModel",
+ "gpuVendor",
+ "cpuSpeed",
+ "ram",
+ "cpuCores",
+ "cpuVendor",
+ "osName",
+ "browserArch",
+ "osArch",
+ "hasFlash"
+ ],
+ "summaryMetrics": [
+ "gpuVendor",
+ "cpuVendor",
+ "osName",
+ "hasFlash"
+ ],
+ "dates": [
+ "2026-10-12",
+ "2026-10-05",
+ "2026-09-28",
+ "2026-09-21",
+ "2026-09-14",
+ "2026-09-07",
+ "2026-08-31",
+ "2026-08-24",
+ "2026-08-17",
+ "2026-08-10",
+ "2026-08-03",
+ "2026-07-27"
+ ],
+ "sections": [
+ {
+ "key": "graphics",
+ "title": "Graphics",
+ "metrics": [
+ "gpuModel",
+ "gpuVendor",
+ "resolution"
+ ]
+ },
+ {
+ "key": "processor",
+ "title": "Processor",
+ "metrics": [
+ "cpuVendor",
+ "cpuCores",
+ "cpuSpeed",
+ "ram"
+ ]
+ },
+ {
+ "key": "operating-system",
+ "title": "Operating System",
+ "metrics": [
+ "osName",
+ "browserArch",
+ "osArch"
+ ]
+ },
+ {
+ "key": "plugins",
+ "title": "Plugins",
+ "metrics": [
+ "hasFlash"
+ ]
+ }
+ ],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/Germany/locale/index.json b/tests/playwright/fixtures/usage-behavior/Germany/locale/index.json
new file mode 100644
index 00000000..59448a65
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/Germany/locale/index.json
@@ -0,0 +1,269 @@
+{
+ "title": "Top Languages",
+ "description": [
+ "Top Languages shows the distribution of the top 5 language settings for Firefox Desktop.",
+ "Worldwide, English (US) remains the most common, at about 40% of the population, with German (11%) and French (8.1%) coming 2^nd^ and 3^rd^. Simplified Chinese is the 4^th^ most common language (6.7%), and Spanish (Spain) is the 5^th^ most common language (5%).",
+ "For most countries in the top 10, the majority (>90%) of users have their language set to the local language, with a notable exception in Indonesia, which has about 71% English (US) and 27% Indonesian."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "de": [
+ {
+ "x": "2026-09-21",
+ "y": 88.56700407916604
+ },
+ {
+ "x": "2026-08-31",
+ "y": 88.41887067674945
+ },
+ {
+ "x": "2026-07-27",
+ "y": 88.66706977389649
+ },
+ {
+ "x": "2026-08-17",
+ "y": 88.74229736135251
+ },
+ {
+ "x": "2026-10-12",
+ "y": 88.43227066187339
+ },
+ {
+ "x": "2026-09-14",
+ "y": 88.48005566734997
+ },
+ {
+ "x": "2026-09-28",
+ "y": 88.54088281178306
+ },
+ {
+ "x": "2026-08-03",
+ "y": 88.67328292057466
+ },
+ {
+ "x": "2026-08-10",
+ "y": 88.6664247116703
+ },
+ {
+ "x": "2026-09-07",
+ "y": 88.48276975990727
+ },
+ {
+ "x": "2026-08-24",
+ "y": 88.69156950760738
+ },
+ {
+ "x": "2026-10-05",
+ "y": 88.60579032022225
+ }
+ ],
+ "en-US": [
+ {
+ "x": "2026-09-21",
+ "y": 9.050649644961474
+ },
+ {
+ "x": "2026-08-31",
+ "y": 9.253963379524778
+ },
+ {
+ "x": "2026-07-27",
+ "y": 8.987639146337836
+ },
+ {
+ "x": "2026-08-17",
+ "y": 8.979499130984356
+ },
+ {
+ "x": "2026-10-12",
+ "y": 9.148889578276975
+ },
+ {
+ "x": "2026-09-14",
+ "y": 9.157843266319372
+ },
+ {
+ "x": "2026-09-28",
+ "y": 9.152977883821235
+ },
+ {
+ "x": "2026-08-03",
+ "y": 9.022806893557735
+ },
+ {
+ "x": "2026-08-10",
+ "y": 9.076336801354948
+ },
+ {
+ "x": "2026-09-07",
+ "y": 9.190411492527387
+ },
+ {
+ "x": "2026-08-24",
+ "y": 8.986435383905157
+ },
+ {
+ "x": "2026-10-05",
+ "y": 9.036408831700541
+ }
+ ],
+ "en-GB": [
+ {
+ "x": "2026-09-21",
+ "y": 0.704411542529083
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.6947909869102913
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.7206367772568223
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.6863248538473693
+ },
+ {
+ "x": "2026-10-12",
+ "y": 0.7244657749229001
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.6901999134899292
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.6882628246306323
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.6946057002089875
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.6673925316557787
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.693580108502532
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.6924731605260832
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.6844933469805528
+ }
+ ],
+ "ru": [
+ {
+ "x": "2026-09-21",
+ "y": 0.6789167547967971
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.6688802733100457
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.6928818532359268
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.6576868383630905
+ },
+ {
+ "x": "2026-10-12",
+ "y": 0.7062172667384441
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.6920805672051605
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.6891805083968064
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.6703752688063485
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.669408823292201
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.6470247313564716
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.6688996061251953
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.7183067699956134
+ }
+ ],
+ "pl": [
+ {
+ "x": "2026-09-21",
+ "y": 0.21812207282066778
+ },
+ {
+ "x": "2026-08-31",
+ "y": 0.20152777244635522
+ },
+ {
+ "x": "2026-07-27",
+ "y": 0.1932932208598079
+ },
+ {
+ "x": "2026-08-17",
+ "y": 0.19552851951335123
+ },
+ {
+ "x": "2026-10-12",
+ "y": 0.20073359002901514
+ },
+ {
+ "x": "2026-09-14",
+ "y": 0.21815583096684407
+ },
+ {
+ "x": "2026-09-28",
+ "y": 0.18537212076718362
+ },
+ {
+ "x": "2026-08-03",
+ "y": 0.18374743813667985
+ },
+ {
+ "x": "2026-08-10",
+ "y": 0.18449068473263974
+ },
+ {
+ "x": "2026-09-07",
+ "y": 0.21472480071448252
+ },
+ {
+ "x": "2026-08-24",
+ "y": 0.19742851810743647
+ },
+ {
+ "x": "2026-10-05",
+ "y": 0.2028805380903641
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/Germany/pct_addon/index.json b/tests/playwright/fixtures/usage-behavior/Germany/pct_addon/index.json
new file mode 100644
index 00000000..e12ab9e0
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/Germany/pct_addon/index.json
@@ -0,0 +1,71 @@
+{
+ "title": "Has Add-on",
+ "description": [
+ "Has Add-on shows the percentage of Firefox Desktop clients with user-installed add-ons.",
+ "One of the best things about Firefox is the robust add-on community which gives users the option to customize and control their browsing experience. Our users agree, with over 40% of Firefox users having at least 1 installed add-on.",
+ "Add-on usage measured here reflects multiple facets of browser customization, including web extensions, language packs, and themes.",
+ "This metric varies globally. On one end is India, where 19% of users have add-ons, and on the other is Russia and Canada, where almost 60% of users have add-ons."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 14.619844387369692
+ },
+ {
+ "x": "2026-08-31",
+ "y": 54.84818241142374
+ },
+ {
+ "x": "2026-07-27",
+ "y": 55.771537325416574
+ },
+ {
+ "x": "2026-08-17",
+ "y": 55.52219939958919
+ },
+ {
+ "x": "2026-10-12",
+ "y": 8.588660377014179
+ },
+ {
+ "x": "2026-09-14",
+ "y": 27.509262219547516
+ },
+ {
+ "x": "2026-09-28",
+ "y": 11.594016701844545
+ },
+ {
+ "x": "2026-08-03",
+ "y": 55.648214520086015
+ },
+ {
+ "x": "2026-08-10",
+ "y": 55.62041293652714
+ },
+ {
+ "x": "2026-09-07",
+ "y": 54.80803032750282
+ },
+ {
+ "x": "2026-08-24",
+ "y": 55.26132267284818
+ },
+ {
+ "x": "2026-10-05",
+ "y": 9.747404591314519
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/Germany/top10addons/index.json b/tests/playwright/fixtures/usage-behavior/Germany/top10addons/index.json
new file mode 100644
index 00000000..8275aa99
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/Germany/top10addons/index.json
@@ -0,0 +1,550 @@
+{
+ "title": "Top Add-ons",
+ "description": [
+ "Top Add-ons shows the top 10 most popular Firefox Desktop add-ons for a week.",
+ "Overall, the most popular add-ons are ad-blockers, coming in first in almost all of the top 10 countries. Ad-blocking seems particularly popular in Germany and France, with 4 of the top 10 add-ons being ad-blockers in France."
+ ],
+ "type": "table",
+ "columns": [
+ {
+ "name": "Add-on"
+ },
+ {
+ "name": "Usage",
+ "unit": "%"
+ }
+ ],
+ "data": {
+ "dates": {
+ "2026-09-21": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 4.520131439794531
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 2.9545626227526816
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 1.260575615651911
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 1.119882157425593
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 1.1142166490406404
+ },
+ {
+ "name": "German Dictionary",
+ "value": 0.8611572745127662
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 0.774286145943496
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 0.6704184922193684
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 0.6647529838344161
+ },
+ {
+ "name": "Yahoo Search From PDF to DOC",
+ "value": 0.4891222239008914
+ }
+ ]
+ },
+ "2026-08-31": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.329622663237494
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 12.891059844151856
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.667767072281294
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.871290929330928
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.7494146098038463
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.419292925415531
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.2302406817396645
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.2196844650877128
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 2.0325515335303828
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.9884073548040382
+ }
+ ]
+ },
+ "2026-07-27": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.214317575805637
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 13.02498934409167
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.910639057125581
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.814151046261511
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.665463953292428
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.3423173379062874
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.1817352774996777
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.1648840736298482
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 2.090540527145307
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 2.0538643775462666
+ }
+ ]
+ },
+ "2026-08-17": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.337415073471323
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 12.940432927792699
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.793411281403065
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.857876441775952
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.770974877547796
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.4697819560752095
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.220927476694581
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.2021646389635015
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 2.0787249170485067
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 2.0411992415863485
+ }
+ ]
+ },
+ "2026-10-12": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 2.5447544663223782
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 1.6542272669209293
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 0.8166207412544025
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 0.7354148798335736
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 0.6989178634646618
+ },
+ {
+ "name": "German Dictionary",
+ "value": 0.5155203562108798
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 0.4370517710177193
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 0.397817478421139
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 0.3932553513750251
+ },
+ {
+ "name": "Yahoo Search From PDF to DOC",
+ "value": 0.31296191536341905
+ }
+ ]
+ },
+ "2026-09-14": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 8.27581667387584
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 5.801816711488914
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 2.06119647189363
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 1.8449212946420175
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 1.6530946156884132
+ },
+ {
+ "name": "German Dictionary",
+ "value": 1.6295864442480206
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 1.268500930923589
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 1.2600379892050477
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 1.2026780508904895
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 0.9873432004964926
+ }
+ ]
+ },
+ "2026-09-28": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 3.6432045517114804
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 2.277691107644306
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 1.0957144168119666
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 0.968156373313756
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 0.8589520051390291
+ },
+ {
+ "name": "German Dictionary",
+ "value": 0.7148756538496834
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 0.6478847389189685
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 0.5570340460677251
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 0.55152794347068
+ },
+ {
+ "name": "Yahoo Search From PDF to DOC",
+ "value": 0.40745159218133437
+ }
+ ]
+ },
+ "2026-08-03": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.129865016305061
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 13.096548173126433
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.869307110622016
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.8218356570990117
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.6825106765338367
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.396793506244384
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.1666044079193125
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.148431584367333
+ },
+ {
+ "name": "Easy Print",
+ "value": 2.076749891467859
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 2.06160587184121
+ }
+ ]
+ },
+ "2026-08-10": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.31861440438745
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 13.075651262198566
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.830026615049601
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.8853133317203
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.790547624808452
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.4830631502540528
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.2199370917009436
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.201790466973143
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 2.092910718606339
+ },
+ {
+ "name": "Easy Print",
+ "value": 2.035446406968304
+ }
+ ]
+ },
+ "2026-09-07": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.408898727803061
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 12.942394846604783
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.717294847554893
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.921587443349707
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.723014508175694
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.412328623956067
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.261261175665789
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.2489097490760184
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 2.0759897768192226
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.9990308880675718
+ }
+ ]
+ },
+ "2026-08-24": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 13.300395839267647
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 13.09805616399336
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 4.771680303313067
+ },
+ {
+ "name": "German Dictionary",
+ "value": 2.9290141343103264
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.7620347906373697
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.4398628804919014
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 2.292528165486352
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 2.278776925419167
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 2.0715260929780275
+ },
+ {
+ "name": "G DATA WebProtection",
+ "value": 2.0204500584427705
+ }
+ ]
+ },
+ "2026-10-05": {
+ "rows": [
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 3.014877906126627
+ },
+ {
+ "name": "uBlock Origin",
+ "value": 1.8195277087293464
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 0.903823658429595
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 0.813349904956865
+ },
+ {
+ "name": "Adblock Plus - kostenloser Adblocker",
+ "value": 0.775880976750987
+ },
+ {
+ "name": "German Dictionary",
+ "value": 0.6004167275917532
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 0.5346176341570406
+ },
+ {
+ "name": "German Dictionary, extended for Austria",
+ "value": 0.46059365404298874
+ },
+ {
+ "name": "German Dictionary (Switzerland)",
+ "value": 0.45602427255446704
+ },
+ {
+ "name": "Yahoo Search From PDF to DOC",
+ "value": 0.3408758590437198
+ }
+ ]
+ }
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/Worldwide/locale/index.json b/tests/playwright/fixtures/usage-behavior/Worldwide/locale/index.json
new file mode 100644
index 00000000..ed63e719
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/Worldwide/locale/index.json
@@ -0,0 +1,269 @@
+{
+ "title": "Top Languages",
+ "description": [
+ "Top Languages shows the distribution of the top 5 language settings for Firefox Desktop.",
+ "Worldwide, English (US) remains the most common, at about 40% of the population, with German (11%) and French (8.1%) coming 2^nd^ and 3^rd^. Simplified Chinese is the 4^th^ most common language (6.7%), and Spanish (Spain) is the 5^th^ most common language (5%).",
+ "For most countries in the top 10, the majority (>90%) of users have their language set to the local language, with a notable exception in Indonesia, which has about 71% English (US) and 27% Indonesian."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "en-US": [
+ {
+ "x": "2026-09-21",
+ "y": 41.753585275318365
+ },
+ {
+ "x": "2026-08-31",
+ "y": 41.90575553749922
+ },
+ {
+ "x": "2026-07-27",
+ "y": 42.99768796648421
+ },
+ {
+ "x": "2026-08-17",
+ "y": 43.43598104745385
+ },
+ {
+ "x": "2026-10-12",
+ "y": 41.62759816807133
+ },
+ {
+ "x": "2026-09-14",
+ "y": 41.67256200425215
+ },
+ {
+ "x": "2026-09-28",
+ "y": 41.67537627354837
+ },
+ {
+ "x": "2026-08-03",
+ "y": 43.24106041923551
+ },
+ {
+ "x": "2026-08-10",
+ "y": 43.649904346863416
+ },
+ {
+ "x": "2026-09-07",
+ "y": 41.86618376989249
+ },
+ {
+ "x": "2026-08-24",
+ "y": 42.45126652974453
+ },
+ {
+ "x": "2026-10-05",
+ "y": 41.51853945234058
+ }
+ ],
+ "de": [
+ {
+ "x": "2026-09-21",
+ "y": 10.36670957862459
+ },
+ {
+ "x": "2026-08-31",
+ "y": 10.44285570048704
+ },
+ {
+ "x": "2026-07-27",
+ "y": 10.699367130675398
+ },
+ {
+ "x": "2026-08-17",
+ "y": 10.805626141527453
+ },
+ {
+ "x": "2026-10-12",
+ "y": 10.329440033242099
+ },
+ {
+ "x": "2026-09-14",
+ "y": 10.40059522580863
+ },
+ {
+ "x": "2026-09-28",
+ "y": 10.497670643436106
+ },
+ {
+ "x": "2026-08-03",
+ "y": 10.58600493218249
+ },
+ {
+ "x": "2026-08-10",
+ "y": 10.726705176236637
+ },
+ {
+ "x": "2026-09-07",
+ "y": 10.415636152063207
+ },
+ {
+ "x": "2026-08-24",
+ "y": 10.651000692617815
+ },
+ {
+ "x": "2026-10-05",
+ "y": 10.47767407877142
+ }
+ ],
+ "zh-CN": [
+ {
+ "x": "2026-09-21",
+ "y": 4.859966896489602
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.171858397719132
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.926645853933882
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.9358906951410875
+ },
+ {
+ "x": "2026-10-12",
+ "y": 5.098326151956135
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.231494425856669
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.44588369153908
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.956535141800247
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.996001088448888
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.16176473375293
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.068020674441602
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.79218075793711
+ }
+ ],
+ "fr": [
+ {
+ "x": "2026-09-21",
+ "y": 8.224270653099982
+ },
+ {
+ "x": "2026-08-31",
+ "y": 7.935439514209749
+ },
+ {
+ "x": "2026-07-27",
+ "y": 7.182540210056579
+ },
+ {
+ "x": "2026-08-17",
+ "y": 6.957128082826017
+ },
+ {
+ "x": "2026-10-12",
+ "y": 8.088850347325726
+ },
+ {
+ "x": "2026-09-14",
+ "y": 8.162587468357518
+ },
+ {
+ "x": "2026-09-28",
+ "y": 8.259116364054155
+ },
+ {
+ "x": "2026-08-03",
+ "y": 6.884401972872996
+ },
+ {
+ "x": "2026-08-10",
+ "y": 6.715688252511864
+ },
+ {
+ "x": "2026-09-07",
+ "y": 8.088710487464205
+ },
+ {
+ "x": "2026-08-24",
+ "y": 7.540426060045561
+ },
+ {
+ "x": "2026-10-05",
+ "y": 8.19182324910278
+ }
+ ],
+ "es-ES": [
+ {
+ "x": "2026-09-21",
+ "y": 5.350514380510214
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.086373929641186
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.020620148538694
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.852582799982439
+ },
+ {
+ "x": "2026-10-12",
+ "y": 5.397505035997218
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.259829576267529
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.436091869674631
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.906905055487052
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.911263480160121
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.166597491893285
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.977339787090084
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.370720118373105
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/Worldwide/pct_addon/index.json b/tests/playwright/fixtures/usage-behavior/Worldwide/pct_addon/index.json
new file mode 100644
index 00000000..0d8a2255
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/Worldwide/pct_addon/index.json
@@ -0,0 +1,71 @@
+{
+ "title": "Has Add-on",
+ "description": [
+ "Has Add-on shows the percentage of Firefox Desktop clients with user-installed add-ons.",
+ "One of the best things about Firefox is the robust add-on community which gives users the option to customize and control their browsing experience. Our users agree, with over 40% of Firefox users having at least 1 installed add-on.",
+ "Add-on usage measured here reflects multiple facets of browser customization, including web extensions, language packs, and themes.",
+ "This metric varies globally. On one end is India, where 19% of users have add-ons, and on the other is Russia and Canada, where almost 60% of users have add-ons."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 14.51253016660599
+ },
+ {
+ "x": "2026-08-31",
+ "y": 45.8082320869653
+ },
+ {
+ "x": "2026-07-27",
+ "y": 46.106406715429685
+ },
+ {
+ "x": "2026-08-17",
+ "y": 46.25436064763721
+ },
+ {
+ "x": "2026-10-12",
+ "y": 9.152145831639611
+ },
+ {
+ "x": "2026-09-14",
+ "y": 24.85593171041101
+ },
+ {
+ "x": "2026-09-28",
+ "y": 11.789472123698655
+ },
+ {
+ "x": "2026-08-03",
+ "y": 46.08621043978627
+ },
+ {
+ "x": "2026-08-10",
+ "y": 46.267458378728485
+ },
+ {
+ "x": "2026-09-07",
+ "y": 45.661320309523944
+ },
+ {
+ "x": "2026-08-24",
+ "y": 46.302702010193094
+ },
+ {
+ "x": "2026-10-05",
+ "y": 10.220616955476824
+ }
+ ]
+ }
+ },
+ "annotations": [],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/Worldwide/top10addons/index.json b/tests/playwright/fixtures/usage-behavior/Worldwide/top10addons/index.json
new file mode 100644
index 00000000..b36c81e0
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/Worldwide/top10addons/index.json
@@ -0,0 +1,550 @@
+{
+ "title": "Top Add-ons",
+ "description": [
+ "Top Add-ons shows the top 10 most popular Firefox Desktop add-ons for a week.",
+ "Overall, the most popular add-ons are ad-blockers, coming in first in almost all of the top 10 countries. Ad-blocking seems particularly popular in Germany and France, with 4 of the top 10 add-ons being ad-blockers in France."
+ ],
+ "type": "table",
+ "columns": [
+ {
+ "name": "Add-on"
+ },
+ {
+ "name": "Usage",
+ "unit": "%"
+ }
+ ],
+ "data": {
+ "dates": {
+ "2026-09-21": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 2.4794792014622593
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 1.691206551642602
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 1.3996742194214766
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 1.23762840746593
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 0.7159290939321491
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 0.6628208544609044
+ },
+ {
+ "name": "Language: Español (ES) (Spanish, Spain)",
+ "value": 0.5604514388016679
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 0.4137938799791321
+ },
+ {
+ "name": "Language: Português (BR)",
+ "value": 0.3989686117168411
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 0.3739157849698052
+ }
+ ]
+ },
+ "2026-08-31": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.224865498987937
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.7889139433917816
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.5234216865152814
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.811371645888524
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.8875816742070572
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.8170978551956576
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.6456481290055818
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.4568899615848763
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.362462798871105
+ },
+ {
+ "name": "Video Download Helper",
+ "value": 1.322461068026982
+ }
+ ]
+ },
+ "2026-07-27": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.30731332706838
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.8729884745998424
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.4106840285624593
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.7563407084577887
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.9009598875989722
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.8555577256789109
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.53774259223848
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.528232679944413
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.4144205037799344
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.385788509776292
+ }
+ ]
+ },
+ "2026-08-17": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.439214333041688
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.871571078684103
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.554163914409305
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.8532954091836746
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.9418090268596497
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.8811657796485755
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.5434421066279802
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.5090367626715289
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.421338868135649
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.3741718980825912
+ }
+ ]
+ },
+ "2026-10-12": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 1.426429274726069
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 1.003224844855153
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 0.8800122851232578
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 0.8224709357469988
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 0.4266370378399863
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 0.3954725707523735
+ },
+ {
+ "name": "Language: Español (ES) (Spanish, Spain)",
+ "value": 0.35455227048950794
+ },
+ {
+ "name": "Language: Português (BR)",
+ "value": 0.24362483401533835
+ },
+ {
+ "name": "Language: Español (MX) (Spanish, Mexico)",
+ "value": 0.22483582197411092
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 0.2238421722988537
+ }
+ ]
+ },
+ "2026-09-14": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 4.62116279200686
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 2.648961263409938
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 2.398167034773422
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 1.9201286528418655
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.1772222920697233
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.1152039661704576
+ },
+ {
+ "name": "Language: Español (ES) (Spanish, Spain)",
+ "value": 0.835980762121721
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 0.8313833370550585
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 0.6614662595912608
+ },
+ {
+ "name": "Language: Português (BR)",
+ "value": 0.6528343594660981
+ }
+ ]
+ },
+ "2026-09-28": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 1.907959839446736
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 1.3578647750141761
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 1.145313304943721
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 1.0470059336513267
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 0.5764091958623808
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 0.5441651193216144
+ },
+ {
+ "name": "Language: Español (ES) (Spanish, Spain)",
+ "value": 0.4636475833620565
+ },
+ {
+ "name": "Language: Português (BR)",
+ "value": 0.33439331102191483
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 0.30594810556784785
+ },
+ {
+ "name": "Language: Español (MX) (Spanish, Mexico)",
+ "value": 0.30150064673463867
+ }
+ ]
+ },
+ "2026-08-03": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.344019728729963
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.8856350184956847
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.46167283189478
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.7884299219071105
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.8922112618166873
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.8819358816276202
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.5291820797369504
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.5141800246609125
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.4082408549116319
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.3657007809288944
+ }
+ ]
+ },
+ "2026-08-10": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.460904302373585
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.886341932286477
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.559289942297724
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.847763040952372
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.9333418174406345
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.892266322336068
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.5474183999594417
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.5198967835921502
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.429261686081822
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.3783570422746512
+ }
+ ]
+ },
+ "2026-09-07": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.20725899224674
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.8085924544536236
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.551129633525317
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.818256075535062
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.8904612725505021
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.7957960689776726
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.6440853673559506
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.439119566226791
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.3596159568197799
+ },
+ {
+ "name": "Video Download Helper",
+ "value": 1.3314722476494791
+ }
+ ]
+ },
+ "2026-08-24": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 10.468137578720219
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 3.8268135176577505
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 3.5514679093751624
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 2.829724113909607
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 1.9223147047166074
+ },
+ {
+ "name": "Privacy Badger",
+ "value": 1.8579572979097354
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 1.6282524011418185
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 1.4975358019353262
+ },
+ {
+ "name": "DuckDuckGo Search & Tracker Protection",
+ "value": 1.4084563430580077
+ },
+ {
+ "name": "AdBlocker Ultimate",
+ "value": 1.3725242916681681
+ }
+ ]
+ },
+ "2026-10-05": {
+ "rows": [
+ {
+ "name": "uBlock Origin",
+ "value": 1.6005183418574838
+ },
+ {
+ "name": "Language: English (US)",
+ "value": 1.1698534581397593
+ },
+ {
+ "name": "Language: English (CA)",
+ "value": 0.9970728389785725
+ },
+ {
+ "name": "Language: English (GB)",
+ "value": 0.9286039234067192
+ },
+ {
+ "name": "Language: Français (French)",
+ "value": 0.4889323903922947
+ },
+ {
+ "name": "Language: Deutsch (German)",
+ "value": 0.46182053791753397
+ },
+ {
+ "name": "Language: Español (ES) (Spanish, Spain)",
+ "value": 0.40392064958160434
+ },
+ {
+ "name": "Language: Português (BR)",
+ "value": 0.2917970562961533
+ },
+ {
+ "name": "Language: Español (MX) (Spanish, Mexico)",
+ "value": 0.26716662760721815
+ },
+ {
+ "name": "Adblock Plus - free ad blocker",
+ "value": 0.2528295124002261
+ }
+ ]
+ }
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/usage-behavior/index.json b/tests/playwright/fixtures/usage-behavior/index.json
new file mode 100644
index 00000000..4dfa424a
--- /dev/null
+++ b/tests/playwright/fixtures/usage-behavior/index.json
@@ -0,0 +1,188 @@
+{
+ "title": "Usage Behavior",
+ "description": "Usage Behavior is a weekly report describing ways in which desktop users are interacting with the web.",
+ "metaDescription": "How Firefox users interact with the web, including top languages, tracking protection, and top add-ons. Use our public data to understand your audience!",
+ "categories": [
+ "French Polynesia",
+ "Philippines",
+ "United Kingdom",
+ "Costa Rica",
+ "Worldwide",
+ "Morocco",
+ "Vietnam",
+ "Nigeria",
+ "Chile",
+ "Ukraine",
+ "Croatia",
+ "Papua New Guinea",
+ "Cuba",
+ "Qatar",
+ "Albania",
+ "Guatemala",
+ "Angola",
+ "Myanmar",
+ "Brunei",
+ "Haiti",
+ "Kosovo",
+ "Argentina",
+ "Spain",
+ "Ireland",
+ "Guinea",
+ "Democratic Republic of the Congo",
+ "Malta",
+ "Canada",
+ "Réunion",
+ "Mauritius",
+ "Kazakhstan",
+ "Israel",
+ "Syria",
+ "Macao",
+ "Cameroon",
+ "Egypt",
+ "North Macedonia",
+ "Algeria",
+ "China",
+ "Malawi",
+ "Venezuela",
+ "Senegal",
+ "Lebanon",
+ "Bolivia",
+ "Madagascar",
+ "Taiwan",
+ "South Africa",
+ "Indonesia",
+ "Puerto Rico",
+ "Sweden",
+ "Japan",
+ "Gabon",
+ "Côte d'Ivoire",
+ "Georgia",
+ "Martinique",
+ "Kyrgyzstan",
+ "Portugal",
+ "Kuwait",
+ "Hungary",
+ "France",
+ "Dominican Republic",
+ "Netherlands",
+ "Zambia",
+ "Saudi Arabia",
+ "Paraguay",
+ "South Korea",
+ "Jordan",
+ "Niger",
+ "Italy",
+ "Germany",
+ "Denmark",
+ "Czechia",
+ "Norway",
+ "Singapore",
+ "Poland",
+ "Uganda",
+ "Armenia",
+ "Sri Lanka",
+ "Türkiye",
+ "Estonia",
+ "Somalia",
+ "Bahrain",
+ "Namibia",
+ "Guadeloupe",
+ "Afghanistan",
+ "Belgium",
+ "Bulgaria",
+ "Maldives",
+ "Slovenia",
+ "Moldova",
+ "Latvia",
+ "Rwanda",
+ "Lithuania",
+ "Sudan",
+ "Ethiopia",
+ "Jamaica",
+ "Benin",
+ "United Arab Emirates",
+ "Palestine",
+ "United States",
+ "Hong Kong",
+ "Mexico",
+ "Romania",
+ "Zimbabwe",
+ "Luxembourg",
+ "Tunisia",
+ "India",
+ "Trinidad and Tobago",
+ "Brazil",
+ "Serbia",
+ "Libya",
+ "Tanzania",
+ "Iceland",
+ "Pakistan",
+ "Belarus",
+ "Austria",
+ "Honduras",
+ "Kenya",
+ "New Caledonia",
+ "Russia",
+ "Thailand",
+ "Mongolia",
+ "New Zealand",
+ "Yemen",
+ "Panama",
+ "Nicaragua",
+ "Cyprus",
+ "Iraq",
+ "Australia",
+ "Switzerland",
+ "Greece",
+ "Nepal",
+ "Bangladesh",
+ "Ghana",
+ "Colombia",
+ "Burundi",
+ "Azerbaijan",
+ "Mozambique",
+ "Slovakia",
+ "Ecuador",
+ "Togo",
+ "Uzbekistan",
+ "El Salvador",
+ "Laos",
+ "Malaysia",
+ "Peru",
+ "Burkina Faso",
+ "Republic of the Congo",
+ "Cambodia",
+ "Bosnia and Herzegovina",
+ "French Guiana",
+ "Finland",
+ "Botswana",
+ "Montenegro",
+ "Uruguay",
+ "Oman",
+ "Mali",
+ "French Southern Territories",
+ "United States Minor Outlying Islands",
+ "Bouvet Island"
+ ],
+ "defaultCategory": "Worldwide",
+ "metrics": [
+ "locale",
+ "pct_addon",
+ "top10addons"
+ ],
+ "dates": [
+ "2026-10-12",
+ "2026-10-05",
+ "2026-09-28",
+ "2026-09-21",
+ "2026-09-14",
+ "2026-09-07",
+ "2026-08-31",
+ "2026-08-24",
+ "2026-08-17",
+ "2026-08-10",
+ "2026-08-03",
+ "2026-07-27"
+ ],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Germany/MAU/index.json b/tests/playwright/fixtures/user-activity/Germany/MAU/index.json
new file mode 100644
index 00000000..be92b76d
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Germany/MAU/index.json
@@ -0,0 +1,67 @@
+{
+ "title": "Monthly Active Users",
+ "description": [
+ "Monthly Active Users (MAU) measures the number of Desktop and Mobile clients active in the past 28 days. MAU typically fluctuates over the year: dipping in the summer, around the New Year, and during major holidays."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "clients"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 21589455
+ },
+ {
+ "x": "2026-08-31",
+ "y": 20996923
+ },
+ {
+ "x": "2026-07-27",
+ "y": 21251304
+ },
+ {
+ "x": "2026-08-17",
+ "y": 20911450
+ },
+ {
+ "x": "2026-10-12",
+ "y": 21964942
+ },
+ {
+ "x": "2026-09-14",
+ "y": 21417171
+ },
+ {
+ "x": "2026-09-28",
+ "y": 21672310
+ },
+ {
+ "x": "2026-08-03",
+ "y": 21094832
+ },
+ {
+ "x": "2026-08-10",
+ "y": 21000960
+ },
+ {
+ "x": "2026-09-07",
+ "y": 21211255
+ },
+ {
+ "x": "2026-08-24",
+ "y": 20961929
+ },
+ {
+ "x": "2026-10-05",
+ "y": 21856572
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Germany/avg_daily_usage(hours)/index.json b/tests/playwright/fixtures/user-activity/Germany/avg_daily_usage(hours)/index.json
new file mode 100644
index 00000000..c1a37a9d
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Germany/avg_daily_usage(hours)/index.json
@@ -0,0 +1,67 @@
+{
+ "title": "Daily Usage",
+ "description": [
+ "Daily Usage shows the hours spent browsing for a typical Firefox Desktop client in a typical day of use. Globally, the typical Firefox client averages around 4.5 hours of use per day."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "hours per day"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 4.908532772
+ },
+ {
+ "x": "2026-08-31",
+ "y": 4.888539798
+ },
+ {
+ "x": "2026-07-27",
+ "y": 4.890976002
+ },
+ {
+ "x": "2026-08-17",
+ "y": 4.905641105
+ },
+ {
+ "x": "2026-10-12",
+ "y": 4.923479385
+ },
+ {
+ "x": "2026-09-14",
+ "y": 4.834337969
+ },
+ {
+ "x": "2026-09-28",
+ "y": 4.874121456
+ },
+ {
+ "x": "2026-08-03",
+ "y": 4.924976957
+ },
+ {
+ "x": "2026-08-10",
+ "y": 4.859320729
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.863572183
+ },
+ {
+ "x": "2026-08-24",
+ "y": 4.924580331
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.929526131
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Germany/avg_intensity/index.json b/tests/playwright/fixtures/user-activity/Germany/avg_intensity/index.json
new file mode 100644
index 00000000..d0a9ac96
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Germany/avg_intensity/index.json
@@ -0,0 +1,67 @@
+{
+ "title": "Average Intensity",
+ "description": [
+ "Intensity shows how many days per week users use Firefox Desktop. Overall, the typical Firefox client uses the browser 3.5 days per week."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "intensity"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 3.664063680314247
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.672872442516602
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.642873427633992
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.6565511929214725
+ },
+ {
+ "x": "2026-10-12",
+ "y": 3.7781802587638462
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.6532262614484794
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.6744425071120492
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.6499207462972874
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.6350814581821114
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.6519082954081195
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.648380791482089
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.735103816347419
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Germany/pct_latest_version/index.json b/tests/playwright/fixtures/user-activity/Germany/pct_latest_version/index.json
new file mode 100644
index 00000000..922aea10
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Germany/pct_latest_version/index.json
@@ -0,0 +1,89 @@
+{
+ "title": "Latest Version",
+ "description": [
+ "Latest Version shows the percentage of Firefox Desktop clients running the latest version of Firefox or greater (for that week). Firefox typically releases major updates roughly every 30 days and generally releases on Tuesdays."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 9.80227375736516
+ },
+ {
+ "x": "2026-08-31",
+ "y": 44.73340754673525
+ },
+ {
+ "x": "2026-07-27",
+ "y": 69.05722470584736
+ },
+ {
+ "x": "2026-08-17",
+ "y": 52.706786222152004
+ },
+ {
+ "x": "2026-10-12",
+ "y": 54.42891293636746
+ },
+ {
+ "x": "2026-09-14",
+ "y": 53.80456246591315
+ },
+ {
+ "x": "2026-09-28",
+ "y": 53.29632008809764
+ },
+ {
+ "x": "2026-08-03",
+ "y": 77.31930660582135
+ },
+ {
+ "x": "2026-08-10",
+ "y": 6.977377207839341
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.731546493620013
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.524069581274739
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.876443924550373
+ }
+ ]
+ }
+ },
+ "annotations": [
+ {
+ "date": "2026-10-12",
+ "label": "FF158"
+ },
+ {
+ "date": "2026-09-28",
+ "label": "FF157"
+ },
+ {
+ "date": "2026-09-14",
+ "label": "FF156"
+ },
+ {
+ "date": "2026-08-31",
+ "label": "FF155"
+ },
+ {
+ "date": "2026-08-17",
+ "label": "FF154"
+ }
+ ],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Germany/pct_new_user/index.json b/tests/playwright/fixtures/user-activity/Germany/pct_new_user/index.json
new file mode 100644
index 00000000..7d6f9403
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Germany/pct_new_user/index.json
@@ -0,0 +1,63 @@
+{
+ "title": "New Profile Rate",
+ "description": [
+ "New Profile Rate measures how often new Firefox Desktop profiles are created. A profile is the folder Firefox uses to store your add-ons, settings, and other customizations; essentially, your browser's identity.",
+ "We measure profiles rather than “new users” because Firefox doesn't track individuals. Profiles let us understand how the browser is used without identifying who's using it. It makes our job harder, but we think your privacy is worth it."
+ ],
+ "type": "line",
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 1.962154403988518
+ },
+ {
+ "x": "2026-08-31",
+ "y": 1.9356262715442785
+ },
+ {
+ "x": "2026-07-27",
+ "y": 1.7436039768841134
+ },
+ {
+ "x": "2026-08-17",
+ "y": 1.8723337020066362
+ },
+ {
+ "x": "2026-10-12",
+ "y": 1.9334294421431049
+ },
+ {
+ "x": "2026-09-14",
+ "y": 2.11385477592011
+ },
+ {
+ "x": "2026-09-28",
+ "y": 2.074882995319813
+ },
+ {
+ "x": "2026-08-03",
+ "y": 1.7284374400549223
+ },
+ {
+ "x": "2026-08-10",
+ "y": 1.6997338495039922
+ },
+ {
+ "x": "2026-09-07",
+ "y": 1.9334733161680173
+ },
+ {
+ "x": "2026-08-24",
+ "y": 1.8632930291035175
+ },
+ {
+ "x": "2026-10-05",
+ "y": 2.0169249890334844
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Worldwide/MAU/index.json b/tests/playwright/fixtures/user-activity/Worldwide/MAU/index.json
new file mode 100644
index 00000000..7f3f44f3
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Worldwide/MAU/index.json
@@ -0,0 +1,67 @@
+{
+ "title": "Monthly Active Users",
+ "description": [
+ "Monthly Active Users (MAU) measures the number of Desktop and Mobile clients active in the past 28 days. MAU typically fluctuates over the year: dipping in the summer, around the New Year, and during major holidays."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "clients"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 208112827
+ },
+ {
+ "x": "2026-08-31",
+ "y": 198638945
+ },
+ {
+ "x": "2026-07-27",
+ "y": 197492367
+ },
+ {
+ "x": "2026-08-17",
+ "y": 196913811
+ },
+ {
+ "x": "2026-10-12",
+ "y": 212735695
+ },
+ {
+ "x": "2026-09-14",
+ "y": 205161810
+ },
+ {
+ "x": "2026-09-28",
+ "y": 210082701
+ },
+ {
+ "x": "2026-08-03",
+ "y": 196962457
+ },
+ {
+ "x": "2026-08-10",
+ "y": 197086013
+ },
+ {
+ "x": "2026-09-07",
+ "y": 201623188
+ },
+ {
+ "x": "2026-08-24",
+ "y": 197605408
+ },
+ {
+ "x": "2026-10-05",
+ "y": 211909144
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Worldwide/avg_daily_usage(hours)/index.json b/tests/playwright/fixtures/user-activity/Worldwide/avg_daily_usage(hours)/index.json
new file mode 100644
index 00000000..1f669851
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Worldwide/avg_daily_usage(hours)/index.json
@@ -0,0 +1,67 @@
+{
+ "title": "Daily Usage",
+ "description": [
+ "Daily Usage shows the hours spent browsing for a typical Firefox Desktop client in a typical day of use. Globally, the typical Firefox client averages around 4.5 hours of use per day."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "hours per day"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 5.59307547
+ },
+ {
+ "x": "2026-08-31",
+ "y": 5.633154047
+ },
+ {
+ "x": "2026-07-27",
+ "y": 5.775847409
+ },
+ {
+ "x": "2026-08-17",
+ "y": 5.764601773
+ },
+ {
+ "x": "2026-10-12",
+ "y": 5.524167031
+ },
+ {
+ "x": "2026-09-14",
+ "y": 5.511424525
+ },
+ {
+ "x": "2026-09-28",
+ "y": 5.54034429
+ },
+ {
+ "x": "2026-08-03",
+ "y": 5.791423401
+ },
+ {
+ "x": "2026-08-10",
+ "y": 5.746895868
+ },
+ {
+ "x": "2026-09-07",
+ "y": 5.578621705
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.763263788
+ },
+ {
+ "x": "2026-10-05",
+ "y": 5.561114764
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Worldwide/avg_intensity/index.json b/tests/playwright/fixtures/user-activity/Worldwide/avg_intensity/index.json
new file mode 100644
index 00000000..21c61c64
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Worldwide/avg_intensity/index.json
@@ -0,0 +1,67 @@
+{
+ "title": "Average Intensity",
+ "description": [
+ "Intensity shows how many days per week users use Firefox Desktop. Overall, the typical Firefox client uses the browser 3.5 days per week."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "intensity"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 3.5928512181115986
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.588079291892438
+ },
+ {
+ "x": "2026-07-27",
+ "y": 3.5826140396982598
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.5807632269149843
+ },
+ {
+ "x": "2026-10-12",
+ "y": 3.7304309729636955
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.5951516868796993
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.6107787871038517
+ },
+ {
+ "x": "2026-08-03",
+ "y": 3.573041512535964
+ },
+ {
+ "x": "2026-08-10",
+ "y": 3.5520763817972445
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.5770663831448557
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.5696160975590225
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.6645252898440837
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Worldwide/pct_latest_version/index.json b/tests/playwright/fixtures/user-activity/Worldwide/pct_latest_version/index.json
new file mode 100644
index 00000000..0a720f36
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Worldwide/pct_latest_version/index.json
@@ -0,0 +1,89 @@
+{
+ "title": "Latest Version",
+ "description": [
+ "Latest Version shows the percentage of Firefox Desktop clients running the latest version of Firefox or greater (for that week). Firefox typically releases major updates roughly every 30 days and generally releases on Tuesdays."
+ ],
+ "type": "line",
+ "axes": {
+ "y": {
+ "unit": "%"
+ }
+ },
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 9.386271426265871
+ },
+ {
+ "x": "2026-08-31",
+ "y": 40.05807943612945
+ },
+ {
+ "x": "2026-07-27",
+ "y": 61.87016004262077
+ },
+ {
+ "x": "2026-08-17",
+ "y": 46.11081787730258
+ },
+ {
+ "x": "2026-10-12",
+ "y": 48.42425227861937
+ },
+ {
+ "x": "2026-09-14",
+ "y": 47.23216245986636
+ },
+ {
+ "x": "2026-09-28",
+ "y": 45.679479054322
+ },
+ {
+ "x": "2026-08-03",
+ "y": 69.32357172215372
+ },
+ {
+ "x": "2026-08-10",
+ "y": 7.0450164664106945
+ },
+ {
+ "x": "2026-09-07",
+ "y": 4.569799241541252
+ },
+ {
+ "x": "2026-08-24",
+ "y": 5.334158068997546
+ },
+ {
+ "x": "2026-10-05",
+ "y": 4.791261712090508
+ }
+ ]
+ }
+ },
+ "annotations": [
+ {
+ "date": "2026-10-12",
+ "label": "FF158"
+ },
+ {
+ "date": "2026-09-28",
+ "label": "FF157"
+ },
+ {
+ "date": "2026-09-14",
+ "label": "FF156"
+ },
+ {
+ "date": "2026-08-31",
+ "label": "FF155"
+ },
+ {
+ "date": "2026-08-17",
+ "label": "FF154"
+ }
+ ],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/Worldwide/pct_new_user/index.json b/tests/playwright/fixtures/user-activity/Worldwide/pct_new_user/index.json
new file mode 100644
index 00000000..6fe61c51
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/Worldwide/pct_new_user/index.json
@@ -0,0 +1,63 @@
+{
+ "title": "New Profile Rate",
+ "description": [
+ "New Profile Rate measures how often new Firefox Desktop profiles are created. A profile is the folder Firefox uses to store your add-ons, settings, and other customizations; essentially, your browser's identity.",
+ "We measure profiles rather than “new users” because Firefox doesn't track individuals. Profiles let us understand how the browser is used without identifying who's using it. It makes our job harder, but we think your privacy is worth it."
+ ],
+ "type": "line",
+ "data": {
+ "populations": {
+ "default": [
+ {
+ "x": "2026-09-21",
+ "y": 3.181615166061771
+ },
+ {
+ "x": "2026-08-31",
+ "y": 3.1617714228019485
+ },
+ {
+ "x": "2026-07-27",
+ "y": 2.90655641985547
+ },
+ {
+ "x": "2026-08-17",
+ "y": 3.0440051495713627
+ },
+ {
+ "x": "2026-10-12",
+ "y": 3.033522126771632
+ },
+ {
+ "x": "2026-09-14",
+ "y": 3.348238998549465
+ },
+ {
+ "x": "2026-09-28",
+ "y": 3.1419443548775647
+ },
+ {
+ "x": "2026-08-03",
+ "y": 2.9863337443485407
+ },
+ {
+ "x": "2026-08-10",
+ "y": 2.9657128253836214
+ },
+ {
+ "x": "2026-09-07",
+ "y": 3.2310304766994724
+ },
+ {
+ "x": "2026-08-24",
+ "y": 3.0393110654704
+ },
+ {
+ "x": "2026-10-05",
+ "y": 3.1320163406351527
+ }
+ ]
+ }
+ },
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/fixtures/user-activity/index.json b/tests/playwright/fixtures/user-activity/index.json
new file mode 100644
index 00000000..7d0cfad2
--- /dev/null
+++ b/tests/playwright/fixtures/user-activity/index.json
@@ -0,0 +1,190 @@
+{
+ "title": "User Activity",
+ "description": "Usage Behavior is a weekly report describing ways in which users are interacting with the web.",
+ "metaDescription": "The state of the Firefox userbase, including user totals, usage hours, and version adoption. Use our public data to improve your Firefox support!",
+ "categories": [
+ "French Polynesia",
+ "Philippines",
+ "United Kingdom",
+ "Costa Rica",
+ "Worldwide",
+ "Morocco",
+ "Vietnam",
+ "Nigeria",
+ "Chile",
+ "Ukraine",
+ "Croatia",
+ "Papua New Guinea",
+ "Cuba",
+ "Qatar",
+ "Albania",
+ "Guatemala",
+ "Angola",
+ "Myanmar",
+ "Brunei",
+ "Haiti",
+ "Kosovo",
+ "Argentina",
+ "Spain",
+ "Ireland",
+ "Guinea",
+ "Democratic Republic of the Congo",
+ "Malta",
+ "Canada",
+ "Réunion",
+ "Mauritius",
+ "Kazakhstan",
+ "Israel",
+ "Syria",
+ "Macao",
+ "Cameroon",
+ "Egypt",
+ "North Macedonia",
+ "Algeria",
+ "China",
+ "Malawi",
+ "Venezuela",
+ "Senegal",
+ "Lebanon",
+ "Bolivia",
+ "Madagascar",
+ "Taiwan",
+ "South Africa",
+ "Indonesia",
+ "Puerto Rico",
+ "Sweden",
+ "Japan",
+ "Gabon",
+ "Côte d'Ivoire",
+ "Georgia",
+ "Martinique",
+ "Kyrgyzstan",
+ "Portugal",
+ "Kuwait",
+ "Hungary",
+ "France",
+ "Dominican Republic",
+ "Netherlands",
+ "Zambia",
+ "Saudi Arabia",
+ "Paraguay",
+ "South Korea",
+ "Jordan",
+ "Niger",
+ "Italy",
+ "Germany",
+ "Denmark",
+ "Czechia",
+ "Norway",
+ "Singapore",
+ "Poland",
+ "Uganda",
+ "Armenia",
+ "Sri Lanka",
+ "Türkiye",
+ "Estonia",
+ "Somalia",
+ "Bahrain",
+ "Namibia",
+ "Guadeloupe",
+ "Afghanistan",
+ "Belgium",
+ "Bulgaria",
+ "Maldives",
+ "Slovenia",
+ "Moldova",
+ "Latvia",
+ "Rwanda",
+ "Lithuania",
+ "Sudan",
+ "Ethiopia",
+ "Jamaica",
+ "Benin",
+ "United Arab Emirates",
+ "Palestine",
+ "United States",
+ "Hong Kong",
+ "Mexico",
+ "Romania",
+ "Zimbabwe",
+ "Luxembourg",
+ "Tunisia",
+ "India",
+ "Trinidad and Tobago",
+ "Brazil",
+ "Serbia",
+ "Libya",
+ "Tanzania",
+ "Iceland",
+ "Pakistan",
+ "Belarus",
+ "Austria",
+ "Honduras",
+ "Kenya",
+ "New Caledonia",
+ "Russia",
+ "Thailand",
+ "Mongolia",
+ "New Zealand",
+ "Yemen",
+ "Panama",
+ "Nicaragua",
+ "Cyprus",
+ "Iraq",
+ "Australia",
+ "Switzerland",
+ "Greece",
+ "Nepal",
+ "Bangladesh",
+ "Ghana",
+ "Colombia",
+ "Burundi",
+ "Azerbaijan",
+ "Mozambique",
+ "Slovakia",
+ "Ecuador",
+ "Togo",
+ "Uzbekistan",
+ "El Salvador",
+ "Laos",
+ "Malaysia",
+ "Peru",
+ "Burkina Faso",
+ "Republic of the Congo",
+ "Cambodia",
+ "Bosnia and Herzegovina",
+ "French Guiana",
+ "Finland",
+ "Botswana",
+ "Montenegro",
+ "Uruguay",
+ "Oman",
+ "Mali",
+ "French Southern Territories",
+ "United States Minor Outlying Islands",
+ "Bouvet Island"
+ ],
+ "defaultCategory": "Worldwide",
+ "metrics": [
+ "MAU",
+ "avg_daily_usage(hours)",
+ "avg_intensity",
+ "pct_new_user",
+ "pct_latest_version"
+ ],
+ "dates": [
+ "2026-10-12",
+ "2026-10-05",
+ "2026-09-28",
+ "2026-09-21",
+ "2026-09-14",
+ "2026-09-07",
+ "2026-08-31",
+ "2026-08-24",
+ "2026-08-17",
+ "2026-08-10",
+ "2026-08-03",
+ "2026-07-27"
+ ],
+ "apiVersion": "2.0.0"
+}
diff --git a/tests/playwright/specs/basics.spec.js b/tests/playwright/specs/basics.spec.js
index 239755d0..1065633f 100644
--- a/tests/playwright/specs/basics.spec.js
+++ b/tests/playwright/specs/basics.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
test.beforeEach(async ({ page }) => {
diff --git a/tests/playwright/specs/contact.spec.js b/tests/playwright/specs/contact.spec.js
index 1043b148..8635af1a 100644
--- a/tests/playwright/specs/contact.spec.js
+++ b/tests/playwright/specs/contact.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
test.beforeEach(async ({ page }) => {
diff --git a/tests/playwright/specs/dashboards/hardware.spec.js b/tests/playwright/specs/dashboards/hardware.spec.js
index 78086ca5..cc23ec6e 100644
--- a/tests/playwright/specs/dashboards/hardware.spec.js
+++ b/tests/playwright/specs/dashboards/hardware.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../../fixtureData');
const { flagForUpdate, metricTitleIsCorrect } = require('../../utils');
@@ -44,42 +44,45 @@ test('Metric titles and order are correct', async ({ page }) => {
await metricTitleIsCorrect(page, '#dashboard-sections .dashboard-section:nth-child(3) #operating-system-metric-overview-3 h5', 'Operating Systems by Architecture');
await metricTitleIsCorrect(page, '#dashboard-sections .dashboard-section:nth-child(4) #plugins-metric-overview-1 h5', 'Has Flash');
- await flagForUpdate(page, '.metric', 'metrics in the hardware dashboard', 11);
+ await flagForUpdate(page, '.metric-overview', 'metrics in the hardware dashboard', 11);
});
+// Any drawn line counts: a population with a single data point draws a
+// zero-size line path (metrics-graphics shows it as a dot instead), so the
+// first population's line alone isn't a reliable signal.
test('Charts render', async ({ page }) => {
await expect(page.locator('#graphics-metric-overview-1 svg')).toBeVisible();
- await expect(page.locator('#graphics-metric-overview-1 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#graphics-metric-overview-1 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#graphics-metric-overview-2 svg')).toBeVisible();
- await expect(page.locator('#graphics-metric-overview-2 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#graphics-metric-overview-2 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#graphics-metric-overview-3 svg')).toBeVisible();
- await expect(page.locator('#graphics-metric-overview-3 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#graphics-metric-overview-3 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#processor-metric-overview-1 svg')).toBeVisible();
- await expect(page.locator('#processor-metric-overview-1 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#processor-metric-overview-1 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#processor-metric-overview-2 svg')).toBeVisible();
- await expect(page.locator('#processor-metric-overview-2 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#processor-metric-overview-2 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#processor-metric-overview-3 svg')).toBeVisible();
- await expect(page.locator('#processor-metric-overview-3 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#processor-metric-overview-3 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#processor-metric-overview-4 svg')).toBeVisible();
- await expect(page.locator('#processor-metric-overview-4 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#processor-metric-overview-4 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#operating-system-metric-overview-1 svg')).toBeVisible();
- await expect(page.locator('#operating-system-metric-overview-1 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#operating-system-metric-overview-1 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#operating-system-metric-overview-2 svg')).toBeVisible();
- await expect(page.locator('#operating-system-metric-overview-2 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#operating-system-metric-overview-2 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#operating-system-metric-overview-3 svg')).toBeVisible();
- await expect(page.locator('#operating-system-metric-overview-3 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#operating-system-metric-overview-3 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#plugins-metric-overview-1 svg')).toBeVisible();
- await expect(page.locator('#plugins-metric-overview-1 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#plugins-metric-overview-1 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await flagForUpdate(page, '.chart', 'charts in the hardware dashboard', 11);
});
diff --git a/tests/playwright/specs/dashboards/usage-behavior.spec.js b/tests/playwright/specs/dashboards/usage-behavior.spec.js
index 899bb41c..bae51404 100644
--- a/tests/playwright/specs/dashboards/usage-behavior.spec.js
+++ b/tests/playwright/specs/dashboards/usage-behavior.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../../fixtureData');
const { linkWorks, flagForUpdate, metricTitleIsCorrect, changeRegionAndWaitForMetrics } = require('../../utils');
@@ -25,15 +25,18 @@ test('Metric titles and order are correct', async ({ page }) => {
await metricTitleIsCorrect(page, '#metric-overview-2 h4', 'Has Add-on');
await metricTitleIsCorrect(page, '#metric-overview-3 h4', 'Top Add-ons');
- await flagForUpdate(page, '.metric', 'metrics in the usage dashboard', 3);
+ await flagForUpdate(page, '.metric-overview', 'metrics in the usage dashboard', 3);
});
+// Any drawn line counts: a population with a single data point draws a
+// zero-size line path (metrics-graphics shows it as a dot instead), so the
+// first population's line alone isn't a reliable signal.
test('Charts render', async ({ page }) => {
await expect(page.locator('#metric-overview-1 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-1 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-1 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#metric-overview-2 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-2 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-2 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await flagForUpdate(page, '.chart', 'charts in the usage dashboard', 2);
});
diff --git a/tests/playwright/specs/dashboards/user-activity.spec.js b/tests/playwright/specs/dashboards/user-activity.spec.js
index a59df34c..a0a9b75a 100644
--- a/tests/playwright/specs/dashboards/user-activity.spec.js
+++ b/tests/playwright/specs/dashboards/user-activity.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../../fixtureData');
const { linkWorks, flagForUpdate, metricTitleIsCorrect, changeRegionAndWaitForMetrics } = require('../../utils');
@@ -27,24 +27,27 @@ test('Chart titles and order are correct', async ({ page }) => {
await metricTitleIsCorrect(page, '#metric-overview-4 h4', 'New Profile Rate');
await metricTitleIsCorrect(page, '#metric-overview-5 h4', 'Latest Version');
- await flagForUpdate(page, '.metric', 'metrics in the activity dashboard', 5);
+ await flagForUpdate(page, '.metric-overview', 'metrics in the activity dashboard', 5);
});
+// Any drawn line counts: a population with a single data point draws a
+// zero-size line path (metrics-graphics shows it as a dot instead), so the
+// first population's line alone isn't a reliable signal.
test('Charts render', async ({ page }) => {
await expect(page.locator('#metric-overview-1 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-1 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-1 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#metric-overview-2 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-2 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-2 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#metric-overview-3 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-3 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-3 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#metric-overview-4 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-4 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-4 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await expect(page.locator('#metric-overview-5 svg')).toBeVisible();
- await expect(page.locator('#metric-overview-5 path.mg-line1').first()).toBeVisible();
+ await expect(page.locator('#metric-overview-5 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
await flagForUpdate(page, '.metric', 'metrics in the activity dashboard', 5);
});
diff --git a/tests/playwright/specs/fixtures/boundaries.spec.js b/tests/playwright/specs/fixtures/boundaries.spec.js
new file mode 100644
index 00000000..b4689b22
--- /dev/null
+++ b/tests/playwright/specs/fixtures/boundaries.spec.js
@@ -0,0 +1,51 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+const dashboard = readFixture('user-activity/index.json');
+const slug = dashboard.metrics[0];
+
+async function serveFirstMetricWith(page, populations) {
+ const metric = readFixture(`user-activity/${dashboard.defaultCategory}/${slug}/index.json`);
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/user-activity/${dashboard.defaultCategory}/${slug}/index.json`),
+ route => route.fulfill({ json: { ...metric, axes: {}, data: { populations } } })
+ );
+}
+
+test('A population with a single data point is drawn as a dot', async ({ page }) => {
+ // The single point has the highest value, so it is population 1 - the
+ // case that live hardware data hit with a one-week "NULL" CPU speed.
+ await serveFirstMetricWith(page, {
+ Solo: [{ x: dashboard.dates[0], y: 900 }],
+ Steady: dashboard.dates.map((date, index) => ({ x: date, y: 400 + index })),
+ });
+
+ await page.goto('/dashboard/user-activity');
+
+ const metricOverview = page.locator('#metric-overview-1');
+ const dot = metricOverview.locator('circle.mg-line-rollover-circle.mg-line1');
+ await expect(dot).toBeVisible();
+ expect(Number(await dot.getAttribute('r'))).toBeGreaterThan(0);
+ await expect(metricOverview.locator('path.mg-main-line.mg-line2')).toBeVisible();
+ await expect(metricOverview.locator('.legend span')).toHaveText([/Solo/, /Steady/]);
+});
+
+test.describe('A metric whose populations are all null', () => {
+ // Pinned as current behavior, not as a design: react-metrics-graphics
+ // throws on a chart with no points, and the error boundary around the chart
+ // shows its generic "Load error". React logs the caught error too.
+ test.use({ allowedErrors: /Cannot read properties of undefined \(reading 'x'\)|The above error occurred/ });
+
+ test('shows "Load error" in place of its chart, and the other metrics still render', async ({ page }) => {
+ await serveFirstMetricWith(page, {
+ Empty: dashboard.dates.map(date => ({ x: date, y: null })),
+ AlsoEmpty: dashboard.dates.map(date => ({ x: date, y: null })),
+ });
+
+ await page.goto('/dashboard/user-activity');
+
+ await expect(page.locator('#metric-overview-1 .error')).toContainText('Load error');
+ await expect(page.locator('#metric-overview-1 svg')).toHaveCount(0);
+ await expect(page.locator('#metric-overview-2 path.mg-main-line').filter({ visible: true }).first()).toBeVisible();
+ });
+});
diff --git a/tests/playwright/specs/fixtures/chart-series.spec.js b/tests/playwright/specs/fixtures/chart-series.spec.js
new file mode 100644
index 00000000..0a057df2
--- /dev/null
+++ b/tests/playwright/specs/fixtures/chart-series.spec.js
@@ -0,0 +1,59 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+const dashboard = readFixture('user-activity/index.json');
+const slug = dashboard.metrics[0];
+
+// Listed out of order on purpose: the chart orders populations by their
+// highest value. The ranges don't overlap, so each line's vertical position
+// identifies which data it drew.
+const populationRanges = {
+ Charlie: [50, 100],
+ Alpha: [250, 300],
+ Bravo: [150, 200],
+};
+const expectedOrder = ['Alpha', 'Bravo', 'Charlie'];
+
+test.beforeEach(async ({ page }) => {
+ const metric = readFixture(`user-activity/${dashboard.defaultCategory}/${slug}/index.json`);
+ const populations = Object.fromEntries(Object.entries(populationRanges).map(([populationName, [low, high]]) => (
+ [populationName, dashboard.dates.map((date, index) => ({ x: date, y: index % 2 ? low : high }))]
+ )));
+
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/user-activity/${dashboard.defaultCategory}/${slug}/index.json`),
+ route => route.fulfill({ json: { ...metric, axes: {}, data: { populations } } })
+ );
+
+ await page.goto('/dashboard/user-activity');
+});
+
+test('Every population is drawn once, in order, with a matching legend entry', async ({ page }) => {
+ const metricOverview = page.locator('#metric-overview-1');
+ const lines = metricOverview.locator('path.mg-main-line');
+
+ await expect(lines.filter({ visible: true })).toHaveCount(expectedOrder.length);
+ await expect(metricOverview.locator('.legend span')).toHaveText(expectedOrder.map(populationName => new RegExp(populationName)));
+
+ let previousTop = -Infinity;
+ const colors = new Set();
+ for (const [index, populationName] of expectedOrder.entries()) {
+ const lineNumber = index + 1;
+ const legendEntry = metricOverview.locator(`.legend .mg-line${lineNumber}-legend-color`);
+ const line = metricOverview.locator(`path.mg-main-line.mg-line${lineNumber}`);
+
+ await expect(legendEntry).toHaveText(new RegExp(populationName));
+
+ const legendColor = await legendEntry.evaluate(element => getComputedStyle(element).color);
+ const lineColor = await line.evaluate(element => getComputedStyle(element).stroke);
+ expect(lineColor, `${populationName}'s line and legend entry share a color`).toBe(legendColor);
+ colors.add(lineColor);
+
+ // Higher values are drawn nearer the top of the chart.
+ const lineTop = (await line.boundingBox()).y;
+ expect(lineTop, `${populationName} is drawn below the population with higher values`).toBeGreaterThan(previousTop);
+ previousTop = lineTop;
+ }
+
+ expect(colors.size, 'Each population has its own color').toBe(expectedOrder.length);
+});
diff --git a/tests/playwright/specs/fixtures/dates.spec.js b/tests/playwright/specs/fixtures/dates.spec.js
new file mode 100644
index 00000000..6e827c13
--- /dev/null
+++ b/tests/playwright/specs/fixtures/dates.spec.js
@@ -0,0 +1,55 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+const { formatPercent } = require('../../utils');
+
+test('Choosing another summary date redraws the summary bars with that date\'s values', async ({ page }) => {
+ const dashboard = readFixture('hardware/index.json');
+ const slug = dashboard.summaryMetrics[0];
+ const metric = readFixture(`hardware/default/${slug}/index.json`);
+ const [newestDate, olderDate] = dashboard.dates;
+
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/hardware/default/${slug}/index.json`),
+ route => route.fulfill({
+ json: {
+ ...metric,
+ title: 'Controlled Vendors',
+ data: {
+ populations: {
+ Intel: [{ x: newestDate, y: 60 }, { x: olderDate, y: 30 }],
+ AMD: [{ x: newestDate, y: 40 }, { x: olderDate, y: 70 }],
+ },
+ },
+ },
+ })
+ );
+ await page.goto('/dashboard/hardware');
+ const bar = page.locator('#summary-metrics .summary-metric').filter({ hasText: 'Controlled Vendors' });
+ await expect(bar.locator('text.bar-label:visible')).toHaveText(['Intel (60%)']);
+
+ await page.locator('#summary-metrics #date-selector').selectOption(olderDate);
+
+ await expect(bar.locator('text.bar-label:visible')).toHaveText(['AMD (70%)']);
+ await expect(bar.locator('text.bar-label')).toHaveText(['AMD (70%)', 'Intel (30%)']);
+});
+
+test('Choosing another table date shows that date\'s complete ranking', async ({ page }) => {
+ const dashboard = readFixture('usage-behavior/index.json');
+ const tableNumber = dashboard.metrics.indexOf('top10addons') + 1;
+ const metric = readFixture(`usage-behavior/${dashboard.defaultCategory}/top10addons/index.json`);
+ const olderDate = Object.keys(metric.data.dates).sort().reverse()[1];
+ const expectedRows = metric.data.dates[olderDate].rows.slice()
+ .sort((a, b) => b.value - a.value)
+ .map((row, index) => [String(index + 1), row.name, formatPercent(row.value)]);
+
+ await page.goto('/dashboard/usage-behavior');
+ const table = page.locator(`#metric-overview-${tableNumber}`);
+ await expect(table.locator('tbody tr')).not.toHaveCount(0);
+
+ await table.locator('#date-selector').selectOption(olderDate);
+
+ const rows = table.locator('tbody tr');
+ await expect(rows).toHaveCount(expectedRows.length);
+ for (const [index, cells] of expectedRows.entries()) {
+ await expect(rows.nth(index).locator('td')).toHaveText(cells);
+ }
+});
diff --git a/tests/playwright/specs/fixtures/descriptions.spec.js b/tests/playwright/specs/fixtures/descriptions.spec.js
new file mode 100644
index 00000000..f3374ece
--- /dev/null
+++ b/tests/playwright/specs/fixtures/descriptions.spec.js
@@ -0,0 +1,24 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+test('Metric descriptions render Markdown links and escape raw HTML', async ({ page }) => {
+ const dashboard = readFixture('user-activity/index.json');
+ const slug = dashboard.metrics[0];
+ const metric = readFixture(`user-activity/${dashboard.defaultCategory}/${slug}/index.json`);
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/user-activity/${dashboard.defaultCategory}/${slug}/index.json`),
+ route => route.fulfill({
+ json: {
+ ...metric,
+ description: ['See the [methodology](https://example.com/methodology).', '
stays text.'],
+ },
+ })
+ );
+
+ await page.goto('/dashboard/user-activity');
+
+ const description = page.locator('#metric-overview-1 .metric-description');
+ await expect(description.getByRole('link', { name: 'methodology' })).toHaveAttribute('href', 'https://example.com/methodology');
+ await expect(description).toContainText('
stays text.');
+ await expect(description.locator('img')).toHaveCount(0);
+});
diff --git a/tests/playwright/specs/fixtures/errors.spec.js b/tests/playwright/specs/fixtures/errors.spec.js
new file mode 100644
index 00000000..416acd4b
--- /dev/null
+++ b/tests/playwright/specs/fixtures/errors.spec.js
@@ -0,0 +1,30 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+// The browser logs the failed request, and the container logs the reason.
+test.use({ allowedErrors: /status of 500|Internal Server Error/ });
+
+const internalServerError = { status: 500, json: { error: 'Internal Server Error' } };
+
+test('A dashboard whose data fails to load shows #dashboard-fetch-error', async ({ page }) => {
+ await page.route('https://data.firefox.com/datasets/desktop/user-activity/index.json', route => route.fulfill(internalServerError));
+
+ await page.goto('/dashboard/user-activity');
+
+ await expect(page.locator('#dashboard-fetch-error')).toBeVisible();
+});
+
+test('A metric that fails to load shows #metric-overview-fetch-error while the others render', async ({ page }) => {
+ const dashboard = readFixture('user-activity/index.json');
+ const [firstSlug, secondSlug] = dashboard.metrics;
+ const secondMetric = readFixture(`user-activity/${dashboard.defaultCategory}/${secondSlug}/index.json`);
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/user-activity/${dashboard.defaultCategory}/${firstSlug}/index.json`),
+ route => route.fulfill(internalServerError)
+ );
+
+ await page.goto('/dashboard/user-activity');
+
+ await expect(page.locator('#metric-overview-fetch-error')).toBeVisible();
+ await expect(page.locator('#metric-overview-2 h4')).toHaveText(secondMetric.title);
+});
diff --git a/tests/playwright/specs/fixtures/hardware.spec.js b/tests/playwright/specs/fixtures/hardware.spec.js
new file mode 100644
index 00000000..5b5c791c
--- /dev/null
+++ b/tests/playwright/specs/fixtures/hardware.spec.js
@@ -0,0 +1,47 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+const dashboard = readFixture('hardware/index.json');
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/dashboard/hardware');
+});
+
+test('Sections and their metrics come from the data, in order', async ({ page }) => {
+ const sections = page.locator('#dashboard-sections .dashboard-section');
+ await expect(sections.locator('h4.dashboard-section-title')).toHaveText(dashboard.sections.map(section => section.title));
+
+ for (const section of dashboard.sections) {
+ for (const [index, slug] of section.metrics.entries()) {
+ const metric = readFixture(`hardware/default/${slug}/index.json`);
+ await expect(page.locator(`#${section.key}-metric-overview-${index + 1} h5`)).toHaveText(metric.title);
+ }
+ }
+});
+
+test('There is no region selector', async ({ page }) => {
+ await expect(page.locator('#dashboard-title')).toHaveText(dashboard.title);
+ await expect(page.locator('#region-selector')).toHaveCount(0);
+});
+
+test('Each summary metric draws bars labelled with the largest population first', async ({ page }) => {
+ const summaryMetrics = page.locator('#summary-metrics .summary-metric');
+ await expect(summaryMetrics).toHaveCount(dashboard.summaryMetrics.length);
+
+ const mostRecentDate = dashboard.dates[0];
+ for (const [index, slug] of dashboard.summaryMetrics.entries()) {
+ const metric = readFixture(`hardware/default/${slug}/index.json`);
+ const summaryMetric = summaryMetrics.nth(index);
+
+ await expect(summaryMetric.locator('h4')).toHaveText(metric.title);
+ await expect(summaryMetric.locator('rect.bar').first()).toBeVisible();
+
+ const populations = Object.entries(metric.data.populations);
+ if (populations.length > 1) {
+ const [largestName] = populations
+ .map(([populationName, points]) => [populationName, (points.find(point => point.x === mostRecentDate) || { y: 0 }).y])
+ .sort((a, b) => b[1] - a[1])[0];
+ await expect(summaryMetric.locator('text.bar-label').first()).toContainText(largestName);
+ }
+ }
+});
diff --git a/tests/playwright/specs/fixtures/next-buttons.spec.js b/tests/playwright/specs/fixtures/next-buttons.spec.js
new file mode 100644
index 00000000..602fbd1c
--- /dev/null
+++ b/tests/playwright/specs/fixtures/next-buttons.spec.js
@@ -0,0 +1,21 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+const { nextButtons } = require('../../../../src/config.json');
+
+
+test('The proceed buttons walk from the home page through every dashboard', async ({ page }) => {
+ await page.goto('/');
+
+ for (const { from, to, text } of nextButtons) {
+ await expect(page).toHaveURL(from);
+ // Wait for this page's button, not the previous page's, before clicking.
+ const button = page.locator('.next-button a');
+ await expect(button).toHaveText(text);
+
+ await button.click();
+
+ await expect(page).toHaveURL(to);
+ await expect(page.locator('#dashboard-title')).toHaveText(readFixture(`${to.replace('/dashboard/', '')}/index.json`).title);
+ }
+
+ await expect(page.locator('.next-button')).toHaveCount(0);
+});
diff --git a/tests/playwright/specs/fixtures/region-data.spec.js b/tests/playwright/specs/fixtures/region-data.spec.js
new file mode 100644
index 00000000..273d202a
--- /dev/null
+++ b/tests/playwright/specs/fixtures/region-data.spec.js
@@ -0,0 +1,50 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+const dashboard = readFixture('user-activity/index.json');
+const slug = dashboard.metrics[0];
+
+// Deliberately different data per region, so the chart can only show one of
+// them: different population names and values two orders of magnitude apart.
+const populationsByRegion = {
+ Worldwide: { 'Worldwide high': 1000, 'Worldwide low': 500 },
+ Germany: { 'Germany high': 50, 'Germany low': 20 },
+};
+
+test.beforeEach(async ({ page }) => {
+ const metric = readFixture(`user-activity/${dashboard.defaultCategory}/${slug}/index.json`);
+
+ for (const [region, values] of Object.entries(populationsByRegion)) {
+ const populations = Object.fromEntries(Object.entries(values).map(([populationName, value]) => (
+ [populationName, dashboard.dates.map(date => ({ x: date, y: value }))]
+ )));
+
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/user-activity/${region}/${slug}/index.json`),
+ route => route.fulfill({ json: { ...metric, axes: {}, data: { populations } } })
+ );
+ }
+
+ await page.goto('/dashboard/user-activity');
+});
+
+async function hoveredValue(page) {
+ const chart = page.locator('#metric-overview-1 svg').first();
+ const box = await chart.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 3);
+ const datapoint = page.locator('#metric-overview-1 .mg-active-datapoint');
+ await expect(datapoint).not.toBeEmpty();
+ return datapoint.textContent();
+}
+
+test('Changing the region redraws the chart with that region\'s data', async ({ page }) => {
+ const legend = page.locator('#metric-overview-1 .legend span');
+
+ await expect(legend).toHaveText([/Worldwide high/, /Worldwide low/]);
+ expect(await hoveredValue(page)).toMatch(/: (1,000|500)$/);
+
+ await page.selectOption('#region-selector', 'Germany');
+
+ await expect(legend).toHaveText([/Germany high/, /Germany low/]);
+ expect(await hoveredValue(page)).toMatch(/: (50|20)$/);
+});
diff --git a/tests/playwright/specs/fixtures/summary-bars.spec.js b/tests/playwright/specs/fixtures/summary-bars.spec.js
new file mode 100644
index 00000000..5e5eacb2
--- /dev/null
+++ b/tests/playwright/specs/fixtures/summary-bars.spec.js
@@ -0,0 +1,65 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+const dashboard = readFixture('hardware/index.json');
+const slug = dashboard.summaryMetrics[0];
+
+test.beforeEach(async ({ page }) => {
+ const metric = readFixture(`hardware/default/${slug}/index.json`);
+ const onActiveDate = value => [{ x: dashboard.dates[0], y: value }];
+
+ await page.route(
+ url => decodeURIComponent(url.pathname).endsWith(`/hardware/default/${slug}/index.json`),
+ route => route.fulfill({
+ json: {
+ ...metric,
+ title: 'Controlled Vendors',
+ data: {
+ populations: {
+ Intel: onActiveDate(50),
+ AMD: onActiveDate(30),
+ NVIDIA: onActiveDate(15),
+ VIA: onActiveDate(2),
+ Matrox: onActiveDate(3),
+ },
+ },
+ },
+ })
+ );
+
+ await page.goto('/dashboard/hardware');
+});
+
+function summaryBar(page) {
+ return page.locator('#summary-metrics .summary-metric').filter({ hasText: 'Controlled Vendors' });
+}
+
+test('The largest segment\'s label shows first', async ({ page }) => {
+ const bar = summaryBar(page);
+
+ await expect(bar.locator('text.bar-label', { hasText: 'Intel (50%)' })).toBeVisible();
+ await expect(bar.locator('text.bar-label:visible')).toHaveCount(1);
+});
+
+test('Hovering a segment shows its label and hides the previous one', async ({ page }) => {
+ const bar = summaryBar(page);
+ await expect(bar.locator('text.bar-label', { hasText: 'Intel (50%)' })).toBeVisible();
+
+ await bar.locator('rect.bar').nth(1).hover();
+
+ await expect(bar.locator('text.bar-label', { hasText: 'AMD (30%)' })).toBeVisible();
+ await expect(bar.locator('text.bar-label', { hasText: 'Intel (50%)' })).toBeHidden();
+ await expect(bar.locator('text.bar-label:visible')).toHaveCount(1);
+});
+
+test('Clicking a segment shows its label and hides the previous one', async ({ page }) => {
+ const bar = summaryBar(page);
+ await bar.locator('rect.bar').nth(1).hover();
+ await expect(bar.locator('text.bar-label', { hasText: 'AMD (30%)' })).toBeVisible();
+
+ await bar.locator('rect.bar').last().click();
+
+ await expect(bar.locator('text.bar-label', { hasText: 'Other (5%)' })).toBeVisible();
+ await expect(bar.locator('text.bar-label', { hasText: 'AMD (30%)' })).toBeHidden();
+ await expect(bar.locator('text.bar-label:visible')).toHaveCount(1);
+});
diff --git a/tests/playwright/specs/fixtures/usage-behavior.spec.js b/tests/playwright/specs/fixtures/usage-behavior.spec.js
new file mode 100644
index 00000000..67709ae9
--- /dev/null
+++ b/tests/playwright/specs/fixtures/usage-behavior.spec.js
@@ -0,0 +1,50 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+const { formatPercent } = require('../../utils');
+
+
+const dashboard = readFixture('usage-behavior/index.json');
+const tableIndex = dashboard.metrics.indexOf('top10addons') + 1;
+
+// What the table should show for a region: the most recent date's rows,
+// highest value first.
+function expectedRanking(region) {
+ const metric = readFixture(`usage-behavior/${region}/top10addons/index.json`);
+ const mostRecentDate = Object.keys(metric.data.dates).sort().reverse()[0];
+ const rows = metric.data.dates[mostRecentDate].rows.slice().sort((a, b) => b.value - a.value);
+ return {
+ title: metric.title,
+ rows: rows.map((row, index) => [String(index + 1), row.name, formatPercent(row.value)]),
+ };
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/dashboard/usage-behavior');
+});
+
+test('The Top Add-ons table ranks the most recent date\'s rows by value', async ({ page }) => {
+ const expected = expectedRanking(dashboard.defaultCategory);
+ const table = page.locator(`#metric-overview-${tableIndex}`);
+
+ await expect(table.locator('h4')).toHaveText(expected.title);
+ const rows = table.locator('tbody tr');
+ await expect(rows).toHaveCount(expected.rows.length);
+ for (const [index, cells] of expected.rows.entries()) {
+ await expect(rows.nth(index).locator('td')).toHaveText(cells);
+ }
+});
+
+test('Choosing a region shows that region\'s complete ranking', async ({ page }) => {
+ const worldwide = expectedRanking(dashboard.defaultCategory).rows;
+ const germany = expectedRanking('Germany').rows;
+ const rows = page.locator(`#metric-overview-${tableIndex} tbody tr`);
+
+ expect(germany, 'The recording needs regions with different rankings').not.toEqual(worldwide);
+ await expect(rows.first().locator('td')).toHaveText(worldwide[0]);
+
+ await page.selectOption('#region-selector', 'Germany');
+
+ await expect(rows).toHaveCount(germany.length);
+ for (const [index, cells] of germany.entries()) {
+ await expect(rows.nth(index).locator('td')).toHaveText(cells);
+ }
+});
diff --git a/tests/playwright/specs/fixtures/user-activity.spec.js b/tests/playwright/specs/fixtures/user-activity.spec.js
new file mode 100644
index 00000000..e22cd0e0
--- /dev/null
+++ b/tests/playwright/specs/fixtures/user-activity.spec.js
@@ -0,0 +1,44 @@
+const { test, expect, readFixture } = require('../../fixtureData');
+
+
+const dashboard = readFixture('user-activity/index.json');
+
+test.beforeEach(async ({ page }) => {
+ await page.goto('/dashboard/user-activity');
+});
+
+test('Dashboard and metric titles come from the data, in order', async ({ page }) => {
+ await expect(page.locator('#dashboard-title')).toHaveText(dashboard.title);
+
+ for (const [index, slug] of dashboard.metrics.entries()) {
+ const metric = readFixture(`user-activity/${dashboard.defaultCategory}/${slug}/index.json`);
+ await expect(page.locator(`#metric-overview-${index + 1} h4`)).toHaveText(metric.title);
+ }
+});
+
+test('Every metric draws a line chart', async ({ page }) => {
+ for (const index of dashboard.metrics.keys()) {
+ await expect(page.locator(`#metric-overview-${index + 1} path.mg-line1`).first()).toBeVisible();
+ }
+});
+
+test('The region selector starts on the default region', async ({ page }) => {
+ await expect(page.locator('#region-selector')).toHaveValue(dashboard.defaultCategory);
+});
+
+test('Choosing a region loads every metric for it and remembers the choice', async ({ page }) => {
+ await expect(page.locator('#metric-overview-1 path.mg-line1').first()).toBeVisible();
+
+ const germanyRequests = dashboard.metrics.map(slug => page.waitForResponse(response => (
+ decodeURIComponent(response.url()).endsWith(`/user-activity/Germany/${slug}/index.json`) && response.ok()
+ )));
+ await page.selectOption('#region-selector', 'Germany');
+ await Promise.all(germanyRequests);
+
+ await expect(page.locator('#region-selector')).toHaveValue('Germany');
+ await expect(page.locator('#metric-overview-1 path.mg-line1').first()).toBeVisible();
+ expect(await page.evaluate(() => sessionStorage.getItem('preferredRegion'))).toBe('Germany');
+
+ await page.reload();
+ await expect(page.locator('#region-selector')).toHaveValue('Germany');
+});
diff --git a/tests/playwright/specs/footer.spec.js b/tests/playwright/specs/footer.spec.js
index 35460e13..277ff929 100644
--- a/tests/playwright/specs/footer.spec.js
+++ b/tests/playwright/specs/footer.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
test.beforeEach(async ({ page }) => {
diff --git a/tests/playwright/specs/home.spec.js b/tests/playwright/specs/home.spec.js
index 7f972eda..33f90707 100644
--- a/tests/playwright/specs/home.spec.js
+++ b/tests/playwright/specs/home.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
const { linkWorks } = require('../utils');
diff --git a/tests/playwright/specs/menu.spec.js b/tests/playwright/specs/menu.spec.js
index 73cad992..8925f3d6 100644
--- a/tests/playwright/specs/menu.spec.js
+++ b/tests/playwright/specs/menu.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
const { linksWork, flagForUpdate } = require('../utils');
diff --git a/tests/playwright/specs/noindex.spec.js b/tests/playwright/specs/noindex.spec.js
index 0833b6e7..23ec4140 100644
--- a/tests/playwright/specs/noindex.spec.js
+++ b/tests/playwright/specs/noindex.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
const selector = 'meta[name="robots"][content="noindex"]';
diff --git a/tests/playwright/specs/regionSelector.spec.js b/tests/playwright/specs/regionSelector.spec.js
index 80024553..375f52f6 100644
--- a/tests/playwright/specs/regionSelector.spec.js
+++ b/tests/playwright/specs/regionSelector.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
const { dashboards } = require('../../../src/config.json');
diff --git a/tests/playwright/specs/routes.spec.js b/tests/playwright/specs/routes.spec.js
index 2b4cbfca..4bf77de9 100644
--- a/tests/playwright/specs/routes.spec.js
+++ b/tests/playwright/specs/routes.spec.js
@@ -1,4 +1,4 @@
-const { test, expect } = require('@playwright/test');
+const { test, expect } = require('../fixtureData');
test('Not Found page loads for paths with superfluous suffixes', async ({ page }) => {
diff --git a/tests/playwright/utils.js b/tests/playwright/utils.js
index c4831b62..9f7edc2f 100644
--- a/tests/playwright/utils.js
+++ b/tests/playwright/utils.js
@@ -66,7 +66,16 @@ async function changeRegionAndWaitForMetrics(page, index, numMetrics) {
await metricsLoaded;
}
+// How the app shows a percentage (prettifyNumber plus the unit): floats get
+// exactly three decimal places, integers none.
+function formatPercent(value) {
+ return Number.isInteger(value)
+ ? `${value.toLocaleString('en-US')}%`
+ : `${value.toLocaleString('en-US', { minimumFractionDigits: 3, maximumFractionDigits: 3 })}%`;
+}
+
module.exports = {
+ formatPercent,
linkWorks,
linksWork,
flagForUpdate,
diff --git a/vite.config.mjs b/vite.config.mjs
index 0735f6de..6db49baa 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -14,6 +14,19 @@ export default defineConfig({
server: {
port: 3000,
},
+ optimizeDeps: {
+ // The React plugin sets the classic runtime for transforms but not for
+ // Vite's dependency scan, which then assumes the automatic runtime,
+ // fails to resolve react/jsx-dev-runtime (React 16 has none), and
+ // skips pre-bundling. The dev server then discovers dependencies one
+ // page at a time, answering "504 (Outdated Optimize Dep)" and
+ // reloading on first visits.
+ rolldownOptions: {
+ transform: {
+ jsx: { runtime: 'classic' },
+ },
+ },
+ },
build: {
outDir: 'build',
// Needed for `npm run size` (source-map-explorer) to inspect the