Files
psysonic/src/test
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

* chore(nix): sync npmDepsHash with package-lock.json

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 01:33:34 +02:00
..

Frontend test framework

Vitest + jsdom + @testing-library/react. Existing util tests in 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: jest-dom, cleanup, vi.mock for tauri/*,
                                # localStorage polyfill, browser-mock install
  mocks/
    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 / 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

Running tests

npm test                       # one-shot run
npm run test:watch             # watch mode
npm run test:coverage          # with v8 coverage → ./coverage/
npm run check:css-imports      # only the global stylesheet @import graph (see below)

CSS @import graph

Vitest does not load the full global CSS bundle from main.tsx, so a broken relative @import under src/styles/** can slip past the suite until Vite runs (ENOENT from postcss-import).

After vitest run, npm test and npm run test:coverage run npm run check:css-imports, which executes scripts/check-css-import-graph.mjs and walks the same four root stylesheets as src/main.tsx, resolving only filesystem-relative imports (./…, ../…). Package imports such as @fontsource/... are ignored.

Where tests go

  • Co-located with the unit under test: Foo.tsxFoo.test.tsx, barStore.tsbarStore.test.ts. Mirrors the existing util test layout and avoids a parallel directory tree.
  • Vitest picks them up via include: src/**/*.test.{ts,tsx} in vitest.config.ts.

Mocking Tauri

@tauri-apps/api/core and @tauri-apps/api/event are mocked globally in setup.ts. Configure per-test behaviour via the helpers in mocks/tauri.ts:

import {
  onInvoke, emitTauriEvent, invokeMock, tauriMockListenerCount,
  registerDefaultCoverInvokeHandlers,
} from '@/test/mocks/tauri';

beforeEach(() => {
  // Optional for cover-aware UI suites that don't assert native cache internals.
  registerDefaultCoverInvokeHandlers();
  onInvoke('audio_play', () => undefined);
});

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 + listeners are auto-cleared between tests.

Mocking Subsonic / HTTP

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:

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 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:

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' }:

renderWithProviders(<MyComponent />, { 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

Direct import + assert (see src/utils/dynamicColors.test.ts). No setup needed beyond import { describe, it, expect } from 'vitest'.

Zustand stores

  • Import the hook, drive it via useFooStore.getState().
  • Reset state in a beforeEach via storeReset.ts.
  • Stub Tauri side effects via onInvoke().
  • Use emitTauriEvent() to drive event-driven state transitions.

See src/store/previewStore.test.ts for the reference pattern.

Components

  • renderWithProviders(<MyComponent />) from helpers/renderWithProviders.
  • 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.

See src/components/CoverLightbox.test.tsx.

Hooks

Wrap in renderHook() from @testing-library/react. Provide custom wrappers when the hook reads from a provider.

What to NOT mock

  • Real Zustand stores. The whole point of characterization tests is to exercise the actual state graph. Mock only at the system boundary (Tauri / network / browser APIs).
  • The router. MemoryRouter via renderWithProviders is fine — don't stub useNavigate etc. unless a test specifically inspects navigation.
  • 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 a required PR check — the coverage job in frontend-tests fails when any listed file drops below the floor. 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+.