Skip to content

Add MSW-mocked Playwright coverage for the whole console - #594

Merged
sandhose merged 15 commits into
quenting/i18n-englishfrom
quenting/msw-page-coverage
Sep 2, 2026
Merged

Add MSW-mocked Playwright coverage for the whole console#594
sandhose merged 15 commits into
quenting/i18n-englishfrom
quenting/msw-page-coverage

Conversation

@sandhose

@sandhose sandhose commented Aug 24, 2026

Copy link
Copy Markdown
Member

Full-app Playwright tests with no backend: MSW intercepts every request via @msw/playwright, with resolvers running in the Node test process and handlers hand-written to satisfies the generated MAS types. onUnhandledRequest is strict from the first commit.

128 tests in the container (104 outside @screenshot), covering every console section plus the cross-cutting axes: reduced-capability deployments, empty and error states, cursor pagination against the real infinite-query UI, axe scans, filters and search, and visual-regression baselines.

Best reviewed commit by commit: infrastructure first, then one commit per console section introducing its spec and its mock slice in final form, then the cross-cutting specs, then baselines and docs.

Known-open a11y defects are asserted exactly: each scan pins the rules it trips as sorted rule-id [impact] ×nodes strings, with the full axe report attached to the test results, so a fix and a regression both go red — the two PRs stacked on top empty the lists as they go.

Testing: pnpm build && pnpm test --grep-invert "@screenshot" locally; the @screenshot baselines only regenerate in the pinned Playwright container (see tests/README.md).

@sandhose
sandhose force-pushed the quenting/msw-page-coverage branch from fe70c0d to 42d659d Compare August 24, 2026 08:08
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying admin-beta-element-dev with  Cloudflare Pages  Cloudflare Pages

Latest commit: b57fa56
Status: ✅  Deploy successful!
Preview URL: https://4354ec33.admin-beta-element-dev.pages.dev
Branch Preview URL: https://quenting-msw-page-coverage.admin-beta-element-dev.pages.dev

View logs

@sandhose
sandhose force-pushed the quenting/msw-page-coverage branch 6 times, most recently from e3c7768 to 289faa0 Compare August 25, 2026 10:26
@sandhose
sandhose marked this pull request as ready for review August 25, 2026 11:01
`tests/mocks/` holds hand-written MSW handlers typed against the checked-in
MAS types, wired into Playwright via `@msw/playwright` (resolvers run in the
test process, so there is no service worker and nothing mock-related in the
app bundle). msw's public/ worker postinstall is explicitly disabled since
that worker script is never needed, and knip learns about the test tree: its
`project` was scoped to `src/**`, so it would not follow the specs' imports
into `tests/mocks/` and would report msw as an unused dependency, and the
specs join `entry`, which is what lets an unused mock export surface as dead
code rather than the whole tree reading as unreachable.

Unhandled requests fail from the start, through an `onUnhandledRequest`
callback that returns for the `blob:` and `data:` URLs the page mints for
itself and errors on everything else: a forgotten endpoint fails the test
naming the exact URL, instead of hanging a suspended list query or quietly
hitting the real internet.

The static server becomes `pnpm serve` — vite preview — so deep links such as
`/callback` and `/users/:id` resolve to the SPA document instead of 404ing
against a plain static server.

Two specs prove the plumbing: the full mocked OIDC flow (discovery, dynamic
registration, the /authorize redirect, token exchange) and the dashboard
rendering mocked counts and versions.
`tests/index.spec.ts` predates the mocked suite and bundled three unrelated
checks, so it is deleted here. Its title assertion already landed with the
mocking infrastructure, inside `tests/pages/auth.spec.ts`, which drives the
whole sign-in flow rather than just the landing page; the axe scan and the
screenshot come back later in this branch, with `tests/pages/a11y.spec.ts`
and `tests/pages/screenshots.spec.ts`.

The six baselines are renamed rather than regenerated, so the login page is
still compared against the images it was compared against before.
The template the remaining sections follow: fixtures -> handlers -> spec,
asserting the page heading plus at least one piece of mocked data, so a
passing test proves the suspense boundary resolved with our fixtures rather
than a fallback.

Covers the list, the detail drawer (whose action buttons are gated on the
mocked site-config) and the not-found path for a well-formed but unknown
ULID.
The room fixtures landed with the mocking infrastructure, hand-crafted from
the valibot schemas in `src/api/synapse.ts`; this spec and its handlers are
what assert them.

Two traps worth noting. `scheduled_tasks` is mocked as an empty list because
a scheduled or active task makes the room detail page poll every second for
the rest of the run. And the rooms *list* needs the detail and members
handlers too: each row's avatar and display name fire their own per-room
queries.
Nine tests: the three device list pages, the three detail drawers, and a
not-found case for each detail route. Fixtures for oauth2-clients,
oauth2-sessions and compat-sessions are built from the generated types and
valibot schemas.

Device fixtures sit in clock-independent activity buckets (a null
`last_active_at` reads as "Never used", a set `finished_at` as "Signed out"),
so the badge assertions do not rot as the suite ages. `logo_uri` stays null
everywhere so no third-party image request escapes strict mode.

As with the rooms list, the list pages need the *detail* handlers too: a
session's `client_id` is used directly as the client detail path param.
Known-domains list and detail, plus allowed-domains with the ESS Pro
allowlist module present and absent.

Destination fixtures are hand-crafted. They cover all four
`getDestinationStatus` branches, and avoid asserting on `retry_last_ts`
(timezone-dependent) or `retry_interval` (locale-dependent
`Intl.DurationFormat`).

The destination detail page prefetches
`https://{destination}/.well-known/matrix/support` — an arbitrary
third-party origin. It is tolerant of failure, but strict mode still
requires the request to be *handled*, so there is a real handler for it
rather than a blanket passthrough.
List, detail and not-found for both collections. Fixtures are built from the
generated types.

Two traps. A registration token's `valid` field is server-computed and
short-circuits the status badge to "Active", so a non-active fixture has to
set `valid: false` explicitly or the whole list silently reads as active.
And personal-token rows resolve a user query plus a Matrix profile per
actor, so every actor and owner id has to be a real fixture user.

No fixture carries a future expiry date, so the status badges stay
clock-independent as the suite ages.
Both pages render on any deployment; only their bodies change with the ESS
edition, so this covers ESS Pro, ESS Community and non-ESS.

`useEssVariant` returns null on a non-ESS deployment and every consumer
treats that as community, which makes those two paths indistinguishable on
these pages — the dashboard is where they actually diverge.

Supervision's loader prefetches the adminbot query *outside* the edition
check, so `/_synapse/ess/adminbot` is requested on every edition and has to
be handled everywhere, not just on the Pro deployment.

The supervision launch control is asserted as enabled but never clicked: it
calls window.open.
Adds an `essCommunity` deployment and a spec exercising it alongside
`plainMas`. Two independent axes gate the console — the ESS edition
(`/_synapse/ess/version`) and the MAS version (`/api/admin/v1/version`) —
so `essCommunity` turns off only the edition axis while `plainMas` is off
on both, which is what proves they are not one flag in disguise.

The spec opens with a positive control on the default deployment asserting
the same selectors present, since an absence assertion on its own would pass
against a blank page.
Every fixture so far was non-empty and successful, leaving two whole
rendering paths untested.

Empty states for all eight list pages. There is no empty-table placeholder,
so a page's empty state is its zero-item count heading plus the absence of
data rows; `/federation/allowed-domains` is the one page with empty-state
copy of its own.

Error states cover all four error-body branches, and in particular that a
single failing dashboard tile degrades to a scoped "Failed to load" while
the rest of the page still renders — which is the whole reason
Data.DynamicValue wraps each tile in its own error boundary.

Queries started from a loader do not retry (fetchQuery forces retry: false),
so these tests are fast rather than slow.
Every fixture so far was deliberately single-page, which left the pagination
mechanism unverified.

Covers both pagination styles, which work differently — MAS's opaque cursor
(`page[after]`, taken from the last item's `meta.page.cursor` and gated on
`links.next`) and Synapse's numeric offsets (`next_batch` for rooms,
`next_token` for destinations) — plus the boundary case of a first page
exactly PAGE_SIZE long with nothing after it.

There is no "load more" button: the table is a window virtualizer, so the
tests scroll the window and retry, because each new page makes the document
taller. Jumping to the bottom never mounts the intermediate rows, which keeps
250-item fixtures cheap. The per-test timeout goes up to make room for that
retry budget.

Each test was checked against a deliberately broken cursor to be sure it
would go red — one of those breakages made the app request 470 pages, so the
terminal condition is worth asserting rather than assuming.

The new handlers are additive: the existing single-page fixtures delegate to
the same slicing helpers, so no other test changed.
Until now axe only ever scanned the login page, because rendering a
logged-in page needed a backend.

Five pages scan clean. The other eleven assert the exact rules they trip,
as sorted `rule-id [impact] ×nodes` strings with the cause named in a
comment above each test and the full axe report attached to the test
results — so a fixed defect and a new violation both go red. Nothing is
suppressed: no disableRules, no narrowed tags, and no exclude beyond the
pre-existing floating-UI portal.

Every scan first waits for the page's own data, so it cannot pass by scanning
a loading skeleton — the one way this kind of test fails silently.

Also settles a question the mocking setup left open: axe and MSW strict mode
never interact. axe-core is injected as a string through page.evaluate, so
nothing reaches context.route().
Until now no handler read the filter query parameters — the paginated ones
already honour their cursors — which was the right call for render coverage
but left the mapping from UI control to query parameter untested. That
mapping is app logic, and a control wired to the wrong parameter would look
perfectly correct on screen, so the users and rooms tests assert the request
that leaves the app as well as the rows that come back.

Two deliberate sabotages confirmed the tests would fail if filtering were
ignored or the controls wired to nothing.

Filtering selects indices rather than slicing arrays, because a fixture's
ULID, username and room ID all derive from its array index — renumbering
would silently change identities.
Nine pages, one per distinct layout rather than one per route, since a
tagged test costs one baseline per project. Generated in the same container
image CI uses.

Chromium only, and that is a limitation rather than a choice: WebKit enforces
CORS on responses Playwright fulfils, so every cross-origin mock fails there
with "due to access control checks". The app discovers Synapse and MAS on
other origins at runtime, so that is nearly every request — the console still
renders, because the version and ESS queries swallow their errors by design,
but it renders degraded. Baselines taken there would pin broken pages. The
tablet and mobile projects use iPad/iPhone descriptors, which default to
WebKit, hence the skip; the login baselines cover all six because that page
makes no cross-origin request.

Timezone and locale are now pinned in the Playwright config. Fixture dates
are rendered, so without that a machine outside UTC produces different pixels
from the CI container.

Each screenshot waits for its page's late-arriving content rather than just
the heading — the per-row profile on /users, the room detail on /rooms, the
client and user cells on /devices/user, the Synapse version tile on the
dashboard — since those columns are per-row suspense boundaries that resolve
after the table.
A teammate pulling this branch would otherwise find fifteen spec files and
no orientation.

Covers how to run the suite, how to regenerate the screenshot baselines in
the container image CI uses (and why `exposeNetwork` is required rather than
pointing BASE_URL at host.docker.internal — that origin is not a secure
context, which breaks login silently), how the a11y scans pin each page's
known violations, the three deployments and why they are named rather than
passed as arrays, and the traps that caught every section: list pages
needing related detail handlers, queries that fire regardless of gating, and
fixture identities deriving from array order.
@sandhose
sandhose force-pushed the quenting/msw-page-coverage branch from 289faa0 to b57fa56 Compare September 2, 2026 08:25
@sandhose
sandhose merged commit d98f267 into main Sep 2, 2026
10 checks passed
@sandhose
sandhose deleted the quenting/msw-page-coverage branch September 2, 2026 08:36
@sandhose sandhose added the T-Task Refactoring, removal, replacement, enabling or disabling functionality, other engineering tasks. label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-Task Refactoring, removal, replacement, enabling or disabling functionality, other engineering tasks.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant