Testing the player
Every piece of new logic in the frontend ships with a unit test. The harness is a standard Jest setup. What matters more is the set of conventions that keep the code testable in the first place.
The harness
- jest-expo preset (Jest 30 runtime). It resolves and transforms React
Native and Expo modules. Config lives in
jest.config.js:moduleNameMappermaps the@/alias tosrc/(and@/assets/toassets/);transformIgnorePatternsre-includes the ESM packages the app imports (expo, react-native-*, uniwind,@rn-primitives/*,@tanstack/*, zustand, …) so they are transpiled instead of failing onimport;testMatchpicks up**/*.test.tsand**/*.test.tsx;collectCoverageFromcoverssrc/**/*.{ts,tsx}but excludessrc/app/**. Screens are intentionally out of coverage scope (see Conventions).
- @testing-library/react-native 14 for component and hook tests. Its
matchers are built in. There is no
@testing-library/jest-nativedependency; don't add one.
Run with npm test; coverage with npm test -- --coverage. npm test first runs
three fast Node checks before jest:
-
node scripts/gen-tokens.mjs --checkfails if the generated colour tokens (the generated region ofsrc/global.css, andsrc/theme/tokens.ts) drifted fromsrc/theme/tokens.json. -
node --test scripts/gen-tokens.test.mjsunit-tests the generator itself, with Node's built-in test runner rather than jest. It covers:- colour parsing and normalising;
- the camelCase names;
- the fixed palette (
parsePalette: plain colours only, no shade families or aliases); - that both themes carry the same keys with valid colours and no clash with the palette;
- the CSS and TS it emits.
-
node scripts/check-styles.cjscompilessrc/global.cssthrough Uniwind's real compiler, with the same options Metro uses (both read the rootuniwind.config.js). It asserts styling guarantees that no unit test can see:- A
dark:utility still has a rule outside every@scope, for dark mode in browsers without CSS@scope. - Native keeps the px letter-spacing scale (
tracking-wideris 0.5 on iOS). - The themed Stacks tokens resolve per theme on iOS, also through an opacity
modifier such as
bg-brand/10, and switch under.darkon web.
If a Uniwind upgrade renames the compiler internals it reads, the check fails loudly. Update the hook; don't delete the guard.
- A
Global setup (jest.setup.ts)
Loaded via setupFilesAfterEnv, it does six things:
- Imports
@/i18nso i18next is initialised with the English catalog. Components usinguseTranslationand the locale-aware formatters resolve real strings under the fallback. - Sets
IS_REACT_ACT_ENVIRONMENT = true. React 19 gatesact(...)support behind this flag, andrenderandrenderHookneed it to flush state updates. Pure-logic suites are unaffected. - Mocks
@react-native-async-storage/async-storagewith an in-memoryMap(getItem/setItem/removeItem/clearas jest fns). - Mocks
expo-secure-storethe same way (getItemAsync/setItemAsync/deleteItemAsync). - Mocks
react-native-reanimatedwith a small self-contained stand-in, because the real module initialises its native Worklets module and throws under Node. In the stand-in:- animations resolve synchronously (timing callbacks fire with
finished: true); Animated.*map to plain RN components;- shared values carry the
getandsetaccessors as well as.value; - the layout-animation builders (
FadeIn,SlideInDown,LinearTransition, …) are chainable no-op stubs; useReducedMotionis ajest.fnreturningfalsethat a test can flip.
- animations resolve synchronously (timing callbacks fire with
- Mocks FlashList v2's measurements
(
@shopify/flash-list/dist/recyclerview/utils/measureLayout). FlashList measures its parent and items natively, which yields nothing under Node, so a list would render no items at all. The mock answers with a fixed 400x900 viewport and 100x100 items. These are the values of the package's ownjestSetup.js, whose FlashList-to-RecyclerView swap no longer matches its exports. Because of this mock, anyShelfRoworCoverGridrenders its first items in a test.
Together these let the storage, session, sync, settings and downloads layers
run unchanged without a device or browser. Nothing else is mocked globally.
fetch, reachability, expo-localization and the rest are mocked per test file
as needed.
Conventions
- Logic stays out of
src/app/**screens. Screens compose hooks and components. Behavior lives insrc/lib,src/api,src/playback,src/downloads,src/storesandsrc/i18n: pure or framework-light modules that get co-located*.test.ts(x)files. This is why the coverage config can exclude screens outright. - Test the seam you changed. A wire-format change needs a test on the frontend and the server side (see cross-repo changes).
- Prefer direct function tests for pure modules. For hooks, note that
renderHookis incompatible with this jest-expo and React 19 setup. The hook tests (for examplesrc/components/account/use-sign-out.test.tsx) instead mount a tiny probe component withrender(...)that calls the hook and exposes its result.
The shared player-store double (src/testing/player-store-mock.ts)
The player store is the hardest dependency to bring into a test: it owns the native engine, the API layer and the download store. Three suites need it without any of that:
src/playback/sleep-timer.test.ts;src/playback/auto-sleep-controller.test.ts;src/components/player/sleep-timer-button.test.tsx.
They share one double rather than three near-copies, so its fidelity is decided in one place.
Use it as the whole mocked module, and pull the same instance back out with
playerStoreMock() to drive it:
jest.mock('@/playback/store', () =>
// `require` (not an import) because a jest.mock factory is hoisted above every import.
require('@/testing/player-store-mock').createPlayerStoreMock(),
);
const player = playerStoreMock();
What it models faithfully, the parts a test may lean on:
- It is a real zustand store, so
subscribe((state, prev) => …), notification order and equality behave exactly as in production. The sleep timer freezes its countdown off a store notification, and the auto sleep controller detects the play edge by comparingstateandprev. Both would be testing a fake otherwise. - It uses the store's real selectors over the stand-in state:
selectBookKey(connectionId:libraryId:path),selectIsPlaying(strictlyplaying),selectIsTransportLive(playingorloading) andselectBookPosition. pause()records the call and then writes the paused snapshot, in that order, so a listener reacting to the write is ordered after the pause as it is in production.setOutputVolumedrops a write that would not change the gain, exactly as the real store does. So the timer's many defensive volume restores do not show up as writes production never makes.
Its deliberate divergences, which a test must not read as production behaviour:
toggle()is a pure spy. It does not synthesise aplayingsnapshot; a test that needs a resume to land writes the snapshot itself (setPlayState). The realtogglegoes through the engine, and faking the outcome would test the double.bookPositionis a plain field, set by the test. The realselectBookPositionderives the whole-book position from the queue's chapter offsets and the engine's per-track position.MockNowPlayingis a subset of the realNowPlaying:connectionId,libraryId,pathandqueue(chapters+total), which is all the selectors and the chapter scan read.subscribeis wrapped so the double can reportsubscriberCount()anddropSubscribers(). Production has no such hook; they exist for the suites that attach and detach a subscription rather than holding one for the process lifetime.patch()writes without notifying. It is for fixture setup, and for the shape of a change that happened while nothing was subscribed. UsesetPlayState()when the notification is the point.- Nothing loads a book. There is no engine, no API and no persistence, so
nowPlaying,bookPositionand the play state are whatever the test sets.
createPlayerStoreMock() runs once per module registry, so a suite that calls
jest.resetModules() must re-require both the mocked module and anything under
test (playerStoreMock() throws rather than hand back a stale instance).
render and fireEvent are async (RNTL 14)
In @testing-library/react-native 14 both render and fireEvent return
promises, and both must be awaited. A missing await does worse than make a
test flaky. A test that fires two un-awaited presses leaves every later
render in that file mounting into a detached tree. Unrelated cases further
down the file then fail with queries that find nothing. That reads as "the
component stopped mounting" rather than as a missing await several tests
earlier.
await render(<TimeStepper value="22:00" onChange={onChange} label="From" />);
await fireEvent.press(screen.getByLabelText(LATER));
Note that the common await act(async () => { render(ui) }) helper does not
await render, because the act callback returns before the render promise settles.
Prefer awaiting render directly; where a mount helper wraps it in act, the
render inside still needs its own await.
Rendering overlays (src/testing/render-overlay.tsx)
The portal-based primitives (Dialog, AlertDialog, Select, Popover, DropdownMenu,
Tooltip) render nothing on native without the root <PortalHost />. Mount them
with mountWithPortal(ui), which renders ui inside a SafeAreaProvider and the
RootInsetsProvider the overlays read their insets from, with a PortalHost
after it, the way the app's root layout does. The positioned
overlays also only appear once they have measured their trigger. React
Native's jest preset stubs measure with a no-op, so the helper answers it with
a fixed box. Await it, and every fireEvent after it (see
render and fireEvent are async).
Rendering covers, shelves and grids
A component that draws a BookCover (every CoverTile, shelf and grid) reaches
into several app-wide modules, so a render test mocks them at the top of the file,
as src/components/library/cover-pieces.test.tsx and
src/components/series/browse-pieces.test.tsx do:
@/api/provider:useOptionalApi(anduseCidwhere the screen reads it), for the cover URL.@/api/hooks:useServerInfo, for thecover_sizesflag. While the flag is unknown, the cover deliberately fetches nothing.@/downloads/store:useDownloadEntry, for the downloaded-copy source and the downloaded flag.@/theme/theme-provider:useTheme.CoverFrame's iOS shadow reads the scheme, and the real provider importsglobal.css.@/lib/layout: a fixeduseLayout(), because tile and grid sizes depend on it.@/components/player/mini-player:useMiniPlayerInset, for a grid's bottom padding.
book-cover.test.tsx replaces expo-image with a host View so it can read the
chosen source and fire its onError (the thumbnail-to-full-art fallback). Screens
that publish into the sub-nav mock @/components/shell/tab-root-nav (or render on a
phone layout, where the sections render in place).
Route-tree tests
src/components/shell/route-tree.test.tsx, route-tree-cold.test.tsx and
route-tree-connect.test.tsx are the regression net for the
routing rules. They drive
expo-router's renderRouter over the real src/app file list.
realRouteTree() (src/testing/route-tree.tsx) walks src/app and stubs every
screen with its route key as text. It swaps each layout for a plain JS navigator
of the same shape: Stack for the root and the tab stacks, and Tabs for the
(app) NativeTabs and the headless web Tabs. The pieces that decide behaviour are
kept real: the root's anchor, the tab stacks' TAB_STACK_SETTINGS and their
tabStackListeners. Moving or renaming a route file therefore changes the tree
under test rather than a hand-copied list.
Between them the suites cover:
- each tab root at its unchanged URL;
- a book pushed from a tab staying in that tab, and back returning there;
- folder drilling inside the Library stack;
JUMP_TOkeeping each tab's stack;- the account screen opening in the tab that pushed it;
- the player as a root modal over the tabs;
- a cold book link owned by Home, with Home underneath and no link params left on
/after back; - onboarding leaving exactly one
(app)under the stack, both throughleaveOnboarding()(dismissTo, notreplace) and through<LeaveOnboarding />at render time.
Harness notes, forced by RNTL 14 and expo-router 58:
- Read the navigation container, not
renderRouter's helpers. RNTL 14 broke the helpersrenderRouterreturns. So the suites read where the router is throughrouteInfo(), which takes the route info fromnavigationRefinsrc/lib/router-ref.tsbecause expo-router 58 removed its global store. They drive the globalrouterinsidenav(fn), an awaitedact, so each navigation commits before the assertion.renderRouteritself returns the async render's promise; await it. - Read a tab's stack, not
canGoBack(). Since expo-router 58 the tab navigator's history keeps the first tab under a cold link into another tab, socanGoBack()is true at a tab root.tabStack(tab),rootRoutes()andstackRoutes(parent)read the route names instead. - The JS
Tabsstand-in declares the five tabs (JsTabs). Since expo-router 58 a tab navigator shows only the screens its layout declares. - A cold start gets its own file. The router's previous segments leak
between renders in one file, which would make a cold deep link look warm. So
the cold-link case and the cold-on-
/connectcase each live in a file of their own.
The chrome itself (the web tab bar, the top bar, the docked and accessory
players) is covered by src/components/shell/shell-chrome.test.tsx, the web
command palette and the profile menu by their own suites.
Mocking fetch
src/api/client.test.ts installs a fake global fetch driven by a per-test
implementation:
function installFetch(impl: (url: string, init: RequestInit) => FetchResult): jest.Mock {
const mock = jest.fn((input: RequestInfo | URL, init?: RequestInit) => {
const { status, body } = impl(String(input), init ?? {});
// …build a minimal Response with ok/status/text()…
});
globalThis.fetch = mock as unknown as typeof globalThis.fetch;
return mock;
}
Assertions then inspect mock.mock.calls for URLs, headers and bodies.
Mocking reachability
Modules that gate on connectivity (progress-sync, the downloads and player
stores) import @/api/reachability; tests replace it wholesale. From
src/playback/progress-sync.test.ts:
// babel-jest hoists jest.mock above the imports, so the module under test sees
// the mock at import time (it gates every save on isReachable(cid)).
jest.mock('@/api/reachability', () => ({
isReachable: jest.fn(() => true),
noteFailure: jest.fn(),
noteSuccess: jest.fn(),
}));
Flipping isReachable per test is how the offline-queue branches are covered.
Flipping Platform.OS
jest-expo defaults Platform.OS to ios. Modules that branch on it at call
time (not at import time) can be covered for both platforms by mutating it.
This is the pattern from src/lib/secure-store.test.ts:
import { Platform } from 'react-native';
// secure-store.ts branches on Platform.OS at call time, so we flip it per suite.
function setPlatform(os: string) {
(Platform as { OS: string }).OS = os;
}
describe('secure-store (web)', () => {
beforeEach(() => setPlatform('web'));
afterEach(() => setPlatform('ios')); // always restore
// …
});
The same trick covers the web and native branches in book-queue (auth headers
on tracks) and in reachability (the browser's online and offline listeners).
Suites that need a real DOM
Name @jest-environment ./src/testing/jsdom-environment.js in the file's docblock,
never plain jsdom. On Jest 30 the stock jsdom environment breaks in two places:
under React Native's jest setup, which redefines window, and when clearing
Expo's lazy globals after a test. The file fixes both and says how.
What's covered today
Co-located suites exist for:
| Area | Tested modules |
|---|---|
| API layer | src/api/client.test.ts, hooks-queue.test.ts, hooks-capability.test.tsx (capability gating, and the gated queries and mutations with their cache updates), connection-clients.test.ts, hooks.test.ts, reachability.test.ts, provider.test.tsx, online-wiring.test.ts (the app's online flag: queries held and resumed, writes never held), server-snapshots.test.ts (the kept /server answer), address-route.test.ts, address-runner.test.ts (the home/away pick, leaving home, the playing book's restart), startup-cache.test.ts (what is kept, restore and drop on a 401, the size cap); src/lib/device-online.test.ts, offline-state.test.ts (the online flag and the offline rule) |
| Playback | src/playback/book-queue.test.ts, progress-sync.test.ts, store.test.ts, service.web.test.ts, sleep-timer.test.ts, auto-sleep.test.ts, auto-sleep-controller.test.ts, rate.test.ts, next-book.test.ts, prettify-title.test.ts, types.test.ts, transcode.test.ts (the web negotiation rules), up-next-resolver.test.ts (what plays next), up-next-sources.test.ts, jump-undo.test.ts, time-left.test.ts, drift.test.ts, drift-controller.test.ts, last-interaction.test.ts, use-shake-to-extend.test.ts (the burst detector and sensitivity tunings), effects.test.ts (where Smart speed and Voice boost apply), time-saved.test.ts, book-source.test.ts (where a book plays and resumes from) |
| Playback: saves, the engine and the bridge | src/playback/place-reconcile.test.ts (a loaded book picks up another device's place), engine-ticks.test.ts (engineTicker, a period read at each check), store-away.test.ts (the save's 15 s and 60 s paces, the device's own record at 15 s, leaving and coming back, the saves that still go at once, what the hook shows off screen), src/lib/app-activity.test.ts, src/lib/visible-store.test.tsx (quietOffScreen: a tick is not shown off screen, every other change is), service.native.test.ts (the native bridge: the newer events, load's book only on a binary that takes it), store-race.test.ts (the bridge created once however many callers race; a remote move while a book loads is ignored) |
| Playback: adopting a book | src/playback/store-adopt.test.ts (adoptLoaded: no engine reload, saves under the adopted book, the floor is the adopted place, the service's book read again. In the engine's file order: the server's chapters in that order, streaming a file the download lacks, else a reload at the same file and in-file place) |
| Playback: one tab at a time (web) | src/playback/playing-claim.test.ts and store-tabs.test.ts (one tab plays at a time, the place the tabs share and the answer of the tab that gave way; two tabs run as two copies of the modules over src/testing/fake-browser.ts), other-tab-notice.test.ts, listening-outbox-tabs.test.ts (an Undo and a send in different tabs, the swept span); src/lib/tab-channel.test.ts (both transports) |
| Downloads | src/downloads/store.test.ts, offline-meta.test.ts, offline-companion.test.tsx (a downloaded book's community data end to end, offline), keep-ahead.test.ts, keep-ahead-controller.test.ts, failure.test.ts, downloads-view.test.ts, use-download-controls.test.ts; src/components/downloads/downloads-screen.test.tsx |
| Car and widgets | src/car/car-model.test.ts (the snapshot, item ids, play specs), car-artwork.test.ts, car-controller.test.ts (writes, play requests, bookmarks, a kept bookmark's file across a re-ordered download, adopting), car-task.test.ts; src/widgets/widget-model.test.ts, widget-sync.ios.test.ts; src/lib/bootstrap.test.ts (the shared launch steps) |
| Config plugins | plugins/withCarPlay.test.ts (the scene manifest, the AppDelegate rewrite over the SDK 58 fixture, the phone scene's RCTLinkingManager scene calls, the default-on entitlement and its AUDIOSILO_CARPLAY=0 opt-out), plugins/withCast.test.ts (the Cast options, the Android expanded controller, both plugins in app.json order over the SDK 58 AppDelegate), plugins/widgets-entitlements.test.ts (no target carries aps-environment), plugins/withWatchScheme.test.ts (finding the watch target, a scheme that builds only it), plugins/build-properties.test.ts (the expo-build-properties options: cleartext http and R8 on Android, React Native from source on iOS) |
| Watches, the phone's side | src/watch/watch-model.test.ts (messages, sent_at, visibility, expiry, the Wear wording), send.test.ts, use-watch.test.tsx; watch-rows.test.ts and watch-ledger.test.ts (the Downloads section's rows and the ledger); src/components/account/watch-card.test.tsx, src/components/downloads/watch-section.test.tsx. The Apple Watch's Core is Swift: npm run test:watch. The Wear OS core is Kotlin: npm run test:wear |
| Cast | src/cast/cast-media.test.ts (the pure casting rules), cast-token.test.ts, cast-prepare.test.ts, cast-service.test.ts, routed-service.test.ts (the route switch), cast-session.test.ts, cast-store.test.ts, cast-button-model.test.ts, cast-button.test.tsx, driver.native.test.ts, driver.web.test.ts (one tab owns a session), sdk-shapes.test.ts, web-sender.test.ts |
| Listening in the house | src/components/house/house-model.test.ts, house-row.test.tsx, in-the-house-card.test.tsx, house-sharing-row.test.tsx; src/components/series/portrait.test.tsx (the avatar and its ring); src/api/hooks-household.test.tsx (gating and polling) |
| Profiles | src/stores/profiles.test.ts, src/lib/profile-pin.test.ts, sha256.test.ts, random.test.ts; src/components/profiles/profiles-model.test.ts, profiles-screen.test.tsx, pin-dialog.test.tsx; src/components/shell/auth-gate.test.tsx, profile-menu.test.tsx |
| Household page and members | src/components/household/household-page.test.tsx, members-section.test.tsx, member-card.test.tsx, household-model.test.ts, use-sign-in-here.test.ts; src/components/connect/add-member-here.test.ts; src/api/hooks-members.test.tsx (gating, what each write refreshes, alsoMembers); src/components/shell/route-tree-household.test.tsx and route-tree-household-cold.test.tsx (the pushed page, the old /settings?section=household link, a cold link); src/api/provider.test.tsx (read again on a new token) |
| Kids mode | src/components/kids/kids-model.test.ts, use-kids.test.tsx, kids-screen.test.tsx, kids-player.test.tsx, grown-ups-button.test.tsx, kids-route-guard.test.tsx, kids-gate.test.tsx; the connect flow's gate in src/components/connect/connect-layout.test.tsx; 1× and the bedtime rule in src/playback/listener-settings.test.ts, the bedtime regressions in src/playback/auto-sleep-controller.test.ts |
| Connection identity | src/lib/connection-id.test.ts, src/lib/identity-migration.test.ts (every store before and after, the abort paths before the plan, forward-only recovery after it (a failed file move or document write finished by the next try, never reversed), resumption, idempotency, the native file moves, the web keeping its files under the legacy id), src/lib/storage-migration.test.ts (readers held while a stored plan is retried, and released after the five tries) |
| Stores | src/stores/session.test.ts, settings.test.ts, series-orderings.test.ts, library-selection.test.ts, search.test.ts |
| Home | src/components/home/home-model.test.ts, previously-on-model.test.ts, previously-on.test.tsx, listening.test.ts, now-card-model.test.ts, now-card.test.tsx, home-screen.test.tsx |
| Upcoming releases | src/lib/upcoming.test.ts (eligibility, the merge and cross-server order, the "New" window), release-date.test.ts, format.test.ts (formatReleaseDate); src/api/hooks-upcoming.test.tsx (no request to a server without the flag or for a kid, dedupe, statuses, failures), client.test.ts; src/components/upcoming/upcoming.test.tsx (the rail, the sections and the page's states, the cover over the ghost with and without upcoming_covers and the ghost after a failed load); src/components/series/series-model.test.ts, series-parts.test.tsx (dated ghosts); src/components/you/you-model.test.ts, you-hub.test.tsx (the Upcoming section); fixtures in src/testing/upcoming-fixtures.ts |
| Upcoming releases: the calendar | src/lib/upcoming-calendar.test.ts (calendarFeedUrl, canSubscribeWebcal, calendarConnections, calendarRowState, the device copy), src/components/upcoming/upcoming-calendar.test.tsx (no link, made, active elsewhere, demo, error, Reset and Turn off confirmed, kids never asked), src/stores/session.test.ts (the copy forgotten on sign-out, a retired identity and the auth reset), src/api/provider.test.tsx (a meta.local query says nothing of the server) |
| Search | src/components/search/search-model.test.ts (the spoiler rules), use-search-sources.test.tsx, search-screen.test.tsx |
| Series and people | src/components/series/series-model.test.ts, spine-fit.test.ts, people-model.test.ts, series-page.test.tsx, browse-pieces.test.tsx, keep-ahead-card.test.tsx |
| Up next | src/components/upnext/up-next-model.test.ts, up-next-store.test.ts, use-up-next.test.tsx, queue-list.test.tsx, up-next-panel.test.tsx, up-next-button.test.tsx |
| Theme | src/theme/scheme-pref.test.ts (the default-theme rule), theme-provider.test.tsx, use-theme-colors.test.tsx |
| i18n | src/i18n/language.test.ts, language-provider.test.tsx (the capped wait for a saved language), locales.test.ts (every catalog names the auto-download setting once), catalogs.test.ts (English alone at start, native and web loaders for the rest, the last switch wins), fell-asleep-notes.test.ts (the note table matches the catalogs) |
| Shell | src/components/shell/destinations.test.ts, shell-chrome.test.tsx, docked-player.test.tsx (dockLayout, the actions, the centred transport, and the floating Undo chip with its band, which gives way to the grace card), accessory-player.test.tsx, top-shell.test.tsx, route-tree-shell-nav.test.tsx (pages from over the player land in the one shell), command-palette.test.tsx, palette-model.test.ts, palette-store.test.ts, profile-menu.test.tsx, shell-metrics.test.ts, phone-header.test.tsx, tab-root-nav.test.tsx, use-shell-effects.test.ts, toast-offset.test.ts, route-tree-mini.test.tsx (one floating mini player under the real native (app) layout), route-tree-you.test.tsx (the You hub as the Me tab's root), root-services-impl.test.tsx (the root's services start in order and stop; the web's RootServices on mount), tab-page-scroll.test.tsx, and the route-tree suites (above); src/components/layout/offline-banner.test.tsx |
| Account and Settings | src/components/account/account-model.test.ts (the pairing countdown, canRevoke, lastSeen), account-section.test.tsx, devices-section.test.tsx, pair-device-card.test.tsx, password-card.test.tsx, connections-section.test.tsx, use-api-keys-manager.test.tsx, use-sign-out.test.tsx; src/components/settings/settings-model.test.ts, settings-content.test.tsx |
| You | src/components/you/you-model.test.ts, you-hub.test.tsx; stats/stats-model.test.ts, stats-format.test.ts, stats-section.test.tsx, weekly-bars.test.tsx, listening-clock.test.tsx; year/year-model.test.ts, year-copy.test.ts, year-params.test.ts, story-model.test.ts, story-stage.test.tsx, use-story-player.test.tsx, use-share-card.test.tsx, share-card.test.ts, share-card.web.test.ts, year-probes.test.ts (findStoryYears), year-section.test.tsx, year-story-screen.test.tsx |
| Connect | src/components/connect/connect-model.test.ts (repairPlan, readyLine, reconnectAddress), finish-connect.test.ts, connect-frame.test.tsx, connect-start.test.tsx, sign-in-step.test.tsx, ready-screen.test.tsx, use-ready-summary.test.tsx, connect-routes.test.tsx, route-ready.test.tsx, cover-patterns.test.ts; src/components/layout/reconnect-banner.test.tsx |
| Player UI | src/components/player/play-failed.test.ts (the start-failure toast's reason), sleep-timer-button.test.tsx, sleep-sheet-model.test.ts, grace-card.test.tsx, speed-button.test.tsx, speed-model.test.ts, effects-model.test.ts, effects-settings.test.tsx, end-credits-logic.test.ts, end-credits.test.tsx, book-ended-listener.test.tsx, seek-bar.test.tsx, seek-texture.test.ts, book-timeline.test.tsx, book-timeline-model.test.ts, transport-controls.test.tsx, undo-chip.test.tsx, use-time-left.test.tsx, player-shortcuts.test.ts, shortcuts-dialog.test.tsx, player-view.test.tsx (the layouts), player-parts.test.tsx, player-sheet-host.test.tsx (one active host), mini-player.test.tsx, companion/companion-model.test.ts, companion/companion-panels.test.tsx, companion/companion.test.tsx, companion/use-companion-data.test.tsx, end-of-book.test.ts, start-book.test.ts, end-credits-route.test.tsx, player-route.test.tsx, player-view-model.test.ts, place-sync.test.ts, playing-target.test.tsx, rating-stars.test.tsx, use-player-shortcuts.test.ts, use-playing-pins.test.ts, use-playing-segment.test.ts, companion/reveal-listener.test.tsx (natural crossings only), book-progress.test.tsx, transport.test.ts (the shared previous/next and chapter-segment math of the full player and the docked bar), use-listening-position.test.tsx, play-route.test.ts (where a play or a jump goes), use-book-rating.test.tsx |
| Library UI | src/components/library/book-meta.test.ts, use-book-community.test.tsx, book-meta.render.test.tsx, book-tabs.test.ts, cover-frame.test.tsx, meta-gating.test.ts, entry-row.test.tsx, history-section.test.tsx, annotation-sections.test.tsx, download-control.test.tsx, transcode-note.test.tsx, book-cover.test.tsx, cover-pieces.test.tsx, cover-layout.test.ts, cover-wash.test.tsx, library-modes.test.ts, library-screen.test.tsx, use-queue-actions.test.tsx, tile-actions.test.tsx, and under books/ (books-view, book-actions, list-columns, use-whole-library), collections/ (collections-model, collection-dialogs) and modes/books-mode; src/components/player/use-play-book.test.tsx; src/components/layout/content-scope.test.tsx |
| Book page | src/components/book/book-page-model.test.ts (layout, primary action, chapter list, facts, Your listening), book-details-model.test.ts, book-about.test.tsx, hero-actions.test.tsx, book-page.test.tsx |
| Bookmarks and notes | src/api/bookmark-labels.test.ts; src/components/annotations/editor-model.test.ts (what Save sends), labels.test.ts, drift-marker.test.ts, use-book-place.test.ts, annotation-editor.test.tsx, annotation-rows.test.tsx; src/components/player/player-sheets.test.ts (editor requests) |
| Journal | src/lib/listening-sessions.test.ts (sessions, days), use-day-label.test.tsx, use-infinite-queries.test.tsx; src/components/journal/merge-model.test.ts, diary-model.test.ts (days, bar colours, drift matching), journal-model.test.ts, use-journal-sources.test.tsx, journal-screen.test.tsx, journal-route-cold.test.tsx, export-collect.test.ts, export-format.test.ts, export-save.test.ts, export-save.web.test.ts, export-actions.test.tsx, use-journal-export.test.tsx |
| UI primitives | src/components/ui/: animated-pressable, badge, breadcrumbs, button, confirm-dialog, cover, cover-image.web (the CORS attribute only cross-origin, onError), dialog, empty-state, icon-data (validates every vendored SVG glyph), input, notice, overlay (root insets, withFlatStyle), overlay-host, popover, progress-bar, progress-ring, row-surface, section-header, select, sheet, skeleton, slider, use-slider-control, switch, tabs, text, time-stepper, toast, touch-target, toggle-group |
src/lib helpers | account, after-paint.web (the capped wait), alpha-sections, app-resume, auth-failure, base-url, batch-loader (windows, chunks by count and size, per-chunk failures), by-position, chapter-label, client-id, clipboard, content-key, cors-mode (corsModeFor), cover-tint, dedup, deferred (the load after paint and its retry), fnv1a, format, hhmm, keyboard, keyboard-lift, known-servers, layout, map-limit, monogram, names, native-intent (app links keep their encoding), network, pairing, paths, progress-view, published, register-sw.web, service-worker (public/sw.js's build precache), rnw-button-fix, root-stack, same-origin, scroll-memory, secure-store, serial-queue, series-orderings, server-address (the pick rule, mergeAddresses), share, storage-migration, support, ticker, use-debounced-value, use-dom-id, use-latest, use-now, utils, when-active |
| Generators (Node, not jest) | scripts/gen-tokens.test.mjs |
The shared test helpers live outside that list, in src/testing/:
- the player-store double (
player-store-mock.ts), described in The shared player-store double; - the native touch-target assertion (
touch-target.ts,expectNativeTarget); mountWithPortal(render-overlay.tsx);- the route-tree harness (
route-tree.tsx); - the
@/api/providerstand-in (api-provider-mock.ts,apiProviderMock); settleFlashList(flash-list.ts);notifyQueriesSynchronously(query-notify.ts);- the stand-in for one browser's tabs (
fake-browser.ts, withtabStoragefor a per-tab@/lib/storage); driveAppState(app-state.ts), which setsAppStateand tells its listeners, for a suite that puts the app in the background.
Not covered by Jest, by design or necessity:
src/app/**screens, which are kept logic-free;- the
'widget'functions insrc/widgets/, which run in the widget extension; - the native module (
modules/audiosilo-player). The native checks cover Swift and Kotlin. For everything with a device or OS in the loop, only a native build on a device, an emulator or the Simulator checks it. That is why its invariants are documented so heavily in Playback.
Native checks
The pure parts of the native module have tests of their own. Everything else needs a build on a device, an emulator, the Android Auto Desktop Head Unit or the iOS Simulator. Jest and the web prove nothing about Swift or Kotlin.
| You changed... | Run... |
|---|---|
Android TimelineMap (in MediaItems.kt) or the effects processors | the Android JVM tests |
iOS VoiceBoostDSP.swift or ChapterClips.swift | the iOS self-check |
The watch's targets/watch/Core/ | npm run test:watch |
The Wear OS app's wear-core/ | npm run test:wear |
The rest of the Wear OS app, the phone's modules/audiosilo-watch/android | the Gradle tests after a prebuild, then the Wear OS emulator (Wear OS) |
The rest of the watch app, the phone's modules/audiosilo-watch | a simulator, then a device for what only a device shows (Apple Watch) |
Android Auto (the service, the browse tree, the MediaItems queue builder and extras, the car snapshot, the car JS) | the DHU |
CarPlay, the UIScene life cycle (withCarPlay.js, the scene delegates) | the CarPlay Simulator |
| Lock screens, the audio effects by ear, widgets, the Live Activity, the token store | a device |
A native dependency added or upgraded, android.extraProguardRules | an Android release build |
Android JVM tests
TimelineMapTest (file to item mapping and back), NarrationSilenceProcessorTest and
VoiceBoostProcessorTest (synthetic PCM from effects/Pcm.kt). No device; after a prebuild:
CI=1 npx expo prebuild -p android --no-install
cd android
./gradlew :audiosilo-player:compileReleaseKotlin :audiosilo-player:testDebugUnitTest
Gradle needs JDK 17; point JAVA_HOME at it if the default is newer.
An Android release build
Release builds run R8 (minify and resource shrinking, see
releasing); debug builds
don't, so only a release build shows a class or resource R8 stripped. run:android reuses
an existing android/ folder without applying app.json again, so prebuild first:
CI=1 npx expo prebuild -p android --no-install
npx expo run:android --variant release # signed with the debug keystore
Then exercise what the dependency does (playback, Cast, Android Auto) and watch
adb logcat for a ClassNotFoundException, NoSuchMethodError, NoSuchFieldError or
Resources$NotFoundException.
The iOS self-check
This needs no Simulator, only the Xcode toolchain. It compiles VoiceBoostDSP.swift and
ChapterClips.swift with SelfCheck/main.swift (excluded from the pod). It checks the
chapter-clip maths and the Voice boost DSP:
- on synthetic signals, the ceiling holds and quiet speech comes up more than 8 dB;
- on speech it makes with
say, quiet, normal and loud must come up by more than 8, 6 and 3 dB.
It prints the measured lifts and exits non-zero on a failure:
modules/audiosilo-player/ios/SelfCheck/run.sh
The watch Core
This needs no Simulator either. SwiftPM builds the Apple Watch app's Foundation-only targets/watch/Core/
through watch-core/ and runs its XCTest on the host. It skips, and passes, without swift, and
CI's watch.yml runs it on a macOS runner. See Apple Watch.
npm run test:watch
The Wear OS core
This needs no emulator. Gradle builds the Wear OS app's pure Kotlin wear-core/ and runs its
JUnit suites on the JVM (JDK 17 or later). It skips, and passes, with no JDK, and CI's wear.yml runs it
before building the app. See Wear OS.
npm run test:wear
Android Auto on the Desktop Head Unit
-
Install the DHU from the Android SDK Manager (SDK Tools > Android Auto Desktop Head Unit Emulator). It lands in
$ANDROID_HOME/extras/google/auto/. -
On the phone, install Android Auto, open its settings and tap Version and permission info ten times to enable developer mode. From the overflow menu, open Developer settings and turn on Unknown sources. Otherwise Auto lists only apps from Google Play, and a dev build is sideloaded. Then choose Start head unit server.
-
Connect the phone by USB and install a dev build. Leave it running, because
run:androidkeeps Metro in the foreground and the debug build loads its JS from it:npx expo run:android -
In a second terminal, start the DHU:
adb forward tcp:5277 tcp:5277cd "$ANDROID_HOME/extras/google/auto" && ./desktop-head-unit
Check:
- the tabs with covers and progress, and no search button;
- a downloaded book started with the app force-stopped (no JS; then JS boots and adopts it);
- a streaming book (JS starts it), and a tap on the playing book (it plays on);
- "Hey Google, play AudioSilo";
- the chapter queue, the skips, the bookmark in the overflow, saves reaching the server;
- the car's play button after a stop (playback resumption);
- on the phone, the lock screen row still shows five actions (
adb shell dumpsys notificationshowsactions=5).
Log lines from System UI's ImageLoader with cover.jpg and ENOENT are harmless. Android's
app data isolation keeps System UI out of the app's files, and System UI falls back to the
session's bitmap.
CarPlay in the iOS Simulator
-
Prebuild (the CarPlay entitlement is on by default;
AUDIOSILO_CARPLAY=0would leave it out):CI=1 LANG=en_US.UTF-8 npx expo prebuild -p ios --clean -
Build for the Simulator (no signing needed there):
xcodebuild -workspace ios/AudioSilo.xcworkspace -scheme AudioSilo -configuration Debug \-destination 'generic/platform=iOS Simulator' -derivedDataPath ios/build-sim \CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= build -
Boot a Simulator, install and launch the app (start Metro with
npx expo startfor the Debug build's JS):xcrun simctl install booted ios/build-sim/Build/Products/Debug-iphonesimulator/AudioSilo.appxcrun simctl launch booted app.audiosilo -
In the Simulator, open I/O > External Displays > CarPlay.
Check:
- the tabs and covers;
- a tap: the spinner, then Now Playing;
- Now Playing's chapter info, elapsed time and rate, which rely on the Simulator-only
playbackState(see Playback); - the Chapters list, and the rate and bookmark buttons;
- a CarPlay-first cold launch (quit the app, then open it from the CarPlay window);
- the pairing deep link, cold and warm, on the phone window (the UIScene life cycle's riskiest regression).
On devices
Nothing else can show these:
- the iOS lock screen's chapter title, chapter scrubber and chapter next/previous;
- a lock-screen scrub back of more than two minutes, then kill the app: it resumes at the scrubbed place (both platforms);
- Smart speed on real narration on Android (no clipped word edges) and its time saved;
- Voice boost by ear, including a toggle mid-book (no dropout on iOS);
- the widget and the sleep timer Live Activity (lock screen and Dynamic Island). The widget extension in a dev build beside the store app needs its own ids, listed after this list;
- a token read with the iPhone locked, after the token has moved to the after-first-unlock store;
- the watch's books arriving from the phone, because
transferUserInfodoes not deliver between paired simulators (Apple Watch); - the watch's background playback with the wrist down, and its background sync;
- everything on the Wear OS list in What the emulator can't show.
A dev build installed beside the store app (say app.audiosilo.dev) must not claim the
store app's ids, and prebuild writes the store's ids into every target. After prebuild,
change:
- in
ios/AudioSilo.xcodeproj/project.pbxproj(Debug and Release), the app target'sPRODUCT_BUNDLE_IDENTIFIERfromapp.audiosilotoapp.audiosilo.devand the extension target's fromapp.audiosilo.widgettoapp.audiosilo.dev.widget, each with aDEVELOPMENT_TEAMbeside it; com.apple.security.application-groupsfromgroup.app.audiosilotogroup.app.audiosilo.devinios/AudioSilo/AudioSilo.entitlementsandios/ExpoWidgetsTarget/ExpoWidgetsTarget.entitlements;ExpoWidgetsAppGroupIdentifier(which expo-widgets reads at runtime) to the same group inios/AudioSilo/Info.plistandios/ExpoWidgetsTarget/Info.plist.
A half-patched build signs but shows an empty widget, so after the build check the app's bundle id and the embedded extension's bundle id and group.
The full gate and CI
Before calling any change done:
npx tsc --noEmit && npm run lint && npm run format && npm test
# after a dependency change, also:
npx expo install --check && node scripts/check-native-dupes.mjs
CI (.github/workflows/ci.yml) gates all of them on every PR and push:
- the Expo SDK pins;
- one copy of each native module;
- typecheck;
- ESLint;
- prettier
--check(theformatscript; fix withnpx prettier --write .); - the Jest suite.
CI reads the Node version from .nvmrc (24.16.0) via node-version-file, and
installs with a frozen npm ci. Keep package-lock.json committed in sync after
dependency changes. See Gates and CI for the
workspace-wide picture.
The gates run on Node with full Intl and no device: they cannot see
Hermes-runtime crashes (see the Hermes Intl caveat),
native-module behavior, CSS and layout regressions, or live-API integration. For
anything touching those seams, verify on the real surface (a device build, the web
export or a running server) before claiming it works.