From 1daf6f6e51dd7a125fd831a4e6c1f32dfbebfe88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:33:01 +0000 Subject: [PATCH 1/3] perf: skip Tamagui static extraction for unit vitest project The 'unit' project (happy-dom) never observes Tamagui's compiled-CSS output - it asserts on rendered DOM, not shipped bundle size - but paid the extraction plugin's full per-file AST walk anyway, across all ~70 test files regardless of whether they touch Tamagui. Disabling it only for that project (detected via argv, not a global toggle) cut its measured transform time roughly 4x (~43s -> ~9-10s) with an identical pass/fail test count. Production build and the 'storybook' project keep the optimization untouched (verified npm run build:production still runs ~1800+ tamagui-extract transforms). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014iu8KPSj96K2guMzMRzeN3 --- frontend/vite.config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 13f8d5f5..d6fc5edb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,6 +10,17 @@ import { tamaguiPlugin } from '@tamagui/vite-plugin' const dirname = fileURLToPath(new URL('.', import.meta.url)) +// The 'unit' vitest project (happy-dom) doesn't benefit from Tamagui's +// static extraction - it's a bundle-size/runtime-perf optimization that +// tests never observe, since they assert on rendered DOM, not shipped CSS. +// Its per-file AST walk is real cost for a mostly-not-Tamagui codebase +// though: disabling it cut this project's transform time roughly 4x (~43s +// -> ~9s across a 70-file/560-test run measured locally) with zero test +// diff. Left enabled everywhere else (build, dev, the 'storybook' project) +// since those do care about the optimized output. +const isUnitTestRun = + !!process.env.VITEST && process.argv.includes('--project') && process.argv.includes('unit') + // https://vite.dev/config/ export default defineConfig(() => { return { @@ -51,6 +62,7 @@ export default defineConfig(() => { tamaguiPlugin({ config: './tamagui.config.ts', components: ['tamagui'], + disable: isUnitTestRun, }), ], base: '/', From a0e54922bba5e008f5bd794da28ba228a7e54454 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:53:08 +0000 Subject: [PATCH 2/3] docs: add plan for further vitest speed restructuring Profiled the remaining ~43s unit-test run after the Tamagui-extraction fix (this PR): Map.test.tsx alone is 17.3s/44 tests (~40% of total file-time) and, being one file, can't be split across Vitest's per-file worker parallelism. maxWorkers=1 vs 4 (96s vs 38s, ~2.5x not ~4x) is consistent with it forming a long serial tail. Also verified adding pointerEventsCheck:0 to userEvent.setup() cuts that file's time 17.3s -> 14.2s with zero test changes; 149 call sites across 34 files still pay the stricter default. Plan covers splitting Map.test.tsx along its existing describe boundaries and centralizing a shared userEvent helper - implementation not started. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014iu8KPSj96K2guMzMRzeN3 --- .../frontend-test-speed-restructuring-plan.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .claude/plans/frontend-test-speed-restructuring-plan.md diff --git a/.claude/plans/frontend-test-speed-restructuring-plan.md b/.claude/plans/frontend-test-speed-restructuring-plan.md new file mode 100644 index 00000000..65ebc08f --- /dev/null +++ b/.claude/plans/frontend-test-speed-restructuring-plan.md @@ -0,0 +1,110 @@ +# Frontend Vitest Restructuring for Speed + +## Context + +Follow-up to the Tamagui-extraction fix (PR #789, merged into `npm run test`'s `unit` project), which cut `npm run test` from ~58s to ~43s by skipping Tamagui's build-time style compiler for tests. This plan covers further, code-level restructuring opportunities found while profiling the remaining ~43s, using Vitest's JSON reporter (`--reporter=json`) for per-file timings and controlled `maxWorkers`/flag experiments. All numbers below are measured in this sandbox (4 CPUs) and are relative, not absolute guarantees on other hardware. + +Previously investigated and rejected (do not re-attempt without new evidence): +- `test.isolate: false` — causes 25-44 test failures (mock state leaks across the 38 files using `vi.mock()`). Unsafe. +- `pool: 'threads'` instead of default `forks` — no measured speed difference. + +--- + +## 1. High-level strategy + +Three independent, separately-landable changes, ordered by evidence strength and risk: + +1. **Split `Map.test.tsx`** (1533 lines / 44 tests / 17.3s — ~40% of total file-time, the single largest file by a wide margin) along its existing `describe` boundaries into 2-3 files sharing a common test-helpers module, so Vitest's file-level parallelism (which currently can't subdivide one file across workers) can run them concurrently instead of serially. +2. **Centralize a `pointerEventsCheck: 0` userEvent helper** and adopt it where the pointer-events check isn't the point of the test. Verified 17.3s → 14.2s (~18%) on `Map.test.tsx` alone, zero test changes needed. 149 call sites across 34 files currently pay the default (strict) check. +3. **Flag, but do not yet implement**, reducing repeated `TamaguiProvider` mount cost in Tamagui-touching test files (`ProgressBar.test.tsx`, `Map.test.tsx`) — real (~150-250ms/test overhead measured) but requires trading off RTL's automatic per-test `cleanup()`/isolation, which needs more careful design than this pass affords. Proposed as a follow-up spike, not part of this plan's implementation. + +Not proposed: reducing worker count assumptions, coverage/`test:ui` changes, or touching the `storybook` project (couldn't be verified in this sandbox — pre-existing Playwright binary mismatch, unrelated to test structure). + +--- + +## 2. Files likely to change + +| File | Change | Exists? | +|---|---|---| +| `frontend/src/components/Map/Map.test.tsx` | Split into 2-3 files; shared setup extracted | Yes (modified + reduced) | +| `frontend/src/components/Map/Map.test-helpers.tsx` (or similar, exact name TBD at implementation) | New: `FakeMap`/`FakeMarker`/`FakeGeoJSONSource` classes, `renderMap`, `currentMap`, `villageSourceFeatures`, `characterMarkers`, `positionAlongPathForTest`, shared fixtures | New | +| `frontend/src/components/Map/Map.entityDetailCard.test.tsx` | New: the `PopulationCentreMap entity detail card` describe block (currently lines ~1106-1399) | New | +| `frontend/src/components/Map/Map.pathInterpolation.test.tsx` | New: the `PopulationCentreMap path-aware interpolation (#615)` describe block (currently lines ~1400-end) | New | +| `frontend/src/testUtils/setupUser.ts` | New: shared `setupUser()` wrapping `userEvent.setup({ pointerEventsCheck: 0, ...overrides })` | New | +| ~34 `*.test.tsx` files currently calling `userEvent.setup()` | Swap to the shared helper, opportunistically or in one mechanical pass | Existing (modified) | + +No production (non-test) source files change. + +--- + +## 3. Implementation plan + +**Step 1 — `Map.test.tsx` split (own PR)** +1. Extract the shared header (imports, both `vi.mock()` calls, `Fake*` classes, helper functions, `renderMap`, fixture builders currently at lines 1-314) into a new non-test helper module. +2. Keep `vi.mock('maplibre-gl', ...)` and `vi.mock('../../api/map', ...)` as short (1-line-ish) calls repeated in each of the 3 split test files — `vi.mock()` must be hoisted per-file by Vitest's transform, so the *factories* can be imported from the shared module, but the `vi.mock()` call sites themselves stay in each test file. This is standard Vitest practice, not a new pattern for this codebase. +3. Move the `PopulationCentreMap entity detail card` describe block to `Map.entityDetailCard.test.tsx`. +4. Move the `PopulationCentreMap path-aware interpolation (#615)` describe block (has its own `beforeEach` using fake timers) to `Map.pathInterpolation.test.tsx`. +5. Leave the main `PopulationCentreMap` describe block in `Map.test.tsx`. +6. Run all three files individually and together; compare total wall-clock and per-file timings against the current baseline (17.3s / 44 tests in one file) to confirm the parallelism benefit materializes, since Vitest's actual worker scheduling (not just file count) determines the real gain. + +**Step 2 — shared `userEvent` helper (own PR, independent of Step 1)** +1. Add `frontend/src/testUtils/setupUser.ts` exporting a `setupUser(options?)` that defaults to `pointerEventsCheck: 0`, matching the `mockAuthContextValue`/`mockGameContextValue` convention already in `src/testUtils/`. +2. Migrate `Map.test.tsx` (and its post-split siblings) to it first, since that's the measured, verified win. +3. Migrate the remaining 33 files opportunistically — either as one mechanical follow-up PR (search-and-replace `userEvent.setup()` → `setupUser()`, drop the import), or file-by-file as they're next touched. Recommend the mechanical pass since the change is uniform and low-risk (see Design Decisions). + +**Step 3 — TamaguiProvider mount-cost spike (not implemented here)** +- Time-box a spike to check whether sharing one `render()` + `rerender()` per file (instead of per test) for `ProgressBar.test.tsx` is safe and how much it saves, before committing to changing the pattern more broadly. Out of scope for this plan's implementation. + +--- + +## 4. Design decisions + +**Split boundary: existing `describe` blocks, not test count.** +- Alternative: split by roughly-equal test count (e.g., ~15 tests/file) regardless of topic. +- Why existing boundaries: they're already coherent behavioral groupings (main map behavior / detail-card interactions / path interpolation, the last of which has its own `beforeEach`/fake-timer setup that's already isolated). Splitting along them keeps each file's `beforeEach` and describe-local intent legible, and follows how the codebase already organizes test structure elsewhere. Splitting by count alone would need to still separate the fake-timer describe block anyway to avoid cross-contaminating other tests' timer setup, so it doesn't actually save work. + +**Shared helpers as a non-test module, `vi.mock()` repeated per file.** +- Alternative: put `vi.mock('maplibre-gl', ...)` itself inside the shared module and have each test file just import it for side effects. +- Why not: Vitest hoists `vi.mock()` calls via static analysis of the file that calls them; moving the call into an imported module is a known footgun (mock hoisting order becomes import-order-dependent and fragile). Keeping the 1-line `vi.mock()` call in each file (referencing a shared factory) is the documented-safe pattern and only costs 3 near-identical lines total across the split files. + +**Centralized `setupUser()` helper vs. per-call-site edits.** +- Alternative: mechanically add `{ pointerEventsCheck: 0 }` to all 149 call sites directly. +- Why a helper: matches the existing `src/testUtils/` convention (shared mock/setup helpers rather than duplicated inline config), and gives one place to revisit if a future test genuinely needs the strict check (see Edge Cases) rather than 149 places to search. + +**Not touching `isolate` or `pool` again.** +- Already measured and rejected in prior investigation (see Context). Restated here only so this plan doesn't get re-litigated against them. + +--- + +## 5. Edge cases + +- **Tests that intentionally rely on the pointer-events check.** A test asserting that clicking a `pointer-events: none`/disabled element does *not* register a click could behave differently with the check disabled (userEvent would no longer throw/skip — it may now dispatch the event and the assertion might pass for the wrong reason, or fail differently). Audit call sites for this pattern specifically before the mechanical migration in Step 2, not after — grep for tests near `disabled`, `pointer-events`, or `toBeDisabled` assertions and leave those on the strict default. +- **Split-file cross-contamination.** The `path-aware interpolation` describe block uses its own `beforeEach` (fake timers) at line 1401 that must not leak into the other two files once split — since each becomes its own file, Vitest's default `isolate: true` already guarantees this is a non-issue (separate module registries), but worth an explicit note since it's exactly the isolation guarantee the earlier `isolate: false` experiment showed we depend on. +- **`FakeMap.instances`/`FakeMarker.instances` static arrays.** Currently reset in a file-level `beforeEach`. After the split, each file gets its own module instance of the shared helper (since ESM modules aren't shared state across separate test files/workers), so this remains file-scoped correctly — no change needed, but worth confirming empirically during implementation (Step 1.6) rather than assuming. +- **Import-order sensitivity.** `Map.test.tsx` currently does `const { default: PopulationCentreMap } = await import('./Map')` *after* both `vi.mock()` calls specifically so the mocks apply. Each split file must preserve this ordering independently — a straight copy-paste of the header per file (rather than a shared "run this setup" function) makes this easiest to get right and audit. + +--- + +## 6. Tests + +- No behavioral test changes — this is a pure restructuring of *where* existing assertions live and *how* `userEvent` is configured, not what's asserted. +- New tests: none needed for the restructuring itself. +- Existing tests to modify: the ~44 tests currently in `Map.test.tsx` move file (no content change) as part of Step 1; the `userEvent.setup()` call sites are swapped for `setupUser()` in Step 2. +- Verification for each step: full `npm run test` pass/fail count must stay identical (currently 561 passing / 1 pre-existing unrelated `UnifiedTimerHome.test.tsx` failure) before and after, plus `npx tsc --noEmit` and `npm run lint` clean, consistent with how the Tamagui-extraction fix (PR #789) was verified. +- Worth capturing new per-file timings (via `--reporter=json`) before/after Step 1 specifically, since the parallelism benefit depends on actual worker scheduling and should be confirmed rather than assumed. + +--- + +## 7. Risks + +- **Splitting `Map.test.tsx` might not yield the full expected parallelism win** if Vitest's scheduler already interleaves it efficiently with other files rather than leaving it as an isolated tail — the `maxWorkers=1` vs `4` comparison (96s vs 38s, ~2.5x not ~4x) is suggestive but not a direct measurement of "what happens if this one file becomes three." Verify with real timings during implementation before treating the win as proven. +- **Silently changing pointer-events assertions.** The biggest correctness risk in Step 2 — a careless mechanical migration could mask a test that currently (correctly) fails to interact with a disabled element, turning a real bug-catching test into a false pass. Requires the audit called out in Edge Cases, not just a blind find-and-replace. +- **Mock hoisting mistakes during the Map split.** `vi.mock()` ordering relative to the dynamic `await import('./Map')` is easy to get subtly wrong when copy-pasting across 3 files; a mistake here fails loudly (import errors / real maplibre-gl loading in jsdom) rather than silently, which is the safer failure mode but still worth flagging as the most likely implementation slip. + +--- + +## 8. Open questions + +- Exact filenames for the split `Map.test.tsx` pieces and the shared helper module — proposed above, open to whatever naming the team prefers (e.g. colocating helpers under `src/components/Map/__tests__/` instead of flat sibling files, if that's a preferred convention elsewhere in the codebase — a quick check didn't find an existing `__tests__/` convention here, so flat sibling files matching the existing `*.test.tsx` pattern seems consistent, but worth confirming). +- Whether Step 2's 149-call-site migration should be one mechanical PR or spread across incidental touches — recommended as one PR above, but this is a process preference rather than a technical constraint. +- Whether the Step 3 TamaguiProvider spike is worth scheduling at all, given it's the smallest and riskiest of the three opportunities relative to the ~5-10s (rough estimate, unverified) it might recover. From fd23511080721706abebde717ac26da0d43a6f4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:13:51 +0000 Subject: [PATCH 3/3] perf: skip pointer-events check in userEvent.setup() across unit tests Adds frontend/src/testUtils/setupUser.ts (userEvent.setup() defaulting to pointerEventsCheck: 0) and migrates all 35 test files / ~150 userEvent.setup() call sites onto it, replacing an inline workaround already used ad hoc in a few places (TasksPanel.test.tsx). Verified via audit that no test in the codebase relies on the strict check itself - every toBeDisabled()/pointer-events reference is either a static assertion or clicks a different element that causes disabling, never a click on the disabled element expecting it to be blocked. Cut Map.test.tsx's isolated run time ~18% (17.3s -> 14.2s) with zero test changes; full suite pass/fail count is unchanged (561 passed, 1 pre-existing unrelated failure). Also implemented and then reverted a Map.test.tsx file split (the other opportunity from the linked plan): measured no wall-clock benefit on a clean A/B, since Vitest's default scheduler already duration-aware-prioritizes slow files, so splitting the one big file didn't relieve a bottleneck that didn't exist in practice. Recorded in the plan doc for future reference. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014iu8KPSj96K2guMzMRzeN3 --- .../frontend-test-speed-restructuring-plan.md | 7 +-- .../ActivitiesPanel/ActivitiesPanel.test.tsx | 6 +-- .../ActivityInput/ActivityInput.test.tsx | 8 ++-- .../AlertDialog/AlertDialog.test.tsx | 8 ++-- .../BuildingDetail/BuildingDetail.test.tsx | 4 +- .../CategoriesPanel/CategoriesPanel.test.tsx | 4 +- .../components/DetailCard/DetailCard.test.tsx | 6 +-- .../DetailSurface/DetailSurface.test.tsx | 4 +- .../EntitySearchInput.test.tsx | 10 ++--- frontend/src/components/Form/Form.test.tsx | 6 +-- frontend/src/components/Input/Input.test.tsx | 8 ++-- frontend/src/components/List/List.test.tsx | 6 +-- .../LogOfflineActivityModal.test.tsx | 10 ++--- frontend/src/components/Map/Map.test.tsx | 16 +++---- frontend/src/components/Modal/Modal.test.tsx | 12 ++--- .../ModeSwitcher/ModeSwitcher.test.tsx | 10 ++--- .../components/NotesPanel/NotesPanel.test.tsx | 12 ++--- .../PlayerItemList/PlayerItemList.test.tsx | 16 +++---- .../ProjectsPanel/ProjectsPanel.test.tsx | 8 ++-- .../SkillsPanel/SkillsPanel.test.tsx | 6 +-- .../SupportFlow/SupportFlowModal.test.tsx | 44 +++++++++---------- .../components/TasksPanel/TasksPanel.test.tsx | 34 +++++++------- .../src/components/Tooltip/Tooltip.test.tsx | 12 ++--- .../UnifiedTimerHome/TimerNoteField.test.tsx | 6 +-- .../UnifiedTimerHome.test.tsx | 32 +++++++------- .../layout/Infobar/AchievementBadges.test.tsx | 4 +- .../src/layout/NavDrawer/NavDrawer.test.tsx | 12 ++--- frontend/src/layout/Navbar/Navbar.test.tsx | 12 ++--- frontend/src/pages/Account/Account.test.tsx | 8 ++-- .../src/pages/Checkout/UpgradePage.test.tsx | 4 +- frontend/src/pages/Home/Home.test.tsx | 14 +++--- .../pages/LibraryPage/LibraryPage.test.tsx | 12 ++--- .../src/pages/LoginPage/LoginPage.test.tsx | 8 ++-- .../PasswordResetConfirmPage.test.tsx | 4 +- .../PasswordResetRequestPage.test.tsx | 4 +- .../pages/RegisterPage/RegisterPage.test.tsx | 12 ++--- frontend/src/testUtils/setupUser.ts | 21 +++++++++ 37 files changed, 215 insertions(+), 195 deletions(-) create mode 100644 frontend/src/testUtils/setupUser.ts diff --git a/.claude/plans/frontend-test-speed-restructuring-plan.md b/.claude/plans/frontend-test-speed-restructuring-plan.md index 65ebc08f..1f22d5ac 100644 --- a/.claude/plans/frontend-test-speed-restructuring-plan.md +++ b/.claude/plans/frontend-test-speed-restructuring-plan.md @@ -7,15 +7,16 @@ Follow-up to the Tamagui-extraction fix (PR #789, merged into `npm run test`'s ` Previously investigated and rejected (do not re-attempt without new evidence): - `test.isolate: false` — causes 25-44 test failures (mock state leaks across the 38 files using `vi.mock()`). Unsafe. - `pool: 'threads'` instead of default `forks` — no measured speed difference. +- **Splitting `Map.test.tsx` into 3 files (Opportunity 1 below) — implemented, measured, and reverted.** Built the split exactly as planned (shared `Map.testHelpers.tsx` + 3 test files) and ran a clean A/B on identical machine state: unsplit baseline ~42.7s/44.1s (two runs) vs. split ~44.6s/43.9s — no measurable difference, arguably a hair worse due to 2 extra files' worth of fixed per-file `environment`/`setup` overhead. Root cause: Vitest's default sequencer already schedules known-slow files first (confirmed via `vitest --help --sequence.shuffle.files`: disabling shuffle - the default - is what makes "long running tests start earlier"), so the single 17.3s `Map.test.tsx` was already getting dispatched onto its own worker at the start of the run, achieving close to the packing this split was meant to produce. This repo's CI also doesn't use `--shard`, so there was no secondary multi-machine-sharding win either. The split code was fully implemented and verified passing (44/44 tests, `tsc --noEmit` clean) before being reverted for not paying for itself - available in git history on `claude/pr-702-review-wii8hq` if this needs revisiting under different scheduling conditions. --- ## 1. High-level strategy -Three independent, separately-landable changes, ordered by evidence strength and risk: +Originally three independent, separately-landable changes; the first (splitting `Map.test.tsx`) was implemented and measured but reverted - see Context above. Remaining: -1. **Split `Map.test.tsx`** (1533 lines / 44 tests / 17.3s — ~40% of total file-time, the single largest file by a wide margin) along its existing `describe` boundaries into 2-3 files sharing a common test-helpers module, so Vitest's file-level parallelism (which currently can't subdivide one file across workers) can run them concurrently instead of serially. -2. **Centralize a `pointerEventsCheck: 0` userEvent helper** and adopt it where the pointer-events check isn't the point of the test. Verified 17.3s → 14.2s (~18%) on `Map.test.tsx` alone, zero test changes needed. 149 call sites across 34 files currently pay the default (strict) check. +1. ~~Split `Map.test.tsx`~~ — implemented, measured (no wall-clock benefit; Vitest's scheduler already handles this), reverted. Not part of the implementation. +2. **Centralize a `pointerEventsCheck: 0` userEvent helper** — **implemented.** Added `frontend/src/testUtils/setupUser.ts`; migrated all 35 files / ~150 call sites (audited first: no test in the codebase relies on the strict check itself - every `toBeDisabled()`/pointer-events reference was either a static assertion or clicked a *different* element that caused disabling, never a click on the disabled element expecting it to be blocked). Full suite: same 561 passed / 1 pre-existing unrelated failure before and after, `tsc --noEmit` and lint clean. Wall-clock effect on the full suite is modest (~42-44s baseline → ~42s after, within run-to-run noise) since `Map.test.tsx`'s isolated ~18% improvement doesn't fully translate 1:1 once it's running concurrently with 69 other files - `user` (CPU) time dropped more consistently (~2m1-2m5s → ~1m59-2m0s), which matters more for CI compute cost than wall-clock alone. 3. **Flag, but do not yet implement**, reducing repeated `TamaguiProvider` mount cost in Tamagui-touching test files (`ProgressBar.test.tsx`, `Map.test.tsx`) — real (~150-250ms/test overhead measured) but requires trading off RTL's automatic per-test `cleanup()`/isolation, which needs more careful design than this pass affords. Proposed as a follow-up spike, not part of this plan's implementation. Not proposed: reducing worker count assumptions, coverage/`test:ui` changes, or touching the `storybook` project (couldn't be verified in this sandbox — pre-existing Playwright binary mismatch, unrelated to test structure). diff --git a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx index 99bfd72f..cd225136 100644 --- a/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx +++ b/frontend/src/components/ActivitiesPanel/ActivitiesPanel.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "../Tooltip/Tooltip"; @@ -47,7 +47,7 @@ describe("ActivitiesPanel", () => { }); it("renders activities and delegates edit through PlayerItemList", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderActivitiesPanel(); expect(screen.getByText("Write docs")).toBeInTheDocument(); @@ -67,7 +67,7 @@ describe("ActivitiesPanel", () => { }); it("delegates delete confirmation through PlayerItemList", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderActivitiesPanel(); await user.click(screen.getByRole("button", { name: "Open activity Write docs" })); diff --git a/frontend/src/components/ActivityInput/ActivityInput.test.tsx b/frontend/src/components/ActivityInput/ActivityInput.test.tsx index a449e0a7..a4c3998c 100644 --- a/frontend/src/components/ActivityInput/ActivityInput.test.tsx +++ b/frontend/src/components/ActivityInput/ActivityInput.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ActivityInput from './ActivityInput'; @@ -142,7 +142,7 @@ describe('ActivityInput', () => { }); it('refreshes player data after a manual stop so the infobar updates xp', async () => { - const user = userEvent.setup(); + const user = setupUser(); stop.mockResolvedValue({ xp_gained: 16, base_xp: 16, xp_multiplier: 1, level_ups: [2], duration_seconds: 16 }); mockUseGame.mockReturnValue({ @@ -300,7 +300,7 @@ describe('ActivityInput', () => { }); it('does not submit the timer or open Task Support when the user cancels', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseGame.mockReturnValue({ activityTimer: { @@ -337,7 +337,7 @@ describe('ActivityInput', () => { }); it('submits the active timer and opens Task Support when confirmed', async () => { - const user = userEvent.setup(); + const user = setupUser(); stop.mockResolvedValue(null); mockUseGame.mockReturnValue({ diff --git a/frontend/src/components/AlertDialog/AlertDialog.test.tsx b/frontend/src/components/AlertDialog/AlertDialog.test.tsx index e237b11d..536b3d3d 100644 --- a/frontend/src/components/AlertDialog/AlertDialog.test.tsx +++ b/frontend/src/components/AlertDialog/AlertDialog.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import AlertDialog from './AlertDialog'; function renderDialog(overrides: Partial> = {}) { @@ -30,21 +30,21 @@ describe('AlertDialog', () => { }); it('calls onConfirm when the confirm button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onConfirm } = renderDialog({ confirmLabel: 'Yes, do it' }); await user.click(screen.getByRole('button', { name: 'Yes, do it' })); expect(onConfirm).toHaveBeenCalledTimes(1); }); it('calls onCancel when the cancel button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onCancel } = renderDialog({ cancelLabel: 'No thanks' }); await user.click(screen.getByRole('button', { name: 'No thanks' })); expect(onCancel).toHaveBeenCalledTimes(1); }); it('calls onCancel when Escape is pressed', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onCancel } = renderDialog(); await user.keyboard('{Escape}'); expect(onCancel).toHaveBeenCalledTimes(1); diff --git a/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx b/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx index 6d986ec6..afbce3a1 100644 --- a/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx +++ b/frontend/src/components/BuildingDetail/BuildingDetail.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { describe, expect, it, vi } from 'vitest'; import BuildingDetail from './BuildingDetail'; @@ -72,7 +72,7 @@ describe('BuildingDetail', () => { }); it('calls onSelectResident when a resident row is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onSelectResident = vi.fn(); render( { }); it("edits and deletes categories through PlayerItemList", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open category Deep Work" })); diff --git a/frontend/src/components/DetailCard/DetailCard.test.tsx b/frontend/src/components/DetailCard/DetailCard.test.tsx index 37036dea..f11cc6cf 100644 --- a/frontend/src/components/DetailCard/DetailCard.test.tsx +++ b/frontend/src/components/DetailCard/DetailCard.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import DetailCard from './DetailCard'; function renderCard(overrides: Partial> = {}) { @@ -29,14 +29,14 @@ describe('DetailCard', () => { }); it('calls onClose when the close button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderCard(); await user.click(screen.getByRole('button', { name: 'Close' })); expect(onClose).toHaveBeenCalledTimes(1); }); it('calls onClose when Escape is pressed', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderCard(); await user.keyboard('{Escape}'); expect(onClose).toHaveBeenCalledTimes(1); diff --git a/frontend/src/components/DetailSurface/DetailSurface.test.tsx b/frontend/src/components/DetailSurface/DetailSurface.test.tsx index acf792f8..9073b3df 100644 --- a/frontend/src/components/DetailSurface/DetailSurface.test.tsx +++ b/frontend/src/components/DetailSurface/DetailSurface.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import DetailSurface from './DetailSurface'; function renderSurface(overrides: Partial> = {}) { @@ -33,7 +33,7 @@ describe('DetailSurface', () => { }); it('calls onOpenChange(false) when Escape is pressed', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onOpenChange } = renderSurface(); await user.keyboard('{Escape}'); expect(onOpenChange).toHaveBeenCalledWith(false); diff --git a/frontend/src/components/EntitySearchInput/EntitySearchInput.test.tsx b/frontend/src/components/EntitySearchInput/EntitySearchInput.test.tsx index d6eedcd5..8b1e7f6a 100644 --- a/frontend/src/components/EntitySearchInput/EntitySearchInput.test.tsx +++ b/frontend/src/components/EntitySearchInput/EntitySearchInput.test.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import EntitySearchInput from "./EntitySearchInput"; @@ -59,7 +59,7 @@ describe("EntitySearchInput", () => { }); it("dedupes a task and an identically-named activity into one suggestion", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockEntities = [ { id: "t1", name: "Write report", taskId: 1, source: "task" }, { id: "a1", name: "Write report", taskId: null, source: "activity" }, @@ -79,7 +79,7 @@ describe("EntitySearchInput", () => { }); it("includes completed tasks among the suggestions", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockEntities = [ // Completed tasks are kept in the search cache and should still surface. { id: "t9", name: "Archive logs", taskId: 9, source: "task" }, @@ -94,7 +94,7 @@ describe("EntitySearchInput", () => { }); it("creates a new task when the typed name has no match", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onCreate = vi.fn(); mockEntities = [{ id: "t1", name: "Write report", taskId: 1, source: "task" }]; @@ -129,7 +129,7 @@ describe("EntitySearchInput", () => { }); it("switches from defaultResults to Fuse results once a query is typed", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockEntities = [{ id: "a1", name: "Write report", taskId: null, source: "activity" }]; render( diff --git a/frontend/src/components/Form/Form.test.tsx b/frontend/src/components/Form/Form.test.tsx index 1059e83e..835eb5d3 100644 --- a/frontend/src/components/Form/Form.test.tsx +++ b/frontend/src/components/Form/Form.test.tsx @@ -1,13 +1,13 @@ import React from "react"; import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import Form from "./Form"; describe("Form", () => { it("shows required validation after blur", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onSubmit = vi.fn((event) => event.preventDefault()); render( @@ -58,7 +58,7 @@ describe("Form", () => { }); it("validates required checkboxes against their checked state", async () => { - const user = userEvent.setup(); + const user = setupUser(); const { rerender } = render(
{ @@ -56,7 +56,7 @@ describe('Input', () => { }); it('calls onChange with value for text input', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleChange = vi.fn(); render(); @@ -76,7 +76,7 @@ describe('Input', () => { }); it('calls onChange with boolean for checkbox input', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleChange = vi.fn(); render( @@ -127,7 +127,7 @@ describe('Input', () => { }); it('does not call onChange if onChange prop is not provided', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); diff --git a/frontend/src/components/List/List.test.tsx b/frontend/src/components/List/List.test.tsx index bb0a58a1..44a871d8 100644 --- a/frontend/src/components/List/List.test.tsx +++ b/frontend/src/components/List/List.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import List from './List'; import type { PaginatedResponse } from '../../types'; @@ -68,7 +68,7 @@ describe('List', () => { }); it('calls onSelect when selectable item is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const items = asItems(['Apple', 'Banana']); const handleSelect = vi.fn(); @@ -86,7 +86,7 @@ describe('List', () => { }); it('does not call onSelect when selectable is false', async () => { - const user = userEvent.setup(); + const user = setupUser(); const items = asItems(['Apple', 'Banana']); const handleSelect = vi.fn(); diff --git a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx index b8e3763b..850db926 100644 --- a/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx +++ b/frontend/src/components/LogOfflineActivityModal/LogOfflineActivityModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "../Tooltip/Tooltip"; @@ -53,7 +53,7 @@ describe("LogOfflineActivityModal", () => { }); it("blocks submission and shows errors when required fields are missing", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.click(screen.getByRole("button", { name: "Log activity" })); @@ -64,7 +64,7 @@ describe("LogOfflineActivityModal", () => { }); it("submits a valid entry and shows the XP confirmation", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.type(screen.getByLabelText("Task"), "Write docs"); @@ -91,7 +91,7 @@ describe("LogOfflineActivityModal", () => { }); it("surfaces the backend error message on failure", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.type(screen.getByLabelText("Task"), "Write docs"); @@ -108,7 +108,7 @@ describe("LogOfflineActivityModal", () => { it("warns free-tier users that XP won't be awarded", async () => { gameValue = { player: { is_premium: false }, fetchPlayerAndCharacter }; - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.type(screen.getByLabelText("Minutes"), "10"); diff --git a/frontend/src/components/Map/Map.test.tsx b/frontend/src/components/Map/Map.test.tsx index 0210fa76..73cd67c6 100644 --- a/frontend/src/components/Map/Map.test.tsx +++ b/frontend/src/components/Map/Map.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { act, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ComponentProps } from 'react'; import { TamaguiProvider } from 'tamagui'; @@ -1105,7 +1105,7 @@ describe('PopulationCentreMap', () => { describe('PopulationCentreMap entity detail card', () => { it('opens a character detail card when "View details" is clicked in its tooltip', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithCharacter = { ...baseGeojson, features: [ @@ -1133,7 +1133,7 @@ describe('PopulationCentreMap entity detail card', () => { }); it('opens a building detail card showing its residents when "View details" is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithHouse = { ...baseGeojson, features: [ @@ -1176,7 +1176,7 @@ describe('PopulationCentreMap entity detail card', () => { }); it('switches to a resident\'s own detail card when clicked inside the building detail card', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithHouse = { ...baseGeojson, features: [ @@ -1213,7 +1213,7 @@ describe('PopulationCentreMap entity detail card', () => { }); it('closes the detail card via its close button', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithCharacter = { ...baseGeojson, features: [ @@ -1239,7 +1239,7 @@ describe('PopulationCentreMap entity detail card', () => { }); it('outlines the selected building on the map while its detail card is open', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithHouse = { ...baseGeojson, features: [ @@ -1281,7 +1281,7 @@ describe('PopulationCentreMap entity detail card', () => { }); it('outlines a building at reduced opacity while just its tooltip is open, then at full opacity once its detail card opens', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithHouse = { ...baseGeojson, features: [ @@ -1366,7 +1366,7 @@ describe('PopulationCentreMap entity detail card', () => { }); it('highlights the selected character on the map while its detail card is open', async () => { - const user = userEvent.setup(); + const user = setupUser(); const geojsonWithCharacter = { ...baseGeojson, features: [ diff --git a/frontend/src/components/Modal/Modal.test.tsx b/frontend/src/components/Modal/Modal.test.tsx index f9889767..ada7b0d2 100644 --- a/frontend/src/components/Modal/Modal.test.tsx +++ b/frontend/src/components/Modal/Modal.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import Modal from './Modal'; describe('Modal', () => { @@ -27,7 +27,7 @@ describe('Modal', () => { }); it('calls onClose when close button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleClose = vi.fn(); render( @@ -43,7 +43,7 @@ describe('Modal', () => { }); it('calls onClose when Escape key is pressed', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleClose = vi.fn(); render( @@ -58,7 +58,7 @@ describe('Modal', () => { }); it('calls onClose when backdrop is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleClose = vi.fn(); render( @@ -74,7 +74,7 @@ describe('Modal', () => { }); it('does not call onClose when modal content is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleClose = vi.fn(); render( @@ -90,7 +90,7 @@ describe('Modal', () => { }); it('works when onClose is not provided', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( diff --git a/frontend/src/components/ModeSwitcher/ModeSwitcher.test.tsx b/frontend/src/components/ModeSwitcher/ModeSwitcher.test.tsx index 87fb34ea..31ca197c 100644 --- a/frontend/src/components/ModeSwitcher/ModeSwitcher.test.tsx +++ b/frontend/src/components/ModeSwitcher/ModeSwitcher.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { describe, expect, it, vi } from 'vitest'; import ModeSwitcher from './ModeSwitcher'; @@ -36,7 +36,7 @@ describe('ModeSwitcher', () => { }); it('calls onSelect with the clicked chip key', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onSelect = vi.fn(); render(); @@ -46,7 +46,7 @@ describe('ModeSwitcher', () => { }); it('ArrowRight moves selection to the next chip and wraps at the end', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onSelect = vi.fn(); render(); @@ -57,7 +57,7 @@ describe('ModeSwitcher', () => { }); it('ArrowLeft moves selection to the previous chip and wraps at the start', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onSelect = vi.fn(); render(); @@ -68,7 +68,7 @@ describe('ModeSwitcher', () => { }); it('Home and End jump to the first and last chip', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onSelect = vi.fn(); render(); diff --git a/frontend/src/components/NotesPanel/NotesPanel.test.tsx b/frontend/src/components/NotesPanel/NotesPanel.test.tsx index 3e068266..a204ddb8 100644 --- a/frontend/src/components/NotesPanel/NotesPanel.test.tsx +++ b/frontend/src/components/NotesPanel/NotesPanel.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import NotesPanel from "./NotesPanel"; @@ -57,7 +57,7 @@ describe("NotesPanel", () => { }); it("creates a note with the add form", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.type(screen.getByPlaceholderText("New note title"), "Trip plan"); @@ -74,7 +74,7 @@ describe("NotesPanel", () => { }); it("edits a note's title and body", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open note Groceries" })); @@ -94,7 +94,7 @@ describe("NotesPanel", () => { }); it("does not offer a way to link a note to a task", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); expect(screen.queryByLabelText("Attach to task")).not.toBeInTheDocument(); @@ -105,7 +105,7 @@ describe("NotesPanel", () => { }); it("shows the linked task in the edit modal when one is set", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseNotes.mockReturnValue({ isLoading: false, data: [{ ...note, task: 5 }] }); render(); @@ -124,7 +124,7 @@ describe("NotesPanel", () => { }); it("deletes a note after confirming", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open note Groceries" })); diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx index 6142c2df..f2116236 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { describe, expect, it, vi } from "vitest"; import PlayerItemList from "./PlayerItemList"; @@ -27,7 +27,7 @@ describe("PlayerItemList", () => { }); it("autosaves a trimmed name on blur", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onEdit = vi.fn(); render( @@ -55,7 +55,7 @@ describe("PlayerItemList", () => { }); it("does not re-fire the save when blurring without changing the name", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onEdit = vi.fn(); render( @@ -71,7 +71,7 @@ describe("PlayerItemList", () => { }); it("shows a Saved indicator once the autosave succeeds, then shows a warning on failure", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onEdit = vi.fn((_item, _name, callbacks) => callbacks?.onSuccess?.()); const { rerender } = render( @@ -97,7 +97,7 @@ describe("PlayerItemList", () => { }); it("opens the delete modal and confirms deletion", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onDelete = vi.fn(); render( @@ -116,7 +116,7 @@ describe("PlayerItemList", () => { }); it("shows item details inside the modal", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { }); it("reorders items when a different sort option is chosen", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( , ); @@ -173,7 +173,7 @@ describe("PlayerItemList", () => { }); it("applies the active filter predicate", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { }); it("creates a project", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.type(screen.getByRole("textbox"), "New client portal"); @@ -56,7 +56,7 @@ describe("ProjectsPanel", () => { }); it("toggles project completion with the row checkbox", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("checkbox", { name: "Mark Website overhaul as complete" })); @@ -70,7 +70,7 @@ describe("ProjectsPanel", () => { }); it("edits and deletes projects through PlayerItemList", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open project Website overhaul" })); diff --git a/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx b/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx index a927c541..93639596 100644 --- a/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx +++ b/frontend/src/components/SkillsPanel/SkillsPanel.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import SkillsPanel from "./SkillsPanel"; @@ -37,7 +37,7 @@ describe("SkillsPanel", () => { }); it("edits a skill through PlayerItemList", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open skill Writing" })); @@ -55,7 +55,7 @@ describe("SkillsPanel", () => { }); it("deletes a skill through PlayerItemList", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open skill Writing" })); diff --git a/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx b/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx index 7651e19a..9d4bcffc 100644 --- a/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx +++ b/frontend/src/components/SupportFlow/SupportFlowModal.test.tsx @@ -1,7 +1,7 @@ // SupportFlow/SupportFlowModal.test.tsx import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { useReducer } from "react"; import type { Dispatch } from "react"; import SupportFlowModal from "./SupportFlowModal"; @@ -72,7 +72,7 @@ describe("SupportFlowModal", () => { }); it("opens welcome message screen", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { }); it("renders repeat-login welcome copy", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { }); it("opens activity reward screen", async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { }); it("opens support mode directly to support menu", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); expect(screen.getByText("How are you feeling?")).toBeInTheDocument(); @@ -154,7 +154,7 @@ describe("SupportFlowModal", () => { }); it("navigates from welcome message to support menu", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -162,7 +162,7 @@ describe("SupportFlowModal", () => { }); it("back from support menu returns to welcome message", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -172,7 +172,7 @@ describe("SupportFlowModal", () => { }); it("back from support menu returns to activity reward", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Continue with support" })); @@ -187,7 +187,7 @@ describe("SupportFlowModal", () => { }); it("support mode menu does not show reward back button", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); expect(screen.getByText("How are you feeling?")).toBeInTheDocument(); @@ -195,7 +195,7 @@ describe("SupportFlowModal", () => { }); it("back from ready menu returns to support menu", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -211,7 +211,7 @@ describe("SupportFlowModal", () => { }); it("back from not-ready menu returns to support menu", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -222,7 +222,7 @@ describe("SupportFlowModal", () => { }); it("header back from activity input returns to ready menu", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -234,7 +234,7 @@ describe("SupportFlowModal", () => { }); it("header back works while a task input is focused", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -250,7 +250,7 @@ describe("SupportFlowModal", () => { }); it("tiniest-step preset shows examples only and can start without text input", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfirm = vi.fn(); render( @@ -271,7 +271,7 @@ describe("SupportFlowModal", () => { }); it("navigates not-ready path to support detail", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Continue with support" })); @@ -290,7 +290,7 @@ describe("SupportFlowModal", () => { }); it("returning from support detail to support menu hides reward back button", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Continue with support" })); @@ -307,7 +307,7 @@ describe("SupportFlowModal", () => { }); it("closes modal when close button is clicked", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); expect(screen.getByRole("heading", { name: "Welcome!" })).toBeInTheDocument(); @@ -316,7 +316,7 @@ describe("SupportFlowModal", () => { }); it("close button works while a task input is focused", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -332,7 +332,7 @@ describe("SupportFlowModal", () => { }); it("priority-three preset shows three task inputs", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); @@ -353,7 +353,7 @@ describe("SupportFlowModal", () => { }); it("priority-three start-this uses selected task text", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfirm = vi.fn(); render( @@ -371,7 +371,7 @@ describe("SupportFlowModal", () => { }); it("priority-three randomise starts one of filled tasks", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onConfirm = vi.fn(); const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.9); @@ -400,7 +400,7 @@ describe("SupportFlowModal", () => { }); it("priority-three randomise remains disabled when empty", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("button", { name: "Open" })); await user.click(screen.getByRole("button", { name: "Get support" })); diff --git a/frontend/src/components/TasksPanel/TasksPanel.test.tsx b/frontend/src/components/TasksPanel/TasksPanel.test.tsx index a503b5cf..a6ca94c7 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.test.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.test.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from "react"; import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "../Tooltip/Tooltip"; @@ -139,7 +139,7 @@ describe("TasksPanel", () => { }); it("reveals completed tasks and persists the preference when toggled", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); await user.click(screen.getByRole("button", { name: "Show complete" })); @@ -158,7 +158,7 @@ describe("TasksPanel", () => { }); it("toggles task completion with the row checkbox", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); await user.click( @@ -174,9 +174,7 @@ describe("TasksPanel", () => { }); it("starts a linked activity and navigates to the timer from the play button", async () => { - // The play button lives in a hover-revealed row (pointer-events:none until - // hover), which jsdom can't simulate, so skip the pointer-events guard. - const user = userEvent.setup({ pointerEventsCheck: 0 }); + const user = setupUser(); renderTasksPanel(); await user.click(screen.getByRole("button", { name: "Start working on Morning routine" })); @@ -192,7 +190,7 @@ describe("TasksPanel", () => { }); it("does not start an activity when a timer is already running", async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); + const user = setupUser(); gameValue.activityTimer = { status: "active", startActivity }; renderTasksPanel(); @@ -223,7 +221,7 @@ describe("TasksPanel", () => { }); it("does not show note controls in the edit dialog when onOpenNote is not provided", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); await user.click( @@ -236,7 +234,7 @@ describe("TasksPanel", () => { }); it("offers to create a note for a task with no linked note", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOpenNote = vi.fn(); createNoteMutate.mockImplementation((_data, { onSuccess }) => onSuccess({ id: 9 })); renderTasksPanel({ onOpenNote }); @@ -256,7 +254,7 @@ describe("TasksPanel", () => { }); it("shows a link to an existing linked note instead of the create button", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOpenNote = vi.fn(); mockUseNotes.mockReturnValue({ data: [{ id: 3, title: "Routine notes", body: "", player: 1, task: 1 }], @@ -274,7 +272,7 @@ describe("TasksPanel", () => { }); it("edits a task name through the PlayerItemList dialog", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); // hoverEdit renders the name and a 📝 button with the same label; either opens the dialog. @@ -360,7 +358,7 @@ describe("TasksPanel", () => { }); it("opens the task detail modal for a blank draft subtask without creating one yet", async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); + const user = setupUser(); mockUseTasks.mockReturnValue({ isLoading: false, data: [parentTask], @@ -375,7 +373,7 @@ describe("TasksPanel", () => { }); it("creates the subtask only once its draft name has actually been edited, then opens the persisted task", async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); + const user = setupUser(); const newSubtask = { ...childTask, id: 7, name: "New task" }; createMutate.mockImplementation((_data, callbacks) => { callbacks?.onSuccess?.(newSubtask); @@ -413,7 +411,7 @@ describe("TasksPanel", () => { }); it("discards the draft subtask, without creating anything, when its modal is closed unedited", async () => { - const user = userEvent.setup({ pointerEventsCheck: 0 }); + const user = setupUser(); mockUseTasks.mockReturnValue({ isLoading: false, data: [parentTask], @@ -431,7 +429,7 @@ describe("TasksPanel", () => { }); it("disables the parent picker for a task that already has subtasks", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseTasks.mockReturnValue({ isLoading: false, data: [parentTask, childTask], @@ -448,7 +446,7 @@ describe("TasksPanel", () => { describe("due date", () => { it("commits a due date edit immediately on blur", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); await user.click( @@ -468,7 +466,7 @@ describe("TasksPanel", () => { }); it("defaults the date to today when only a time is set", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); await user.click( @@ -497,7 +495,7 @@ describe("TasksPanel", () => { describe("timestamps tooltip", () => { it("shows Created/Modified/Completed on click of the clock button", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTasksPanel(); await user.click( diff --git a/frontend/src/components/Tooltip/Tooltip.test.tsx b/frontend/src/components/Tooltip/Tooltip.test.tsx index fb88aad5..b87bdf80 100644 --- a/frontend/src/components/Tooltip/Tooltip.test.tsx +++ b/frontend/src/components/Tooltip/Tooltip.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import Tooltip, { TooltipProvider } from './Tooltip'; function renderTooltip() { @@ -16,7 +16,7 @@ function renderTooltip() { describe('Tooltip', () => { it('does not open on hover', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTooltip(); @@ -26,7 +26,7 @@ describe('Tooltip', () => { }); it('opens on click and closes when the trigger is clicked again', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTooltip(); @@ -42,7 +42,7 @@ describe('Tooltip', () => { }); it('closes when clicking outside the trigger, including unrelated elements', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTooltip(); @@ -57,7 +57,7 @@ describe('Tooltip', () => { }); it('shows on focus and wires the trigger to aria-describedby', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTooltip(); @@ -71,7 +71,7 @@ describe('Tooltip', () => { }); it('dismisses when Escape is pressed', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTooltip(); diff --git a/frontend/src/components/UnifiedTimerHome/TimerNoteField.test.tsx b/frontend/src/components/UnifiedTimerHome/TimerNoteField.test.tsx index 3974e6ec..e27e8eb9 100644 --- a/frontend/src/components/UnifiedTimerHome/TimerNoteField.test.tsx +++ b/frontend/src/components/UnifiedTimerHome/TimerNoteField.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import TimerNoteField from "./TimerNoteField"; @@ -48,7 +48,7 @@ describe("TimerNoteField", () => { }); it("saves on blur", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const field = screen.getByLabelText("Note for what you're timing"); @@ -60,7 +60,7 @@ describe("TimerNoteField", () => { }); it("updates the hook's value as the user types", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.type(screen.getByLabelText("Note for what you're timing"), "x"); diff --git a/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx b/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx index b13dc507..8176ef14 100644 --- a/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx +++ b/frontend/src/components/UnifiedTimerHome/UnifiedTimerHome.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import UnifiedTimerHome from './UnifiedTimerHome'; @@ -200,7 +200,7 @@ describe('UnifiedTimerHome', () => { }); it('Start with an empty input labels the timer "Planning" and switches to Planning mode', async () => { - const user = userEvent.setup(); + const user = setupUser(); startActivity.mockResolvedValue(null); const { rerender } = render(); @@ -220,7 +220,7 @@ describe('UnifiedTimerHome', () => { }); it('Start/Stop is the same persistent button element, not a swap', async () => { - const user = userEvent.setup(); + const user = setupUser(); startActivity.mockResolvedValue(null); const { rerender } = render(); @@ -234,7 +234,7 @@ describe('UnifiedTimerHome', () => { }); it('Start with typed text starts a named timer instead of a blank one', async () => { - const user = userEvent.setup(); + const user = setupUser(); startActivity.mockResolvedValue(null); render(); @@ -246,7 +246,7 @@ describe('UnifiedTimerHome', () => { }); it('selecting a suggestion while unlabelled-running labels the timer in place', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); mockUseDefaultActivityEntries.mockReturnValue([ { id: 'activity-1', name: 'Washing dishes', taskId: null, source: 'activity' }, @@ -271,7 +271,7 @@ describe('UnifiedTimerHome', () => { }); it('clicking the running-labelled name switches to click-to-edit and pre-fills the input', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: 'Deep work' } }); render(); @@ -283,7 +283,7 @@ describe('UnifiedTimerHome', () => { }); it('Escape cancels click-to-edit without calling labelActivity', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: 'Deep work' } }); render(); @@ -319,7 +319,7 @@ describe('UnifiedTimerHome', () => { }); it('renders the Results panel instead of the timer body after a results_mode stop', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseFeatureFlag.mockImplementation((flag: string) => flag === 'results_mode'); mockGame({ status: 'active', currentActivity: { id: 1, name: 'Deep work' }, elapsed: 30 }); stop.mockResolvedValue({ @@ -358,7 +358,7 @@ describe('UnifiedTimerHome', () => { }); it('auto-selects Planning when the activity name contains "plan"', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); render(); @@ -369,7 +369,7 @@ describe('UnifiedTimerHome', () => { }); it('matches "plan" case-insensitively and as a substring (e.g. "Planning")', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); render(); @@ -379,7 +379,7 @@ describe('UnifiedTimerHome', () => { }); it('does not auto-select Planning for names without "plan"', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); render(); @@ -389,7 +389,7 @@ describe('UnifiedTimerHome', () => { }); it('clicking the Planning chip renders TasksPanel and keeps timer controls visible', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); render(); @@ -401,7 +401,7 @@ describe('UnifiedTimerHome', () => { }); it('switching back to Doing unmounts TasksPanel', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); render(); @@ -413,7 +413,7 @@ describe('UnifiedTimerHome', () => { }); it('timer controls stay functional while in Planning mode', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: '' } }); render(); @@ -429,7 +429,7 @@ describe('UnifiedTimerHome', () => { // `handleWrapperBlur` already commits (same as clicking anywhere else // outside the card) — the mode switch itself doesn't add any extra // reset logic on top of that pre-existing behaviour. - const user = userEvent.setup(); + const user = setupUser(); mockGame({ status: 'active', currentActivity: { name: 'Deep work' } }); render(); @@ -480,7 +480,7 @@ describe('UnifiedTimerHome', () => { }); it('is hidden in Planning mode even with the flag on and a task attached', async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseFeatureFlag.mockReset().mockImplementation((flag: string) => flag === 'notesFeature'); mockGame({ status: 'active', currentActivity: { name: 'Deep work', taskId: 5 } }); render(); diff --git a/frontend/src/layout/Infobar/AchievementBadges.test.tsx b/frontend/src/layout/Infobar/AchievementBadges.test.tsx index 815c93cc..f3b3c7b4 100644 --- a/frontend/src/layout/Infobar/AchievementBadges.test.tsx +++ b/frontend/src/layout/Infobar/AchievementBadges.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { TooltipProvider } from "../../components/Tooltip/Tooltip"; import AchievementBadges from "./AchievementBadges"; @@ -58,7 +58,7 @@ describe("AchievementBadges", () => { }); it("shows a tooltip with the renamed label and remaining progress on focus", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderBadges(); await user.tab(); diff --git a/frontend/src/layout/NavDrawer/NavDrawer.test.tsx b/frontend/src/layout/NavDrawer/NavDrawer.test.tsx index 50fb44fe..b64f0ff8 100644 --- a/frontend/src/layout/NavDrawer/NavDrawer.test.tsx +++ b/frontend/src/layout/NavDrawer/NavDrawer.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { MemoryRouter } from "react-router"; import NavDrawer from "./NavDrawer"; @@ -88,7 +88,7 @@ describe("NavDrawer", () => { }); it("calls onClose when the close button is clicked", async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderDrawer({ drawerOpen: true }); await user.click(screen.getByRole("button", { name: "Close navigation drawer", ...HIDDEN })); @@ -97,7 +97,7 @@ describe("NavDrawer", () => { }); it("calls onClose when the overlay is clicked", async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose, container } = renderDrawer({ drawerOpen: true }); const overlay = container.querySelector('[aria-hidden="true"].visible, [class*="overlay"]'); @@ -108,7 +108,7 @@ describe("NavDrawer", () => { }); it("calls onClose when a nav link is clicked", async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderDrawer({ drawerOpen: true }); await user.click(screen.getByRole("link", { name: /Home/, ...HIDDEN })); @@ -117,7 +117,7 @@ describe("NavDrawer", () => { }); it("calls onClose when Escape is pressed while open", async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderDrawer({ drawerOpen: true }); await user.keyboard("{Escape}"); @@ -126,7 +126,7 @@ describe("NavDrawer", () => { }); it("does not call onClose on Escape when the drawer is closed", async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderDrawer({ drawerOpen: false }); await user.keyboard("{Escape}"); diff --git a/frontend/src/layout/Navbar/Navbar.test.tsx b/frontend/src/layout/Navbar/Navbar.test.tsx index 87611aae..1521a25c 100644 --- a/frontend/src/layout/Navbar/Navbar.test.tsx +++ b/frontend/src/layout/Navbar/Navbar.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { MemoryRouter } from "react-router"; import Navbar from "./Navbar"; @@ -88,7 +88,7 @@ describe("Navbar", () => { }); it("renders the help button when onHelpClick is provided and calls it on click", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseAuth.mockReturnValue({ isAuthenticated: true }); const onHelpClick = vi.fn(); renderNavbar({ onHelpClick }); @@ -107,7 +107,7 @@ describe("Navbar", () => { }); it("calls onMenuClick when the mobile menu button is clicked", async () => { - const user = userEvent.setup(); + const user = setupUser(); const onMenuClick = vi.fn(); renderNavbar({ onMenuClick }); @@ -142,7 +142,7 @@ describe("Navbar", () => { }); it("shows announcements in the popover and marks all read", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseAuth.mockReturnValue({ isAuthenticated: true }); mockUseFeatureFlag.mockReturnValue(true); mockUseAnnouncements.mockReturnValue({ @@ -173,7 +173,7 @@ describe("Navbar", () => { }); it("renders announcement body as markdown", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseAuth.mockReturnValue({ isAuthenticated: true }); mockUseFeatureFlag.mockReturnValue(true); mockUseAnnouncements.mockReturnValue({ @@ -204,7 +204,7 @@ describe("Navbar", () => { }); it("marks a single announcement as read", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockUseAuth.mockReturnValue({ isAuthenticated: true }); mockUseFeatureFlag.mockReturnValue(true); mockUseAnnouncements.mockReturnValue({ diff --git a/frontend/src/pages/Account/Account.test.tsx b/frontend/src/pages/Account/Account.test.tsx index ba3f5dac..f6ab4b1b 100644 --- a/frontend/src/pages/Account/Account.test.tsx +++ b/frontend/src/pages/Account/Account.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { MemoryRouter } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import Account from "./Account"; @@ -180,7 +180,7 @@ describe("Account", () => { }); it("edits the player name inline instead of linking to another page", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderAccount(); @@ -195,7 +195,7 @@ describe("Account", () => { }); it("shows violated name rules and keeps save disabled for invalid input", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderAccount(); @@ -215,7 +215,7 @@ describe("Account", () => { }); it("allows boundary punctuation but rejects too-short names", async () => { - const user = userEvent.setup(); + const user = setupUser(); renderAccount(); diff --git a/frontend/src/pages/Checkout/UpgradePage.test.tsx b/frontend/src/pages/Checkout/UpgradePage.test.tsx index d816cd93..1f2cb67b 100644 --- a/frontend/src/pages/Checkout/UpgradePage.test.tsx +++ b/frontend/src/pages/Checkout/UpgradePage.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import UpgradePage from "./UpgradePage"; @@ -59,7 +59,7 @@ describe("UpgradePage", () => { }); it("submits checkout with the monthly plan", async () => { - const user = userEvent.setup(); + const user = setupUser(); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); mockApiFetch.mockResolvedValue({}); diff --git a/frontend/src/pages/Home/Home.test.tsx b/frontend/src/pages/Home/Home.test.tsx index 1f325fac..bf4aa103 100644 --- a/frontend/src/pages/Home/Home.test.tsx +++ b/frontend/src/pages/Home/Home.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { MemoryRouter } from 'react-router'; import Home from './Home'; @@ -92,7 +92,7 @@ describe('Home', () => { it('shows a clear error when the waitlist form is submitted without an email', async () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, loading: false }); - const user = userEvent.setup(); + const user = setupUser(); renderHome(); @@ -105,7 +105,7 @@ describe('Home', () => { it('shows a clear error when the waitlist form email is invalid', async () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, loading: false }); - const user = userEvent.setup(); + const user = setupUser(); renderHome(); @@ -118,7 +118,7 @@ describe('Home', () => { it('only shows success after the backend confirms the signup', async () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, loading: false }); - const user = userEvent.setup(); + const user = setupUser(); const deferred = createDeferred(); mockRequestWaitlistSignup.mockReturnValue(deferred.promise); @@ -145,7 +145,7 @@ describe('Home', () => { it('shows the backend error instead of a false success message', async () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, loading: false }); - const user = userEvent.setup(); + const user = setupUser(); mockRequestWaitlistSignup.mockResolvedValue({ success: false, errorMessage: 'Unable to join the waitlist right now. Please try again later.', @@ -165,7 +165,7 @@ describe('Home', () => { it('shows the backend success message for pending confirmation signups', async () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, loading: false }); - const user = userEvent.setup(); + const user = setupUser(); mockRequestWaitlistSignup.mockResolvedValue({ success: true, message: 'Check your email to confirm your place on the waitlist.', @@ -192,7 +192,7 @@ describe('Home', () => { data: { waitlist_signup_provider: 'internal' }, isLoading: false, }); - const user = userEvent.setup(); + const user = setupUser(); mockJoinWaitlist.mockResolvedValue({ success: true, message: "You're on the waitlist." }); renderHome(); diff --git a/frontend/src/pages/LibraryPage/LibraryPage.test.tsx b/frontend/src/pages/LibraryPage/LibraryPage.test.tsx index fe92f259..728ba7b3 100644 --- a/frontend/src/pages/LibraryPage/LibraryPage.test.tsx +++ b/frontend/src/pages/LibraryPage/LibraryPage.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import { beforeEach, describe, expect, it, vi } from "vitest"; import LibraryPage from "./LibraryPage"; @@ -43,7 +43,7 @@ describe("LibraryPage", () => { }); it("switches to the Tasks tab, rendering TasksPanel", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("tab", { name: "Tasks" })); @@ -53,7 +53,7 @@ describe("LibraryPage", () => { }); it("switches to the Skills tab, rendering SkillsPanel", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("tab", { name: "Skills" })); @@ -76,7 +76,7 @@ describe("LibraryPage", () => { }); it("still shows the Tasks tab but renders a coming-soon notice", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("tab", { name: "Tasks" })); @@ -86,7 +86,7 @@ describe("LibraryPage", () => { }); it("still shows the Skills tab but renders a coming-soon notice", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("tab", { name: "Skills" })); @@ -96,7 +96,7 @@ describe("LibraryPage", () => { }); it("always renders the Activities tab regardless of flags", async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole("tab", { name: "Activities" })); diff --git a/frontend/src/pages/LoginPage/LoginPage.test.tsx b/frontend/src/pages/LoginPage/LoginPage.test.tsx index 2dda974e..efa0ba39 100644 --- a/frontend/src/pages/LoginPage/LoginPage.test.tsx +++ b/frontend/src/pages/LoginPage/LoginPage.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { MemoryRouter } from "react-router"; import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import LoginPage from "./LoginPage"; @@ -44,7 +44,7 @@ describe("LoginPage", () => { }); it("submits credentials without remember me by default", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockLoginWithJwt.mockResolvedValue({ success: true, tokens: { @@ -68,7 +68,7 @@ describe("LoginPage", () => { }); it("submits remembered sessions when the checkbox is selected", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockLoginWithJwt.mockResolvedValue({ success: true, tokens: { @@ -93,7 +93,7 @@ describe("LoginPage", () => { }); it("shows a login error when JWT login fails", async () => { - const user = userEvent.setup(); + const user = setupUser(); mockLoginWithJwt.mockResolvedValue({ success: false, error: "Invalid email or password; please try again.", diff --git a/frontend/src/pages/PasswordResetConfirmPage/PasswordResetConfirmPage.test.tsx b/frontend/src/pages/PasswordResetConfirmPage/PasswordResetConfirmPage.test.tsx index 9487da23..819f40c1 100644 --- a/frontend/src/pages/PasswordResetConfirmPage/PasswordResetConfirmPage.test.tsx +++ b/frontend/src/pages/PasswordResetConfirmPage/PasswordResetConfirmPage.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { MemoryRouter, Route, Routes } from 'react-router'; import PasswordResetConfirmPage from './PasswordResetConfirmPage'; @@ -28,7 +28,7 @@ describe('PasswordResetConfirmPage', () => { }); it('submits the new password using the parsed reset key', async () => { - const user = userEvent.setup(); + const user = setupUser(); (globalThis.fetch as ReturnType).mockResolvedValue({ ok: true, diff --git a/frontend/src/pages/PasswordResetRequestPage/PasswordResetRequestPage.test.tsx b/frontend/src/pages/PasswordResetRequestPage/PasswordResetRequestPage.test.tsx index 5aa2712b..f7d3a822 100644 --- a/frontend/src/pages/PasswordResetRequestPage/PasswordResetRequestPage.test.tsx +++ b/frontend/src/pages/PasswordResetRequestPage/PasswordResetRequestPage.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '../../testUtils/setupUser'; import { MemoryRouter } from 'react-router'; import PasswordResetRequestPage from './PasswordResetRequestPage'; @@ -12,7 +12,7 @@ describe('PasswordResetRequestPage', () => { }); it('submits the reset request and shows a success message', async () => { - const user = userEvent.setup(); + const user = setupUser(); (globalThis.fetch as ReturnType).mockResolvedValue({ ok: true, diff --git a/frontend/src/pages/RegisterPage/RegisterPage.test.tsx b/frontend/src/pages/RegisterPage/RegisterPage.test.tsx index cf1e493f..700f9da1 100644 --- a/frontend/src/pages/RegisterPage/RegisterPage.test.tsx +++ b/frontend/src/pages/RegisterPage/RegisterPage.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { MemoryRouter, Routes, Route } from "react-router"; import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { setupUser } from '../../testUtils/setupUser'; import RegisterPage from "./RegisterPage"; @@ -43,7 +43,7 @@ function openRegistration(turnstileSiteKey = "site-key-123") { }); } -async function fillForm(user: ReturnType) { +async function fillForm(user: ReturnType) { await user.type(screen.getByLabelText(/^email/i), "new@example.com"); await user.type(screen.getByLabelText(/^password/i), "sup3rs3cret!"); await user.type(screen.getByLabelText(/confirm password/i), "sup3rs3cret!"); @@ -197,7 +197,7 @@ describe("RegisterPage", () => { it("blocks submission until the security check produces a token", async () => { installTurnstile(); openRegistration(); - const user = userEvent.setup(); + const user = setupUser(); renderRegisterPage(); await fillForm(user); @@ -212,7 +212,7 @@ describe("RegisterPage", () => { it("submits with the token once the security check completes", async () => { installTurnstile(); openRegistration(); - const user = userEvent.setup(); + const user = setupUser(); renderRegisterPage(); await fillForm(user); @@ -227,7 +227,7 @@ describe("RegisterPage", () => { it("does not gate submission on a token when no site key is configured", async () => { vi.stubEnv("VITE_TURNSTILE_SITE_KEY", ""); openRegistration(""); - const user = userEvent.setup(); + const user = setupUser(); renderRegisterPage(); expect(screen.queryByTestId("turnstile")).not.toBeInTheDocument(); @@ -252,7 +252,7 @@ describe("RegisterPage", () => { it("requires the terms checkbox before submitting", async () => { installTurnstile(); openRegistration(); - const user = userEvent.setup(); + const user = setupUser(); renderRegisterPage(); await user.type(screen.getByLabelText(/^email/i), "new@example.com"); diff --git a/frontend/src/testUtils/setupUser.ts b/frontend/src/testUtils/setupUser.ts new file mode 100644 index 00000000..ca9b8bbf --- /dev/null +++ b/frontend/src/testUtils/setupUser.ts @@ -0,0 +1,21 @@ +import userEvent, { type Options } from '@testing-library/user-event'; + +/** + * `userEvent.setup()` defaults to the strictest pointer-events check + * (re-validating `pointer-events`/visibility via `getComputedStyle` on every + * interaction), which is real, measured per-interaction cost across a test + * file with many clicks - disabling it cut Map.test.tsx's run time ~18% + * with zero test changes (see + * .claude/plans/frontend-test-speed-restructuring-plan.md). It's also + * already an established workaround here for jsdom not simulating + * hover-revealed `pointer-events: none` elements (see TasksPanel.test.tsx's + * original comment on this). + * + * No test in this codebase currently depends on the strict check itself + * (i.e. asserting that userEvent refuses to interact with a disabled/ + * inert element) - audited before adding this. If a future test needs that + * behavior, pass `{ pointerEventsCheck: }` to override. + */ +export function setupUser(options?: Options) { + return userEvent.setup({ pointerEventsCheck: 0, ...options }); +}