Add MSW-mocked Playwright coverage for the whole console - #594
Merged
Conversation
sandhose
force-pushed
the
quenting/msw-page-coverage
branch
from
August 24, 2026 08:08
fe70c0d to
42d659d
Compare
Deploying admin-beta-element-dev with
|
| 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 |
sandhose
force-pushed
the
quenting/msw-page-coverage
branch
6 times, most recently
from
August 25, 2026 10:26
e3c7768 to
289faa0
Compare
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
force-pushed
the
quenting/msw-page-coverage
branch
from
September 2, 2026 08:25
289faa0 to
b57fa56
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tosatisfiesthe generated MAS types.onUnhandledRequestis 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] ×nodesstrings, 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@screenshotbaselines only regenerate in the pinned Playwright container (seetests/README.md).