import fs from 'fs';
import path from 'path';
import React from 'react';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {MantineProvider} from '@mantine/core';
import {IconMoodSmile} from '@tabler/icons-react';
import {ThemeContext} from '../../contexts/contexts';
import {cssVariablesResolver, mantineTheme, paletteColorNames} from '../../themes/mantine';
import {themeChoices} from '../../themes/names';
import {
ActionInput,
Button,
Card,
CardGroup,
Checkbox,
Confirm,
Header,
Icon,
IconStack,
Loader,
Loading,
Placeholder,
Pagination,
SearchBox,
TabBar,
tabClassName,
IconButton,
Label,
Message,
Modal,
Panel,
PathInput,
Progress,
resolveIconName,
Statistic,
StatisticGroup,
Status,
Table,
ThemePicker,
Toggle,
} from './index';
// The components need Mantine's provider, exactly as ThemeProvider supplies it.
const renderUI = (ui) => render(
{ui}
);
describe('Icon', () => {
it('renders a named icon as an SVG', () => {
const {container} = renderUI();
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('accepts a Tabler component directly', () => {
const {container} = renderUI();
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('maps every icon name the app uses', () => {
// A name that falls through renders a fallback glyph, leaving a hole in the UI.
const names = ['trash', 'warning sign', 'download', 'refresh', 'plus', 'edit', 'wifi',
'search', 'lock', 'check', 'upload', 'play', 'folder', 'filter', 'file text',
'external', 'disk', 'circle notched', 'terminal', 'eye'];
const unmapped = names.filter(name => !resolveIconName(name));
expect(unmapped).toEqual([]);
});
it('hides decorative icons from assistive technology but names labelled ones', () => {
const {container} = renderUI(<>>);
const [decorative, labelled] = [...container.querySelectorAll('svg')];
expect(decorative).toHaveAttribute('aria-hidden', 'true');
expect(labelled).toHaveAttribute('aria-label', 'Delete');
expect(labelled).not.toHaveAttribute('aria-hidden');
});
it('maps every icon the theme choices ask for', () => {
// Regression: the map was built by grepping literal name='...' strings, so icons
// passed through a variable (the nav bar's picker) were missed and three of the
// five theme choices rendered a fallback glyph.
const unmapped = themeChoices.filter(choice => !resolveIconName(choice.icon));
expect(unmapped.map(c => `${c.text}: ${c.icon}`)).toEqual([]);
});
it('maps every name the mimetype and file-suffix helpers can return', () => {
// These two build icon names from arbitrary files in the user's library, so an
// unmapped one is not hypothetical — it is a console error and a fallback glyph
// the moment somebody opens a folder containing that file type.
const common = fs.readFileSync(path.join(__dirname, '..', 'Common.js'), 'utf8');
const section = common.slice(
common.indexOf('export function mimetypeIconName'),
common.indexOf('export function FileIcon'));
const returned = [...new Set([...section.matchAll(/return '([^']+)'/g)].map(m => m[1]))];
expect(returned.length).toBeGreaterThan(10);
expect(returned.filter(name => !resolveIconName(name))).toEqual([]);
});
it('points every mapped name at a Tabler component that exists', () => {
// A typo in the map resolves to undefined and renders the fallback glyph, which
// looks like a design choice rather than a bug.
const source = fs.readFileSync(path.join(__dirname, 'Icon.tsx'), 'utf8');
const names = [...source.matchAll(/^ {4}'([^']+)': '([^']+)',$/gm)].map(m => m[1]);
expect(names.length).toBeGreaterThan(100);
expect(names.filter(name => !resolveIconName(name))).toEqual([]);
});
it('maps every icon name written anywhere in the app', () => {
/*
* The three checks above cover the theme picker, the mimetype helpers, and the map's
* own integrity -- and between them they missed eleven names, because none of them
* looks at what call sites actually write. The calculators page had seven "?"
* glyphs in a row: `cogs`, `tint`, `food`, `signal`, `thermometer`, `car`, `th
* large`. `arrow left` was missing while `arrow down`, `right` and `up` were there.
*
* An unmapped name is not subtle -- it is a help-circle glyph and a console error --
* but nothing failed, so it shipped and stayed until somebody looked at the page.
*
* Every spelling matters, and each one hid a real defect:
* `` and `icon='x'` -- the plain JSX prop
* `{icon: 'x'}` -- a data table; how calculators declare theirs
* `icon={active ? 'tags' : 'tag'}` -- a ternary, where `tags` was unmapped
* The last two are exactly the forms an audit by eye skips.
*/
const patterns = [
/]*?\bname=['"]([^'"]+)['"]/gs,
/\bicons?(?:After)?=['"]([^'"]+)['"]/g,
/\bicons?(?:Name)?\s*:\s*['"]([^'"]+)['"]/g,
];
// Any string literal inside an icon={...} expression is a candidate name.
const expressions = /\b(?:icons?(?:After)?|name)=\{([^}]*)\}/g;
const src = path.join(__dirname, '..', '..');
const walk = (directory) => fs.readdirSync(directory, {withFileTypes: true})
.flatMap(entry => {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : walk(full);
if (!/\.(js|jsx|ts|tsx)$/.test(entry.name)) return [];
// This file names an unmapped icon on purpose, one test below.
if (full === __filename) return [];
return [[full, fs.readFileSync(full, 'utf8')]];
});
const unmapped = [];
for (const [file, source] of walk(src)) {
const names = patterns.flatMap(pattern => [...source.matchAll(pattern)].map(m => m[1]));
for (const match of source.matchAll(expressions)) {
/*
* Drop comparison operands first. `name={sorted === 'descending' ? 'arrow
* down' : 'arrow up'}` picks an icon by testing a sort direction, and
* 'descending' is the thing being tested, not a name.
*/
const expression = match[1]
.replace(/[=!]==?\s*['"][^'"]*['"]/g, '')
.replace(/['"][^'"]*['"]\s*[=!]==?/g, '');
names.push(...[...expression.matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]));
}
for (const name of names) {
if (!resolveIconName(name)) {
unmapped.push(`${path.relative(src, file)}: ${name}`);
}
}
}
expect([...new Set(unmapped)]).toEqual([]);
});
it('reports an unknown name instead of failing silently', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
renderUI();
expect(error).toHaveBeenCalledWith(expect.stringContaining('not a real icon'));
error.mockRestore();
});
});
describe('Button', () => {
it('renders a role as a themed button', () => {
renderUI();
expect(screen.getByRole('button', {name: 'Download'})).toBeInTheDocument();
});
it('marks destructive buttons so night mode can restyle them', () => {
// The dashed-outline treatment lives in CSS keyed on this class; without it a
// Delete button would stay filled red in night mode.
renderUI();
expect(screen.getByRole('button', {name: 'Delete'})).toHaveClass('wrolpi-button-danger');
});
it('lets a call site override the role colors', () => {
renderUI();
expect(screen.getByRole('button', {name: 'Odd one out'})).toHaveAttribute('data-variant', 'outline');
});
it('renders an icon alongside the label', () => {
const {container} = renderUI();
expect(container.querySelector('svg')).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
});
it('translates our size names, which Mantine would otherwise ignore', () => {
// Unmigrated call sites still pass size='tiny'. Mantine drops a size it does not
// recognise without warning, so the button quietly renders at the default size.
renderUI(<>>);
expect(screen.getByRole('button', {name: 'Old'})).toHaveAttribute('data-size', 'xs');
// A Mantine size still passes through untouched.
expect(screen.getByRole('button', {name: 'New'})).toHaveAttribute('data-size', 'sm');
});
it('renders an anchor when given an href, without being told twice', () => {
/*
* Mantine drops `href` unless it also gets `component='a'`, so a button carrying
* only an href looked like a link and navigated nowhere. The old library spelled this
* `as='a'`, so every migrated call site that kept just the href was silently
* broken — a download button that downloads nothing.
*/
renderUI(<>
>);
const link = screen.getByRole('link', {name: 'Download'});
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', '/media/thing.pdf');
expect(screen.getByRole('link', {name: 'Save file'}).tagName).toBe('A');
});
it('forwards a ref to the underlying DOM element', () => {
/*
* Ported from the old Theme.test.js, which caught a real crash: the old Button
* was a class component, so a forwarded ref resolved to the class instance, and
* anything that called `node.contains(...)` on the trigger -- a Modal or Popup
* portal checking whether a click landed inside -- threw "contains is not a
* function". Our Tooltip and Menu targets rely on this ref too.
*/
const ref = React.createRef();
renderUI();
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(typeof ref.current.contains).toBe('function');
expect(ref.current.tagName).toBe('BUTTON');
});
it('survives a document click while used as a modal trigger', () => {
// The other half of that regression: the Control page crashed when the document
// was clicked with a modal open whose trigger was a themed Button.
renderUI(<>
content
>);
expect(() => document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})))
.not.toThrow();
});
it('is still a button when there is no href', () => {
renderUI();
expect(screen.getByRole('button', {name: 'Save'}).tagName).toBe('BUTTON');
});
it('gives icon-only buttons an accessible name', () => {
renderUI();
expect(screen.getByRole('button', {name: 'Delete channel'})).toBeInTheDocument();
});
});
describe('Message', () => {
it('announces errors assertively and other kinds politely', () => {
renderUI(<>>);
expect(screen.getByRole('alert')).toHaveTextContent('Failed');
expect(screen.getByRole('status')).toHaveTextContent('Working');
});
it('gives errors the dashed danger border in every theme', () => {
renderUI(Try again);
expect(screen.getByRole('alert')).toHaveClass('wrolpi-message-error');
});
it('dismisses when asked', async () => {
const onDismiss = jest.fn();
renderUI();
await userEvent.click(screen.getByRole('button', {name: 'Dismiss'}));
expect(onDismiss).toHaveBeenCalled();
});
it('offers no dismiss button when the message cannot be cleared', () => {
renderUI();
expect(screen.queryByRole('button', {name: 'Dismiss'})).not.toBeInTheDocument();
});
it('renders a leading icon when given one', () => {
const {container} = renderUI();
expect(container.querySelector('.wrolpi-message-icon svg')).toBeInTheDocument();
});
});
describe('Header', () => {
it('renders the requested heading level', () => {
renderUI(Browser Extension);
expect(screen.getByRole('heading', {level: 1, name: 'Browser Extension'})).toBeInTheDocument();
});
it('defaults to h3 so a call site that omits the level still nests sanely', () => {
renderUI(Firefox);
expect(screen.getByRole('heading', {level: 3, name: 'Firefox'})).toBeInTheDocument();
});
it('takes its size from the level, not from the call site', () => {
// The class is what ui.css keys the type scale on; a call site passing its own
// font-size is the thing this component exists to prevent.
const {container} = renderUI(Install steps);
expect(container.querySelector('.wrolpi-header-h4')).toBeInTheDocument();
});
it('renders an icon and a subheader', () => {
const {container} = renderUI(
This WROLPi's URL
);
expect(container.querySelector('svg')).toBeInTheDocument();
expect(screen.getByText('Paste this into the extension')).toBeInTheDocument();
});
it("puts trailing content in the heading's row, not after its wrapper", () => {
/*
* The whole point of the slot. `.wrolpi-header` is a block, so anything placed after
* it starts a new line -- which is what happened to every help icon in the app when
* these headings stopped being a bare `
`. A shared flex row holds them together.
*/
const {container} = renderUI(
Help}>Root CA Certificate);
const row = container.querySelector('.wrolpi-header-row');
expect(row).toContainElement(container.querySelector('h3'));
expect(row).toContainElement(screen.getByRole('button', {name: 'Help'}));
});
it("keeps the control out of the heading's accessible name", () => {
// Inside the
the control becomes part of the name, so heading navigation
// announces "Root CA Certificate Help".
renderUI(Help}>Root CA Certificate);
expect(screen.getByRole('heading')).toHaveAccessibleName('Root CA Certificate');
});
it('puts layout styles on the block, not on the heading inside it', () => {
/*
* `style` is used for spacing the header as a whole -- marginBottom on Status's
* "Drive Bandwidth", marginTop on the extension page. On the inner heading those set
* a margin inside the wrapper and worked only through margin collapse, which stops as
* soon as the wrapper gains padding or a border.
*/
const {container} = renderUI(
Drive Bandwidth);
expect(container.querySelector('.wrolpi-header')).toHaveStyle({marginBottom: '1em'});
expect(container.querySelector('h3').style.marginBottom).toBe('');
});
it('still colors the heading text from the color prop', () => {
const {container} = renderUI(2 items to remove);
expect(container.querySelector('h4')).toHaveStyle({color: 'var(--danger)'});
});
it('renders nothing extra when there is no trailing content', () => {
const {container} = renderUI(Root CA Certificate);
expect(container.querySelector('.wrolpi-header-after')).toBeNull();
});
});
describe('PathInput', () => {
/*
* These need no mocks at all -- no context, no API, no router. That is the point of
* putting the field in the library: the page that uses it needs a pile of provider and
* fetch mocks, so its inputs were never covered, which is how a field shipped printing
* its own prefix over its own value.
*/
it('keeps the prefix out of the value', () => {
// The bug this exists to prevent: the prefix must be its own element, never part of
// the text the user is editing, so it cannot be typed over or submitted.
renderUI();
const input = screen.getByLabelText(/Archive Directory/);
expect(input).toHaveValue('archive/%(domain)s');
expect(input.value).not.toContain('/media/wrolpi');
// A sibling, not a child: an element inside the box could overlap it.
expect(input.querySelector('*')).toBeNull();
expect(screen.getByText('/media/wrolpi/')).not.toBe(input);
});
it('reports only what was typed, never the prefix', async () => {
// Read the value inside the handler: React nulls `currentTarget` once the event has
// been dispatched, so inspecting it afterwards from mock.calls throws.
const seen = [];
const onChange = jest.fn(event => seen.push(event.currentTarget.value));
renderUI();
await userEvent.type(screen.getByLabelText(/Map Directory/), 'map');
expect(seen).toEqual(['m', 'ma', 'map']);
seen.forEach(value => expect(value).not.toContain('/media/wrolpi'));
});
it('tells assistive technology what the path is relative to', () => {
// Sighted users get that from the prefix; hiding it would leave everyone else
// typing a relative path with no idea what it is relative to.
renderUI();
expect(screen.getByLabelText(/Zims Directory/)).toHaveAccessibleDescription('/media/wrolpi/');
});
it('disables the input, not just the wrapper', async () => {
const onChange = jest.fn();
renderUI();
const input = screen.getByLabelText(/Videos Directory/);
expect(input).toBeDisabled();
await userEvent.type(input, 'x');
expect(onChange).not.toHaveBeenCalled();
});
it('marks an invalid path so the dashed danger treatment applies', () => {
const {container} = renderUI();
expect(container.querySelector('[data-error]')).toBeInTheDocument();
expect(screen.getByText('Not a directory')).toBeInTheDocument();
});
it('reads out its help text as well as the prefix', () => {
/*
* Rendering the description on screen is not the same as announcing it. Unless it is
* referenced, a screen reader user hears the label and the prefix and never learns
* what the field expects -- the help text is the part that explains the variables
* these paths are allowed to contain.
*/
renderUI();
expect(screen.getByLabelText(/Archive Directory/))
.toHaveAccessibleDescription('/media/wrolpi/ Accepts %(domain)s and %(tag)s.');
});
it('announces the reason a path was rejected', () => {
// The dashed border and red text carry the failure for sighted users; without the
// error in the accessible description nothing carries it for anyone else.
renderUI();
const input = screen.getByLabelText(/Zims Directory/);
expect(input).toHaveAccessibleDescription(/Not a directory/);
expect(input).toBeInvalid();
});
it('is not marked invalid when there is nothing wrong with it', () => {
renderUI();
expect(screen.getByLabelText(/Fine/)).toBeValid();
});
it('forwards a ref to the input, for callers that focus it', () => {
const ref = React.createRef();
renderUI();
expect(ref.current.tagName).toBe('INPUT');
});
});
describe('no input reserves space for a section it cannot measure', () => {
it('nothing passes leftSectionWidth="auto"', () => {
/*
* `leftSectionWidth` becomes `--input-padding-inline-start`, and
* `padding-inline-start: auto` is not valid CSS -- it resolves to zero, so the
* section prints straight over the value. Five fields on the Settings page shipped
* that way and were unreadable. It fails silently, in CSS, at runtime only, which
* is exactly the kind of thing worth a source check.
*/
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);
// Specs are exempt: ui-layout.cy.js reproduces this exact mistake on
// purpose, to prove its overlap check can still detect it.
else if (/\.(js|jsx|tsx)$/.test(entry.name)
&& !/\.test\.|\.cy\./.test(entry.name)) {
// Comments are stripped first: the component that replaced this
// pattern documents it by name, and prose about a mistake is not the
// mistake.
const source = fs.readFileSync(full, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
if (/leftSectionWidth\s*=\s*['"`]auto['"`]/.test(source)
|| /rightSectionWidth\s*=\s*['"`]auto['"`]/.test(source)) {
offenders.push(path.relative(path.join(__dirname, '..', '..'), full));
}
}
}
};
walk(path.join(__dirname, '..', '..'));
expect(offenders).toEqual([]);
});
});
describe('the library stays mockable-free', () => {
/*
* The whole reason these 80-odd tests need no setup is that a library component takes
* props and reaches for nothing else -- no module-level fetch, no API helper, no
* ambient state it has to be lied to about. Pages are the opposite: DomainEditPage
* needs seven jest.mock calls before it will render at all, which is precisely why the
* inputs on the Settings page had no test when they broke.
*
* That property is worth asserting rather than hoping for. The day a library
* component grows a fetch, the mock somebody has to add to keep this file passing
* trips the first check, and the import trips the second.
*/
const libraryFiles = () => fs.readdirSync(__dirname)
.filter(name => /\.(ts|tsx)$/.test(name) && !/\.test\./.test(name))
.map(name => [name, fs.readFileSync(path.join(__dirname, name), 'utf8')]);
it('needs no module mocked to render any of it', () => {
const source = fs.readFileSync(path.join(__dirname, 'ui.test.js'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
expect(source).not.toMatch(/jest\.mock\s*\(/);
});
it('asks the network for nothing', () => {
const offenders = libraryFiles()
.filter(([, source]) => /\bfetch\s*\(|\bapiCall\b|\baxios\b/
.test(source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')))
.map(([name]) => name);
expect(offenders).toEqual([]);
});
it('reads ambient state only in the theme controls', () => {
/*
* ThemePicker and MediaFilterToggle are the deliberate exception -- a control whose
* entire job is to show and change the current theme has to know it. Their tests
* pass a real ThemeContext.Provider holding plain values, which is supplying data,
* not mocking a module. Everything else takes props.
*/
const offenders = libraryFiles()
.filter(([name]) => name !== 'ThemePicker.tsx')
.filter(([, source]) => /useContext\s*\(/.test(source))
.map(([name]) => name);
expect(offenders).toEqual([]);
});
});
describe('ActionInput', () => {
it('joins the input and its action into one control', () => {
const {container} = renderUI(
Copy}/>
);
expect(container.querySelector('.wrolpi-action-input')).toBeInTheDocument();
expect(screen.getByRole('textbox', {name: 'URL'})).toHaveValue('https://wrolpi.local');
expect(screen.getByRole('button', {name: 'Copy'})).toBeInTheDocument();
});
it('is a plain input when no action is given', () => {
const {container} = renderUI();
expect(container.querySelector('.wrolpi-action-input')).not.toBeInTheDocument();
expect(screen.getByRole('textbox', {name: 'URL'})).toBeInTheDocument();
});
});
describe('Progress', () => {
it('reports its value to assistive technology', () => {
renderUI(