diff --git a/.github/frontend-hot-path-files.txt b/.github/frontend-hot-path-files.txt
index f5db2c71..d9c4ce2a 100644
--- a/.github/frontend-hot-path-files.txt
+++ b/.github/frontend-hot-path-files.txt
@@ -7,22 +7,26 @@
#
# Soft today (warnings only — the workflow carries `continue-on-error: true`).
# Flip to a hard PR-blocker by removing `continue-on-error` from the
-# `frontend-tests` workflow once a few PRs have run cleanly.
+# `frontend-tests` workflow at the start of M4 in the pre-refactor testing
+# plan (2026-05-11), once Phase 1–3 characterization tests have proven the
+# gate stable on a handful of real PRs.
#
# Curation rule (mirrors the backend list): a file belongs here when its
# hot-path code dominates the file and ≥70 % is a reasonable floor. Files
# that are partially covered today but contain large untested surface area
# (e.g. `playerStore.ts`, the unfinished half of `previewStore.ts`) live
-# OUTSIDE the gate until their tests grow. Add them as Phase 1 coverage
+# OUTSIDE the gate until their tests grow. Add them as Phase 1–4 coverage
# work lands.
#
# Deferred from the gate, with current coverage shown for reference:
-# - src/store/previewStore.ts (33 % — only `_on*` + `stopPreview` covered)
-# - src/store/playerStore.ts (0 % — 3300-LOC monolith, Phase 2 target)
-# - src/utils/dynamicColors.ts (44 % — existing tests miss branches)
-# - src/utils/shareLink.ts (69 % — borderline, ~5 lines short)
+# - src/store/previewStore.ts (33 % — only `_on*` + `stopPreview` covered, Phase 4 target)
+# - src/store/playerStore.ts (0 % — 3700-LOC monolith, Phase 1 target)
+# - src/store/authStore.ts (18 % — Phase 2 target)
+# - src/api/subsonic.ts (Phase 3 target)
# ── utils (already at or above threshold) ────────────────────────────
src/utils/coverArtRegisteredSizes.ts
src/utils/serverDisplayName.ts
src/utils/serverMagicString.ts
+src/utils/shareLink.ts
+src/utils/dynamicColors.ts
diff --git a/src/test/README.md b/src/test/README.md
index 1bcdade6..af637a80 100644
--- a/src/test/README.md
+++ b/src/test/README.md
@@ -1,20 +1,29 @@
# Frontend test framework
Vitest + jsdom + @testing-library/react. Existing util tests in
-`src/utils/*.test.ts` keep working; this folder adds the harness for store,
+`src/utils/*.test.ts` keep working; this folder hosts the harness for store,
hook, component and (eventually) integration tests.
## Layout
```
src/test/
- setup.ts # global setup: jest-dom, @testing-library cleanup,
- # vi.mock for @tauri-apps/api/*
+ setup.ts # global: jest-dom, cleanup, vi.mock for tauri/*,
+ # localStorage polyfill, browser-mock install
mocks/
- tauri.ts # programmable invoke() + listen() helpers
+ tauri.ts # programmable invoke() + listen() helpers,
+ # tauriMockListenerCount(event) for lifecycle tests
+ subsonic.ts # realistic Subsonic fixture data
+ browser.ts # ResizeObserver / IntersectionObserver /
+ # matchMedia / clipboard / object URL mocks
helpers/
- factories.ts # makeTrack / makeTracks fixtures
+ factories.ts # makeTrack / makeTracks / makeSubsonicSong /
+ # makeServer / makeAuthState / makeQueueState
+ storeReset.ts # resetPlayerStore / resetAuthStore /
+ # resetPreviewStore / resetOrbitStore /
+ # resetAllStores
renderWithProviders.tsx # render() wrapped with MemoryRouter + i18n
+ # (en-pinned by default)
README.md # this file
```
@@ -37,11 +46,11 @@ npm run test:coverage # with v8 coverage → ./coverage/
## Mocking Tauri
`@tauri-apps/api/core` and `@tauri-apps/api/event` are mocked globally in
-`setup.ts`. To configure per-test behaviour, import the helpers in
+`setup.ts`. Configure per-test behaviour via the helpers in
`mocks/tauri.ts`:
```ts
-import { onInvoke, emitTauriEvent, invokeMock } from '@/test/mocks/tauri';
+import { onInvoke, emitTauriEvent, invokeMock, tauriMockListenerCount } from '@/test/mocks/tauri';
beforeEach(() => {
onInvoke('audio_play', () => undefined);
@@ -51,27 +60,76 @@ it('responds to engine events', () => {
emitTauriEvent('audio:progress', { id: 't1', currentTime: 42 });
expect(invokeMock).toHaveBeenCalledWith('audio_play', { id: 't1' });
});
+
+it('does not double-register listeners on re-init', () => {
+ // ... call init logic twice, assert listener count is 1
+ expect(tauriMockListenerCount('audio:progress')).toBe(1);
+});
```
Unhandled `invoke()` calls throw a descriptive error — tests are honest about
-which commands they exercise. Handlers are auto-cleared between tests.
+which commands they exercise. Handlers + listeners are auto-cleared between
+tests.
-## Mocking HTTP
+## Mocking Subsonic / HTTP
-For Subsonic / Navidrome / Last.fm calls, prefer **module-level mocks** for
-unit tests:
+Hoist the mock in the test file (vitest limitation — factory functions can't
+import helper modules at hoist time), then inject realistic fixture data
+from `mocks/subsonic.ts`:
```ts
-vi.mock('@/api/subsonic', () => ({
- getAlbum: vi.fn(async () => ({ id: 'a1', tracks: [] })),
- buildStreamUrl: (id: string) => `https://mock/stream/${id}`,
-}));
+import { vi, describe, it, beforeEach, expect } from 'vitest';
+vi.mock('@/api/subsonic');
+import { getAlbum, buildStreamUrl } from '@/api/subsonic';
+import { sampleAlbumWithSongs, mockStreamUrl } from '@/test/mocks/subsonic';
+
+beforeEach(() => {
+ vi.mocked(getAlbum).mockResolvedValue(sampleAlbumWithSongs);
+ vi.mocked(buildStreamUrl).mockImplementation(mockStreamUrl);
+});
```
-For broader integration tests that touch many endpoints we can introduce
+For broader integration tests that touch many endpoints we may introduce
**MSW** later. The framework is intentionally MSW-free right now to keep
the dep surface small until we need it.
+## Resetting stores
+
+Zustand stores are module-level singletons and leak state across tests
+unless explicitly reset. setup.ts already clears localStorage between
+tests, but the in-memory `getState()` snapshot survives.
+
+Use `helpers/storeReset.ts`:
+
+```ts
+import { resetPlayerStore, resetAllStores } from '@/test/helpers/storeReset';
+
+describe('myFeature', () => {
+ beforeEach(resetPlayerStore);
+ // or, for cross-store tests:
+ beforeEach(resetAllStores);
+});
+```
+
+Each reset replaces the live state with the snapshot captured at module
+import time. Action references are preserved (they're closed over the
+original `set`/`get`, stable across `setState`).
+
+## i18n language is pinned to `en`
+
+`renderWithProviders` calls `i18n.changeLanguage('en')` synchronously before
+render, so `getByText('Settings')` finds the English label regardless of
+the developer's local language preference. Tests that want to assert
+against another translation pass `{ language: 'de' }`:
+
+```ts
+renderWithProviders(, { language: 'de' });
+```
+
+Rationale: option 5a from the pre-refactor testing plan (2026-05-11). Without
+a fixed test language, every translation edit risks flipping a green test
+red on a contributor's machine.
+
## Patterns
### Pure utilities
@@ -82,8 +140,7 @@ needed beyond `import { describe, it, expect } from 'vitest'`.
### Zustand stores
- Import the hook, drive it via `useFooStore.getState()`.
-- Reset state in a `beforeEach` — Zustand stores are module-level singletons
- and leak across tests otherwise.
+- Reset state in a `beforeEach` via `storeReset.ts`.
- Stub Tauri side effects via `onInvoke()`.
- Use `emitTauriEvent()` to drive event-driven state transitions.
@@ -92,8 +149,10 @@ See `src/store/previewStore.test.ts` for the reference pattern.
### Components
- `renderWithProviders()` from `helpers/renderWithProviders`.
-- Query by role / label / text first; fall back to `data-testid` only when
- the DOM provides no semantic anchor.
+- Prefer `getByRole({ name: ... })` over `getByText` when a semantic role
+ exists — the role survives translation tweaks and refactors that move
+ labels into different elements.
+- Fall back to `data-testid` only when the DOM provides no semantic anchor.
- Use `userEvent` (not `fireEvent`) for click / type / keyboard, with the
exception of `keydown` on `window` for global shortcut paths.
@@ -114,12 +173,30 @@ wrappers when the hook reads from a provider.
- **react-i18next.** `I18nextProvider` with the real `i18n.ts` instance is
cheap and avoids tests that lie about labels.
+## What to NOT snapshot
+
+- Large rendered trees from `renderWithProviders`. Translation edits, CSS
+ class renames, or unrelated child-component refactors flip the snapshot
+ for reasons unrelated to the unit under test. Assert on specific
+ observable behaviour instead — a button is enabled, an aria-label is
+ present, a class is set.
+- Zustand state snapshots that include action functions. Function identity
+ changes break the snapshot without any behavioural reason.
+
## Coverage gates
- `vitest run --coverage` writes `coverage/coverage-summary.json` which the
hot-path gate consumes.
- `.github/frontend-hot-path-files.txt` lists the files held to ≥70% line
coverage by `scripts/check-frontend-hot-path-coverage.sh`.
-- CI runs both. The gate is currently soft (`continue-on-error: true`) —
- flip to a hard PR-blocker once a few PRs run cleanly. Mirrors the backend
- rust-tests rollout.
+- CI runs both. The gate is soft today (`continue-on-error: true`) —
+ it flips to a hard PR-blocker at the start of M4 in the pre-refactor
+ testing plan. Mirrors the backend rust-tests rollout.
+
+## Process isolation
+
+`vitest.config.ts` pins `pool: 'forks'` + `isolate: true`. Each test file
+runs in its own forked process with a fresh module graph. ~20% slower
+locally than the default thread pool but avoids the fake-timer +
+module-mock + Zustand-global flake class that surfaces around suite-size
+30+.
diff --git a/src/test/helpers/factories.ts b/src/test/helpers/factories.ts
index ef141456..9b12537a 100644
--- a/src/test/helpers/factories.ts
+++ b/src/test/helpers/factories.ts
@@ -6,8 +6,12 @@
* than on assembling boilerplate.
*/
import type { Track } from '@/store/playerStore';
+import type { ServerProfile } from '@/store/authStore';
+import type { SubsonicSong } from '@/api/subsonic';
let trackCounter = 0;
+let songCounter = 0;
+let serverCounter = 0;
export function makeTrack(overrides: Partial