test: one place for fixtures, real providers instead of mocked context modules

Specs had no shared vocabulary for the app's data or its contexts, so each one
rebuilt both.  A settings object was written out by hand wherever one was needed,
twenty-odd keys at a time, and nothing held those copies to each other or to the
API.  Contexts were worse: to render a consumer a spec mocked the whole context
module, which replaces every export including the providers, so each spec
reinvented the pieces it still needed.  One supplies a `Media` that always
reports desktop -- quietly leaving that component's mobile branch untested -- and
one hand-writes a context as a bare `_currentValue` with no provider, working only
because useContext reads that React internal.

test-fixtures.js is now the single definition of both, shared by jest, the
Cypress component specs, and e2e intercept bodies, so the three cannot disagree
about what the API returns.  Every fixture is a function taking overrides; a
shared mutable object lets one test change what the next one sees.

The drift this prevents was already real.  The theme fixture supplied `i`, `s`
and `t` holding hardcoded Semantic greys for months after ThemeProvider stopped
supplying them, and ThemeContext still declared all four.  So the fixture agreed
with the tests, and with nothing else: FileBrowser was reading `inverted` off the
context and pasting the resulting `undefined` into a className, which is why the
file browser's footer -- near-white #f9fafb, with a `.inverted` dark variant that
nothing could select any more -- stayed light in dark, night and amber.  A white
bar pinned to the bottom of the screen in night mode costs the user exactly the
dark adaptation the theme exists to protect.  It takes tokens now.

test-fixtures.test.js holds each fixture to its context's declared shape in both
directions, so a property added to one and forgotten in the other fails a test.

Two failed attempts are recorded in test-utils.js because both were instructive.
Importing Tags.js and FileWorkerStatusContext.js so the harness could supply
those providers broke eight suites: it put both into the module graph of every
spec ahead of that spec's own mocks, and components bound to a second instance of
a mocked module -- a different jest.fn() than the test had configured, so a hook
mocked to return a value returned undefined, in a file no test mentions.
Requiring them lazily fixed that and not its mirror image: useContext read a
different TagsContext than the harness had required, so the provider was ignored
and the context default won.  A harness must not change what a spec imports, so
it owns only contexts/contexts.js and a spec passes any other context itself.

DomainTagging is converted off the `_currentValue` fake as proof, and now asserts
the tag name a user sees rather than a test id its own mock invented.  The five
remaining specs are listed in a ratchet so the count can only fall.

974 jest, 15 browser component, 46 e2e, clean cache-busted build, tsc clean.
Fixtures are confirmed absent from the shipped bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-30 16:30:49 -06:00 • committed by lrnselfreliance
parent 510b24c298
commit 35151a5fb0
10 changed files with 570 additions and 221 deletions

View file

@ -1,43 +1,9 @@
import {settingsFixture, statusFixture} from '../../../src/test-fixtures';
describe('Download Window Settings', () => {
const mockSettings = {
archive_destination: 'archive/%(domain_tag)s/%(domain)s',
download_manager_disabled: false,
download_manager_stopped: false,
download_on_startup: true,
download_timeout: 0,
download_wait: 20,
download_window_start: null,
download_window_end: null,
hotspot_device: 'wlan0',
hotspot_on_startup: true,
hotspot_password: 'wrolpi hotspot',
hotspot_ssid: 'WROLPi',
check_for_upgrades: true,
ignore_outdated_zims: false,
log_level: 'info',
map_destination: 'map',
nav_color: 'violet',
media_directory: '/media/wrolpi',
tags_directory: true,
throttle_on_startup: false,
version: '1.0.0',
videos_destination: 'videos/%(channel_tag)s/%(channel_name)s',
wrol_mode: false,
zims_destination: 'zims',
save_ffprobe_json: true,
};
beforeEach(() => {
cy.intercept('GET', '/api/status', {
statusCode: 200,
body: {
version: '1.0.0',
flags: {},
cpu_percent: 10,
memory_percent: 30,
downloads: {pending: 0, recurring: 0, disabled: false, stopped: false, outside_download_window: false},
}
}).as('getStatus');
cy.intercept('GET', '/api/status', {statusCode: 200, body: statusFixture()}).as('getStatus');
cy.intercept('GET', '/api/tags', {
statusCode: 200,
@ -58,7 +24,7 @@ describe('Download Window Settings', () => {
it('time inputs are empty when no window is configured', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {...mockSettings},
body: settingsFixture(),
}).as('getSettings');
cy.visit('/admin/settings');
@ -73,7 +39,7 @@ describe('Download Window Settings', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {
...mockSettings,
...settingsFixture(),
download_window_start: '08:00',
download_window_end: '17:00',
},
@ -90,7 +56,7 @@ describe('Download Window Settings', () => {
it('can set download window values', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {...mockSettings},
body: settingsFixture(),
}).as('getSettings');
cy.visit('/admin/settings');
@ -107,7 +73,7 @@ describe('Download Window Settings', () => {
cy.intercept('GET', '/api/settings', {
statusCode: 200,
body: {
...mockSettings,
...settingsFixture(),
download_window_start: '22:00',
download_window_end: '06:00',
},

View file

@ -9,35 +9,9 @@
* Intercepting the API is not mocking the component -- the page really renders, so the
* accessible names and the real geometry are the ones a user gets.
*/
import {settingsFixture, statusFixture} from '../../../src/test-fixtures';
describe('Special Directories settings', () => {
const mockSettings = {
archive_destination: 'archive/%(domain_tag)s/%(domain)s',
download_manager_disabled: false,
download_manager_stopped: false,
download_on_startup: true,
download_timeout: 0,
download_wait: 20,
download_window_start: null,
download_window_end: null,
hotspot_device: 'wlan0',
hotspot_on_startup: true,
hotspot_password: 'wrolpi hotspot',
hotspot_ssid: 'WROLPi',
check_for_upgrades: true,
ignore_outdated_zims: false,
log_level: 'info',
map_destination: 'map',
nav_color: 'violet',
media_directory: '/media/wrolpi',
playlists_destination: 'playlists',
tags_directory: true,
throttle_on_startup: false,
version: '1.0.0',
videos_destination: 'videos/%(channel_tag)s/%(channel_name)s',
wrol_mode: false,
zims_destination: 'zims',
save_ffprobe_json: true,
};
const directories = [
['Archive Directory', 'archive/%(domain_tag)s/%(domain)s'],
@ -48,26 +22,14 @@ describe('Special Directories settings', () => {
];
beforeEach(() => {
cy.intercept('GET', '/api/status', {
statusCode: 200,
body: {
version: '1.0.0',
flags: {},
cpu_percent: 10,
memory_percent: 30,
downloads: {
pending: 0, recurring: 0, disabled: false, stopped: false,
outside_download_window: false,
},
},
}).as('getStatus');
cy.intercept('GET', '/api/status', {statusCode: 200, body: statusFixture()}).as('getStatus');
cy.intercept('GET', '/api/tags', {statusCode: 200, body: {tags: []}}).as('getTags');
cy.intercept('GET', '/api/events', {statusCode: 200, body: {events: []}}).as('getEvents');
cy.intercept('POST', '/api/search/suggestions', {
statusCode: 200,
body: {fileGroups: 0, zimsEstimates: [], channels: [], domains: []},
}).as('getSuggestions');
cy.intercept('GET', '/api/settings', {statusCode: 200, body: {...mockSettings}})
cy.intercept('GET', '/api/settings', {statusCode: 200, body: settingsFixture()})
.as('getSettings');
cy.visit('/admin/settings');

View file

@ -466,23 +466,26 @@ table.ratio-table td.min-width {
animation: skeleton-shimmer 1.5s infinite;
}
/* Sticky footer for file browser */
/*
* Sticky footer for the file browser.
*
* These were two hardcoded Semantic greys with `.inverted` selecting the dark one, but
* nothing has set `.inverted` since ThemeProvider stopped supplying it -- so the footer stayed
* at near-white `#f9fafb` in dark, night and amber. In night mode a white bar pinned to the
* bottom of the screen costs the user the dark adaptation the whole theme exists to protect.
* Tokens have no such failure mode: there is one rule and each theme fills it in.
*/
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 0.5em 1em;
background-color: #f9fafb;
border-top: 1px solid rgba(34, 36, 38, 0.15);
background-color: var(--panel);
border-top: 1px solid var(--border);
z-index: 100;
}
.sticky-footer.inverted {
background-color: #1b1c1d;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Drag selection visual feedback for FileBrowser */
tr.drag-selecting td {
background-color: rgba(33, 133, 208, 0.15) !important;

View file

@ -1,16 +1,17 @@
import React from 'react';
import {createMockDomain, createTestForm, render, screen} from '../test-utils';
import {tagsContextFixture} from '../test-fixtures';
import {TagsContext} from '../Tags';
import {CollectionEditForm} from './collections/CollectionEditForm';
import {DomainsPage} from './Archive';
// Mock the TagsContext
jest.mock('../Tags', () => ({
TagsContext: {
_currentValue: {
SingleTag: ({name}) => <span data-testid="applied-tag">{name}</span>
}
},
}));
/*
* The Tags context used to be faked here as a bare object with a `_currentValue` and no
* `.Provider`, which worked only because `useContext` reads that React internal. It is now
* the real context carrying a fixture value, passed in by this spec rather than by the harness
* so that it is the same module instance the component reads -- see test-utils.js.
*/
const withTags = {contexts: [[TagsContext, tagsContextFixture()]]};
// Mock the DirectorySearch and DestinationForm components
jest.mock('./Common', () => ({
@ -96,7 +97,8 @@ describe('Domain Tagging Logic', () => {
appliedTagName="News"
>
<div data-testid="child-content">Child content</div>
</CollectionEditForm>
</CollectionEditForm>,
withTags,
);
// Should render title
@ -105,8 +107,8 @@ describe('Domain Tagging Logic', () => {
// Should render children
expect(screen.getByTestId('child-content')).toBeInTheDocument();
// Should render applied tag
expect(screen.getByTestId('applied-tag')).toHaveTextContent('News');
// The tag name a user would see, rather than a test id the old mock invented.
expect(screen.getByText('News')).toBeInTheDocument();
// Should render Save button
expect(screen.getByRole('button', {name: /save/i})).toBeInTheDocument();
@ -148,7 +150,7 @@ describe('Domain Tagging Logic', () => {
</CollectionEditForm>
);
expect(screen.queryByTestId('applied-tag')).not.toBeInTheDocument();
expect(screen.queryByText('News')).not.toBeInTheDocument();
});
});

View file

@ -266,7 +266,6 @@ export function FileBrowser() {
}, {enableOnFormTags: false});
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {inverted} = React.useContext(ThemeContext);
const selectedPathsCount = selectedPaths ? selectedPaths.length : 0;
@ -397,8 +396,7 @@ export function FileBrowser() {
// Show a label alongside the icon once there's room; otherwise the icon carries the meaning alone.
const label = (text) => showLabels ? text : null;
const footerClassName = `sticky-footer ${inverted}`;
const footer = <div className={footerClassName} ref={footerRef}>
const footer = <div className='sticky-footer' ref={footerRef}>
<FilesRefreshButton paths={selectedPaths} showLabel={showLabels}/>
<APIButton
icon='trash'

View file

@ -1,7 +1,13 @@
import React, {createContext, useContext, useState, useEffect, useCallback, useRef} from 'react';
import {fetchFilesProgress} from '../api';
const FileWorkerStatusContext = createContext(null);
/*
* Exported so a test can wrap a component in the real provider holding a fixture value. While
* this was private the only way to render a consumer was `jest.mock` on this whole module,
* which replaces the provider and the hooks together and has to be rewritten in every spec
* that needs it.
*/
export const FileWorkerStatusContext = createContext(null);
export function FileWorkerStatusProvider({children, pollInterval = 3000}) {
const [status, setStatus] = useState(null);

View file

@ -23,14 +23,20 @@ export const useIsTouchDevice = () => {
};
/** @type {React.Context<import('../types/theme').ThemeContextValue>} */
/*
* The default is the declared shape of this context, and test-fixtures.test.js holds the
* fixtures to it key for key. It used to carry `i`, `s`, `t` and `inverted` -- Semantic
* compatibility props holding hardcoded greys -- long after ThemeProvider stopped supplying
* them, so the context advertised four properties that were always undefined in a real tree.
* FileBrowser was still reading one of them.
*/
export const ThemeContext = React.createContext({
theme: 'light',
i: {},
s: {},
t: {},
inverted: '',
savedTheme: null,
isDark: false,
mediaFilter: undefined,
mediaFilterEnabled: false,
setMediaFilterEnabled: () => {},
setTheme: () => {},
setDarkTheme: () => {},
setLightTheme: () => {},

190
app/src/test-fixtures.js Normal file
View file

@ -0,0 +1,190 @@
/**
* Shared test fixtures: the data and context values the app expects, in one place.
*
* Two problems this exists to solve.
*
* The first is duplication. A settings object was written out by hand in every spec that
* needed one, twenty-odd keys at a time, so a spec asserting one field still had to know and
* restate the other nineteen. Nothing kept those copies in step with each other or with the
* API, and adding a required field meant hunting them down.
*
* The second is drift, which is the worse of the two. A fixture that no longer matches the
* shape it stands in for makes a test *confidently* wrong: it passes while the component
* would break in the browser. The theme context fixture carried `i`, `s` and `t` with
* hardcoded Semantic greys for months after ThemeProvider stopped supplying them, and one
* component was still reading a prop that no longer existed. test-fixtures.test.js compares
* these against the real contexts so that class of drift fails a test rather than surviving.
*
* Everything here is a function taking overrides, never an exported constant: a shared mutable
* object lets one test change what the next one sees, and that failure is miserable to find.
*
* Importable from jest tests, Cypress component specs, and e2e `cy.intercept` bodies alike --
* the same fixture in all three, so they cannot disagree about what the API returns.
*/
import {defaultTheme} from './themes/names';
/* --------------------------------------------------------------------------- */
/* API shapes */
/* --------------------------------------------------------------------------- */
/** GET /api/settings */
export const settingsFixture = (overrides = {}) => ({
archive_destination: 'archive/%(domain_tag)s/%(domain)s',
check_for_upgrades: true,
download_manager_disabled: false,
download_manager_stopped: false,
download_on_startup: true,
download_timeout: 0,
download_wait: 20,
download_window_start: null,
download_window_end: null,
hotspot_device: 'wlan0',
hotspot_on_startup: true,
hotspot_password: 'wrolpi hotspot',
hotspot_ssid: 'WROLPi',
ignore_outdated_zims: false,
log_level: 'info',
map_destination: 'map',
// `/media/wrolpi` on a Pi, but the API is the authority on this and a test that
// hardcodes it elsewhere is asserting its own guess.
media_directory: '/media/wrolpi',
nav_color: 'violet',
playlists_destination: 'playlists',
save_ffprobe_json: true,
tags_directory: true,
throttle_on_startup: false,
version: '1.0.0',
videos_destination: 'videos/%(channel_tag)s/%(channel_name)s',
wrol_mode: false,
zims_destination: 'zims',
...overrides,
});
/** GET /api/status */
export const statusFixture = (overrides = {}) => ({
version: '1.0.0',
flags: {},
cpu_percent: 10,
memory_percent: 30,
downloads: {
pending: 0,
recurring: 0,
disabled: false,
stopped: false,
outside_download_window: false,
},
...overrides,
});
/** A Domain Collection, as the domains endpoints return it. */
export const domainFixture = (overrides = {}) => ({
id: 1,
domain: 'example.com',
archive_count: 42,
size: 1024000,
tag_name: null,
directory: '',
can_be_tagged: false,
description: '',
...overrides,
});
export const domainsFixture = (count = 3) => Array.from({length: count}, (_, i) => domainFixture({
id: i + 1,
domain: `example${i + 1}.com`,
archive_count: (i + 1) * 10,
size: (i + 1) * 1000000,
}));
export const tagFixture = (overrides = {}) => ({
id: 1,
name: 'Favorite',
color: '#gray',
file_group_count: 0,
zim_entry_count: 0,
channel_count: 0,
domain_count: 0,
...overrides,
});
/* --------------------------------------------------------------------------- */
/* Context values */
/* --------------------------------------------------------------------------- */
/*
* These are the values a provider carries, not mocks of a provider. Passing one to the real
* `Context.Provider` runs the real consumers against real data, which is why none of the
* components using them need `jest.mock` -- see test-utils' render().
*/
export const themeContextFixture = (overrides = {}) => ({
theme: defaultTheme,
savedTheme: null,
isDark: false,
// Media filtering is off unless a test asks for it; the themes that offer a filter
// decide their own default, and a fixture should not pre-empt that.
mediaFilter: undefined,
mediaFilterEnabled: false,
setMediaFilterEnabled: () => {},
setTheme: () => {},
setDarkTheme: () => {},
setLightTheme: () => {},
cycleSavedTheme: () => {},
...overrides,
});
export const statusContextFixture = (overrides = {}) => ({
status: statusFixture(),
fetchStatus: () => Promise.resolve(),
...overrides,
});
export const settingsContextFixture = (overrides = {}) => ({
settings: settingsFixture(),
fetchSettings: () => Promise.resolve(),
saveSettings: () => Promise.resolve(),
pending: false,
...overrides,
});
export const queryContextFixture = (overrides = {}) => ({
searchParams: new URLSearchParams(),
setSearchParams: () => {},
updateQuery: () => {},
getLocationStr: () => '',
...overrides,
});
/**
* The value `useTags` returns.
*
* The render helpers supply the real `TagsProvider` when a test does not pass tags, so this
* is for the cases that need particular tags without an API round trip. The component
* members are rendered as plain text: a test asserting on tag chips wants to find the name,
* not to re-test how Tags.js draws one.
*/
export const tagsContextFixture = (overrides = {}) => {
const tags = overrides.tags ?? [];
const findTagByName = (name) => tags.find(tag => tag.name === name);
return {
tags,
tagNames: tags.map(tag => tag.name),
NameToTagLabel: ({name}) => name ?? null,
TagsGroup: ({tagNames: names}) => (names || []).join(', '),
TagsLinkGroup: ({tagNames: names}) => (names || []).join(', '),
fetchTags: () => Promise.resolve(),
findTagByName,
SingleTag: ({name}) => name ?? null,
fuzzyMatchTagsByName: (name) => tags.filter(tag => tag.name.includes(name)),
...overrides,
};
};
/** The value FileWorkerStatusContext carries; no file worker is running by default. */
export const fileWorkerStatusFixture = (overrides = {}) => ({
status: null,
error: null,
refresh: () => Promise.resolve(),
setFastPolling: () => {},
...overrides,
});

View file

@ -0,0 +1,161 @@
import React from 'react';
import fs from 'fs';
import path from 'path';
import {render} from '@testing-library/react';
import {QueryContext, SettingsContext, StatusContext, ThemeContext} from './contexts/contexts';
import {
queryContextFixture,
settingsContextFixture,
statusContextFixture,
themeContextFixture,
} from './test-fixtures';
/*
* The fixtures are only worth having if they match what the app really carries. A fixture
* that has drifted is worse than none: the test passes, the component breaks in a browser,
* and the fixture is the last place anyone looks.
*
* That is not hypothetical here. The theme fixture supplied `i`, `s` and `t` holding
* hardcoded Semantic greys for months after ThemeProvider stopped supplying them, and
* FileBrowser was still reading `inverted` off the context and pasting it into a className --
* so the tests agreed with the fixture, the fixture agreed with nothing, and the file
* browser's footer stayed light in all three dark themes.
*
* Each context's `createContext` default is the declared shape; these hold the fixtures to it
* key for key, in both directions. A property added to a context and forgotten in a fixture
* fails here, and so does one deleted from a context and left in a fixture.
*/
/** Read a context's default by consuming it with no provider above -- public API only. */
const defaultValueOf = (Context) => {
let captured;
const Probe = () => {
captured = React.useContext(Context);
return null;
};
render(<Probe/>);
return captured;
};
describe('context fixtures match the contexts they stand in for', () => {
const cases = [
['ThemeContext', ThemeContext, themeContextFixture],
['StatusContext', StatusContext, statusContextFixture],
['SettingsContext', SettingsContext, settingsContextFixture],
['QueryContext', QueryContext, queryContextFixture],
];
cases.forEach(([name, Context, fixture]) => {
it(`${name} and its fixture declare the same properties`, () => {
expect(Object.keys(fixture()).sort()).toEqual(Object.keys(defaultValueOf(Context)).sort());
});
it(`${name}'s fixture supplies a function wherever the context does`, () => {
// A callback stubbed as a value rather than a function turns into a "not a
// function" crash the first time a component calls it, usually in an event
// handler where the failure surfaces far from the fixture.
const declared = defaultValueOf(Context);
const supplied = fixture();
Object.entries(declared)
.filter(([, value]) => typeof value === 'function')
.forEach(([key]) => expect(typeof supplied[key]).toBe('function'));
});
});
});
describe('fixtures cannot leak state between tests', () => {
it('hands out a new object every call', () => {
// Exported constants let one test mutate what the next one sees; every fixture is a
// function for that reason, and this fails if one is quietly turned back into a
// shared object.
const first = settingsContextFixture();
const second = settingsContextFixture();
expect(first).not.toBe(second);
expect(first.settings).not.toBe(second.settings);
first.settings.wrol_mode = true;
expect(second.settings.wrol_mode).toBe(false);
});
it('applies overrides without discarding the rest of the shape', () => {
const settings = settingsContextFixture({pending: true});
expect(settings.pending).toBe(true);
expect(settings.settings.media_directory).toBe('/media/wrolpi');
});
});
describe('specs still mocking a context module', () => {
/*
* A ratchet, not a prohibition. Mocking a context module replaces every export it has,
* providers included, so each spec that does it reinvents the pieces it still needs --
* one of them supplies a `Media` that always reports desktop, which quietly leaves the
* mobile branch of that component untested, and another hand-writes a context as a bare
* `_currentValue` with no provider at all.
*
* These six can pass their contexts to render() instead. The list is here so the count
* can only fall: converting one means deleting its line, and a new spec reaching for the
* old pattern fails this test rather than joining an unread pile.
*/
const ALLOWED = [
'components/collections/BatchReorganizeModal.test.js',
'components/collections/CollectionEditForm.test.js',
'components/collections/CollectionReorganizeModal.test.js',
'components/collections/CollectionTable.test.js',
'components/collections/CollectionTagModal.test.js',
'hooks/customHooks.test.js',
];
it('is only the specs already known to do it', () => {
const offenders = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.test\.js$/.test(entry.name)) {
const source = fs.readFileSync(full, 'utf8');
if (/jest\.mock\('[^']*(contexts\/contexts|FileWorkerStatusContext|\/Tags)'/
.test(source)) {
offenders.push(path.relative(__dirname, full));
}
}
}
};
walk(__dirname);
expect(offenders.sort()).toEqual(ALLOWED);
});
});
describe('the Semantic theme compatibility props are gone for good', () => {
it('no context, fixture, or component carries i/s/t/inverted from the theme', () => {
/*
* These four props are why this whole file exists. They were removed from
* ThemeProvider, but survived in the context default, in the test harness, and in one
* component that read `inverted` and pasted the resulting `undefined` into a
* className. Nothing failed, because the fixture supplied what production did not.
*/
const offenders = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(js|jsx|ts|tsx)$/.test(entry.name)) {
const source = fs.readFileSync(full, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
// Destructuring any of them off ThemeContext, or a bare `inverted:` key
// in a context value.
if (/const\s*\{[^}]*\binverted\b[^}]*}\s*=\s*(React\.)?useContext\(\s*ThemeContext/
.test(source)) {
offenders.push(`${path.relative(__dirname, full)} reads theme.inverted`);
}
}
}
};
walk(__dirname);
expect(offenders).toEqual([]);
});
});

View file

@ -1,114 +1,184 @@
/**
* Test utilities for React Testing Library
* Provides custom render functions with necessary context providers
* Test utilities: render helpers that supply what a component actually needs, and no more.
*
* Three tiers, because components differ in what they depend on and a single do-everything
* wrapper hides that:
*
* renderUI components from src/components/ui. Mantine's provider only. These
* take props and read no ambient state, which is enforced by the
* guards in components/ui/ui.test.js.
* render anything that reads a context. Supplies the REAL providers holding
* fixture values -- see test-fixtures.js -- so consumers run against
* real data instead of a mocked module.
* mockModule the last resort, for hooks that fetch. Always spreads the actual
* module, which hand-written jest.mock factories forget to do.
*
* The point of the middle tier is that `jest.mock('../../contexts/contexts')` should be
* unnecessary. Mocking that module replaces every export, including `Media` and the other
* providers, so each spec that did it reinvented them slightly differently -- one of them
* defaulting the responsive `Media` to desktop, which quietly left the mobile branch of that
* component untested.
*/
import React, {createContext, useContext} from 'react';
import {render} from '@testing-library/react';
import React from 'react';
import {render as rtlRender} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {MantineProvider} from '@mantine/core';
import {MediaContextProvider, ThemeContext} from './contexts/contexts';
import {
MediaContextProvider,
QueryContext,
SettingsContext,
StatusContext,
ThemeContext,
} from './contexts/contexts';
import {cssVariablesResolver, mantineTheme} from './themes/mantine';
import {
domainFixture,
domainsFixture,
queryContextFixture,
settingsContextFixture,
statusContextFixture,
themeContextFixture,
} from './test-fixtures';
// Mock FileWorkerStatusContext for tests - avoids API calls during testing
const MockFileWorkerStatusContext = createContext(null);
/*
* Why this file imports only contexts/contexts, and nothing else of the app's.
*
* A first attempt had the harness import Tags.js and FileWorkerStatusContext.js so it could
* supply those providers too. That put both into the module graph of every spec that imports
* the harness, ahead of the spec's own mocks, and broke eight suites in two different ways.
* Where a spec mocked a module those files pull in, the component under test bound to a second
* instance of it -- a different `jest.fn()` from the one the test configured, so a hook mocked
* to return a value returned undefined, in a file the test never mentions. Requiring them
* lazily fixed that but not the mirror image of it: `useContext(TagsContext)` in a component
* read a different TagsContext than the one the harness had required, so the provider was
* ignored and the context default (`SingleTag: null`) won.
*
* The lesson is that a test harness must not change what a spec imports. So this file owns
* only the four contexts in contexts/contexts.js, and any other context is passed in by the
* spec, which resolves it exactly as the component does:
*
* import {TagsContext} from '../Tags';
* render(<Thing/>, {contexts: [[TagsContext, tagsContextFixture({tags})]]})
*
* A private mock context and re-exported `useFileWorkerStatus`/`useReorganizationStatus` also
* used to live here. They could never have worked: components import those hooks from
* contexts/FileWorkerStatusContext, so a copy exported from the harness was read by nothing.
*/
function MockFileWorkerStatusProvider({children}) {
const mockValue = {
status: null,
error: null,
refresh: jest.fn(),
setFastPolling: jest.fn(),
};
return (
<MockFileWorkerStatusContext.Provider value={mockValue}>
{children}
</MockFileWorkerStatusContext.Provider>
/**
* Render a component from src/components/ui.
*
* Mantine's provider and nothing else, matching what those components can rely on. Reach for
* this rather than the full harness when the component takes props and reads no context: a
* test that supplies providers a component does not use conceals it starting to use one.
*/
export function renderUI(ui, {theme, ...renderOptions} = {}) {
if (theme) document.documentElement.dataset.theme = theme;
return rtlRender(
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
{ui}
</MantineProvider>,
renderOptions,
);
}
// Re-export hooks that use the mock context
export function useFileWorkerStatus() {
const context = useContext(MockFileWorkerStatusContext);
if (!context) {
throw new Error('useFileWorkerStatus must be used within FileWorkerStatusProvider');
}
return context;
}
export function useReorganizationStatus() {
const {status: progress, refresh} = useFileWorkerStatus();
return {
isReorganizing: false,
taskType: null,
collectionId: null,
collectionKind: null,
batchStatus: null,
workerStatus: progress,
refresh,
};
}
/**
* Custom render function that wraps components with necessary providers
* @param {React.Component} ui - Component to render
* @param {Object} options - Render options
* @param {boolean} options.inverted - Whether to use dark theme
* @param {Object} options.themeContext - Custom theme context values
* @param {boolean} options.withMedia - Include MediaContextProvider (default: false)
* @param {Object} options.renderOptions - Additional React Testing Library options
* Render anything that reads a context, with the real providers holding fixture values.
*
* Every context option takes overrides merged into that context's fixture, so a test states
* only the part it cares about:
*
* render(<Page/>, {settings: {wrol_mode: true}})
* render(<Page/>, {status: {status: statusFixture({flags: {db_up: false}})}})
* render(<Page/>, {tags: {tags: [tagFixture({name: 'Repair'})]}})
*
* @param {React.ReactElement} ui
* @param {Object} [options]
* @param {boolean} [options.inverted] render dark; sets the theme and Mantine's scheme together
* @param {Object} [options.theme] ThemeContext overrides
* @param {Object} [options.status] StatusContext overrides
* @param {Object} [options.settings] SettingsContext overrides
* @param {Object} [options.query] QueryContext overrides
* @param {Array} [options.contexts] extra [Context, value] pairs the spec supplies itself,
* for contexts outside contexts/contexts.js -- see the
* note at the top of this file for why
* @param {boolean} [options.withMedia] wrap in MediaContextProvider (needs window.matchMedia)
* @param {string} [options.route] point jsdom's location here before rendering
* @param {Object} [options.themeContext] deprecated alias for `theme`
*/
export function renderWithProviders(
ui,
{
inverted = false,
theme = {},
themeContext = {},
status = {},
settings = {},
query = {},
contexts = [],
withMedia = false,
route,
...renderOptions
} = {}
) {
const defaultThemeContext = {
inverted,
setInverted: jest.fn(),
// Theme components use different properties:
i: inverted ? {inverted: true} : {inverted: undefined}, // For Semantic UI elements (Segment, Form, etc.)
s: inverted ? {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}} : {}, // For style inversion
t: inverted ? {style: {color: '#eeeeee'}} : {}, // For text color inversion (Header, etc.)
// render() wraps content in a BrowserRouter rather than a MemoryRouter, so a test wanting
// a particular URL has to put jsdom's location there first. Several specs were doing
// this by hand.
if (route) window.history.pushState({}, '', route);
const themeValue = themeContextFixture({
theme: inverted ? 'dark' : 'light',
isDark: inverted,
savedTheme: null,
// Media filtering is off by default here; a test that needs it sets these.
mediaFilter: undefined,
mediaFilterEnabled: false,
setMediaFilterEnabled: jest.fn(),
setTheme: jest.fn(),
setDarkTheme: jest.fn(),
setLightTheme: jest.fn(),
cycleSavedTheme: jest.fn(),
...themeContext
};
...themeContext,
...theme,
});
const statusValue = statusContextFixture(status);
const settingsValue = settingsContextFixture(settings);
const queryValue = queryContextFixture(query);
/*
* One provider per context, skipping any a spec has mocked away.
*
* `jest.mock` on contexts/contexts replaces its exports, so a context can arrive here as
* undefined -- and one spec passes a hand-written object with a `_currentValue` and no
* `.Provider`, which works only because `useContext` reads that React internal. Rendering
* `undefined.Provider` fails with "element type is invalid" pointing at this wrapper rather
* than at the mock responsible, which is a miserable trail to follow. So the harness
* degrades rather than demanding every spec convert at once.
*/
const providers = [
[StatusContext, statusValue],
[SettingsContext, settingsValue],
[QueryContext, queryValue],
[ThemeContext, themeValue],
...contexts,
];
function Wrapper({children}) {
// MantineProvider, configured exactly as ThemeProvider configures it: components
// from src/components/ui render Mantine internals and need it in the tree.
const withProviders = providers.reduceRight(
(inner, [Context, value]) => (Context && Context.Provider
? <Context.Provider value={value}>{inner}</Context.Provider>
: inner),
children,
);
const content = (
<BrowserRouter>
{/* Configured exactly as ThemeProvider configures it: components from
src/components/ui render Mantine internals and need it in the tree. */}
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme={inverted ? 'dark' : 'light'}
>
<MockFileWorkerStatusProvider>
<ThemeContext.Provider value={defaultThemeContext}>
{children}
</ThemeContext.Provider>
</MockFileWorkerStatusProvider>
{withProviders}
</MantineProvider>
</BrowserRouter>
);
// Only wrap with MediaContextProvider if explicitly requested
// (since it requires window.matchMedia which can be tricky in tests)
// Opt-in: MediaContextProvider needs window.matchMedia, and a component that does not
// branch on breakpoint gains nothing from it.
if (withMedia) {
return <MediaContextProvider>{content}</MediaContextProvider>;
}
@ -116,37 +186,34 @@ export function renderWithProviders(
return content;
}
return render(ui, {wrapper: Wrapper, ...renderOptions});
return rtlRender(ui, {wrapper: Wrapper, ...renderOptions});
}
/**
* Creates a mock domain collection object for testing
* Replace named exports of a module, keeping every other export intact.
*
* Use inside a `jest.mock` factory:
*
* jest.mock('../hooks/customHooks', () =>
* require('../test-utils').mockModule('../hooks/customHooks', {
* useDomains: () => mockUseDomains(),
* }));
*
* The point is the spread. A factory written by hand returns only the keys it lists, so
* every other export of that module becomes undefined -- one spec mocks six hooks out of
* customHooks and silently blanks the rest, and the next hook a component under it starts
* calling fails as "not a function" a long way from the cause.
*/
export function createMockDomain(overrides = {}) {
return {
id: 1,
domain: 'example.com',
archive_count: 42,
size: 1024000,
tag_name: null,
directory: '',
can_be_tagged: false,
description: '',
...overrides
};
export function mockModule(modulePath, overrides) {
return {...jest.requireActual(modulePath), ...overrides};
}
/**
* Creates multiple mock domains for list testing
/*
* The names the existing specs already import. Both delegate to test-fixtures so a domain is
* defined once rather than in two places free to disagree.
*/
export function createMockDomains(count = 3) {
return Array.from({length: count}, (_, i) => createMockDomain({
id: i + 1,
domain: `example${i + 1}.com`,
archive_count: (i + 1) * 10,
size: (i + 1) * 1000000,
}));
}
export const createMockDomain = domainFixture;
export const createMockDomains = domainsFixture;
/**
* Mock fetch implementation for API calls
@ -224,18 +291,6 @@ export function renderInLightMode(ui, options = {}) {
});
}
/**
* Helper to check if an element has theme-aware (inverted) styling
* Returns true if the element has the 'inverted' class
*
* Usage:
* const segment = container.querySelector('.ui.segment');
* expect(hasInvertedStyling(segment)).toBe(true);
*/
export function hasInvertedStyling(element) {
return element && element.classList.contains('inverted');
}
/**
* Creates a test-friendly form object using real useForm hook
*