From 4f9ad07d65d1409adb6da8540c51f44b886fc1f1 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 11 May 2026 21:11:23 +0200 Subject: [PATCH] test(frontend): harness expansion + utility coverage push (F0 + F6) (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(frontend): expand harness for store/component/contract tests - factories: makeSubsonicSong, makeServer, makeAuthState, makeQueueState - storeReset.ts: per-test reset for player/auth/preview/orbit stores - mocks/subsonic.ts: realistic fixtures + stream/cover URL helpers - mocks/browser.ts: ResizeObserver/IntersectionObserver/matchMedia/clipboard/object URLs - mocks/tauri.ts: tauriMockListenerCount for listener-lifecycle regression tests - renderWithProviders: pin i18n language to 'en' by default; { language } opt-out - vitest.config: pool 'forks' + isolate to avoid module-mock + Zustand-global flake - README: documented patterns, store-reset policy, i18n rule, isolation rationale * test(frontend): bump utility coverage + expand hot-path gate serverMagicString: 71→100% (encode/decode rejection branches, clipboard fallback paths). shareLink: 69→97% (all entity kinds, queue trim, orbit decoder, findServerIdForShareUrl). dynamicColors: 44→100% (extractCoverColors DOM paths via Image / canvas / fetch mocks). Gate adds shareLink.ts and dynamicColors.ts — both stable above 95%. Comments updated for the new floor and the M4 hard-gate handoff. --- .github/frontend-hot-path-files.txt | 16 +- src/test/README.md | 123 ++++++++++--- src/test/helpers/factories.ts | 54 ++++++ src/test/helpers/renderWithProviders.tsx | 18 +- src/test/helpers/storeReset.ts | 54 ++++++ src/test/mocks/browser.ts | 114 ++++++++++++ src/test/mocks/subsonic.ts | 101 +++++++++++ src/test/mocks/tauri.ts | 11 ++ src/test/setup.ts | 11 +- src/utils/dynamicColors.dom.test.ts | 216 +++++++++++++++++++++++ src/utils/serverMagicString.test.ts | 93 +++++++++- src/utils/shareLink.test.ts | 207 +++++++++++++++++++++- vitest.config.ts | 7 + 13 files changed, 990 insertions(+), 35 deletions(-) create mode 100644 src/test/helpers/storeReset.ts create mode 100644 src/test/mocks/browser.ts create mode 100644 src/test/mocks/subsonic.ts create mode 100644 src/utils/dynamicColors.dom.test.ts 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 = {}): Track { trackCounter += 1; @@ -28,6 +32,56 @@ export function makeTracks(n: number, overridesFn?: (i: number) => Partial makeTrack(overridesFn?.(i) ?? {})); } +export function makeSubsonicSong(overrides: Partial = {}): SubsonicSong { + songCounter += 1; + const id = overrides.id ?? `song-${songCounter}`; + return { + id, + title: `Song ${songCounter}`, + artist: 'Test Artist', + album: 'Test Album', + albumId: `album-${songCounter}`, + duration: 180, + coverArt: id, + bitRate: 320, + suffix: 'flac', + contentType: 'audio/flac', + size: 8_000_000, + ...overrides, + }; +} + +export function makeServer(overrides: Partial = {}): ServerProfile { + serverCounter += 1; + return { + id: `server-${serverCounter}`, + name: `Test Server ${serverCounter}`, + url: `https://server-${serverCounter}.test`, + username: 'tester', + password: 'pw', + ...overrides, + }; +} + +/** Minimal `useAuthStore.setState(...)` partial — extend per test. */ +export function makeAuthState(opts: { servers?: ServerProfile[]; activeServerId?: string | null } = {}) { + const servers = opts.servers ?? [makeServer()]; + return { + servers, + activeServerId: opts.activeServerId === undefined ? servers[0]?.id ?? null : opts.activeServerId, + }; +} + +/** Minimal `usePlayerStore.setState(...)` partial for queue characterization tests. */ +export function makeQueueState(opts: { queue?: Track[]; currentIndex?: number; currentTrack?: Track | null } = {}) { + const queue = opts.queue ?? makeTracks(3); + const currentIndex = opts.currentIndex ?? 0; + const currentTrack = opts.currentTrack === undefined ? (queue[currentIndex] ?? null) : opts.currentTrack; + return { queue, currentIndex, currentTrack }; +} + export function resetFactoryCounters(): void { trackCounter = 0; + songCounter = 0; + serverCounter = 0; } diff --git a/src/test/helpers/renderWithProviders.tsx b/src/test/helpers/renderWithProviders.tsx index c7502324..3b8a54c6 100644 --- a/src/test/helpers/renderWithProviders.tsx +++ b/src/test/helpers/renderWithProviders.tsx @@ -5,6 +5,12 @@ * * Tests that don't need a specific route can call `renderWithProviders()`. * Tests that need a specific URL pass `{ route: '/album/42' }`. + * + * **i18n language is pinned to `en` by default.** This keeps `getByText` / + * `getByRole({ name })` selectors stable regardless of the developer's local + * language preference. A test that wants to assert against a non-English + * translation can pass `{ language: 'de' }`. See `src/test/README.md` for + * the rationale (rule 5a from the pre-refactor testing plan, 2026-05-11). */ import type { ReactElement, ReactNode } from 'react'; import { render, type RenderOptions, type RenderResult } from '@testing-library/react'; @@ -14,9 +20,15 @@ import i18n from '@/i18n'; interface WrapperOptions { route?: string; + language?: string; } -function makeWrapper({ route = '/' }: WrapperOptions) { +function makeWrapper({ route = '/', language = 'en' }: WrapperOptions) { + if (i18n.language !== language) { + // Translations are bundled inline in `src/i18n.ts`, so changeLanguage + // resolves synchronously — safe to ignore the returned promise. + void i18n.changeLanguage(language); + } return function Wrapper({ children }: { children: ReactNode }) { return ( @@ -30,6 +42,6 @@ export function renderWithProviders( ui: ReactElement, options: WrapperOptions & Omit = {}, ): RenderResult { - const { route, ...rest } = options; - return render(ui, { wrapper: makeWrapper({ route }), ...rest }); + const { route, language, ...rest } = options; + return render(ui, { wrapper: makeWrapper({ route, language }), ...rest }); } diff --git a/src/test/helpers/storeReset.ts b/src/test/helpers/storeReset.ts new file mode 100644 index 00000000..1bbf75cd --- /dev/null +++ b/src/test/helpers/storeReset.ts @@ -0,0 +1,54 @@ +/** + * Per-test Zustand store reset helpers. + * + * 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 — a mutation in + * test A is visible to test B unless we reset. + * + * Strategy: capture each store's initial state at module-import time (when + * persist hydration runs against the empty MemoryStorage polyfill in + * setup.ts, so we get the static defaults). Each reset replaces the live + * state with that captured snapshot, preserving the original action + * references (functions are stable across `setState` since they're closed + * over `set`/`get` from the original factory call). + * + * Usage in a test file: + * + * import { resetPlayerStore, resetAllStores } from '@/test/helpers/storeReset'; + * beforeEach(resetPlayerStore); + * // or for cross-store tests: + * beforeEach(resetAllStores); + */ +import { usePlayerStore } from '@/store/playerStore'; +import { useAuthStore } from '@/store/authStore'; +import { usePreviewStore } from '@/store/previewStore'; +import { useOrbitStore } from '@/store/orbitStore'; + +const INITIAL_PLAYER_STATE = usePlayerStore.getState(); +const INITIAL_AUTH_STATE = useAuthStore.getState(); +const INITIAL_PREVIEW_STATE = usePreviewStore.getState(); +const INITIAL_ORBIT_STATE = useOrbitStore.getState(); + +export function resetPlayerStore(): void { + usePlayerStore.setState(INITIAL_PLAYER_STATE, true); +} + +export function resetAuthStore(): void { + useAuthStore.setState(INITIAL_AUTH_STATE, true); +} + +export function resetPreviewStore(): void { + usePreviewStore.setState(INITIAL_PREVIEW_STATE, true); +} + +export function resetOrbitStore(): void { + useOrbitStore.setState(INITIAL_ORBIT_STATE, true); +} + +export function resetAllStores(): void { + resetPlayerStore(); + resetAuthStore(); + resetPreviewStore(); + resetOrbitStore(); +} diff --git a/src/test/mocks/browser.ts b/src/test/mocks/browser.ts new file mode 100644 index 00000000..469b8602 --- /dev/null +++ b/src/test/mocks/browser.ts @@ -0,0 +1,114 @@ +/** + * Browser API mocks for jsdom. + * + * jsdom ships partial implementations of some Web APIs and stubs others + * entirely. Components that legitimately call `ResizeObserver`, + * `IntersectionObserver`, `matchMedia`, `navigator.clipboard`, or + * `URL.createObjectURL` need test-time implementations to avoid `is not a + * function` failures. + * + * `installBrowserMocks()` is idempotent — calling it twice is harmless. + * It is wired into setup.ts so every test file inherits the mocks; per-test + * overrides remain possible via `vi.spyOn` on the specific mock instance. + */ +import { vi } from 'vitest'; + +// ─── ResizeObserver ────────────────────────────────────────────────────────── +class MockResizeObserver implements ResizeObserver { + observe = vi.fn<(target: Element) => void>(); + unobserve = vi.fn<(target: Element) => void>(); + disconnect = vi.fn<() => void>(); +} + +// ─── IntersectionObserver ──────────────────────────────────────────────────── +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null = null; + readonly rootMargin: string = '0px'; + readonly scrollMargin: string = '0px'; + readonly thresholds: ReadonlyArray = []; + observe = vi.fn<(target: Element) => void>(); + unobserve = vi.fn<(target: Element) => void>(); + disconnect = vi.fn<() => void>(); + takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => []); +} + +// ─── matchMedia ────────────────────────────────────────────────────────────── +function makeMockMediaQueryList(query: string): MediaQueryList { + const listeners = new Set<(e: MediaQueryListEvent) => void>(); + const list = { + matches: false, + media: query, + onchange: null, + addListener: vi.fn((cb: (e: MediaQueryListEvent) => void) => listeners.add(cb)), + removeListener: vi.fn((cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb)), + addEventListener: vi.fn((_type: string, cb: EventListenerOrEventListenerObject) => + listeners.add(cb as (e: MediaQueryListEvent) => void), + ), + removeEventListener: vi.fn((_type: string, cb: EventListenerOrEventListenerObject) => + listeners.delete(cb as (e: MediaQueryListEvent) => void), + ), + dispatchEvent: vi.fn(() => true), + } as unknown as MediaQueryList; + return list; +} + +// ─── Clipboard ─────────────────────────────────────────────────────────────── +let clipboardContents = ''; +const clipboardMock = { + writeText: vi.fn(async (text: string) => { + clipboardContents = String(text); + }), + readText: vi.fn(async () => clipboardContents), +}; + +export function getMockClipboardContents(): string { + return clipboardContents; +} + +// ─── URL.createObjectURL / revokeObjectURL ─────────────────────────────────── +let objectUrlCounter = 0; +const createObjectUrlMock = vi.fn((_blob: Blob | MediaSource) => { + objectUrlCounter += 1; + return `blob:mock://obj-${objectUrlCounter}`; +}); +const revokeObjectUrlMock = vi.fn<(url: string) => void>(); + +// ─── Install ───────────────────────────────────────────────────────────────── +let installed = false; + +export function installBrowserMocks(): void { + if (installed) return; + installed = true; + + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; + + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: vi.fn((query: string) => makeMockMediaQueryList(query)), + }); + Object.defineProperty(window.navigator, 'clipboard', { + configurable: true, + writable: true, + value: clipboardMock, + }); + } + + if (typeof URL !== 'undefined') { + URL.createObjectURL = createObjectUrlMock as unknown as typeof URL.createObjectURL; + URL.revokeObjectURL = revokeObjectUrlMock as unknown as typeof URL.revokeObjectURL; + } +} + +export function resetBrowserMocks(): void { + clipboardContents = ''; + objectUrlCounter = 0; + clipboardMock.writeText.mockClear(); + clipboardMock.readText.mockClear(); + createObjectUrlMock.mockClear(); + revokeObjectUrlMock.mockClear(); +} + +export { clipboardMock, createObjectUrlMock, revokeObjectUrlMock }; diff --git a/src/test/mocks/subsonic.ts b/src/test/mocks/subsonic.ts new file mode 100644 index 00000000..2a15d3ae --- /dev/null +++ b/src/test/mocks/subsonic.ts @@ -0,0 +1,101 @@ +/** + * Subsonic API mock fixtures. + * + * `vi.mock('@/api/subsonic')` is hoisted in the test file itself (vitest + * limitation — factory functions can't reach helper modules at hoist time). + * This module supplies the *data* the test injects into those mocks: + * + * // In the test: + * 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); + * }); + * + * Realistic shape matters more than perfect coverage — these fixtures + * mirror what Navidrome actually returns for common queries. + */ +import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist } from '@/api/subsonic'; +import { makeSubsonicSong } from '@/test/helpers/factories'; + +export const sampleSubsonicSong: SubsonicSong = makeSubsonicSong({ + id: 'song-1', + title: 'Sample Song', + artist: 'Sample Artist', + album: 'Sample Album', + albumId: 'album-1', + artistId: 'artist-1', +}); + +export const sampleEmptyAlbum: SubsonicAlbum = { + id: 'album-empty', + name: 'Empty Album', + artist: 'Sample Artist', + artistId: 'artist-1', + songCount: 0, + duration: 0, +}; + +export const sampleAlbumWithSong: SubsonicAlbum & { song: SubsonicSong } = { + id: 'album-single', + name: 'Single-Song Album', + artist: 'Sample Artist', + artistId: 'artist-1', + coverArt: 'album-single', + songCount: 1, + duration: 180, + year: 2024, + song: sampleSubsonicSong, +}; + +export const sampleAlbumWithSongs: SubsonicAlbum & { song: SubsonicSong[] } = { + id: 'album-multi', + name: 'Multi-Track Album', + artist: 'Sample Artist', + artistId: 'artist-1', + coverArt: 'album-multi', + songCount: 3, + duration: 540, + year: 2024, + genre: 'Rock', + song: [ + makeSubsonicSong({ id: 'song-m1', title: 'Track 1', album: 'Multi-Track Album', albumId: 'album-multi', track: 1 }), + makeSubsonicSong({ id: 'song-m2', title: 'Track 2', album: 'Multi-Track Album', albumId: 'album-multi', track: 2 }), + makeSubsonicSong({ id: 'song-m3', title: 'Track 3', album: 'Multi-Track Album', albumId: 'album-multi', track: 3 }), + ], +}; + +export const samplePlaylist: SubsonicPlaylist = { + id: 'pl-1', + name: 'Sample Playlist', + songCount: 2, + duration: 360, + created: '2026-01-01T00:00:00Z', + changed: '2026-01-15T00:00:00Z', + owner: 'tester', + public: false, + coverArt: 'pl-1', +}; + +export function mockStreamUrl(id: string): string { + return `https://mock.subsonic.test/rest/stream.view?id=${encodeURIComponent(id)}`; +} + +export function mockCoverArtUrl(id: string, size = 256): string { + return `https://mock.subsonic.test/rest/getCoverArt.view?id=${encodeURIComponent(id)}&size=${size}`; +} + +export function mockCoverArtCacheKey(id: string, size = 256): string { + return `mock-server:cover:${id}:${size}`; +} + +/** Generic Subsonic error shape for tests asserting failure paths. */ +export class MockSubsonicError extends Error { + constructor(message: string, public readonly code = 40) { + super(message); + this.name = 'MockSubsonicError'; + } +} diff --git a/src/test/mocks/tauri.ts b/src/test/mocks/tauri.ts index 5621fe8f..ae14bb50 100644 --- a/src/test/mocks/tauri.ts +++ b/src/test/mocks/tauri.ts @@ -69,6 +69,17 @@ export function emitTauriEvent(event: string, payload: unknown): void { for (const cb of eventListeners.get(event) ?? []) cb(payload); } +/** + * How many `listen()` callbacks are currently registered for ``. + * + * Use for regression tests of listener lifecycle — e.g. re-initializing a + * store should not double-register `audio:progress` handlers. See + * `feedback_global_shortcut_double_fire` for the canonical motivating bug. + */ +export function tauriMockListenerCount(event: string): number { + return eventListeners.get(event)?.length ?? 0; +} + /** Clear all handlers + listeners + call counts. Wired to `beforeEach` below. */ export function resetTauriMocks(): void { invokeHandlers.clear(); diff --git a/src/test/setup.ts b/src/test/setup.ts index 8eb23293..d7aacade 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,7 @@ import '@testing-library/jest-dom/vitest'; -import { afterEach, vi } from 'vitest'; +import { afterEach, beforeEach, vi } from 'vitest'; import { cleanup } from '@testing-library/react'; +import { installBrowserMocks, resetBrowserMocks } from './mocks/browser'; // ───────────────────────────────────────────────────────────────────────────── // Node 25 ships a native `localStorage` global that is broken on this @@ -65,6 +66,10 @@ function installStorage(globalKey: 'localStorage' | 'sessionStorage', store: Sto installStorage('localStorage', memLocal); installStorage('sessionStorage', memSession); +// Install jsdom-gap browser API mocks (ResizeObserver / IntersectionObserver / +// matchMedia / clipboard / object URLs) so components don't crash on import. +installBrowserMocks(); + // ───────────────────────────────────────────────────────────────────────────── // Global Tauri mocks. // @@ -93,6 +98,10 @@ vi.mock('@tauri-apps/plugin-shell', () => ({ open: vi.fn(async () => {}), })); +beforeEach(() => { + resetBrowserMocks(); +}); + afterEach(() => { cleanup(); memLocal.clear(); diff --git a/src/utils/dynamicColors.dom.test.ts b/src/utils/dynamicColors.dom.test.ts new file mode 100644 index 00000000..94631fc7 --- /dev/null +++ b/src/utils/dynamicColors.dom.test.ts @@ -0,0 +1,216 @@ +/** + * DOM-touching tests for `dynamicColors.ts` — orchestrator paths in + * `extractCoverColors` that exercise the Image / canvas / fetch surfaces. + * + * jsdom ships HTMLImageElement but does not fire onload/onerror; canvas + * `getContext('2d')` returns null. We swap in lightweight mocks so the + * orchestrator's branch logic gets covered without a real browser. + * + * Pure-math helpers live in `dynamicColors.test.ts`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { extractCoverColors } from './dynamicColors'; + +type ImageBehavior = 'load' | 'error'; + +interface MockImageHandle { + setBehavior(b: ImageBehavior): void; +} + +function installImageMock(): MockImageHandle { + let behavior: ImageBehavior = 'load'; + const original = globalThis.Image; + + class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + crossOrigin = ''; + private _src = ''; + get src() { return this._src; } + set src(v: string) { + this._src = v; + queueMicrotask(() => { + if (behavior === 'load') this.onload?.(); + else this.onerror?.(); + }); + } + } + + globalThis.Image = MockImage as unknown as typeof Image; + return { + setBehavior(b: ImageBehavior) { behavior = b; }, + [Symbol.dispose]: () => { globalThis.Image = original; }, + } as MockImageHandle; +} + +function installCanvasContextMock(opts: { tainted?: boolean } = {}): () => void { + const originalGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, _id: string) { + if (opts.tainted) { + return { + drawImage: vi.fn(), + getImageData: vi.fn(() => { + throw new Error('tainted canvas'); + }), + } as unknown as CanvasRenderingContext2D; + } + // Return a context with a fixed 8x8 image where every pixel is a vibrant + // orange — the saturation pick will land on it, exercising the + // sampleImageToAccent loop. + const data = new Uint8ClampedArray(8 * 8 * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = 230; // R + data[i + 1] = 110; // G + data[i + 2] = 30; // B + data[i + 3] = 255; // A + } + return { + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data, width: 8, height: 8 })), + } as unknown as CanvasRenderingContext2D; + } as typeof HTMLCanvasElement.prototype.getContext; + return () => { + HTMLCanvasElement.prototype.getContext = originalGetContext; + }; +} + +describe('extractCoverColors — early returns', () => { + it('returns empty for an empty URL', async () => { + expect(await extractCoverColors('')).toEqual({ accent: '' }); + }); + + it('returns empty for the bundled logo (avoids self-tinting)', async () => { + expect(await extractCoverColors('/assets/logo-psysonic-256.png')).toEqual({ accent: '' }); + }); +}); + +describe('extractCoverColors — blob: URL', () => { + let imageMock: MockImageHandle; + let restoreCanvas: () => void; + + beforeEach(() => { + imageMock = installImageMock(); + restoreCanvas = installCanvasContextMock(); + }); + + afterEach(() => { + (imageMock as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + restoreCanvas(); + }); + + it('returns an accent on a successful sample', async () => { + imageMock.setBehavior('load'); + const result = await extractCoverColors('blob:fake/abc-123'); + expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + }); + + it('returns empty when image load fails', async () => { + imageMock.setBehavior('error'); + const result = await extractCoverColors('blob:fake/oops'); + expect(result).toEqual({ accent: '' }); + }); + + it('returns empty when the canvas is tainted', async () => { + imageMock.setBehavior('load'); + restoreCanvas(); // swap to tainted canvas + restoreCanvas = installCanvasContextMock({ tainted: true }); + const result = await extractCoverColors('blob:fake/tainted'); + expect(result).toEqual({ accent: '' }); + }); + + it('returns empty when getContext returns null', async () => { + imageMock.setBehavior('load'); + const originalGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = (() => null) as typeof HTMLCanvasElement.prototype.getContext; + try { + const result = await extractCoverColors('blob:fake/no-ctx'); + expect(result).toEqual({ accent: '' }); + } finally { + HTMLCanvasElement.prototype.getContext = originalGetContext; + } + }); +}); + +describe('extractCoverColors — remote http(s) URL', () => { + let imageMock: MockImageHandle; + let restoreCanvas: () => void; + let fetchMock: ReturnType; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + imageMock = installImageMock(); + restoreCanvas = installCanvasContextMock(); + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + }); + + afterEach(() => { + (imageMock as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + restoreCanvas(); + globalThis.fetch = originalFetch; + }); + + it('fetches the blob, samples, and revokes the object URL', async () => { + imageMock.setBehavior('load'); + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }); + fetchMock.mockResolvedValue({ ok: true, blob: async () => blob } as Response); + + const result = await extractCoverColors('https://music.example.com/cover.png'); + + expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + expect(fetchMock).toHaveBeenCalledWith('https://music.example.com/cover.png'); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it('falls back to crossOrigin=anonymous when fetch fails', async () => { + imageMock.setBehavior('load'); + fetchMock.mockRejectedValue(new Error('CORS blocked')); + + const result = await extractCoverColors('https://music.example.com/cover.png'); + // Anonymous-CORS image load succeeds in the mock → still samples. + expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + }); + + it('falls back to crossOrigin=anonymous when fetch returns !ok', async () => { + imageMock.setBehavior('load'); + fetchMock.mockResolvedValue({ ok: false, blob: async () => new Blob() } as Response); + + const result = await extractCoverColors('https://music.example.com/cover.png'); + expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + }); + + it('returns empty when both fetch and the anonymous fallback fail', async () => { + imageMock.setBehavior('error'); + fetchMock.mockRejectedValue(new Error('network down')); + + const result = await extractCoverColors('https://music.example.com/cover.png'); + expect(result).toEqual({ accent: '' }); + }); +}); + +describe('extractCoverColors — other URL shapes', () => { + let imageMock: MockImageHandle; + let restoreCanvas: () => void; + + beforeEach(() => { + imageMock = installImageMock(); + restoreCanvas = installCanvasContextMock(); + }); + + afterEach(() => { + (imageMock as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + restoreCanvas(); + }); + + it('handles a data: URL through the same blob-or-data path', async () => { + imageMock.setBehavior('load'); + const result = await extractCoverColors('data:image/png;base64,iVBORw0KGgo='); + expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + }); + + it('handles a relative path as a direct image load', async () => { + imageMock.setBehavior('load'); + const result = await extractCoverColors('/local/cover.jpg'); + expect(result.accent).toMatch(/^rgb\(\d+,\d+,\d+\)$/); + }); +}); diff --git a/src/utils/serverMagicString.test.ts b/src/utils/serverMagicString.test.ts index 3e83be09..02f16355 100644 --- a/src/utils/serverMagicString.test.ts +++ b/src/utils/serverMagicString.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SERVER_MAGIC_STRING_PREFIX, DECODED_PASSWORD_VISUAL_MASK, + copyTextToClipboard, decodeServerMagicString, decodeServerMagicStringFromText, encodeServerMagicString, @@ -36,12 +37,55 @@ describe('serverMagicString', () => { expect(decodeServerMagicString(encoded)).toEqual(original); }); + it('drops a name that becomes empty after trim', () => { + const encoded = encodeServerMagicString({ + url: 'https://x.example', + username: 'u', + password: 'p', + name: ' ', + }); + const decoded = decodeServerMagicString(encoded); + expect(decoded?.name).toBeUndefined(); + }); + it('rejects invalid input', () => { expect(decodeServerMagicString('')).toBeNull(); expect(decodeServerMagicString('nope')).toBeNull(); expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX}%%%`)).toBeNull(); }); + it('rejects an empty payload after the prefix', () => { + expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX)).toBeNull(); + expect(decodeServerMagicString(`${SERVER_MAGIC_STRING_PREFIX} `)).toBeNull(); + }); + + it('rejects a payload that is not JSON', () => { + // valid base64url of "not-json" → JSON.parse throws + const garbage = `${SERVER_MAGIC_STRING_PREFIX}bm90LWpzb24`; + expect(decodeServerMagicString(garbage)).toBeNull(); + }); + + it('rejects a payload with the wrong version', () => { + const wrongVersion = btoa(JSON.stringify({ v: 2, url: 'https://x', u: 'u', w: 'p' })) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + wrongVersion)).toBeNull(); + }); + + it('rejects a payload missing url or username', () => { + const noUrl = btoa(JSON.stringify({ v: 1, url: '', u: 'u', w: 'p' })) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + noUrl)).toBeNull(); + const noUser = btoa(JSON.stringify({ v: 1, url: 'https://x', u: '', w: 'p' })) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + noUser)).toBeNull(); + }); + + it('rejects a payload where url/username are not strings', () => { + const wrongTypes = btoa(JSON.stringify({ v: 1, url: 42, u: ['a'], w: 'p' })) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeServerMagicString(SERVER_MAGIC_STRING_PREFIX + wrongTypes)).toBeNull(); + }); + it('decodes invite embedded in surrounding text', () => { const original = { url: 'https://music.example.com', @@ -52,4 +96,51 @@ describe('serverMagicString', () => { expect(decodeServerMagicStringFromText(`Copy:\n${line}\nThanks`)).toEqual(original); expect(decodeServerMagicStringFromText('no token')).toBeNull(); }); + + it('rejects text that contains only the bare prefix', () => { + expect(decodeServerMagicStringFromText(`prefix only: ${SERVER_MAGIC_STRING_PREFIX} done`)).toBeNull(); + }); +}); + +describe('copyTextToClipboard', () => { + const originalExecCommand = document.execCommand; + + beforeEach(() => { + // setup.ts already installs a clipboard mock — start each test fresh. + vi.mocked(navigator.clipboard.writeText).mockResolvedValue(); + }); + + afterEach(() => { + document.execCommand = originalExecCommand; + }); + + it('uses the modern clipboard API on success', async () => { + const ok = await copyTextToClipboard('hello'); + expect(ok).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello'); + }); + + it('falls back to execCommand("copy") when clipboard API rejects', async () => { + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked')); + document.execCommand = vi.fn(() => true) as unknown as typeof document.execCommand; + const ok = await copyTextToClipboard('fallback-text'); + expect(ok).toBe(true); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); + + it('returns false when both clipboard API and execCommand fail', async () => { + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked')); + document.execCommand = vi.fn(() => { + throw new Error('not allowed'); + }) as unknown as typeof document.execCommand; + const ok = await copyTextToClipboard('x'); + expect(ok).toBe(false); + }); + + it('returns the result of execCommand even when it returns false', async () => { + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked')); + document.execCommand = vi.fn(() => false) as unknown as typeof document.execCommand; + const ok = await copyTextToClipboard('x'); + expect(ok).toBe(false); + }); }); diff --git a/src/utils/shareLink.test.ts b/src/utils/shareLink.test.ts index 07934677..153f27aa 100644 --- a/src/utils/shareLink.test.ts +++ b/src/utils/shareLink.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + PSYSONIC_SHARE_PREFIX, + decodeOrbitSharePayloadFromText, decodeSharePayloadFromText, encodeSharePayload, - PSYSONIC_SHARE_PREFIX, + findServerIdForShareUrl, + normalizeShareServerUrl, } from './shareLink'; import { decodeServerMagicString, encodeServerMagicString, SERVER_MAGIC_STRING_PREFIX } from './serverMagicString'; +import { makeServer } from '@/test/helpers/factories'; describe('shareLink vs serverMagicString', () => { it('uses the same psysonic* prefix family as server invites (distinct digit)', () => { @@ -63,3 +67,204 @@ describe('shareLink vs serverMagicString', () => { expect(decodeServerMagicString(encoded)).toBeNull(); }); }); + +describe('normalizeShareServerUrl', () => { + it('returns empty string for whitespace input', () => { + expect(normalizeShareServerUrl(' ')).toBe(''); + expect(normalizeShareServerUrl('')).toBe(''); + }); + + it('strips trailing slashes', () => { + expect(normalizeShareServerUrl('https://x.example/')).toBe('https://x.example'); + expect(normalizeShareServerUrl('https://x.example')).toBe('https://x.example'); + }); + + it('prepends http:// when no scheme is given', () => { + expect(normalizeShareServerUrl('192.168.1.10:4533')).toBe('http://192.168.1.10:4533'); + expect(normalizeShareServerUrl('music.local')).toBe('http://music.local'); + }); + + it('leaves https URLs unchanged (modulo trailing slash)', () => { + expect(normalizeShareServerUrl('https://music.example.com')).toBe('https://music.example.com'); + }); +}); + +describe('encodeSharePayload — entity kinds', () => { + it('round-trips a track share', () => { + const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'track', id: 't-1' }); + expect(decodeSharePayloadFromText(encoded)).toEqual({ + srv: 'https://x.example', + k: 'track', + id: 't-1', + }); + }); + + it('round-trips an artist share', () => { + const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'artist', id: 'ar-1' }); + expect(decodeSharePayloadFromText(encoded)).toEqual({ + srv: 'https://x.example', + k: 'artist', + id: 'ar-1', + }); + }); + + it('round-trips a composer share', () => { + const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'composer', id: 'co-1' }); + expect(decodeSharePayloadFromText(encoded)).toEqual({ + srv: 'https://x.example', + k: 'composer', + id: 'co-1', + }); + }); + + it('trims whitespace in queue ids and drops empty ones', () => { + const encoded = encodeSharePayload({ + srv: 'https://x.example', + k: 'queue', + ids: [' a ', '', 'b', ' '], + }); + expect(decodeSharePayloadFromText(encoded)).toEqual({ + srv: 'https://x.example', + k: 'queue', + ids: ['a', 'b'], + }); + }); +}); + +describe('decodeSharePayloadFromText — rejection paths', () => { + it('rejects text with no prefix', () => { + expect(decodeSharePayloadFromText('just text')).toBeNull(); + }); + + it('rejects bare prefix with no token', () => { + expect(decodeSharePayloadFromText(`a ${PSYSONIC_SHARE_PREFIX} b`)).toBeNull(); + }); + + it('rejects a payload with the wrong version', () => { + const body = JSON.stringify({ v: 2, srv: 'https://x.example', k: 'track', id: 't' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects a payload with non-string srv', () => { + const body = JSON.stringify({ v: 1, srv: 42, k: 'track', id: 't' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects an unknown entity kind', () => { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'playlist', id: 'pl-1' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects an entity payload with an empty id', () => { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'track', id: ' ' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects a queue payload with no ids', () => { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'queue', ids: [] }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects a queue payload where ids is not an array', () => { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'queue', ids: 'a,b' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects a queue payload whose ids are all whitespace', () => { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'queue', ids: [' ', ''] }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects malformed base64', () => { + expect(decodeSharePayloadFromText(`${PSYSONIC_SHARE_PREFIX}!!!notbase64!!!`)).toBeNull(); + }); + + it('refuses to surface an orbit payload via the entity decoder', () => { + const orbit = encodeSharePayload({ srv: 'https://x.example', k: 'orbit', sid: 'abcd1234' }); + expect(decodeSharePayloadFromText(orbit)).toBeNull(); + }); +}); + +describe('decodeOrbitSharePayloadFromText', () => { + it('round-trips an orbit invite', () => { + const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'orbit', sid: 'abcd1234' }); + expect(decodeOrbitSharePayloadFromText(`come to orbit: ${encoded}`)).toEqual({ + srv: 'https://x.example', + k: 'orbit', + sid: 'abcd1234', + }); + }); + + it('lowercases the session id', () => { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'orbit', sid: 'ABCD1234' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toEqual({ + srv: 'https://x.example', + k: 'orbit', + sid: 'abcd1234', + }); + }); + + it('rejects text with no prefix', () => { + expect(decodeOrbitSharePayloadFromText('hello')).toBeNull(); + }); + + it('rejects bare prefix with no token', () => { + expect(decodeOrbitSharePayloadFromText(`a ${PSYSONIC_SHARE_PREFIX} b`)).toBeNull(); + }); + + it('rejects a non-orbit payload kind', () => { + const encoded = encodeSharePayload({ srv: 'https://x.example', k: 'track', id: 't' }); + expect(decodeOrbitSharePayloadFromText(encoded)).toBeNull(); + }); + + it('rejects an invalid version', () => { + const body = JSON.stringify({ v: 9, srv: 'https://x.example', k: 'orbit', sid: 'abcd1234' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects an empty server', () => { + const body = JSON.stringify({ v: 1, srv: '', k: 'orbit', sid: 'abcd1234' }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + }); + + it('rejects a session id that is not 8 hex characters', () => { + const sids = ['', 'abcd', 'abcd1234e', 'zzzz1234']; + for (const sid of sids) { + const body = JSON.stringify({ v: 1, srv: 'https://x.example', k: 'orbit', sid }); + const b64 = btoa(body).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(decodeOrbitSharePayloadFromText(PSYSONIC_SHARE_PREFIX + b64)).toBeNull(); + } + }); + + it('rejects malformed base64', () => { + expect(decodeOrbitSharePayloadFromText(`${PSYSONIC_SHARE_PREFIX}!!!`)).toBeNull(); + }); +}); + +describe('findServerIdForShareUrl', () => { + it('matches by normalized URL', () => { + const a = makeServer({ id: 'a', url: 'https://music.example.com/' }); + const b = makeServer({ id: 'b', url: 'http://other.local' }); + expect(findServerIdForShareUrl([a, b], 'https://music.example.com')).toBe('a'); + expect(findServerIdForShareUrl([a, b], 'http://other.local/')).toBe('b'); + }); + + it('returns null when no server matches', () => { + const a = makeServer({ id: 'a', url: 'https://music.example.com' }); + expect(findServerIdForShareUrl([a], 'https://elsewhere.example')).toBeNull(); + }); + + it('returns null on an empty server list', () => { + expect(findServerIdForShareUrl([], 'https://x.example')).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 03dc5b21..1466f598 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,13 @@ export default defineConfig({ 'dist/**', 'src-tauri/**', ], + // Process isolation. Fake timers + module-level mocks + Zustand globals + // collide unpredictably across files inside a shared worker — forks + + // isolate gives each file a fresh module graph. ~20% slower locally, + // worth it well before the suite hits 30 files. See the pre-refactor + // testing plan (2026-05-11) §3 for the decision context. + pool: 'forks', + isolate: true, coverage: { provider: 'v8', reporter: ['text', 'json', 'json-summary', 'html', 'lcov'],