vsa-validation-and-qa
Load before claiming any VSA-website change is "done" or "verified", when deciding who owns which checks, whether a change needs tests, when adding or modifying Jest tests, when a reviewer asks for ac
SKILL.md.claude/skills/vsa-validation-and-qa/SKILL.md
GitHubVSA Validation & QA
What this skill is for. This is the definition of "verified" for the VSA website. It tells you exactly what evidence a change needs before it can be called done: which commands must pass, which warnings are acceptable, which tests exist and what they certify, when new tests are mandatory, and which manual-QA runbook to execute for your change type. The core stance: "it compiles" is not evidence. A green tsc pass proves only that the types line up — not that the leaderboard is right, the route loads, or the mobile drawer still opens.
This skill is the canonical validation runbook. AGENTS.md, GEMINI.md, .github/CONTRIBUTING.md, and vsa-change-control point here for the definitive matrix and carry at most a minimal pre-push summary — keep the authoritative command matrix and manual-QA runbooks here so they have one home. (The acceptable-warnings rule quoted in §1 is itself governed by AGENTS.md § Testing; this skill quotes it, it does not own it.)
When NOT to use this skill:
| If you need… | Go to |
|---|---|
| Whether a change is allowed at all (protected domains, never-do list, PR conventions) | vsa-change-control |
| Diagnosing why a build/test/page is failing | vsa-debugging-playbook |
Running diagnostic tooling: scripts/verify-rls-security.mjs, bundle analysis, Lighthouse/axe, web-vitals |
vsa-diagnostics-and-measurement |
| Writing or reviewing RLS policies and migrations themselves | vsa-supabase-security-reference |
Jest/CRA environment breakage (Unexpected token 'export', node version, env setup) |
vsa-build-and-env |
| Full incident histories behind these rules | vsa-failure-archaeology |
Jargon used below — RLS: Row Level Security, Postgres per-row access policies (Supabase's authorization layer). CRA: Create React App, the build toolchain this site uses (react-scripts). jsdom: the simulated browser DOM Jest runs tests in. Degraded mode: the app's required behavior when Supabase is unreachable — public pages fall back to static content instead of crashing.
1. The evidence bar and ownership model
A change is verified when the checks assigned by its artifact type, behavior, integration surface, and risk all pass. Before running any check, identify the question it answers. Do not rerun an unchanged scope when successful evidence already answers that question and no relevant semantic change occurred.
Ownership
- Implementer: run the smallest meaningful proof of owned behavior: the changed test file, affected component/integration test, or focused manual reproduction. Do not run the full suite/build by default unless shared infrastructure, concrete regression risk, or the task brief makes that broader question yours.
- Reviewer: read-only by default. Inspect the diff, acceptance criteria, invariants, and implementer evidence. Rerun only for a concrete unanswered doubt; an agent handoff does not invalidate evidence.
- Parent/controller: accept valid focused evidence. Run an integration check only when completed concerns interact, and own integration rather than delegating the whole result.
- Final gate: after semantic edits and formatting are complete, broaden once according to risk.
Preferred final-gate order:
format
→ lint/static analysis relevant to changed artifacts
→ focused integration tests
→ broader relevant tests once
→ production build if application/runtime behavior changed
→ RLS/security checks if relevant
→ manual/browser checks if relevant
→ final review
The complete Jest suite (CI=true npm test -- --watchAll=false) is normally a final-gate check for cross-cutting/high-risk application changes, shared infrastructure, or CI—not documentation-only work, agent configuration, isolated copy, one narrow test change, or every subagent task. npm run build is required when application/runtime configuration, dependencies, or generated application behavior changed; it is not evidence for Markdown-only edits.
Formatting-only or whitespace-only changes do not invalidate broader successful evidence. A new run must answer a new question.
Acceptable warnings. AGENTS.md (§ Testing) states the rule exactly:
"Existing jsdom, ThemeProvider, and Framer Motion console warnings are acceptable when the test command exits successfully."
That is the complete allowlist when Jest is part of the selected evidence. Exit code 0 with those warnings = pass. Any new warning class, or a nonzero exit, is a failure you must resolve — not annotate away.
Why local verification is the real gate (as of 2026-07-06):
- CI does run checks:
.github/workflows/deploy.ymlrunsnpm run lint,npm run typecheck,CI=true npm test -- --coverage --watchAll=false, the timezone re-runs, andnpm run buildonpull_requesttargetingmain; a separateedge-functionsjob runsnpm run typecheck:edgeandnpm run test:edge(Deno). A failing test step fails the job and skips the downstreamsecurity/build/deploy-verceljobs (confirmed from run history, 2026-10-02). - But
mainhas no branch protection:gh api repos/{owner}/{repo}/branches/main/protectionreturns 404 "Branch not protected". Nothing on GitHub blocks a merge (or a direct push) when CI is red. - AGENTS.md now states this distinction directly: CI runs, but it is not a protected merge gate while
mainremains unprotected. Proportional local verification is the enforcement mechanism.
Re-check branch protection if this ever changes:
gh api repos/{owner}/{repo}/branches/main/protection # 404 = still unprotected
What does NOT count as evidence:
- "It compiles" / "TypeScript is happy" — type-checks only.
- "The dev server starts" — CRA dev builds are more permissive than
npm run build. - "Tests pass" alone — the suite covers a handful of pure-logic modules (section 2); your feature is almost certainly not in it.
- A screenshot of one viewport in one theme — see section 7 for the actual matrix.
2. Test inventory — the golden set (reviewed 2026-09-28; completeness enforced by src/__meta__/testInventory.test.ts)
Derive the current file set with find src -name "*.test.ts*" | sort; never trust a copied count. The inventory below explains what the current metadata, pure-logic, and smoke tests certify—and, more importantly, what nothing certifies.
| File | What it certifies | Why it exists |
|---|---|---|
src/__meta__/playbookRoster.test.ts |
The canonical .claude/agents/README.md registry lists exactly the playbook files present on disk, excluding README.md; detects omissions and dangling registry entries without hard-coding the roster in the test. |
Protects workflow metadata. Harness adapters point to one canonical registry, so roster drift must fail CI instead of silently producing different routing across tools. |
src/data/repos/adminMembers.test.ts, src/components/features/admin/MemberWorkflow.test.tsx, src/pages/Admin/Import.file.test.tsx |
Manual member creation validates profile fields; attendance additions use configured event points (including zero), ignore duplicates and require a term; removals target one member/event pair; attendance dialogs confirm removal and refresh history/totals/caches; local Google Form CSVs reuse matching, duplicate handling, member creation and import audit metadata. | Attendance workflow: no check-in-table or cached-total writes. Mock tests do not certify live database triggers or RLS. |
src/lib/uvsaSchoolLogos.test.ts, src/lib/uvsaNetwork.test.ts, src/components/features/uvsa/SchoolVisualMark.test.tsx, src/components/features/uvsa/SchoolCard.test.tsx, src/components/features/uvsa/SchoolDirectory.test.tsx, src/components/features/uvsa/ExternalEvents.test.tsx, src/components/features/uvsa/ExternalEventCard.flyer.test.tsx, src/lib/externalEventDisplay.test.ts, src/pages/UVSANetwork.test.tsx |
/uvsa-network rendering: getSafeLogoUrl accepts https plus this project's own uvsa_school_assets Storage URLs and rejects other projects/buckets/http; logo load → loaded / failed / missing states fall back to initials; school card content (logo, names, one city, system badge, Instagram as the only base-card link, details toggle, UCSD Home Base); All/UC/CSU/Private filtering; host logos on event cards; flyer-forward upcoming cards (linked event thumbnail → full image → listing image_url → date-tile fallback, school logo never used as a flyer, CTAs only when populated); featured spotlight only for upcoming events; archive grouped by academic year; page section order (hero → upcoming → schools → archive → first external → info). |
Public-surface guard. Locks the safe-logo rule that lets the school-logo upload workflow work without opening other Storage buckets or insecure URLs. |
src/data/repos/uvsaSchools.upload.test.ts, src/components/features/admin/SchoolLogoField.test.tsx, src/__meta__/uvsaSchoolAssetsMigration.test.ts |
School logo upload: compress with the logo preset, upload to uvsa_school_assets/<slug>/ with a unique path and no upsert, reject non-PNG/JPG/WebP before touching Storage; admin field preview/upload/paste/Remove behavior; the bucket migration stays public-by-URL, admin-only list/insert/update via is_admin_user(), no anon policy, no DELETE policy, no SVG, no other tables. |
Storage-policy guard. Text-level check of the migration only — it does not certify the applied database; verify the bucket and policies in Supabase after applying. |
src/__meta__/externalEventLinkingMigration.test.ts |
Guard migration 20261002060000_link_external_events_to_events.sql: host_type constraint ('school' or 'uvsa_socal'), unique index for PostgREST upsert on source_event_id, CASCADE delete on parent event, forbids school on UVSA SoCal listing, no fake uvsa_schools row, anon grant allowlist (host_type, source_event_id only; never show_on_network), no policy or row mutations. |
Schema & migration guard. Enforces DB contract and column allowlist for external event linking. |
src/lib/externalEventLinking.test.ts, src/data/repos/externalEvents.linked.test.ts, src/components/features/events/ExternalEventHost.test.tsx |
External event linking helpers and data layer: payload conversion, UVSA SoCal vs school host validation, link URL sanitization, externalEventsRepository methods (upsertLinkedForEvent, getBySourceEventId, deleteBySourceEventId), and public/admin event host badges (renders school logo/name or UVSA SoCal mark, links to UVSA network, warns when school is missing). |
Data integrity & UI guard. Ensures 1-to-1 event mirror sync and public display. |
src/pages/Admin/Events.external.test.tsx, src/pages/Admin/ExternalEvents.linked.test.tsx |
Admin UI workflows for linked external events: choosing External Event displays host and link fields; saves mirror to external_events; event create/edit preserve points and form URLs without retired code controls/fields; retyping away warns and hides the mirror; deleting event confirms mirror deletion; /admin/external-events flags linked events and directs editors to /admin/events. |
Admin workflow guard. Prevents dual-entry and guarantees lifecycle sync across both editors. |
src/routes/publicRouteDiscoverability.test.ts, src/components/layout/Footer.test.tsx |
Every public route in src/routes/index.tsx has a recorded discoverability decision in publicRouteInventory.ts (new public route without one fails); recorded primary-nav/dock/footer/contextual placements are real; legal/help pages stay out of primary nav and the 4-slot dock; no standalone /points-explainer route is recreated; the footer carries Find My Points, Calendar, Get Involved, UVSA Network, Feedback and Privacy and never an admin link. |
Orphaned-route findability (#262) must not regress silently as routes are added. |
src/lib/relatedLinks.test.ts, src/components/common/RelatedLinks.test.tsx, src/pages/Points.related.test.tsx, src/pages/Vcn.related.test.tsx |
The 1-3 contextual "Related" link sets per page; the same-page #how-points-work jump keeps ?view=; program pages compare via /get-involved#programs with no hardcoded application URL; the /vcn apply button renders only for an open window with the URL from the public application-link model, and nothing (no URL) for closed, future or disabled windows. |
Cross-links (#264) are contextual, restrained and cannot leak closed application URLs. |
src/lib/publicSearch.test.ts, src/data/repos/publicSearch.reads.test.ts, src/components/features/search/PublicSearch.test.tsx |
Public site search (#261): the static index is a view over navConfig/footerLinks/publicRouteInventory (every searchable route present, no admin/alias/redirect/parametric entries, no application links); events and albums come from two slim one-shot reads that filter is_published / album-URL at the query and never select check_in_form_url; results group by type (page/program vs event vs photo album, so House the program is not House Reveal the event); ranking, diacritics and caps; no request per keystroke; arrows/Enter/Escape, Tab containment, focus restore, Ctrl/Cmd+K and /; empty state with next steps; albums open externally with noopener. |
Privacy/public-surface accuracy: search must never surface admin routes, drafts, private rows or closed application URLs. |
src/lib/publicBreadcrumbs.test.ts, src/components/common/PublicBreadcrumbs.test.tsx, src/routes/publicBreadcrumbs.routes.test.tsx |
Public breadcrumbs (#265): trails only for nested routes (VCN current/archive, House archive/year/House), none on flat pages or unknown paths; House archive-vs-current is decided by year (not URL prefix) and is display-only; nav aria-label=Breadcrumb, ol, aria-current=page on the last crumb only, ancestors truncate while the current page wraps, tokens only. |
Hierarchy labels must not misstate House archive/current. |
src/lib/notFoundRecovery.test.ts, src/pages/NotFound.test.tsx |
404 recovery (#266): search embedded; Home/Events/Get Involved always offered; deterministic legacy destinations only for /profile and /points-explainer; likely destinations come from the public index; unknown URLs get no guess and no redirect; an admin URL renders byte-identically to any unknown URL. |
A 404 must not reveal whether an admin route exists. |
src/lib/eventGalleryLinks.test.ts, src/pages/Events.eventLink.test.tsx, src/pages/Gallery.lightbox.test.tsx |
Event↔Gallery links use the explicit gallery_events.event_id only (never titles) and only for published events; /events#event-<id> keeps loading archive pages (bounded) until the linked event renders; the Gallery album quick-look opens in place, swipes between albums, pages with arrow keys, closes on Escape and on browser Back (one history entry however many albums are swiped), has a bottom-bar Close, falls back for a missing or broken cover, and keeps the OptimizedImage thumbnail srcset. |
Gallery mobile interaction (#247) on top of the shipped image-delivery work. |
src/lib/eventLifecycle.test.ts, src/pages/Home.upcomingEvents.test.tsx, src/pages/Events.lifecycle.test.tsx |
Event lifecycle classification (America/Los_Angeles): past/ongoing/upcoming boundaries across DST, overnight, multi-day, legacy UTC-midnight dates, and missing-time fallbacks; homepage real-time card removal and empty state; /events upcoming vs past separation and archive availability. | Event display accuracy. Prevents expired events from appearing as upcoming on public pages and keeps past events discoverable. |
src/pages/Leaderboard.mobile.test.tsx |
Display-only mobile contract for /leaderboard: compact podium keeps rank, name and points; ties show shared T-ranks; long/mononym names wrap; rank-gap captions may wrap on phones; the profile sheet collapses event-type/history behind tappable headers on phones and starts expanded on desktop; House shown as text; the How-points-work explainer opens from #how-points-work. Ranking logic stays covered by the leaderboard characterization tests. |
Leaderboard mobile polish (#246) without touching points logic. |
src/App.test.tsx |
The full app (provider hierarchy + router) renders without throwing, against the mocked Supabase client from setupTests.ts. |
Smoke test — catches provider-order breakage and import-time crashes in App.tsx. |
src/data/legacyHouseArchive.test.ts |
The House archive's exact year list (2018-2019 → 2025-2026); 2020-2021 is an unconfirmed gap with no Houses; 2019-2020 = designer Houses (Gucci, Comme des Garçons, Supreme, Yves Saint Laurent); 2023-2024 = beverage Houses (Ca Phe Sua Da, Banana Milk, Matcha, Yakult); 2024-2025 = three Sanrio Houses; the gap year is excluded from verified years. |
Protects domain facts. House-year mapping has been repeatedly corrupted by agents inventing or shuffling Houses (AGENTS.md "Domain-critical facts" pins these years). This test makes the history executable — an agent that "fixes" the archive breaks the build. |
src/utils/seasonalState.test.ts |
Seasonal boundaries in America/Los_Angeles: summer break starts June 15 and ends September 15 (exclusive); shouldUseSummerEmptyState only fires when no active items exist. |
Protects domain facts. The academic-year clock drives visible site behavior (empty states, seasonal content); off-by-one date bugs here silently change the public site twice a year. See vsa-seasonal-operations for the full clock. |
src/lib/applicationLinks.test.ts |
getApplicationStatus window logic: disabled when not enabled regardless of dates; not_open before open; open inside the window with both boundaries inclusive; closed after due. |
Risk-adjacent. Governs whether an application window (and its URL) is exposed — enforces the "closed/future application URLs are never public" safety rule (AGENTS.md). |
src/lib/attendanceImportDecisions.test.ts, src/pages/Admin/Import.identity.test.tsx |
Attendance-import identity resolution: similar and identical names, near-name/email/college conflicts, shared emails, explicit Match Existing / Create New / Skip decisions, unresolved-row acknowledgment, withheld duplicate emails, skipped-row recovery on re-import, no double attendance, and failure/retry reusing already-created members. All synthetic members. | Protected domain (attendance import). A confirmed new member must get their own member ID and attendance; the suggested member is never written to. Mock tests do not certify live DB triggers or RLS. |
src/lib/memberMatching.test.ts |
House-assignment cell parsing (strips preference rank/timestamps, handles quoted CSV commas, doesn't mistake preference for class year) and attendance-import matching (unique email → auto-match; duplicate email → review; near-name matches always review). | Risk-adjacent to a protected domain. This is the matching layer of attendance import; the import/points domain is audit-first (section 4; playbook agent vsa-points-attendance-guardian). |
src/lib/importHistoryAudit.test.ts |
Read-only historical import reconciliation (docs/import-history-audit.md): skipped similar-name attendees, intentional admin skips, duplicates, already-recorded attendance, risky fuzzy / force / legacy exact-name matches, missing audit metadata, repeat runs without mutation; static checks that scripts/audit-import-history.sql has no mutating statements and selects no PII. |
Risk-adjacent to a protected domain. Audit only; never writes attendance or members. |
src/lib/attendanceRecovery.test.ts, src/data/repos/attendanceRecovery.test.ts, src/components/features/admin/HistoricalRecoveryPanel.test.tsx |
Historical attendance recovery (docs/import-history-audit.md): findings bucketed unresolved / needs more information / recovered / dismissed from the audit classifier plus the recovery-history chain; filters by event, import job and issue type; allowed actions per status (recovered is read-only, dismissed only reopens, a row that credits a member offers Correct match, not Restore); the exact-change preview (trigger-recalculated totals, already-credited destination awards nothing, corrections keep and flag the original credit); investigation resolution via the Admin Members editor; a reopened finding returns to Unresolved; candidates never preselected; identity actions blocked until lookups load; identity and change confirmations; one request id reused on retry; stale-finding and held-email handling; every write goes through admin_recover_import_row with no client table writes; member lookups by email, surname and search text go through the POST-only admin_lookup_members / admin_search_members RPCs, never members GET filters (no attendee PII in request URLs). Database behavior (admin-only RLS and execute under Supabase default grants, immutable history, no attendance deletes, atomic rollback, lookup-RPC grants/guard/GET refusal/literal matching, idempotent replay, stale/concurrent attempts incl. same-member/different-event and event-points races, duplicate email under concurrency, live points triggers) is covered by scripts/test-attendance-recovery.sh, not Jest. |
Protected domain (attendance, points), change explicitly requested. Never award a member twice for one event, never write cached totals, never infer identity from a name. |
src/lib/recoveryWorkspace.test.ts, src/lib/recoveryTriage.test.ts, src/lib/recoveryStagingStore.test.ts, src/hooks/useStagedRecovery.test.tsx, src/components/features/admin/recovery/RecoveryWorkspace.test.tsx |
Historical Recovery bulk reconciliation workspace: event-scoped table with triage tiers (an existing-member suggestion only on an exact stored-email match, never a name), diacritic-folded similar-name checks, filters/sort/counts/per-event progress; inline staging (match, new member, credit correct member, dismiss, needs info) with undo and persistence across event switches in tab-scoped sessionStorage (per admin, TTL, other admins swept, tampered entries dropped; kept in memory and storage while a session expiry holds the page, dropped when a different admin takes over); homogeneous bulk staging only for qualifying rows (bulk new members need an email and no possible existing identity; bulk legitimate-duplicate needs ledger evidence); one consolidated review that re-reads the database and blocks stale findings, changed members (re-confirm), one member credited twice per event, one person sent to two members, duplicate or held new-member emails and similar names in bulk; already-recorded restores need a per-row acknowledgement; ledger effects with 0 removals; frozen request payloads; bounded concurrency (2, one member per lane); per-row applied/replayed/conflict/failed/unknown results; lost responses recognized from history or replayed with the same request; read-only cached-total drift check; keyboard shortcuts that never fire while typing; 300-row pagination with batched lookups. The two-lane batch and lost-response replay (equivalent vs. changed payload, request id reused on another row) are also covered in scripts/test-attendance-recovery.sh. |
Protected domain (attendance, points), change explicitly requested. Bulk never matches anyone to an existing member; every write still goes through admin_recover_import_row, one transaction per row. |
src/utils/wrapped.test.ts |
countEventsInWindow (inclusive date-only window) and House-standings helpers: sort by total points descending without mutating input, winner selection, community-point sums. |
Leaderboard-adjacent. Exercises standings ordering/winner logic used by VSA Wrapped. Does not test the canonical leaderboard calculation — that remains untested (see below). |
src/utils/calendar.test.ts |
Calendar date math across month boundaries without timezone shift; vsaEventToCalendarItem maps only public-safe fields; house/application → calendar-item mapping and tag normalization. |
Privacy-adjacent. The "public-safe fields only" assertion guards against leaking non-public event data into the public calendar. |
src/lib/dateOnly.test.ts |
toDateOnlyString / parseDateOnly: parse YYYY-MM-DD as local calendar dates (no timezone shift), reject impossible dates (e.g. Feb 31), fall back to parseISO for other strings. |
Date-only rendering has caused off-by-one display bugs; this pins the safe parsing behavior. |
src/schemas/dataRightsRequests.test.ts |
DataRightsRequestFormSchema / DataRightsDependencyPreviewSchema: accept only non-destructive, read-only metadata; reject destructive action fields, raw content, invalid identifiers, and oversized notes; require an independent reviewer. |
Privacy/safety. Keeps the data-rights workflow read-only-by-construction (see docs/data-rights-anonymization-runbook.md). |
src/schemas/memberPhotoRequests.test.ts |
MemberPhotoRequestFormSchema: requires explicit consent === true; rejects missing name, invalid email, non-@ucsd.edu email (the upload broker rejects it too), oversized notes, and unknown fields. |
Enforces consent + input bounds on the public member-photo-request form. |
src/__meta__/skillsRoster.test.ts |
The .claude/skills/README.md routing table and the §6.4 roster in vsa-docs-and-writing both list exactly the vsa-* skill directories on disk; every backticked/bold vsa-* name in the governance docs resolves to a skill or playbook agent (#305). |
Protects workflow metadata, like playbookRoster.test.ts. |
src/lib/storageContentAudit.test.ts, src/__meta__/storageAuditScript.test.ts |
The read-only Storage egress audit (npm run audit:storage-content): URL classification (local /images/... vs Supabase Storage by bucket vs external vs other; render and signed URLs count as Storage; query tokens stripped; custom-host recognition), per-column summaries, committed-file lookup at the migrate script's chosen name (exact and content-hashed), and the offline public/images byte/thumbnail measurement. The meta test keeps the script select-only (no insert/update/upsert/delete/rpc, no Storage or network calls, never imports the migrate script), keeps its table list in step with the migration categories and src/types/database.ts, and keeps scripts/lib/imageMigrationConfig.ts equal to the migrate script's CATEGORIES until the script imports it. |
Egress guard (#299/#303). The audit decides what is "still on Storage"; a column missing from its list is invisible. Does not prove any live table state; run the audit against Supabase to know that. |
src/__meta__/testInventory.test.ts |
Every *.test.ts(x) file under src/ has a row in this table, and every row points at a file that exists. |
Keeps this inventory honest; it was months stale before this guard existed. |
src/routes/publicRoutes.smoke.test.tsx |
Every public route (incl. parameterised House/VCN routes, /signin redirect and the 404 for retired /profile, /signup, /register, /sign-up) mounts, renders exactly one live <main>, and settles past its loader (#294, #394). |
Catches blank or crashing pages before main auto-deploys. |
src/pages/HouseYearRollover.test.tsx |
Before a new year's Houses are published, /house shows the not-announced state, and year-less House links redirect to the latest year that published that House (#412). |
Protects domain facts during the yearly turnover. |
src/data/repos/events.test.ts, src/data/repos/houseEvents.test.ts |
Public event reads filter is_published = true (discovered by reflection, so new public methods are covered), admin reads see drafts, ordering, and permission errors surface (#291, #292). |
Privacy: drafts never reach public pages. |
src/data/repos/draftLeakGuards.test.ts |
Same draft guard for ACE families, program content and VCN archives: public reads use a published_* view or filter is_published, admin reads see drafts (#292). |
Privacy. |
src/data/repos/applicationLinks.test.ts |
Public application reads use only public_application_links and drop any URL for a non-open window, for all nine keys (#273). |
Safety rule: closed/future application URLs are never public. |
src/lib/applicationWindowBoundaries.test.ts |
Admin-entered window times are San Diego time; every key flips at the exact open/close instant; DST boundaries; malformed or nonexistent times and close-before-open fail closed (#274). Also run under four machine TZs by npm run test:timezones. |
Safety rule + timezone correctness. |
src/lib/applicationWindows.test.ts, src/lib/applicationWindowActivity.test.ts, src/pages/Admin/Applications.window.test.tsx |
Admin Applications as a recruitment tool (#275): every window reads Open / Scheduled / Closed / Disabled / Needs fixing with both dates in Pacific Time ("PT"), a broken schedule or enabled-with-bad-URL window is flagged (a live row whose link is bad is reported as live, matching the DB view), a save or Enable that makes the form URL public asks first (and a live link change asks again) while copy-only edits do not, the public preview is the site's own CTABlock fed by the shared projection so a scheduled/closed window never shows its URL in the student-facing card, and Recent Changes entries carry key/previous/new state but never the form URL. Also run under four machine TZs by npm run test:timezones. |
Safety rule (closed/future URLs never public) + timezone correctness. |
src/components/features/home/ClosingSoonApplications.test.tsx |
Homepage closing-soon notice (#281): only a window open right now with a usable URL gets a link (status recomputed from dates, never a cached row), the Apply link is removed at the exact close instant, the masked links are refetched when a scheduled window opens, a future/closed/stale row's URL never reaches the DOM, nothing renders while loading/failed/empty, the pulse is motion-safe only, and ThisWeekInVSA's summer-break state is untouched. Also run under four machine TZs. |
Safety rule (closed/future URLs never public) + degraded-mode behavior. |
src/lib/adminAttention.test.ts, src/components/features/admin/AttentionQueue.test.tsx |
Admin Overview "needs attention" queue (#253): each count links to the page and ?filter= that resolves it, count-only items (no names/emails/details), a failed source is "could not check" never "all caught up", a genuine caught-up state, summer suppression of the no-upcoming-events warning, and application urgency worded by the same helper as the homepage notice. |
Admin accuracy; privacy (dashboard is counts only). |
src/hooks/useUrlFilter.test.tsx |
A ?filter= quick filter is driven by the URL, not a snapshot: a page that opens on a queue (pending photo requests, unresolved Ask VSA feedback) shows its default with no param, follows a new deep link or Back/Forward while mounted, ignores unknown values, and writes the choice back to the address bar. |
Admin deep links (Overview attention queue). |
src/utils/seasonalBoundaries.test.ts |
Summer break flips at San Diego midnight on Jun 15 / Sep 15 (and not on the UTC date); active items suppress the summer empty state; each page renders its own summer copy (#270). Also run by npm run test:timezones. |
Domain facts: the site's seasonal behaviour. |
src/data/repos/publicColumnAllowlists.test.ts |
The anon events projection excludes check_in_form_url; the admin projection keeps it; updateEvent never writes undefined fields (#379, #395). |
Privacy/regression. |
src/pages/Admin/Members.photo.test.tsx |
Admin -> Members -> Edit shows the member's current approved photo, rejects unsupported file types, refuses to save or publish a selected photo until the admin confirms the member's consent, saves the member's details before publishing, cannot be closed while publishing, and keeps the dialog open with the photo selected when publishing fails. | Privacy: an admin cannot publish a member photo without attesting consent. |
src/data/repos/photoRequests.adminPublish.test.ts |
Admin -> Members photo publishing uploads the pending original and the public thumbnail before calling admin_publish_member_photo. On an RPC error it looks up the request row first: a row means a lost response, so the uploads are kept; an absent row deletes both; a failed lookup deletes nothing. Deletes that fail are reported with their paths, and non-JPEG/PNG/WebP files are rejected before uploading. |
Privacy/integrity: no photo sits in the public avatars bucket without an audited, approved member_photo_requests row, and no approved row loses its image. |
src/data/repos/photoRequests.submit.test.ts |
Public photo submission requests a server-authorized single-object upload before writing Storage; invalid metadata, denied quotas, and upload failures stop the flow. The broker's own refusal reason (read from the non-2xx response body), a network failure, and a failed signed upload each surface as a specific message instead of the generic "non-2xx" error; unsupported file types are rejected before the broker call. | Abuse prevention: public clients never insert request rows or upload without a signed capability. UX: a visitor is always told why a request was not submitted. |
src/components/features/avatar/PhotoRequestSection.test.tsx |
The public photo-request form names every field that blocks submission (name, UCSD email, photo, consent) beside the field and in a banner, flags an unsupported or oversized photo as soon as it is chosen, shows the reason a submission was refused while keeping the form open, and confirms a valid submission. | UX: a request is never silently not submitted; the message states why. |
src/components/ui/BottomSheet.test.tsx |
Modal sheets keep Tab and focus inside, restore their opener on dismissal, isolate background controls, and preserve existing scroll and inert state on cleanup. | Mobile/accessibility: calendar and leaderboard sheets remain usable with keyboard and assistive technology. |
src/hooks/useFindMyPoints.test.tsx |
Find My Points ranks members with the same rules as /leaderboard (events tiebreaker, shared tie ranks, alphabetical within a tie), so a member's rank matches on both pages. |
Leaderboard-adjacent (ordering only, not aggregation). |
src/hooks/useIndividualLeaderboard.test.tsx |
All-time standings refresh from the public projection every 30 seconds (paused in hidden tabs, refreshed on focus) without a raw members realtime event; overlapping polls are deduplicated, a stale all-time response never overwrites a newly selected year, and academic-year standings never poll. Tied members share a rank (T1) and podium members tied at a rank get identical medal styling. |
Freshness/privacy: public standings stay live after raw member reads become admin-only. |
src/lib/houseMembershipIntervals.test.ts, src/lib/houseAssignmentImport.test.ts, src/lib/houseAssignmentDraft.test.ts |
The dated House-membership planner (open membership closed at the effective date, new interval ends where a later one begins, same-House re-apply skipped, overlap and same-day conflicts refused); the wide/long sheet parser incl. preference columns and no-House rows; draft rows never carry emails; House counts, preflight blockers vs warnings, and preference-only balance suggestions. | Protected domain (House membership). Defines the interval semantics shared by preflight and Reveal; balance helper must never invent a wish. |
src/data/repos/houseAssignments.test.ts, src/components/features/admin/HouseDraftEditor.test.tsx |
Locking writes nothing to house_memberships/members; locked/published batches reject edits; publish closes/inserts dated memberships with source_import_id, refreshes members.house, skips ambiguous/unmatched rows and duplicates, is a no-op once published and safe to retry; editor shows live counts/preflight, disables lock on blockers, applies a suggestion only on Accept. |
Protected domain (House membership). Mock tests do not certify the live triggers in 20261001213414_create_house_assignment_batches.sql. |
src/lib/internCohort.test.ts, src/data/repos/internCohort.test.ts, src/pages/Admin/Interns.test.tsx |
Pasted-name parsing and exact-name-only member linking; ordering; preflight (duplicate canonical member blocks, unlinked/duplicate name warn, mentor optional); draft persistence and mentor selection; locked cohorts reject edits; publish creates a cabinet_members Interns row with only public-safe fields, records its id, adopts an existing row, and never duplicates on repeat; locked page is read-only and publish needs confirmation. |
Privacy/integrity: drafts are never public before Publish; cabinet_members stays the single public intern source. |
src/data/repos/academicTerms.test.ts, src/data/repos/houseMemberships.test.ts |
ensureTermForDate never overwrites admin-set term dates; House membership reads are constrained to the current year's effective interval. |
Data integrity for terms and House display. |
src/data/errors.test.ts |
Supabase error payloads normalize correctly (not "Unknown error occurred"); toUserMessage never exposes table/column/policy names, details or hint, while passing through our own RAISE text and validation messages (#351, #410). |
Privacy/security: no database internals in the UI. |
src/lib/imageUpload.test.ts |
An oversized upload is rejected with a ValidationError, so toUserMessage shows the actionable size limit instead of a generic retry message. |
Public photo-request UX (#351 follow-up). |
src/lib/imageDelivery.test.ts, src/components/common/OptimizedImage.test.tsx, src/components/features/events/EventImage.test.tsx |
The shared public-image primitive: explicit width/height always set; lazy by default; priority is eager + fetchpriority=high; a srcset only when a genuinely smaller thumbnail exists (never for equal/larger variants or URLs with commas/whitespace); Supabase transforms win when enabled; failed images fall back and recover on a new src. |
Image delivery (#303/#247): above-the-fold images must not be lazy, cards must not download the full file. |
src/lib/imageRelinkPlan.test.ts |
Phase 2 of the image migration: a plan is fully validated against CATEGORIES before any verification or service-role write (known category, exact table, image field of that category, path inside the category directory, newPath↔filePath correspondence, no conflicting duplicates; tampered plans fail before conditionalUpdate); an asset counts as served only on HTTP 200 + image/* + exact length + SHA-256 (the SPA fallback's 200 index.html and a stale same-size file are rejected); all assets share ONE deadline (elapsed time does not scale with asset count); a failed deploy never calls the DB; rows relink only when still on the expected Storage URL; --verify-only writes nothing. |
#454: a failed push/deploy must leave rows on their working Storage URL; the relink path uses the service-role key. |
src/components/features/cabinet/CabinetBoard.priority.test.tsx |
The Cabinet page spends a page-wide budget of at most two eager/high-priority photos on the topmost (presidents') panels; everything else is lazy with explicit dimensions; no president panel means nothing eager. | Image delivery (#303): per-role budgets made several images compete for LCP. |
src/__meta__/deployWorkflow.test.ts |
deploy.yml keeps the workflow_dispatch trigger the image-migration workflows use (#509) and, once present, the edge-functions job gating build and deploy-vercel (#507), whichever PR merges second. |
Two PRs edit the same workflow file. |
src/components/features/admin/ImageDropzone.test.tsx |
The shared admin image picker previews a valid file, reports the file immediately (so Submit cannot run with no file recorded) and the preview afterwards, shows a visible reason (oversize / unsupported type) instead of silently dropping the file, and a slow read of file A can never revert the form after B was chosen or resurrect a cleared image (generation check, abort, unmount). | Admin upload UX (#259). |
src/lib/snapshotImage.test.ts |
The Find My Points snapshot image draws the member's real name, points, rank, check-ins, house and badges (initials when there is no photo, long names shortened), and sharing hands the native share sheet the PNG file itself; dismissing the sheet is a cancel, and a browser that cannot share files, or refuses the share, downloads the image instead. | UX: "Share snapshot" delivers an image, not just a link to the leaderboard. |
src/lib/aceFamilyImport.test.ts |
The ACE fam JSON importer honours big_id_hint (a little attaches to the named same-named big; an unknown hint warns and makes a root), and every file in data/ace-families/ imports with zero warnings and its exact people / link / root counts, parents before children, same-named people kept apart, Moon starting at its FA23 roots without AAF ancestry, the owner-confirmed lineage chains (2026-09-28) with no leftover alias spellings, and no little recorded in an earlier term than their big (four documented FA23 Bang Mi rows excepted). |
Guards the reconstructed ACE family trees (see docs/ace-family-imports.md) against edits that silently drop or misattach members. |
src/lib/aceFamRoster.test.ts |
The ACE page's eight active fam slots (unique slugs, in order), every configured fam icon exists under public/, icon lookup by slug, and fam-head resolution: admin-labelled heads win, otherwise the roster heads (Sweatpants has one), borrowing a photo from a same-named tree member, and a linked tree member's shared approved avatar beats their ACE photo. |
Guards the public fam lineup and heads against silent drift or broken icon paths. |
src/components/features/ace/FamTabs.test.tsx, src/components/features/ace/GhostFamCard.test.tsx |
The ACE page's active-fam tabs tell visitors how many fams exist (N active fams, n / N), a previous/next pager walks the lineup (no Previous on the first fam, no Next on the last), the tablist supports ArrowLeft/ArrowRight (wrapping), Home and End with a roving tabindex, and the panel is linked to its tab (aria-controls/aria-labelledby). Graveyard ghost cards drop the (Dead) name prefix, show member/gen counts, and open the fam's tree. |
On phones only ~3 of 8 fam tabs fit on screen, and the page looked like only those 3 existed. |
src/components/features/ace/FamilyTree.test.tsx, src/components/features/ace/FamSheet.test.tsx |
Selecting an ACE tree member traces their real lineage root → … → them from the single-Big parent_member_id chain (stops at a Big missing from the tree or at a cycle; a Little whose Big is unpublished is laid out as a root). Only that path is lit and unrelated nodes/edges dim; the light is sequenced root-first within a fixed budget; selecting someone else replays it; reduced motion renders the lit path with zero delays and no travelling spark. The rail lists the lineage trail (ancestors jump-selectable), tapping the selected member again or Clear resets the tree, and Clear returns focus to that node. |
The spotlight must never invent ancestry, and must not break selection, keyboard access, or the photo-request rail. |
src/components/features/admin/preview/PublicPreviewDialog.test.tsx, src/components/features/admin/preview/eventPreview.test.ts, src/components/features/admin/preview/cabinetPreview.test.ts, src/components/features/admin/preview/previewImageUrl.test.ts |
Admin "Preview As Public": the dialog is labeled as an unpublished preview, renders content inside its preview frame, blocks clicks/links/forms inside the preview, switches placements, and closes on Escape with focus restored. Draft events convert San Diego date/time exactly like the save path, carry no admin-only fields, and refuse a missing or non-existent (DST gap) start; draft Cabinet members replace their saved row (or join the selected year) in public sort order, include legacy rows only for the current year, and drop the linked photo on rename. Admin-typed preview image URLs keep only http(s), same-site paths, and local image data (no javascript:/protocol-relative), with markup characters escaped. |
Draft privacy: a preview must never write (e.g. record event interest) or expose admin-only fields. |
src/lib/memberPhotos.test.ts, src/components/features/ace/FamSheet.test.tsx |
A linked person's approved public_member_avatars photo beats the surface's local photo; unlinked people never borrow one. ACE tree nodes show the shared avatar, are selectable by click and keyboard, and the rail offers the Leaderboard's photo request flow only for linked members; that dialog takes focus, keeps Tab inside it, and returns focus to its trigger; Escape closes only that modal. An admin rename clears the link (isRenamed). |
Privacy-adjacent: one approved photo per members.id, shown only where the person is explicitly linked. |
src/lib/memberLinkMatching.test.ts, src/lib/memberLinkSuggestion.test.ts, src/lib/aceMemberLinks.test.ts, src/data/repos/memberLookup.test.ts, src/data/repos/aceFamilies.links.test.ts, src/components/features/admin/AceLinkReviewPanel.test.tsx, src/pages/Admin/AceFamilies.links.test.tsx |
ACE → member linking matches only exact normalized full names (case, spacing, Vietnamese diacritics ignored; middle initials significant). One match is suggested; several same-name members are never auto-picked; a member already linked to another node, or uniquely matching two unlinked nodes, is flagged, not recommended. Bulk link applies only checked unique matches and only to still-unlinked rows (.is('member_id', null)). Admin → ACE shows each node's status, links/unlinks one node with a single-column update, blocks link changes during an unsaved rename, and a saved rename clears the link. Member search reads only public_members (no email). |
Integrity/privacy: a wrong link shows one person's photo on another person, so identity is never guessed. |
src/lib/aceAssignments.test.ts, src/data/repos/aceAssignments.test.ts, src/components/features/admin/AceAssignmentsWorkspace.test.tsx |
The private ACE Big/Little assignment workspace. Preflight hard blockers (unassigned Little, deleted Big, a Little or canonical member appearing twice, already on the tree under that Big) stop Publish; member-link gaps stay warnings. Only a draft cycle is editable (lock freezes it, unlock is explicit); locking publishes nothing. The publish preview puts each Little in the selected Big's family with the Big's node as parent_member_id; publish goes through the single database function, repeat publishes report nothing created, and the UI button is single-flight. Imports create drafts only, match a member by unique email or leave name matches to review, resolve a Big only on a unique exact name, and never carry an email forward. Renaming a Little clears their member link. Database behavior (RLS, lock guards, atomic idempotent publish) is covered by scripts/verify-ace-assignments.sql, not Jest. |
Integrity/privacy: publishing writes the public tree, so every assignment is checked twice (client preflight, then the database function), and applicant emails are never stored. |
src/__meta__/noInlineStyles.test.ts |
The yearly-operations admin surfaces (ops/*, OperationsDashboard, CabinetRollover, YearSetup) have no inline style props, per AGENTS.md § Styling (Tailwind and semantic tokens only). Older admin pages predate the rule and are not covered. |
Styling contract. |
src/lib/cabinetRoster.test.ts, src/data/repos/cabinetRoster.test.ts, src/pages/Admin/CabinetRollover.test.tsx |
The private Cabinet rollover. A structure copy carries role/category/display_order and never names, member links, photos, or bios (and skips Interns). Pasted rosters fill the first empty slot per role, link only exact unambiguous unclaimed names, and never auto-link ambiguous ones. Preflight blocks empty positions, a duplicate canonical member, and an empty roster; unresolved links and missing photos only warn (a photo never blocks). Starting a draft is idempotent; a locked roster is immutable; publish goes through the single publish_cabinet_roster_cycle database function (the client never writes public rows and never touches cabinet_years); activation is a separate confirmed action; a slow response for a roster the admin already left is never shown or written into the one they opened next. Database behavior (atomic publish with full rollback, concurrent publishes serialize, idempotency, adoption without blanking fields, Interns untouched, blockers) is covered by scripts/verify-cabinet-roster.sql, not Jest. |
Integrity/privacy: publishing writes the public Cabinet, and activation changes what the site shows as current. |
src/lib/yearSetup.test.ts, src/data/repos/yearSetup.test.ts, src/pages/Admin/YearSetup.test.tsx |
New Year Setup. Detects what already exists (terms by code or quarter, Cabinet year, roster/ACE/Intern cycles, House profiles/batches; archived cycles count as free), previews before any write, executes only missing pieces and reports existing ones on a re-run, re-reads state right before writing. Terms and the Cabinet year are always created inactive and there is no activation path; House assignments are never copied and an empty House batch is offered only once profiles exist; ACE gets an empty cycle only. The application reset touches only enablement and timing, never a target_url, never enables a window, and leaves open windows unselected. |
Integrity/seasonal: a wrong default here activates a future year or exposes a closed application. |
src/lib/supabaseRequestGuard.test.ts, src/lib/supabaseRequestTelemetry.test.ts, src/lib/supabase.test.ts |
The per-tab Supabase Data API circuit breaker (/rest/v1/* only): normal traffic, a heavy parallel admin burst and an import-sized run of sequential writes all pass; the same GET/HEAD URL more than 10 times in 10s, or more than 200 Data API requests in 60s, trips it; different URLs and GET vs HEAD count separately; writes are exempt from the identical-read rule but count toward the burst; cooldown recovers automatically and doubles on a repeat trip; blocked requests are never sent or retried and return a Supabase-style 429 (over_request_rate_limit) that degraded mode recognises; exactly one warning per trip, never containing headers, tokens or query strings; a declared runBulkWrites scope (import profile updates, House/ACE bulk loops) can send up to 1,000 writes without tripping the burst limit but is still bounded, still protects reads, and never bypasses an already-tripped breaker; Auth/Storage/Edge Function calls are never counted or blocked. The dev-only telemetry labels requests by table/function only, is a no-op outside development, and exposes no console handle. Through the real @supabase/supabase-js, db.retry: false means a failed PostgREST read is sent exactly once. |
Runaway-request containment (the 2026-10-01 incident, ~493k requests in ~5h). |
src/lib/adminOverviewStats.test.ts, src/data/repos/adminOverview.test.ts, src/pages/Admin/Overview.test.tsx, src/data/repos/adminRequestBudget.test.ts |
The Admin Overview health numbers are derived from one narrow read per table (not ~39 HEAD probes): same predicates as before, Storage URL count is case-insensitive across six surfaces, a failed or row-cap-truncated read is reported as unavailable instead of zero, a failed active-term lookup never falls back to the browser year. The page loads once, is cached for a minute, never re-scans on focus/reconnect/interval/retry, re-scans only on Refresh, and says when counts may be incomplete. A full Overview + Operations load stays a few dozen requests and replays through the real guard three times back to back without tripping it. | Request fan-out budget; admin accuracy. |
src/lib/aiKnowledgeFreshness.test.ts, src/data/repos/aiKnowledge.test.ts, src/pages/Admin/AiKnowledge.freshness.test.tsx, src/pages/Admin/LaunchChecklist.knowledge.test.tsx |
Ask VSA knowledge freshness (#239): evergreen (stable) rows are never flagged for age; a passed valid_until is stale and is not cleared by a review; prior-year academic_year rows are High until reviewed after the new year began (archive rows exempt); cadence rules; linked application windows (newest of several per key) and events (edited, past, missing, unpublished); inactive/private rows ignored; unreadable linked data skips the entity rules instead of reading as "gone". Mark reviewed stamps only last_verified_at and logs the reviewer to the admin activity log (never to the public table); a public snippet cannot be linked to, or reactivated against, an unpublished or missing event and nothing is copied from the linked entity (the DB trigger and retrieval skip are proven by scripts/verify-content-health.sql); saving other edits never re-stamps the review date and dates are Pacific; the page shows badge, reason, "Needs review" filter, urgency sort, and reviewer only when the log entry is the current review; the Launch Checklist shows "Ask VSA knowledge is current" from one read. |
Admin accuracy; privacy (draft/unpublished material never becomes a public AI source). |
src/lib/contentHealth.test.ts, src/data/repos/contentHealth.test.ts, src/pages/Admin/ContentHealth.test.tsx, src/lib/contentLinkCheck.test.ts, src/__meta__/contentHealthMigration.test.ts |
Content Health (#287): healthy content yields nothing; draft events past their date, public upcoming events missing fields, empty albums, incomplete/placeholder/prior-year program content, and dead images/links each become a finding with priority, reason, content type, last-checked time and fixing page; an acknowledgement holds only while its fingerprint matches and before it expires, so an edited row or a recurring link failure comes back; Ask VSA findings cannot be acknowledged; no destructive action is offered; unreadable sources are "could not check", never clean. The weekly link check: never contacts social/Drive/Docs/Photos/shortener hosts (including via redirect, followed by hand and re-vetted per hop), never fetches localhost/private/metadata/credentialed/non-default-port URLs, checks /images in the repo, HEAD-only for Supabase hosts, spaces same-host requests, caps and caches runs so a repeat contacts nothing, stops at a time budget and saves progress as it goes, isolates one bad URL, treats 403/429 as inconclusive, confirms uncertain failures only on a second run, and records failures without any destructive action. The migration text: admin-only policies, acknowledgements only writable by admins, no anon/public grants, additive. |
Admin accuracy; egress (no repeated external or Storage fetches); privacy and RLS. DB behavior is proven by scripts/verify-content-health.sql on a local/staging DB, not by these. |
src/lib/adminOperations.test.ts, src/data/repos/adminOperations.test.ts, src/components/features/admin/OperationsDashboard.test.tsx |
The Admin Operations dashboard and cross-program preflight derive counts and statuses from raw inputs (42 / 47, 68 / 73, House balance, 19 / 17 / 2), keep a domain that failed to load as unavailable instead of zero, are diagnostic only, and every link lands on a registered admin route (the test reads src/routes/index.tsx). The events health query counts a NULL or empty check-in form URL, a missing location, or a missing image, on published events only. |
Admin accuracy. |
src/lib/adminSearch.test.ts, src/components/features/admin/AdminQuickSearch.test.tsx |
Admin Quick Search: records open by canonical id (never by name), results carry a type line and destination, ranking ignores case/accents, two people with one name stay separate, only /admin routes are produced, nothing loads until the palette is opened, and admin pages stay searchable if the record index fails. |
Privacy/admin accuracy: search must never route by name alone or reach a public surface. |
src/lib/adminFilters.test.ts, src/lib/adminQueues.test.ts, src/lib/adminYearContext.test.ts |
Quick-filter counts agree with the rows each filter returns, filters are domain-specific, filter choice round-trips through URL params, and year context never mixes rows from another year (rows with no year are dropped, not guessed). | Admin accuracy. |
src/lib/adminBulk.test.ts |
Bulk planning: eligible vs skipped rows with reasons; ambiguous / conflicting identities are never bulk-linked; locked batches refuse every row; destructive actions require confirmation showing the affected count; selection prunes rows that left the data. | Integrity: bulk actions must never resolve an ambiguous identity. |
src/lib/adminDirty.test.ts |
Unsaved-change detection (blank/null/whitespace equivalence, key order), Save state machine (disabled when clean, retryable after failure), and which link clicks warrant a leave prompt. | Admin accuracy. |
src/lib/adminNavigation.test.ts, src/components/features/admin/AdminPageHeader.test.tsx |
Admin nav ↔ router parity: every guarded /admin/* route has a nav entry and every nav entry a route (so no page is unreachable), every admin route sits inside the AdminRoute boundary, no duplicate paths/unresolved parents, group names/order unchanged; breadcrumbs derive from the nav config (group, parents, detail crumb) and the shared page header renders title, trail, aria-current and the actions slot. |
Route-safety guard. A nav entry is not a permission; this only proves reachability and that the access boundary still wraps every admin route. |
src/lib/adminConfirm.test.ts, src/components/common/ConfirmDialog.test.tsx, src/lib/adminDestructiveGuard.test.ts, src/lib/undoableAction.test.ts, src/pages/Admin/AceFamilies.delete.test.tsx, src/pages/Admin/Cabinet.delete.test.tsx, src/pages/Admin/Cabinet.discard.test.tsx, src/pages/Admin/Events.discard.test.tsx, src/pages/Admin/UVSASchools.delete.test.tsx |
Shared destructive-action confirmation: typed tier gates Confirm until the item's name is typed (case/space/dash-insensitive; an empty phrase never matches), failures keep the dialog open with the error, Escape/lock behaviour; no window.confirm remains anywhere in non-test src/ except the allowlisted useUnsavedChangesGuard.ts; discard-unsaved prompts (Cabinet/Events editors, Events duplicate, Members editor, Recovery Clear staged, ACE duplicate link) are dialogs where Cancel keeps state and Confirm runs the pending action; deferred-commit undo never writes once undone and flushes on leave; fam and cabinet deletes are typed, school delete is standard. |
Admin safety. Proves the confirmation UX, not the database effect of a delete (cascades are documented in docs/admin-dx-safety.md). |
src/lib/adminBulkRun.test.ts, src/components/features/admin/ops/BulkRunDialog.test.tsx, src/pages/Admin/Events.bulk.test.tsx, src/pages/Admin/Resources.bulk.test.tsx, src/lib/eventDayLabel.test.ts |
Bulk admin actions: preview lists exactly which items change and which are skipped (with reasons) before any write; per-item failures reported as partial (never generic success) with retry-failed; selection pruned to the current filter and scope disclosed; destructive bulk needs the typed count; no bulk delete on Events/Resources; the preview names the San Diego Event day (run under UTC / Asia/Ho_Chi_Minh / America/Los_Angeles by npm run test:timezones); deleting an Event is split into row delete (failure keeps the dialog open) and best-effort Storage cleanup (Storage { error } or a throw after the row is gone reports "deleted, cleanup failed", never a failed delete). |
Blast-radius guard. Bulk flows here exclude points, attendance, House membership and the leaderboard. |
src/lib/adminForm.test.ts, src/components/features/admin/ops/AdminFormShell.test.tsx, src/pages/Admin/Applications.form.test.tsx, src/schemas/applicationLink.test.ts |
Shared admin form shell: zod errors inline + summary, first invalid field focused, server unique-violation placed beside its field with values kept, unsaved-changes guard on while dirty and off after a successful save, Application link payload/rules unchanged. | Client validation is UX only; DB constraints and RLS remain authoritative. |
src/lib/adminActivity.test.ts, src/data/repos/adminActivity.test.ts, src/data/repos/adminUndo.test.ts, src/data/repos/adminUndo.apply.test.ts |
Activity log: summaries, metadata sanitization (sensitive keys dropped, size capped), best-effort writes that never block an edit, domain filters; undo is allowlisted to House / ACE Big / ACE link / intern mentor, and the write itself is a database-level compare-and-set (matches only the recorded value, exactly one row, else it fails), refused for publication, reveal, year setup, bulk edits, already-undone and expired entries. | Integrity: history stays append-only and undo can never overwrite newer work or reverse a publication. |
src/lib/adminImportReview.test.ts, src/lib/adminConflicts.test.ts |
Import categorization (ready / needs review / unmatched / invalid) with concrete reasons for ACE, Cabinet, Intern, and attendance rows; problem-row export is formula-safe; duplicate detection flags without merging and never calls two canonical members duplicates. | Admin accuracy. |
src/lib/adminPreflight.test.ts, src/lib/adminProgress.test.ts, src/lib/adminContinue.test.ts, src/lib/adminNextSteps.test.ts |
Unified preflight (blockers decide Ready; warnings are never promoted), workflow progress and "where work stopped", Continue Working derived only from persisted state, and a next step on every success. | Admin accuracy. |
src/lib/adminEventDuplicate.test.ts, src/lib/adminStructureCopy.test.ts, src/lib/adminPreviewMappers.test.ts |
Duplicate Event is a Draft with no date and never copies attendance, image, or RSVPs; Cabinet structure copy carries only role / board / order and is idempotent; previews mirror what Publish would write and expose no mentor, caption, or notes. | Privacy/integrity. |
src/components/features/admin/HouseDraftWorkflow.deepLink.test.tsx |
A Quick Search link (?batch=&row=) opens that House batch (switching to its year), highlights the row, and clears the params; nothing happens without them. |
Admin accuracy. |
src/pages/Admin/Members.workflow.test.tsx, src/pages/Admin/Events.duplicate.test.tsx |
Members chips/counts, URL-restored filter, bulk Review OK only on flagged selected members, deep link by id, unsaved-change protection; Duplicate Event copies the shape as a Draft and creates nothing without a new date. | Admin accuracy. |
src/components/features/ai/formatAssistantMessage.test.tsx |
Ask VSA answers render their markdown: flattened * **Label:** bullets become a real list with bold labels, numbered lists/headings/italic/code render, no raw */# reaches the bubble, internal links route in-app, and only http(s) or same-site URLs become links (javascript: and //host are refused), and link URLs are encodeURI-escaped without double-encoding (so /\host stays in-app). |
Real answers showed raw ** and run-on bullets; also guards the link allowlist on model-generated text. |
src/components/features/ai/VsaAiAssistant.test.tsx |
Follow-up history caps each turn at the server's 500-character limit while displaying the full answer; Retry replaces the failed exchange and sends only its preceding context; failures have one visible message and one live announcement; errors and rate limits are excluded from request history. | Prevents long answers from breaking later questions and retries from duplicating failed exchanges. |
src/components/features/auth/SignInForm.test.ts |
Admin sign-in failures give one generic message (no account enumeration via "Email not confirmed"), except rate limiting and connection errors (#235). | Security. |
src/components/features/auth/SignInForm.access.test.tsx, src/routes/AdminRoute.test.tsx, src/context/AuthContext.test.tsx |
Approved admin login and deep-link redirects; non-admin and failed-profile lookup sign-out; gate loading/denials; provider session initialization, sign-in/sign-out and removal of signup API. | Admin auth preservation during member-account retirement; mocked Supabase boundary, not hosted Auth/RLS proof. |
src/components/features/avatar/Avatar.test.tsx, src/__meta__/memberAccountRetirement.test.ts |
Approved avatars render without auth/profile queries; no active code under src/, supabase/functions/ or scripts/ names a retired account/check-in table, RPC, hook or provider (allowlist: generated database.ts, the RLS verifier, offline migration fixtures; historical migrations and docs are not scanned); the scan is proven able to fail; all local signup channels stay disabled. |
Retirement guard; private retention/privacy count fields remain intentional. |
src/__meta__/rlsVerifierPhases.test.ts |
scripts/verify-rls-security.mjs rejects an unknown RLS_RETIREMENT_PHASE, defaults to pre-migration (current production, signed-in sections optional), and in post-migration exits non-zero without BOTH an ordinary-user and an admin account (never SKIP) and asserts 42501 on the retired archives for anon, ordinary users and admins; rls-verify.yml passes the phase from the dispatch input or repository variable and fails early with a clear error when accounts are missing. Same design for RLS_MEMBER_LOOKUP_PHASE (admin_lookup_members / admin_search_members): unknown value rejected, post-migration fails closed without both accounts, a missing lookup RPC is SKIP only pre-migration and FAIL after, an ordinary user passes only on the function's own admin refusal (not a lost-EXECUTE permission denied), and admins must get an array back. |
Rollout guard so the hosted RLS check is valid before and after the manual retirement and member-lookup migrations and cannot pass post-migration without covering all three audiences. |
src/lib/rlsIncidentChecks.test.ts |
The pure incident checks in scripts/lib/rlsIncidentChecks.mjs that verify-rls-security.mjs runs against production (#227): for each 2026-06 incident migration (check-in codes, anon member PII, public view user_id, events.check_in_code, attendance/points exposure, smart_merge_members and is_admin_user grants, user_profiles recursion 42P17) a fake hardened client passes and a fake pre-migration client (rows returned, column resolving, recursion, function executable) fails; the read-only checks issue no writes; no row value reaches a message. |
Fails-if-reverted proof for the historical RLS incidents, since the migration chain cannot be replayed (#393/#396). |
src/__meta__/adminAuthRetention.test.ts |
Reads active source to pin the merged admin-auth architecture (#507) through retirement: authRepository is the single-method isUserAdmin lookup; adminStatusQuery is keyed by user id and used by useAdmin and by SignInForm's fresh fetchQuery verification (no one-off Supabase query); AuthContext keeps sessionExpired, resolveExpiredSession, discardExpiredSession and clears/invalidates the query cache at account and session boundaries; AdminRoute holds the page behind SessionExpiredDialog; request-level expiry detection stays wired; /admin/login stays routed. No member signup/profile/password API anywhere. Also requires #507's lifecycle suites to remain. |
Admin-auth preservation guard. Mutation-checked: swapping in the old pre-#507 AuthContext/useAdmin/SignInForm/AdminRoute fails 8 checks. |
src/components/features/events/EventInterestButtons.test.tsx |
The interest control resets its saved choice and counts when reused for a different event, and invalidates other cached copies (events, event detail, Home preview) after a change (#407 review). | Prevents wrong aggregate interest counts. |
src/components/common/ApplicationCTA.test.tsx |
Admin-authored before-open / after-close messages take precedence over page fallback copy. | Applications UX. |
src/components/common/EditorialHero.test.tsx |
Shared program-page hero renders a single h1 with the emphasized script word, an aria-hidden watermark, and the actions row only when actions are passed. |
Program-page hero structure (ACE, House, Intern, VCN, WNC). |
src/components/layout/navigation/MobileDrawer.test.tsx, src/components/common/PaginationControls.test.tsx, src/components/ui/SplitText.test.tsx |
Drawer traps Tab and restores focus on Escape; page-size selector has an accessible name; animated split text is exposed to assistive tech (#414). | Accessibility regressions. |
src/touchFloor.test.ts, src/components/ui/Button.test.tsx, src/components/layout/touchTargets.test.tsx, src/reducedMotion.test.tsx, src/components/common/PageSkeletons.test.tsx, src/components/ui/FieldError.test.tsx, src/lib/readableInk.test.ts |
The 44x44 public touch-target floor (#244): Tailwind compiles a touch: variant for phone widths and coarse pointers, shared CSS classes (.vsa-filter-btn, .vsa-btn-*, .scrapbook-select, .touch-hit) reach 44px under the same condition, Button sizes, footer links, pagination and back-to-top carry the floor. Reduced motion (#249): SplitText, RevealOnScrollWrapper and BackToTop render final content with no hidden/translated state and jump instead of smooth-scrolling; entrance CSS animations and smooth scroll drop under prefers-reduced-motion; the House-detail and VCN-archive skeletons keep a polite role="status" loading message. No colour-only meaning (#320): active filter chips carry a check mark; FieldError/FormAlert pair an icon with the message and use readable reds in both themes; readableInk keeps House chip text at 4.5:1 on every House colour. |
Accessibility regressions. Class/CSS guarantees only — real hit areas, contrast and motion are measured with scripts/measure-public-quality.mjs (see docs/quality-baseline-2026-10-04.md). |
src/lib/eventTime.test.ts, src/utils/losAngelesDate.test.ts |
Event date ranges and San Diego ↔ instant conversions, including DST days (#415). | Timezone correctness. |
src/data/repos/pointsModelBoundary.test.ts |
Every leaderboard method runs to completion, touches at least one query and never consumes a retired account table/RPC. Exact yearly, House, history and public-member view assertions retain the active member-model boundary. | Points retirement safety (see docs/leaderboard-system.md). |
src/utils/leaderboardRanking.test.ts |
Leaderboard tie-breaking: points first, then events attended; equal on both is a tie. Ties share a competition rank (1, 1, 3; T6 label), list alphabetically within the tie, and only count as "Top N" when the whole tie fits inside the top N. |
Leaderboard-adjacent (ordering only, not aggregation). |
src/utils/leaderboardYears.test.ts |
The public leaderboard's default year: active year with data, else most recent year with data, else active year, else newest; term/data years merge newest-first. | Consistency: the public page and the admin Top 3 Story resolve "current year" identically. |
src/lib/story/top3Story.test.ts |
Top 3 Story selection uses the public comparator (points, then events), skips zero-point members, never mutates source rows, and formats names/points/year labels/file names; House pills use the view's hex accent, then the House constant, then teal. | Leaderboard-adjacent (display only — no point math). |
src/lib/story/storyCanvas.test.ts |
Story canvas helpers: hex alpha/shade math, text fitting steps down then ellipsizes, and long names wrap into two balanced lines before ellipsizing. | Share-graphic legibility. |
src/components/features/admin/Top3StoryDialog.test.tsx |
Admin → Points Top 3 Story dialog lists the Top 3 with points, switches headline presets and dark/light looks, enables Download once rendered, and shows a disabled empty state when the year has no points. | Admin share workflow. |
src/data/wrappedEdition.test.ts |
The Wrapped nav/footer label comes from the published edition's year label, not a literal (#263). | Seasonal content. |
src/utils/generateSlug.test.ts, src/utils/hashScroll.test.ts, src/utils/matchCabinetRole.test.ts, src/utils/sanitizeUrl.test.ts |
Slug generation; hash-link scrolling to late-rendered targets; cabinet role-title normalization; URL protocol allowlisting for <a href> and <img src>. |
Utility behaviour. |
src/components/features/calendar/MonthGrid.test.tsx |
Phone calendar tiles show the first event flyer on a day with that event's name and a +N badge for extra events; an event without an image (or whose image fails to load) still shows its name, and a tile reused for another event retries its image; any day, empty or not, is selectable; the month agenda lists the month's events and narrows to the selected day. | Mobile calendar readability: which event is on which day. |
src/data/repos/leaderboard.test.ts, src/data/repos/leaderboardViews.migration.test.ts, src/utils/housePublicPointOverrides.test.ts |
Leaderboard system only (member_event_attendance → member_yearly_points and the house_* views; never the check-in tables). Every LeaderboardRepository method: happy, empty, null and error paths; the view or table each reads, the year filters, and past-year reproducibility. A text guard that the latest migration defining each public view still has its key clauses (sum vs. count totals, points_earned > 0, House-membership join, the 2025 cutoff, is_published). The frozen 2025 public House totals. |
Characterization (#291, #293): pins today's behavior so a refactor can be judged against it. The migration test is a text guard against accidental redefinition, not proof of SQL behavior, which only a database verifies. |
src/data/repos/events.reads.test.ts |
Public events reads (lists, filters, ranges, by-id, upcoming preview, past-archive availability): published-only on public paths, empty and null data, error mapping. Complements events.test.ts (draft-leak guard). |
Draft-content leaks (#291, #292). |
src/data/repos/auth.test.ts, src/hooks/useAdmin.test.tsx |
isUserAdmin reads only is_admin and treats anything but an explicit true as not admin; useAdmin shares one lookup per user between consumers and across remounts, does not re-query or flash a loader when supabase-js re-emits SIGNED_IN with a new user object, never shows one account's answer to another, and fails closed. |
Admin gate (#231). UX gate only; RLS remains the boundary. |
src/context/AuthContext.test.tsx, src/routes/AdminRoute.test.tsx, src/lib/sessionExpiry.test.ts, src/routes/AdminRoute.directExpiry.test.tsx, src/components/features/auth/SignInForm.flow.test.tsx |
Session lifecycle: deliberate sign-out clears state and the whole query cache; an unprompted SIGNED_OUT flags expiry and keeps what is on screen; the admin page and its unsaved edits survive a re-authentication prompt for the same account but not for another; token refresh changes nothing; a server-side expired-JWT response on any request, including a direct supabase.from(...) call, forces one shared refresh and, with a dead refresh token, the re-authentication prompt (real supabase-js client, scripted network); sign-in verifies admin through the repository. |
Session expiry and sign-out (#232). Mock-level: does not certify real token expiry, which still needs a manual check. |
src/routes/AdminRoute.refocus.test.tsx, src/routes/AdminRoute.supabaseJs.test.tsx, src/hooks/useServerSeededDraft.test.tsx |
Unsaved admin edits survive a tab refocus: AuthProvider → AdminRoute → AdminLayout → a real editor stays mounted through hidden → visible, window focus, a fresh SIGNED_IN/TOKEN_REFRESHED and the stale admin-status focus refetch (including a failed re-check and the real supabase-js client refreshing an expired token); a background refetch that returns a changed row never overwrites a dirty draft (but updates a clean one and loads after save); useServerSeededDraft seed/hold-back/save/discard rules. |
Admin draft preservation. Mock-level plus a real supabase-js auth client; not a real-browser focus run. |
src/utils/chunkLoadRecovery.test.ts, src/components/common/ErrorBoundary.test.tsx, src/vercelConfig.test.ts |
Stale lazy-chunk recovery: isChunkLoadError accepts webpack/native-import/HTML-as-script variants and rejects ordinary errors (including JSON.parse of HTML); one reload per 60s window, no reload when sessionStorage is unavailable; the boundary reloads once, then shows an update prompt, and ordinary errors still reach the normal fallback; vercel.json sets no explicit cache header on /static/**, revalidates SPA HTML, no SPA rewrite for missing /static/**, deep-link rewrite, and all security headers. |
Deploy-skew guard. Config test reads the header/rewrite rules only — it does not certify what Vercel serves; curl a preview deployment after changing vercel.json. |
src/lib/cspInlineHashes.test.ts |
CSP inline-script hashing (#222): a known input gives a known sha256- source (UTF-8, exact whitespace); findInlineScripts ignores src, comment and JSON data scripts; a non-allowlisted, edited or stale inline script is reported; the draft script-src in docs/security-headers-and-csp.md names exactly the hashes in config/csp-script-hashes.json and no 'unsafe-inline'; vercel.json sends no CSP header yet. |
Silent CSP breakage once enforced (#224). Proves the logic and the doc/allowlist agreement only; the real gate is npm run check:csp-hashes against build/index.html in CI. |
Still unprotected — where you have NO automated safety net:
- Points / attendance / leaderboard calculation — no direct tests.
memberMatching.test.tscovers import matching andwrapped.test.tscovers standings sorting, but the canonical points math and leaderboard aggregation are exercised only by manual QA + the leaderboard checklist (section 5). This is the most protected domain in the repo (section 4). - Most repositories in
src/data/repos/— covered: events, leaderboard, auth/admin status, houseEvents, ACE/program content/VCN draft guards, applicationLinks, academicTerms, houseMemberships, column allowlists, admin photo publishing. The rest (gallery, cabinet, members, import, AI knowledge, …) are exercised only by humans (#291). - Most feature components and admin pages — public routes have a mount/settle smoke test (
publicRoutes.smoke.test.tsx); admin gating and the session lifecycle are covered at mock level byAdminRoute.test.tsx/AuthContext.test.tsx(real token expiry still needs a manual check), but individual admin pages mostly are not. - RLS policies — deliberately not covered by Jest (client tests can't prove server policy). Covered instead by
scripts/verify-rls-security.mjs→ interpretation guide invsa-diagnostics-and-measurement, runbook pointer in section 5.
Consequence: for any change in an unprotected area, Gate 4 (manual exercise) carries the entire burden. Do not skip it.
3. How to add a test
Where it goes. Colocate next to the source file: src/utils/foo.ts → src/utils/foo.test.ts (.test.tsx for component tests). CRA discovers *.test.ts(x) anywhere under src/ automatically — no config change needed.
Run just your file:
CI=true npm test -- --watchAll=false --testPathPattern=seasonalState
What's already set up for you (src/setupTests.ts, loaded before every test file):
@testing-library/jest-dommatchers (toBeInTheDocument, etc.)process.env.REACT_APP_SUPABASE_URL/_ANON_KEYset to test dummies — so importingsrc/lib/supabase.ts(which throws on missing env at module load) works in testswindow.matchMediamocked (jsdom lacks it; ThemeContext needs it)@supabase/supabase-jsmodule-mocked with a chainable no-op query builder — tests never hit a real database
Jest quirks pinned in package.json:
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(zod|@hookform/resolvers)/)"
]
}
zod and @hookform/resolvers ship ESM; without this exception Jest fails with Unexpected token 'export'. If a new ESM-only dependency breaks tests the same way, that pattern is where it gets added — but environment breakage diagnosis belongs to vsa-build-and-env.
The pattern to copy — pure-function domain tests, from src/utils/seasonalState.test.ts:
import {
getCurrentVsaSeason,
isSummerBreak,
shouldUseSummerEmptyState,
} from './seasonalState';
describe('seasonal state helpers', () => {
it('treats the day before June 15 as active year', () => {
expect(isSummerBreak(new Date('2026-06-14T19:00:00Z'))).toBe(false);
expect(getCurrentVsaSeason(new Date('2026-06-14T19:00:00Z'))).toBe('active_year');
});
it('starts summer break on June 15 in Los Angeles', () => {
expect(isSummerBreak(new Date('2026-06-15T19:00:00Z'))).toBe(true);
});
});
Notes on the pattern: plain describe/it, exact expected values (not shape assertions), each it name states the domain fact it pins. Prefer this style — extract logic into a pure function and test the function, rather than mounting large component trees.
CRA/jsdom limitations — what Jest here cannot verify:
- No real browser: no layout, no CSS, no Tailwind classes actually applied, no dark-mode rendering, no scroll/resize behavior. Visual claims need manual QA (section 7).
- No real network or database: the Supabase client is mocked, so tests cannot verify queries, RLS, or data correctness.
- Framer Motion and ThemeProvider emit known warnings under jsdom — acceptable per the rule quoted in section 1.
- CRA owns the Jest config; only the whitelisted keys (like
transformIgnorePatterns) can be overridden inpackage.json. Do not attempt a standalonejest.config.jsand do notnpm run eject. - No secrets or real member data in tests or fixtures, ever (
.claude/agents/vsa-testing-qa.md).
4. When tests are REQUIRED vs optional
REQUIRED — protected domains. AGENTS.md forbids modifying attendance import, points calculation, House membership, or leaderboard calculation unless explicitly requested. When such a change IS explicitly requested, .claude/agents/vsa-points-attendance-guardian.md sets the bar:
"Any change requires clear acceptance criteria and tests."
Concretely, before merge a protected-domain change must have: (a) written acceptance criteria (section 6) agreed before coding, (b) new or updated focused tests pinning the changed behavior, (c) the assigned final gates green, and (d) the relevant manual checklist run (leaderboard checklist for anything touching standings — section 5). Whether the change is permissible at all is vsa-change-control's territory; this skill only defines the evidence it must carry.
REQUIRED — domain-fact changes. If you legitimately change House history data or seasonal boundaries, update legacyHouseArchive.test.ts / seasonalState.test.ts in the same PR with the corrected facts. A red golden test is doing its job; never delete or loosen it to get green.
STRONGLY RECOMMENDED: regression test for any fixed logic bug (pin the bug, per .claude/agents/vsa-testing-qa.md); tests for any new pure utility in src/utils/ or src/lib/; tests for new data-transform logic feeding public pages.
OPTIONAL (manual QA carries the burden instead): styling/layout-only changes, copy changes, component rearrangements with no logic change. jsdom can't see what these change anyway — spend the effort on the section 7 runbook instead.
Never acceptable: weakening protected logic, deleting a golden test, or loosening an assertion to make a suite pass (vsa-testing-qa playbook: "Do not weaken protected logic to make a test pass").
5. Checklist routing table
Pick every row that matches your change; run all that apply.
| You changed… | Run this | Notes |
|---|---|---|
Anything under supabase/migrations/ touching RLS, grants, views, or RPCs |
docs/rls-verification-checklist.md → node scripts/verify-rls-security.mjs |
Staging first, production after. Never with a service_role key (bypasses RLS, invalidates every check). Mutation tests (RLS_ALLOW_MUTATION_TESTS=true) never on production without leadership approval. Script env/interpretation details: vsa-diagnostics-and-measurement. |
| Anything near points, attendance, standings, academic terms, or the leaderboard UI | docs/leaderboard-test-checklist.md |
Covers four surfaces: public /leaderboard (year selector defaults to active year, All-Time matches prior standings, year switching/search/pagination), /admin/points (KPI cards and Top-10 follow selected year), /admin/import (term labels shown, no-term warning, points attributed to the event's academic year), /admin/members (Yearly Breakdown sums match per-year events). Run it whenever standings could shift, even from a "display-only" change. |
| Modals, drawers, toasts, calendars, or other dynamic/interactive UI | Open manual a11y items in docs/final-compliance-reaudit.md (§2, §7) |
Still-open live checks as of that audit (2026-06-19): mobile-drawer focus trap (Tab stays inside, focus returns to menu button on close); toast announcements read by screen readers (role="status"/aria-live="polite"); color contrast ≥ 4.5:1 for secondary text in BOTH themes; full keyboard walk with visible focus on every interactive element. |
Routes, auth, providers, or anything in src/routes/ / src/App.tsx |
Route QA (below) |
Route QA runbook:
- Public loads without auth — in a logged-out/incognito session, load
/,/events,/leaderboard,/cabinet,/gallery. No auth prompt, no crash, no private data (check the leaderboard network payload forauth_user_id/emails — must be absent, perdocs/final-compliance-reaudit.md§7). - Protected redirects — logged out, visit
/profile,/points,/feedback: expect redirect to sign-in, not a blank page or error. - Admin gated — as a signed-in non-admin, visit
/admin: expect denial/redirect, and no admin nav items visible. - Degraded mode — the app must survive Supabase being unreachable. To simulate locally: point
.env.localat a syntactically valid but unreachable project (e.g.REACT_APP_SUPABASE_URL=https://unreachable-degraded-test.supabase.co, any non-empty anon key) and restartnpm start. Fetches fail →isSupabaseUnavailable()(src/utils/isSupabaseUnavailable.ts) returns true for network errors and 429/402/503/504 → public pages must show fallback content (src/config/publicFallbackContent.ts) orContentUnavailableState, never a white screen. Do not test by deleting the env vars —src/lib/supabase.tsthrowsMissing Supabase environment variablesat boot, which tests nothing (that symptom belongs tovsa-build-and-env). Restore your real.env.localafterward.
6. Acceptance-criteria discipline
Write acceptance criteria before coding, as observable, testable outcomes — each one something a reviewer can check true/false without reading the diff. "Improve the events page" is not a criterion; "the calendar button stays inside its card at 375px width" is.
The repo template is docs/claude-subagent-task-template.md (fields include Subagent, Goal, Current problem, owned/forbidden scope, safety rules, Acceptance criteria, focused verification, Manual QA, and final report). Its focused-verification block assigns only the question that specialist owns. The filled example is the calibration for how specific criteria should be:
Goal:
Fix the Google Calendar button overflowing on event cards on mobile.
Acceptance criteria:
- Button stays within the card on mobile and desktop
- No regression to other event card content
Manual QA:
- Load /events at mobile and desktop widths; confirm button fits
Rules of thumb:
- 2–5 criteria. More usually means the scope is too big for one PR (AGENTS.md: small, focused PRs with explicit acceptance criteria).
- Every criterion pairs with a verification method: a command, a test, or a specific manual-QA step. If you can't say how you'd check it, rewrite it.
- Include at least one negative criterion (what must NOT change) whenever the change is near a protected domain — e.g. "public leaderboard standings identical before/after".
- For protected-domain changes, criteria are mandatory and precede any edit (section 4).
7. Manual QA runbooks for the riskiest recurring change types
These are the change types that historically break in ways no command catches. Run the matching runbook when that surface is part of the selected final evidence.
A. Public content change (copy, launch content, program pages)
- Load the changed route logged out. Confirm the new content, no placeholder text, no broken images.
- Grep your diff for private data: no emails, check-in codes, real application URLs for closed/future windows (AGENTS.md never-do list; form-link rules live with the
vsa-applications-formsplaybook agent and thevsa-change-controlskill). - Domain-fact spot check: presidents, House names, and years must match AGENTS.md "Domain-critical facts" — never invent Houses or members.
- Degraded mode (section 5 step 4): the changed page still renders fallback content when Supabase is unreachable.
- Both themes: toggle light/dark; new copy must use semantic tokens (no invisible gray-on-gray text in dark mode).
B. Admin CRUD change (events, gallery, cabinet, members, applications)
- As admin: exercise the full cycle — create, edit, verify it renders where members see it, delete/unpublish. Confirm drafts/unpublished items do NOT appear on public routes.
- As non-admin: confirm the admin route is still gated and the new UI leaks nothing (route QA steps 1–3).
- Check mutations flow through the repository layer (
src/data/repos/), not rawsupabase.from(...)in components — verification habit fromdocs/rls-verification-checklist.md§8. - Errors surface via toast or
PageError— force one failure (e.g. required field empty) and confirm it's not swallowed. - If the change touches points/attendance/standings even indirectly, run the leaderboard checklist (section 5).
C. Mobile UI change (the current campaign area — see vsa-ui-excellence-campaign)
Device-emulation matrix — all cells, in browser devtools:
| 375px (mobile) | 768px (tablet) | Desktop | |
|---|---|---|---|
| Light mode | ☐ | ☐ | ☐ |
| Dark mode | ☐ | ☐ | ☐ |
Per cell: no horizontal overflow/clipped tap targets; bottom sheets, snap rails, and the quick-nav dock still open/scroll/dismiss (these shipped recently — commits feb4b263, 368fbf63, 5902bdd6 — and regress easily); fixed/sticky elements don't cover content or each other.
Then: keyboard/focus pass on any new interactive element (a11y items, section 5); degraded mode on the changed page; confirm no inline style props or hardcoded color classes snuck in (npm run lint won't catch these — grep your diff).
D. Migration-coupled change (frontend + supabase/migrations/ together)
- Order matters: migrations are applied manually here — application procedure and ordering live in
vsa-run-and-operate; the policy lives invsa-change-control. QA the frontend against a database that already has the migration. - Test the mismatch window both ways: new frontend against old schema (pre-apply) and old frontend against new schema (post-apply, pre-deploy). Neither may hard-crash public pages — expect graceful errors or fallbacks.
- If the migration touches RLS, grants, a view, or an RPC: RLS checklist row (section 5) is mandatory. New views have a known privilege gotcha — see
vsa-supabase-security-referencebefore writing,scripts/verify-rls-security.mjsafter applying. - Update
src/types/database.tsto match the schema (AGENTS.md convention) — then re-run all of section 1's gates, since the build is what proves the types agree. - Verify with real (staging) data shapes, not just the mocked client — Jest cannot see schema drift (section 3 limitations).
Provenance and maintenance
Sources (repo, branch codex/reactbits-ui, as of 2026-07-06):
AGENTS.md— §Testing (acceptable-warnings rule, quoted verbatim), §PR/branch conventions (CI runs but is not a protected merge gate), §Things to never do, §Domain-critical facts.package.json— scripts andjest.transformIgnorePatterns.src/__meta__/playbookRoster.test.ts,src/App.test.tsx,src/data/legacyHouseArchive.test.ts,src/utils/seasonalState.test.ts,src/setupTests.ts— read in full.src/lib/supabase.ts(env throw at line 13),src/utils/isSupabaseUnavailable.ts(degraded-mode detection)..claude/agents/vsa-testing-qa.md,.claude/agents/vsa-points-attendance-guardian.md(quoted).docs/rls-verification-checklist.md,docs/leaderboard-test-checklist.md,docs/final-compliance-reaudit.md(dated 2026-06-19),docs/claude-subagent-task-template.md(example quoted verbatim)..github/workflows/deploy.yml— lint/test/build onpull_request→main.gh api repos/{owner}/{repo}/branches/main/protection→ HTTP 404 "Branch not protected" (checked 2026-07-06).
Re-verify volatile facts:
find src -name "*.test.ts*" | sort # current test inventory; do not trust a fixed count
grep -n "acceptable" AGENTS.md # acceptable-warnings rule unchanged?
grep -n "transformIgnorePatterns" -A 3 package.json # jest quirk unchanged?
grep -n "pull_request\|npm run lint\|npm test\|npm run build" .github/workflows/deploy.yml
gh api repos/{owner}/{repo}/branches/main/protection # 404 = main still unprotected
grep -n "acceptance criteria and tests" .claude/agents/vsa-points-attendance-guardian.md
ls docs/rls-verification-checklist.md docs/leaderboard-test-checklist.md docs/final-compliance-reaudit.md docs/claude-subagent-task-template.md