Test/frontend coverage #4

Merged
TehRiehlDeal merged 11 commits from test/frontend-coverage into main 2026-09-12 03:07:45 -07:00
Owner
No description provided.
TehRiehlDeal added 11 commits 2026-09-12 02:48:16 -07:00
Fallout from the backend's 2.0.0, found by checking the frontend against a
running backend rather than by compiling it.

The async leaderboard declared `movieATitle` and `movieBTitle` as required
and rendered them directly. The backend now withholds the pair from anyone
who has not played that match - the pair is the puzzle - so for a
non-participant both are absent and the heading rendered as a bare "↔"
between two blanks.

TypeScript could not catch this: these API types are hand-written rather than
generated from the backend, so a field the server stops sending still
compiles. The types now say the titles are optional, which is the truth, and
the page explains that the movies are hidden until you play.

Every path the UI offers to this page belongs to a participant, so this was
only reachable by opening the URL directly - but the type was wrong either
way, and a wrong type is what hides the next one.
Closes WP-10 of the audit: M10-M16, M18, M19, M20 and the client half of
H11. Steps 5 and 7 of the package were already done in WP-1.

An expired token left the app half signed in: the interceptor only wrote to
the console, so the nav still showed the user's name while every request
failed. A 401 now clears the token and navigates to sign-in - a full
navigation rather than a router push, so in-memory state and open sockets go
with the page, and the API client does not have to reach into the stores and
join an import cycle with them. A 401 from /auth/login is excluded: there,
it is an answer.

Signing out disconnected the notifications socket and nothing else. Chat,
versus and game night stayed open holding a token that had just been removed,
and the friends and game stores kept their contents, so signing in as someone
else on the same tab showed the previous person's friend list. Teardown now
lives in one place, because the failure mode was precisely that adding a
socket did not mean remembering to close it.

All four connect() guards tested `.connected` rather than existence, so a
socket that was mid-reconnect was replaced without being closed and the
orphan went on handling every event a second time. disconnect() likewise
cleared only the socket, leaving matchId, result and score behind - the next
match opened showing the previous match's completion modal.

Effects depended on the whole `user` object, which loadUser() rebuilds on
every call, so they re-ran when nothing had changed. In AsyncVersusGame that
effect's cleanup calls resetGame(), so reloading a profile mid-match reset
the board. They depend on the id now. The same effect also reset the store
while its fetch was in flight, so init() then wrote the match back into what
had just been cleared; it aborts before resetting.

Page reads take an AbortSignal and their effects abort on unmount. Several
already had a `cancelled` flag, which stops the state write but lets the
request run to completion; the flag stays to guard the `finally` blocks.

Undo was fire-and-forget: rapid clicks put overlapping POSTs in flight
against a server that removes one link per call, and a failure was logged
while the local chain had already been shortened. Undos are serialised and
the server's chain - which it was already returning and the client was
discarding - is now authoritative.

The runtime API setting was substituted into a JS string literal by envsubst,
which does no escaping, so a value containing a quote ended the literal and
the remainder executed on every page load. It is escaped at render time
(verified under busybox with the production values and with an injection
payload, which comes out inert), and a malformed value is rejected on read.
Note this deliberately does not assert same origin, as the plan suggested:
the frontend and API are served from different subdomains in production, so
that assertion would break the deployment.

The Endless difficulty selector is removed rather than wired. Difficulty is
derived from cast analysis on the server and Endless builds its pairs in the
browser, so there was nothing to wire it to - it silently did nothing while
telling the player their choice mattered.

Verified in the running app: an invalid token clears and redirects to
/login?expired=1 without looping, while a wrong password shows "Invalid email
or password" on the form and stays put; sign-out clears the token, returns to
sign-in and leaves no trace of the previous user; Endless renders without the
control and keeps its real filters. No console errors beyond the 401s those
checks deliberately provoked.
BREAKING CHANGE: the production container listens on 8080 instead of 80.
nginx now runs as an unprivileged user and cannot bind a privileged port
without CAP_NET_BIND_SERVICE, which compose drops. Anything routing to
this container must be repointed - in the homelab Traefik config,
traefik.http.services.movieloop.loadbalancer.server.port 80 -> 8080.

nginx's add_header is not additive across levels: a block declaring any
add_header of its own discards every header inherited from its parent.
Both /assets/ and /config.js declared one, so X-Frame-Options,
X-Content-Type-Options and Referrer-Policy were silently absent from
every JavaScript and CSS response. The headers now come from a snippet
included in each location - verified 6/6 on /, on a hashed asset bundle,
and on /config.js.

Adds a CSP, HSTS and Permissions-Policy. connect-src is derived at
container start from API_URL, because the API is deliberately not
same-origin in production; asserting same-origin would have broken the
deployment outright. The inline theme script is allowed by SHA-256 hash
rather than unsafe-inline, which would have bought almost nothing -
editing that script means regenerating the hash.

config.js is also no longer rendered with envsubst, which does no
escaping: an API_URL containing a double quote closed the JS string
literal and the remainder executed on every page load.

Addresses M31, M34.
There was no test runner. This adds vitest with @testing-library/react and
jsdom, a `test` job in CI, and the first 51 tests.

They start where the audit's most user-visible defect lived. C1: every
multiplayer mode started a game through startGameFromVersus, which never
created a backend session, so `score` was only ever set inside
`if (sessionId)` and the completion modal spun on "Calculating score..."
forever, with no way out and an opponent waiting on the other side. The
store tests pin the repaired path end to end - a multiplayer start
creates a session, closing the loop lands on a score or a visible error,
never the silent null - along with the generation guard that keeps a slow
session from game A out of game B, and the serialised undo. The
chain-validation hook is covered in both its server-validated and
anonymous client-side forms.

Coverage thresholds of 90 on every metric are codified in vite.config.ts.
Two files are tested, at 100%/97.9% and 100%/91.4% branches, so
`npm run test:cov` fails honestly on the whole-tree number while CI runs
the plain `npm test`; the gap is real and is the next frontend work.

The tests intercept the axios client rather than the API modules. An
early version mocked `@/api/games` directly and one test sent a real
request to the development backend: a second concurrent dynamic
`import()` of a mocked aliased module resolved to the real file under
vitest. The client is imported statically, which vitest mocks reliably,
and the routing carries a tripwire that fails loudly on any unrouted
request so a bypass can never reach the network again.

Addresses L1.
App.tsx imported all 24 pages statically, so a first-time visitor
downloaded the admin panel, three multiplayer modes and the preset-pair
table before the landing page could render - one 682 KB chunk. Every
page except Home is now React.lazy behind a Suspense boundary: 56
chunks, pages at 2-11 KB. Verified in a browser against the production
build - the landing page fetches only the app and vendor chunks, a
navigation fetches that route's chunks and renders, and a deep link into
an authenticated route lazy-loads it, redirects to sign-in, and
lazy-loads that too.

Vendor code is split into vendor-react, vendor-router, vendor-ui and
vendor-net via manualChunks. The main chunk was 95% dependencies, so the
first load is not much smaller - the dependency set is the load - but
the chunk that changes on every deploy is now 50 KB (14 KB gzipped)
rather than 682 KB. A returning visitor after a deploy re-downloads the
app, not React.

The favicon was 888 KB: PNG data under a .jpg name, 768x768, with a
fully opaque alpha channel, fetched on every page load. It is a 2.6 KB
32px PNG now, with a 180px apple-touch-icon, and manifest.json points at
real 192px and 512px PNGs with the correct type instead of declaring the
same mislabelled file at both sizes. The service worker's notification
icon pointed at a /favicon.ico that never existed.

The useGameStore selector item from M26 was already done in WP-10.

Addresses M26.
eslint-plugin-jsx-a11y joins the lint step and found 19 violations on
arrival - 15 orphaned labels, a clickable div, a combobox missing its
required attributes, an autoFocus. All fixed, none suppressed, and the
rule set stays enforcing so the next one fails CI.

The two hand-rolled overlays were the worst of it. The notification bell
was a div with div-onClick rows: nothing focusable, nothing announced, no
way to close it from the keyboard. It is a real popover now - the
trigger reports its state and the unread count, the panel is a labelled
dialog, every row is a button with its actions beside it rather than
nested inside it, Escape closes and returns focus. The Challenge Friend
overlay had no role, no focus trap and no Escape, so Tab went straight
through it into the page behind; it is the UI kit's Dialog now, which
supplies all of that.

Every form control has a label, including the three change-password
fields that had only placeholders and a game-night join-password field
that had nothing at all. Every icon-only button says what it does,
including three versus buttons whose content collapsed to a bare spinner
while loading. The search combobox follows the ARIA 1.2 pattern, with
the role on the input; verified live that aria-expanded flips and
aria-controls resolves to the listbox.

State that changed silently is announced: link validation, both lobby
countdowns (assertively - a 3-2-1 that launches the game is not
something to miss), the game-night player list. The game header's
"Passed Movie B" read identically in both states and only the icon
differed, so a screen reader heard the wrong thing until B was reached.

A skip-to-content link on every page, and a global reduced-motion rule
that collapses every animation when the OS asks; the confetti overlay
does not render at all under it.

Addresses audit section 2.8.
Same guard as the backend: the back-push step used `git tag ... || true`,
so an existing v<version> tag made it a silent no-op and left the version
pointing at an older commit. Checked before anything is built.
WP-17. Minor: push notifications become usable and nothing a player could
reach was removed. The admin console's jobs need backend 3.0.0.

Push (M35): the whole browser half - the service worker's handlers, the
subscribe/unsubscribe calls, the server's VAPID key - had shipped with
nothing calling it, so no browser ever held a subscription. The toggle
lives in Profile because the permission prompt has to come from a click.
The old isPushSubscribed awaited serviceWorker.ready, which never settles
with no worker registered; it uses getRegistration() now.

Admin: each job pages and the button becomes Continue with the server's
cursor; the recalculate card also stopped printing "undefined leaderboard
entries" for a field the server had not sent for several releases. The
three copy-pasted job cards are one component.

Consolidation: the four game screens share use-game-screen and one search
panel; the review page's static chain is ChainDisplay in a static mode
(21 rendered states verified byte-identical); formatTime and error
extraction exist once; Endless and quick play share a franchise-aware
picker instead of two divergent sort(() => Math.random() - 0.5) copies.
The dead api/index barrel and the fourteen functions only it referenced
are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected
and every preset title is checked against TMDB.
WP-19. From 105 tests / 14% lines to 1,112 tests / 99% lines and 97%
branches across 93 files, with every source file above the codified 90%
thresholds on its own - no per-file exceptions.

Written risk-first: real Zustand stores seeded via setState, the axios
client mocked with an unrouted-request tripwire, the socket factory
mocked with a controllable fake, so no test can reach a backend and the
stores are covered by the pages that use them as well as directly.

The tests found six defects, each pinned first with it.fails and then
fixed: a failed join to an open Game Night room was still silent (the
error only rendered inside the password prompt); the remove-friend
button had no accessible name; signing out from Profile landed on
/login because the page's auth guard re-ran; the sign-in page neither
redirected a signed-in user nor read the ?expired=1 the API client has
sent since WP-10; and Quick Play / Endless threw out of their click
handlers on a TMDB failure while every start page navigated to /game
whether or not the game had started. startGame now reports its outcome
and the pages branch on it.

One toolchain finding worth keeping: vi.useFakeTimers() combined with
@testing-library/user-event deadlocks under this vitest/jsdom
combination; tests that need both use real timers and waitFor.
The bar codified in WP-14 is cleared as of 2.2.1, so the job that was
deliberately left as a plain run - "gating CI on a bar the codebase is
known not to clear would be a permanently red job nobody reads" - now
enforces it.
fix: what a live play-through found - stale search, hint cost, empty day (2.2.2)
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 8s
frontend-ci / lint (push) Successful in 23s
frontend-ci / typecheck (push) Successful in 24s
frontend-ci / fs-scan (push) Successful in 18s
frontend-ci / test (push) Successful in 37s
frontend-ci / build (push) Successful in 45s
frontend-ci / push (push) Skipped
frontend-ci / secrets-scan (pull_request) Successful in 3s
frontend-ci / sast (pull_request) Successful in 7s
frontend-ci / fs-scan (pull_request) Successful in 14s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 28s
frontend-ci / test (pull_request) Successful in 31s
frontend-ci / build (pull_request) Successful in 38s
frontend-ci / push (pull_request) Skipped
2b13d46254
Played end to end against a clean-install dev stack: Quick Play, a Custom
Game loop closed and scored server-side, a hint, an undo, every anonymous
route. Four things the 1,112 unit tests had not caught.

Picking an actor flips the search to movie mode while the 300ms debounce
still holds the actor's name, so a movie search for "Daniel Craig" ran
and opened a listbox under an empty input; an empty input now means
nothing to search. The hint button said -150 while the server charges
200. A fresh install's empty daily-challenge day (a 404 until the midnight
job) read as "Failed to load". And every abandoned request logged
"[API Error] canceled", two per page change.
TehRiehlDeal merged commit 4e0eb8cd06 into main 2026-09-12 03:07:45 -07:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: TehRiehlDeal/movieloop-frontend#4