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(); const bar = screen.getByRole('progressbar'); expect(bar).toHaveAttribute('aria-valuenow', '62'); expect(bar).toHaveTextContent('62%'); }); it('keeps its percent text readable on every bar color', () => { /* * Ported from Theme.test.js, which asserted the old `inverted-progress-text` * class across all fourteen colors and both modes. That class is gone; the * mechanism now is that the text always takes `--text` while light mode lightens * the fill beneath it, because the text sits across both the filled and unfilled * halves of the bar. */ const css = fs.readFileSync(path.join(__dirname, 'ui.css'), 'utf8'); expect(css).toMatch(/\.wrolpi-progress-text\s*{[^}]*color:\s*var\(--text\)/); expect(css).toMatch(/html\[data-theme="light"] \.wrolpi-progress-fill\s*{[^}]*brightness/); }); it('clamps values outside 0-100', () => { // A download reporting 103% must not overflow its container. renderUI(<>); const [high, low] = screen.getAllByRole('progressbar'); expect(high).toHaveAttribute('aria-valuenow', '100'); expect(low).toHaveAttribute('aria-valuenow', '0'); }); it('survives a missing percentage', () => { renderUI(); expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0'); }); it('reports no value when the size is unknown', () => { // An upload that has not reported its size // would otherwise sit at 0% and read as stalled, and `aria-valuenow=0` would tell // a screen reader the same wrong thing. renderUI(); const bar = screen.getByRole('progressbar'); expect(bar).not.toHaveAttribute('aria-valuenow'); expect(bar).toHaveClass('wrolpi-progress-indeterminate'); }); it('shows arbitrary label text instead of the percentage', () => { renderUI(); expect(screen.getByRole('progressbar')).toHaveTextContent('2.1 GB / 5.3 GB'); }); }); describe('Status', () => { it('names the state in a class so themes can encode it differently', () => { // Night mode has no hue to spend, so it encodes status by brightness. const {container} = renderUI(failed); expect(container.querySelector('.wrolpi-status-failed')).toBeInTheDocument(); }); }); describe('Label', () => { it('supports every palette color', () => { const {container} = renderUI(<> {paletteColorNames.map(color => )} ); expect(container.querySelectorAll('.wrolpi-label')).toHaveLength(paletteColorNames.length); }); }); describe('Label as a tag', () => { it('is a plain chip unless asked to be a tag', () => { // The same component carries the count badges in the Tags table -- file, zim, channel // and domain counts -- and a pointed left edge on a number reads as an arrow. const {container} = renderUI(); expect(container.querySelector('.wrolpi-label')).toBeInTheDocument(); expect(container.querySelector('.wrolpi-tag')).not.toBeInTheDocument(); }); it('takes the tag shape when asked', () => { const {container} = renderUI(); expect(container.querySelector('.wrolpi-tag')).toBeInTheDocument(); }); it('leaves the text color to the stylesheet', () => { // Every label routes its text color through `--label-text` so that night and amber, // which replace the fill with their own, can replace the text color with it. An // inline `color` could not be overridden by either. const {container} = renderUI(); expect(container.querySelector('.wrolpi-label').style.color).toBe(''); }); }); describe('the library names severity by role, never by hue', () => { /* * A source guard, because a hue here is invisible in review and invisible in most tests. * In night `--red` and `--danger` are nearly the same pixel, so a rule that reverts to * `var(--red)` looks right in every screenshot and every distinctness check -- and is * still wrong, because the whole point is that a theme gets to decide what danger looks * like. This is how `.wrolpi-message-error` kept a hardcoded `var(--red)` frame through * the change that moved its accent and icon onto roles. * * Focus rings and active-tab accents deliberately stay `--blue`: those are the primary * accent, not a severity, and `--primary` is a separate token for exactly that reason. */ const severity = /(danger|error|failed|invalid|warning|success|complete)/i; it('uses no severity hue in a rule whose name means severity', () => { const css = fs.readFileSync(path.join(__dirname, 'ui.css'), 'utf8'); const hues = /var\(--(red|green|amber|yellow|orange)\)/; // Split into rules, keeping each selector with its body. const offenders = []; for (const match of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) { const [, selector, body] = match; if (severity.test(selector) && hues.test(body)) { offenders.push(`${selector.trim().split('\n').pop().trim()} -> ${body.match(hues)[0]}`); } } expect(offenders).toEqual([]); }); }); describe('nothing paints a label\'s text with an inline color', () => { /* * A source guard, because this defect appeared at three separate call sites and fixing the * first one did not fix the others. * * A label's text color has to travel through `--label-text`. An inline `color` is a * declaration no stylesheet rule can outrank, so night -- which turns a label into an * outline over a near-black page -- keeps whatever was calculated for the fill it just * discarded. A bright fill leaves black text on near-black: the tag reads as an empty * outline, and nothing fails. * * The three were the tag chip, the tag edit preview (the very preview of the color being * chosen), and the Flasher's chip badges. The stylesheet comment explaining the night and * amber override already named the Flasher, and it was still missed. */ const sourceFiles = (directory) => fs.readdirSync(directory, {withFileTypes: true}) .flatMap(entry => { const full = path.join(directory, entry.name); if (entry.isDirectory()) { return entry.name === 'node_modules' ? [] : sourceFiles(full); } if (!/\.(js|tsx)$/.test(entry.name) || /\.test\.|\.cy\./.test(entry.name)) { return []; } return [[full, fs.readFileSync(full, 'utf8')]]; }); it('sets --label-text instead, everywhere', () => { const src = path.join(__dirname, '..', '..'); const offenders = []; for (const [file, source] of sourceFiles(src)) { const stripped = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); let from = 0; while (true) { const at = stripped.indexOf('wrolpi-label', from); if (at === -1) break; from = at + 1; // The style object belonging to this element, if it has one nearby. const styleAt = stripped.indexOf('style={{', at); if (styleAt === -1 || styleAt - at > 200) continue; const styleEnd = stripped.indexOf('}}', styleAt); if (styleEnd === -1) continue; const styleObject = stripped.slice(styleAt, styleEnd); // A bare `color:` -- not `--label-color`, and not `backgroundColor` and friends, // which are camelCase and so do not match a lowercase `color:`. if (/(?:^|[^-\w])color\s*:/.test(styleObject)) { offenders.push(`${path.relative(src, file)}: ${styleObject.slice(0, 90)}`); } } } expect(offenders).toEqual([]); }); }); describe('Table', () => { it('renders a compound table', () => { renderUI( URL example.com
); expect(screen.getByRole('columnheader', {name: 'URL'})).toBeInTheDocument(); expect(screen.getByRole('cell', {name: 'example.com'})).toBeInTheDocument(); }); it('draws no borders between columns', () => { /* * A full grid is a lot of ink to repeat what the column headings already say, and * it cost the header its identity: with no surface of its own, a bordered header * cell looked like one more cell in the grid. * * The marker goes on the CELLS. Asserting the table lacks it is free -- the table * never carries it either way -- which is what this test did at first. */ const {container} = renderUI( ab
); expect(container.querySelector('[data-with-column-border]')).toBeNull(); // The outline around the table is a different thing, and it does live on the table. expect(container.querySelector('table')).toHaveAttribute('data-with-table-border'); }); it('marks failed rows for the danger border', () => { const {container} = renderUI( broken
); expect(container.querySelector('.wrolpi-row-failed')).toBeInTheDocument(); }); it('makes a sortable column a keyboard-reachable button that announces its state', async () => { // A click handler on the alone is unreachable by keyboard, and the arrow // glyph says nothing to a screen reader. const onSort = jest.fn(); renderUI( Size Name Actions
); expect(screen.getByRole('columnheader', {name: /Size/})).toHaveAttribute('aria-sort', 'descending'); // Sortable but not currently sorted. expect(screen.getByRole('columnheader', {name: /Name/})).toHaveAttribute('aria-sort', 'none'); // Not sortable at all. expect(screen.getByRole('columnheader', {name: 'Actions'})).not.toHaveAttribute('aria-sort'); await userEvent.click(screen.getByRole('button', {name: /Size/})); expect(onSort).toHaveBeenCalled(); }); it('keeps wide tables from scrolling the page sideways', () => { const {container} = renderUI( wide
); expect(container.querySelector('.wrolpi-table-scroll')).toBeInTheDocument(); }); }); describe('Card', () => { it('draws a mimetype accent from a token, not a hex', () => { // File cards carry the mimetype's color on their top edge so a grid of results is // scannable by kind. The old card did this with ``; two migrated // files had dropped the accent because our Card had no equivalent. const {container} = renderUI(); expect(container.firstChild).toHaveStyle({borderBottom: '3px solid var(--red)'}); }); it('has no accent when no color is given', () => { const {container} = renderUI(); expect(container.firstChild.style.borderBottom).toBe(''); }); it('puts the meta line under the title and the actions last', () => { /* * Order is the half of the fix jsdom can judge. An Archive card renders its domain * and date as `meta` and its Details/Open buttons as `actions`; before `actions` * existed the buttons were children and the meta was foot-anchored, so every archive * card read buttons-then-domain. Which slot lands where is what this asserts; that * the foot anchoring actually resolves is in ui-layout.cy.js, since `margin-top: * auto` needs a layout engine. * * That division is why there is no `margin-top: auto` assertion here any more. There * was one, reading the inline style, and it broke when the card's sizes moved into * ui.css so the interface scale could reach them -- jsdom applies no stylesheet. It * was asserting where the declaration was written rather than what it did, and the * cypress test above measures the actual foot alignment either way. */ renderUI(Details}> body ); const body = screen.getByText('Rainwater Harvesting').parentElement; expect([...body.children].map(child => child.textContent)).toEqual([ 'Rainwater Harvesting', 'engineering775.com', 'body', 'Details', ]); expect(body.querySelector('.wrolpi-card-actions')).not.toBeNull(); expect(body.querySelector('.wrolpi-card-meta')).not.toBeNull(); }); it('renders no actions row when a card has no actions', () => { // Otherwise every card in a grid pays for an empty foot, and the spacing below the // meta line differs between a card with buttons and one without. const {container} = renderUI(); expect(container.querySelector('.wrolpi-card-actions')).toBeNull(); }); }); describe('CardGroup', () => { it('renders every card it is given', () => { renderUI( ); expect(screen.getByText('One')).toBeInTheDocument(); expect(screen.getByText('Two')).toBeInTheDocument(); expect(screen.getByText('Three')).toBeInTheDocument(); }); it('gives a card 250px of track by default', () => { /* * Was 200. Raised 25% because a card wall was reading too dense: at 1280px the grid * ran five cards to a row where the pre-migration build ran four, and a video title * had about two words per line. It is the default rather than a per-page choice * because every card wall in the app -- videos, archives, docs, files, the gallery -- * takes it, which is the point of it being a default. * * A phone is unaffected: one column below about 570px of content width either way. */ const {container} = renderUI(); expect(container.querySelector('.wrolpi-card-group')) .toHaveStyle({gridTemplateColumns: 'repeat(auto-fill, minmax(15.625rem, 1fr))'}); }); it('lays cards out on a track no narrower than minWidth, in rem so it scales', () => { /* * The default suits file results; a group of wide cards raises it. If the value were * dropped the grid would fall back to one column, which is how a card wall becomes a * list on a desktop. * * The px the caller names is converted to rem, which is the whole reason a phone shows * one card per row again: as px, the track ignored the interface scale, and a 430px * screen fitted two 200px cards where the pre-migration build fitted one 290px card. */ const {container} = renderUI(); expect(container.querySelector('.wrolpi-card-group')) .toHaveStyle({gridTemplateColumns: 'repeat(auto-fill, minmax(20rem, 1fr))'}); }); }); describe('Statistic', () => { it('shows the value and its label', () => { renderUI(); expect(screen.getByText('87.4 GiB')).toBeInTheDocument(); expect(screen.getByText('Free space')).toBeInTheDocument(); }); it('colors the value from a token, not a hex', () => { // Status turns load, temperature and IO wait red or orange. A hex here would not // remap in night mode, putting a green or orange pixel on a red-only screen. const {container} = renderUI(); expect(container.querySelector('.wrolpi-statistic-value')).toHaveStyle({color: 'var(--red)'}); }); it('leaves the value in body text when no color is given', () => { const {container} = renderUI(); expect(container.querySelector('.wrolpi-statistic-value').style.color).toBe(''); }); it('renders a zero rather than nothing', () => { // `pending_downloads` is 0 on an idle WROLPi, and a falsy check would blank the cell. renderUI(); expect(screen.getByText('0')).toBeInTheDocument(); }); it('forwards the props its wrappers spread onto it', () => { // `LoadStatistic` and the four Status wrappers (`CPUTemperatureStatistic`, // `FanRPMStatistic`, `IOWaitStatistic`, `UptimeStatistic`) all end in `{...props}`. // Statistic destructured a fixed five and dropped the rest on the floor, so anything // a caller passed through a wrapper vanished with no error anywhere. renderUI(); expect(screen.getByTitle('Reported by the fan connector')).toBeInTheDocument(); }); }); describe('StatisticGroup', () => { it('gives each statistic its own cell', () => { const {container} = renderUI( ); expect(container.querySelectorAll('.wrolpi-statistic-cell')).toHaveLength(2); }); it('leaves a cell empty when its statistic renders nothing', () => { // `FanRPMStatistic` returns null on a device with no fan connector, which is most of // them. The group cannot know that in advance, so the cell is still emitted and // `.wrolpi-statistic-cell:empty` takes it back out -- which only works while the cell // has nothing of its own inline for that rule to have to outrank. const Nothing = () => null; const {container} = renderUI( ); const cells = container.querySelectorAll('.wrolpi-statistic-cell'); expect(cells[1].childNodes).toHaveLength(0); expect(cells[1].getAttribute('style')).toBeNull(); }); it('draws no chrome of its own, in markup or in style', () => { // The group is spacing only: the statistics sit on whatever surface they were dropped // onto and take its color. An inline border or background here would override the // stylesheet and could not be themed, so nothing may set one. const {container} = renderUI( ); const group = container.querySelector('.wrolpi-statistic-group'); expect(group.style.border).toBe(''); expect(group.style.background).toBe(''); expect(group.style.backgroundColor).toBe(''); for (const cell of container.querySelectorAll('.wrolpi-statistic-cell')) { expect(cell.getAttribute('style')).toBeNull(); } }); it('still passes a style from the caller through', () => { const {container} = renderUI( ); expect(container.querySelector('.wrolpi-statistic-group')).toHaveStyle({maxWidth: '600px'}); }); }); describe('Panel', () => { it('marks danger zones', () => { const {container} = renderUI(Destructive actions); expect(container.querySelector('.wrolpi-panel-danger')).toBeInTheDocument(); }); }); describe('Toggle', () => { it('carries the class its unchecked-track rule needs', () => { // Mantine writes --switch-bg onto the track element, so the token override has to // be a CSS rule scoped to this class rather than a style on the root. Without it // the unchecked track falls back to Mantine's gray — a non-red pixel in night mode. const {container} = renderUI(); expect(container.querySelector('.wrolpi-switch')).toBeInTheDocument(); }); it('exposes the switch role and reports its state', async () => { const onChange = jest.fn(); renderUI(); const toggle = screen.getByRole('switch', {name: 'Hotspot'}); expect(toggle).not.toBeChecked(); await userEvent.click(toggle); expect(onChange).toHaveBeenCalled(); }); }); describe('Checkbox', () => { it('reports and changes its state', async () => { // Uncontrolled, so the box actually has to become checked. Held at `checked={false}` // it never can, and the test could only ever say "a handler fired". renderUI(); const box = screen.getByRole('checkbox', {name: 'Download comments'}); expect(box).not.toBeChecked(); await userEvent.click(box); expect(box).toBeChecked(); }); it('hands the caller the new state', async () => { /* * Read inside the handler, as every call site does: `currentTarget` is only set * while the event is being dispatched and is null by the time the mock is inspected. * The claim is that the handler sees the state the user just asked for -- fire it * with the old value and each call site writes the state back unchanged. */ let checkedWhenCalled; const onChange = jest.fn(event => { checkedWhenCalled = event.currentTarget.checked; }); renderUI(); await userEvent.click(screen.getByRole('checkbox', {name: 'Download comments'})); expect(onChange).toHaveBeenCalledTimes(1); expect(checkedWhenCalled).toBe(true); }); it('points Mantine\'s own variables at tokens', () => { /* * The only reason this wrapper exists. Mantine draws the box and the tick from * --checkbox-color and --checkbox-icon-color; left alone they are Mantine's blue and * a literal white, and a white tick is exactly the pixel night mode forbids. */ const {container} = renderUI(); const root = container.querySelector('.mantine-Checkbox-root'); expect(root.style.getPropertyValue('--checkbox-color')).toBe('var(--blue)'); expect(root.style.getPropertyValue('--checkbox-icon-color')).toBe('var(--btn-text)'); }); it('keeps those variables when the caller brings a style of its own', () => { // `style={style}` instead of `style={{...checkboxStyles, ...style}}` would drop the // tokens for any call site that sets so much as a margin, and only in that one place. const {container} = renderUI(); const root = container.querySelector('.mantine-Checkbox-root'); expect(root.style.marginTop).toBe('4px'); expect(root.style.getPropertyValue('--checkbox-color')).toBe('var(--blue)'); }); }); describe('Loader', () => { it('has an accessible name, because a spinner with no name is silence', () => { renderUI(); expect(screen.getByLabelText('Loading')).toBeInTheDocument(); }); it('says what is being waited on when the caller knows', () => { renderUI(); expect(screen.getByLabelText('Fetching channels')).toBeInTheDocument(); }); it('takes its color from a token rather than a fixed value', () => { // A hex here would be one color in all four themes, and a blue one in night mode. const {container} = renderUI(); expect(container.querySelector('.mantine-Loader-root').style.getPropertyValue('--loader-color')) .toBe('var(--blue)'); }); }); describe('Loading', () => { it('names the spinner after the caption, so the wait is announced', () => { renderUI(Loading backups…); expect(screen.getByLabelText('Loading backups…')).toBeInTheDocument(); }); it('falls back to a generic name when the caption is not text', () => { // aria-label takes a string; handing it a React element yields "[object Object]". renderUI(Loading backups…); expect(screen.getByLabelText('Loading')).toBeInTheDocument(); }); it('renders no caption when there is nothing to say', () => { const {container} = renderUI(); const loader = container.querySelector('.mantine-Loader-root'); expect(loader).toBeInTheDocument(); /* * An empty caption div would still take its line-height and push the spinner off * centre. Counted against the loader's own parent rather than the container: the * provider is free to add wrappers, and a global count would fail on that instead. */ expect(loader.parentElement.children).toHaveLength(1); }); }); describe('Placeholder', () => { const lineWidths = (container) => [...container.querySelectorAll('.mantine-Skeleton-root')] .map(line => line.style.getPropertyValue('--skeleton-width')); it('stands in for three lines by default', () => { const {container} = renderUI(); expect(lineWidths(container)).toHaveLength(3); }); it('honours the number of lines asked for', () => { const {container} = renderUI(); expect(lineWidths(container)).toHaveLength(5); }); it('ends ragged, so it reads as text rather than a block', () => { const {container} = renderUI(); expect(lineWidths(container)).toEqual(['100%', '100%', '100%', '60%']); }); it('leaves a single line short as well', () => { // lines={1} makes the first line the last one; an off-by-one here would render one // full-width bar, which is a block and not a line of text. const {container} = renderUI(); expect(lineWidths(container)).toEqual(['60%']); }); }); describe('ThemePicker', () => { const renderPicker = (themeContext = {}) => { const setTheme = jest.fn(); const value = { savedTheme: null, setTheme, mediaFilterEnabled: false, setMediaFilterEnabled: jest.fn(), ...themeContext, }; render( ); return setTheme; }; it('offers every theme plus following the system', () => { renderPicker(); const options = screen.getAllByRole('radio').map(o => o.textContent); ['System', 'Light', 'Dark', 'Night', 'Amber'].forEach(name => { expect(options.some(text => text.startsWith(name))).toBe(true); }); }); it('marks the saved theme as selected', () => { renderPicker({savedTheme: 'night'}); const night = screen.getAllByRole('radio').find(o => o.textContent.startsWith('Night')); expect(night).toHaveAttribute('aria-checked', 'true'); }); it('treats a user who has never chosen as following the system', () => { renderPicker({savedTheme: null}); const system = screen.getAllByRole('radio').find(o => o.textContent.startsWith('System')); expect(system).toHaveAttribute('aria-checked', 'true'); }); it('applies the theme a user picks', async () => { const setTheme = renderPicker(); await userEvent.click(screen.getAllByRole('radio').find(o => o.textContent.startsWith('Amber'))); expect(setTheme).toHaveBeenCalledWith('amber'); }); it('explains what night mode is for', () => { // Users need to know it filters media too, not just the interface. renderPicker(); expect(screen.getByText(/night vision/i)).toBeInTheDocument(); }); it('offers the media filter toggle in a theme that has one', () => { renderPicker({ theme: 'night', mediaFilter: {id: 'night-red', defaultOn: true, label: 'Filter media to red', description: 'Videos, images, documents, and maps are remapped to red.'}, mediaFilterEnabled: true, }); expect(screen.getByRole('switch', {name: /filter media to red/i})).toBeChecked(); }); it('offers no toggle in a theme with no filter to offer', () => { // Light and dark have no monochrome media treatment, so there is nothing to say. renderPicker({theme: 'light', mediaFilter: undefined, mediaFilterEnabled: false}); expect(screen.queryByRole('switch')).not.toBeInTheDocument(); }); it('turns the filter off when the user flips the toggle', async () => { const setMediaFilterEnabled = jest.fn(); renderPicker({ theme: 'night', mediaFilter: {id: 'night-red', defaultOn: true, label: 'Filter media to red', description: 'Videos, images, documents, and maps are remapped to red.'}, mediaFilterEnabled: true, setMediaFilterEnabled, }); await userEvent.click(screen.getByRole('switch', {name: /filter media to red/i})); expect(setMediaFilterEnabled).toHaveBeenCalledWith(false); }); }); describe('Pagination', () => { it('reports the current page and moves when one is clicked', async () => { const onPageChange = jest.fn(); renderUI(); await userEvent.click(screen.getByRole('button', {name: 'Page 5'})); expect(onPageChange).toHaveBeenCalledWith(5); }); it('still renders one page when the total is unknown', () => { // Downloads render the paginator before the first response arrives; collapsing to // nothing and back shifts the whole page. renderUI(); expect(screen.getByRole('button', {name: 'Page 1'})).toBeInTheDocument(); }); }); describe('TabBar', () => { it('marks the active tab with a class the themes key on', () => { const {container} = renderUI( ); expect(container.querySelectorAll('.wrolpi-tab')).toHaveLength(2); expect(container.querySelectorAll('.wrolpi-tab-active')).toHaveLength(1); }); }); describe('SearchBox', () => { const results = { directories: {name: 'Directories', results: [{title: 'videos/'}]}, channels: {name: 'Channels', results: [ {title: 'videos/Wranglerstar', description: 'Wranglerstar'}, {title: 'videos/RoseRed', description: 'RoseRed Homestead'}, ]}, }; it('submits what was typed', async () => { const onSubmit = jest.fn(); renderUI(); await userEvent.type(screen.getByRole('textbox'), '{Enter}'); expect(onSubmit).toHaveBeenCalledWith('axe'); }); it('shows suggestions under their group headings', async () => { renderUI(); await userEvent.click(screen.getByRole('combobox')); expect(screen.getByText('Directories')).toBeInTheDocument(); expect(screen.getAllByRole('option')).toHaveLength(3); }); it('selects a suggestion by click', async () => { const onResultSelect = jest.fn(); renderUI(); await userEvent.click(screen.getByRole('combobox')); await userEvent.click(screen.getByText('videos/Wranglerstar')); expect(onResultSelect).toHaveBeenCalledWith( expect.objectContaining({title: 'videos/Wranglerstar'})); }); it('moves through suggestions with the arrow keys and takes one with Enter', async () => { const onResultSelect = jest.fn(); const onSubmit = jest.fn(); renderUI(); const input = screen.getByRole('combobox'); await userEvent.click(input); await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}'); expect(onResultSelect).toHaveBeenCalledWith( expect.objectContaining({title: 'videos/Wranglerstar'})); // Enter took the highlighted suggestion instead of submitting the raw text. expect(onSubmit).not.toHaveBeenCalled(); }); it('wraps around rather than stranding the user at the end of the list', async () => { const onResultSelect = jest.fn(); renderUI(); await userEvent.click(screen.getByRole('combobox')); await userEvent.keyboard('{ArrowUp}{Enter}'); expect(onResultSelect).toHaveBeenCalledWith( expect.objectContaining({title: 'videos/RoseRed'})); }); it('lets a caller render a suggestion its own way', async () => { // The search suggestions render a tag as a chip rather than a line of text; a // dropped resultRenderer loses that presentation silently. renderUI( {`custom: ${result.title}`}}/>); await userEvent.click(screen.getByRole('combobox')); expect(screen.getByText('custom: videos/Wranglerstar').tagName).toBe('EM'); // The default title/description markup is replaced, not wrapped. expect(document.querySelector('.wrolpi-searchbox-result-title')).not.toBeInTheDocument(); }); it('closes on Escape without clearing what was typed', async () => { const onChange = jest.fn(); renderUI(); await userEvent.click(screen.getByRole('combobox')); await userEvent.keyboard('{Escape}'); expect(screen.queryByRole('option')).not.toBeInTheDocument(); expect(screen.getByRole('combobox')).toHaveValue('vid'); expect(onChange).not.toHaveBeenCalled(); }); it('says it is searching rather than "no results" while suggestions are pending', async () => { renderUI(); await userEvent.click(screen.getByRole('combobox')); expect(screen.getByText(/searching/i)).toBeInTheDocument(); }); it('clears the input and submits the empty search', async () => { const onChange = jest.fn(); const onSubmit = jest.fn(); renderUI(); await userEvent.click(screen.getByRole('button', {name: 'Clear search'})); expect(onChange).toHaveBeenCalledWith(''); expect(onSubmit).toHaveBeenCalledWith(''); }); it('is a plain search field when no suggestions are wired up', () => { // Without this, a box that can never show options still claims to be a combobox. renderUI(); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); expect(screen.getByRole('textbox')).toBeInTheDocument(); }); }); describe('IconStack', () => { it('names the pair once, and hides the corner glyph from assistive tech', () => { const {container} = renderUI( } label='WiFi status unknown'> ); expect(screen.getByRole('img', {name: 'WiFi status unknown'})).toBeInTheDocument(); expect(container.querySelector('.wrolpi-icon-stack-corner')).toHaveAttribute('aria-hidden', 'true'); }); }); describe('Confirm', () => { it('names the action on its confirming button', async () => { const onConfirm = jest.fn(); renderUI(); await userEvent.click(screen.getByRole('button', {name: 'Delete'})); expect(onConfirm).toHaveBeenCalled(); }); it('styles a destructive confirmation as destructive', () => { renderUI(); expect(screen.getByRole('button', {name: 'Wipe'})).toHaveClass('wrolpi-button-danger'); }); }); describe('modal sizes', () => { /* * The size table is the single thing that halved every modal in the migration. * * The names were measured on the build users had before: mini 360, tiny 540, small * 720, large 1080, fullscreen 95%. They are mapped onto Mantine's much smaller scale, so * a modal that said `small` went from 720px to 440px and one that said nothing at all * went from 900px to 440px. That is deliberate now -- the sizes were audited call site * by call site against this scale -- but it means the table IS the design, and a quiet * edit to it moves seventy modals at once. */ const MANTINE_PX = {xs: 320, sm: 380, md: 440, lg: 620, xl: 780}; it('resolves each name to the width the audit was done against', () => { // Read out of Mantine's own stylesheet rather than restated, so a version bump that // changes the scale fails here instead of silently resizing the app. const css = fs.readFileSync( path.join(__dirname, '../../../node_modules/@mantine/core/styles.css'), 'utf8'); Object.entries(MANTINE_PX).forEach(([name, px]) => { const declared = css.match( new RegExp(`--modal-size-${name}:\\s*calc\\(([\\d.]+)rem`)); expect({name, px: declared && Math.round(parseFloat(declared[1]) * 16)}) .toEqual({name, px}); }); }); it('maps every size name onto that scale', () => { // The mapping the call sites are written against. `fullscreen` is the one that did // not shrink, and the one several audited modals were moved TO. const source = fs.readFileSync(path.join(__dirname, 'Overlays.tsx'), 'utf8'); const table = source.match(/const modalSizes[^=]*=\s*{([^}]*)}/)[1]; const pairs = Object.fromEntries([...table.matchAll(/(\w+):\s*'([^']+)'/g)] .map(m => [m[1], m[2]])); expect(pairs).toEqual({ mini: 'xs', tiny: 'sm', small: 'md', large: 'lg', fullscreen: '100%', }); }); /** * The size Mantine was actually handed for each open modal, in source order, read off the * inline style it writes on the modal root (`--modal-size: var(--modal-size-lg)`). * * Read rather than restated so a call site that names a size the table cannot translate * shows up as the raw string it passed instead of quietly resolving to the default. * * All roots, not the first: a modal is portalled to `document.body`, so two renders in * one test both land there and `querySelector` on `baseElement` returns whichever opened * first. That is how the first version of this reported the default twice and looked like * the size prop had no effect. */ const renderedModalSizes = (baseElement) => [...baseElement.querySelectorAll('.mantine-Modal-root')] .map(root => (root.getAttribute('style') ?? '').match(/--modal-size:\s*([^;]+)/)?.[1]); it('gives Confirm the tiny width by default, and lets a call site widen it', () => { /* * Confirm hardcoded `size='tiny'` and its props interface accepted no size at all, so * all twelve confirmations in the app were 380px whatever they contained -- including * TaggedDeleteConfirmModal, which renders a two-column table of file paths. No call * site could do anything about it without editing this library. * * The default stays `tiny`: eleven of the twelve are one sentence, and a confirmation * that is wider than its question reads as a bigger decision than it is. */ const {baseElement} = renderUI(<> ); expect(renderedModalSizes(baseElement)) .toEqual(['var(--modal-size-sm)', 'var(--modal-size-lg)']); }); it('never lets a call site name a size the table does not know', () => { /* * Two different hazards, and neither announces itself. * * A misspelled name is not in the table, so it passes through untranslated, * Mantine does not recognise it either, and the modal silently renders at the default * 440px. A raw MANTINE name -- `sm`, `md` -- is recognised and honoured: it works, * so nothing ever complains, and the call site quietly sits outside the vocabulary * the widths were audited in and outside the table that can retune them. Flasher and * Confirm were both doing the second. * * The tag is scanned with a paren counter rather than matched with `[^>]*?`, which is * what the first version of this did. `onClose={() => ...}` contains a `>`, so that * pattern stopped at the arrow and skipped the rest of the tag: it saw 44 of the 56 * modals that declare a size, and the twelve it missed included Confirm's `size='sm'` * in this very library -- the one offender it was written to catch. */ const known = new Set(['mini', 'tiny', 'small', 'large', 'fullscreen']); /** The opening tag starting at `start`, honouring braces in prop expressions. */ const openingTag = (source, start) => { let depth = 0; for (let i = start; i < source.length; i++) { const character = source[i]; if (character === '{' || character === '(') depth += 1; else if (character === '}' || character === ')') depth -= 1; else if (character === '>' && depth === 0) return source.slice(start, i + 1); } return ''; }; 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|tsx)$/.test(entry.name)) return []; if (/\.(test|cy)\./.test(entry.name)) return []; return [[full, fs.readFileSync(full, 'utf8')]]; }); const offenders = []; let scanned = 0; let confirms = 0; for (const [file, source] of walk(path.join(__dirname, '..', '..'))) { /* * `Confirm` as well as `Modal`, now that it takes a size: it forwards the name * straight to Modal, so it is the same vocabulary and the same hazard. * * `(?![.\w])` because `