mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
The cypress component suite has been failing 12 of 311 for long enough to read
as background noise. Every one of them was the test's own fault, and four
distinct causes account for all twelve.
Dead selectors. `div.ui.label.large`, `div.divider.text`, `.item` and
`have.attr 'error'` all named markup the old library rendered. None of them
started failing when the library was replaced -- they went on matching nothing,
or looking for an attribute no control carries, which is the failure mode that
makes a test look broken rather than the code. A tag chip is `.wrolpi-tag`, a
Select is a real input whose value can be read, an option carries the ARIA role,
and an invalid field says `aria-invalid`. Both directions of that last one are
asserted by value: a valid field says `aria-invalid="false"` rather than
dropping the attribute, so `not.have.attr` would have passed on absence.
Missing context. `useFileWorkerStatus` throws rather than defaulting when its
provider is absent, so the channel edit page died on a modal buried three levels
down while every other page mounted fine. App.js wraps the whole app in that
provider; the mount helper now does too, with the poll slowed to a minute.
Unstubbed fetches. The inventory page loads the food catalog from its own
endpoint, and the RSS edit form fetched downloaders before its intercept was
declared. Both rejected with "Failed to fetch" out of an effect, which Cypress
counts as an uncaught application error and fails the running test on -- so
three inventory tests and one download test died for a reason unrelated to
anything they assert.
Assertions reading the wrong thing. `cy.contains('Food Storage')` found the
hidden `.inventory-print` copy, since contains takes the deepest match and the
print sheet is mounted at all times. And the PUT body arrives as a JSON string,
so `req.body.items` was undefined both in the reply the interceptor built and in
the assertion waiting on it. The RSS submit test deep-equalled one exact JSON
string and broke the moment the video downloader gained eight settings the form
fills in by default; the envelope is still matched exactly and the settings are
checked for the values the fixture set. It also never waited on its own alias,
so an interceptor that never ran would have passed in silence.
Separately, the navbar's shrink test read its link count in a bare `.then()`
right after the corner finished growing -- before the recalculation that growth
triggers had run, so `shrunken` was sometimes already the full count and the
assertion came out "expected 10 to be above 10" under load. It now waits for
the bar to settle to one row, which is the same signal the test above it
asserts, and requires the "More" trigger to exist so it cannot pass having
measured a bar that never overflowed.
New: two tests for the file browser filter's width, desktop and phone, which is
what caught the dead selector behind it.
311 passing, 0 failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
8.9 KiB
JavaScript
206 lines
8.9 KiB
JavaScript
// ***********************************************************
|
|
// This example support/component.js is processed and
|
|
// loaded automatically before your test files.
|
|
//
|
|
// This is a great place to put global configuration and
|
|
// behavior that modifies Cypress.
|
|
//
|
|
// You can change the location of this file or turn off
|
|
// automatically serving support files with the
|
|
// 'supportFile' configuration option.
|
|
//
|
|
// You can read more here:
|
|
// https://on.cypress.io/configuration
|
|
// ***********************************************************
|
|
|
|
import './commands'
|
|
// Component tests mount real components, so they need the same styles the app loads.
|
|
import '../../src/index.css';
|
|
import '../../src/themes/fonts.css';
|
|
import '../../src/themes/tokens.css';
|
|
import '@mantine/core/styles.css';
|
|
// App.js loads this too. Without it a Notification renders unstyled here, so a toast had no
|
|
// surface and no title color of Mantine's -- and its contrast test could not fail.
|
|
import '@mantine/notifications/styles.css';
|
|
/*
|
|
* App.js loads this as well. Rules for things outside the component library live here --
|
|
* the nav bar, the file browser rows -- and without it a spec covering one of them measures
|
|
* a component the app never renders.
|
|
*/
|
|
import '../../src/App.css';
|
|
// react-router v7 ships these directly; `react-router-dom` is not installed.
|
|
import {MemoryRouter, Route, Routes} from "react-router";
|
|
import {QueryProvider} from "../../src/hooks/customHooks";
|
|
import {TagsProvider} from "../../src/Tags";
|
|
import {FileWorkerStatusProvider} from "../../src/contexts/FileWorkerStatusContext";
|
|
import {MantineProvider} from "@mantine/core";
|
|
import {cssVariablesResolver, mantineTheme} from "../../src/themes/mantine";
|
|
import {isDarkTheme} from "../../src/themes/names";
|
|
import {MediaFilterDefs} from "../../src/themes/MediaFilterDefs";
|
|
import React from "react";
|
|
|
|
Cypress.on('uncaught:exception', (err, runnable) => {
|
|
if (err.message.includes('ChunkLoadError')) {
|
|
return false; // Ignore this error which happens after CircleCI tests.
|
|
}
|
|
// Otherwise, fail like normal
|
|
return true;
|
|
});
|
|
|
|
Cypress.Commands.add('mountWithRouter', (component, options) => {
|
|
options = options || {};
|
|
const initialEntries = options?.initialEntries || ['/'];
|
|
/*
|
|
* MantineProvider, configured exactly as ThemeProvider configures it. Every component
|
|
* in src/components/ui renders Mantine internals and throws without it -- the same
|
|
* thing that had to be added to test-utils.js for jest.
|
|
*
|
|
* FileWorkerStatusProvider is here for the same reason: App.js wraps the whole app in
|
|
* it, and `useFileWorkerStatus` THROWS rather than returning a default when it is
|
|
* missing. A page that reaches it only through a modal buried a few levels down --
|
|
* the channel edit page reaches it via CollectionReorganizeModal -- therefore mounted
|
|
* fine everywhere else and died here, which is what the edit-channel spec was failing
|
|
* on. The poll is slowed right down because a spec is over long before the second
|
|
* request would tell it anything, and its fetch errors are swallowed by the provider.
|
|
*/
|
|
return cy.mount(
|
|
<MemoryRouter initialEntries={initialEntries}>
|
|
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
|
|
<QueryProvider>
|
|
<FileWorkerStatusProvider pollInterval={60000}>
|
|
<Routes>
|
|
<Route path='/videos/channels/new' element={component}/>
|
|
<Route path='/videos/channel/:channelId/edit' element={component}/>
|
|
<Route path='/videos/channels/:channelId' element={component}/>
|
|
<Route path='*' element={component}/>
|
|
</Routes>
|
|
</FileWorkerStatusProvider>
|
|
</QueryProvider>
|
|
</MantineProvider>
|
|
</MemoryRouter>,
|
|
options
|
|
);
|
|
});
|
|
|
|
/*
|
|
* Mount a component from src/components/ui with nothing but Mantine's provider and the
|
|
* theme stamped on <html> -- no router, no query client, no tag fixtures.
|
|
*
|
|
* That spartan setup is the whole point. These components take props and read no ambient
|
|
* state, so a real browser can render one in isolation, which is what lets a spec assert
|
|
* things jsdom cannot compute: whether two boxes overlap, whether text is clipped, whether
|
|
* a token actually resolved. The Settings page shipped five unreadable inputs because
|
|
* `padding-inline-start: auto` silently became zero, and no jsdom test could have seen it --
|
|
* getBoundingClientRect() returns zeros there.
|
|
*
|
|
* `theme` stamps data-theme the way ThemeProvider does; `mediaFilter` stamps
|
|
* data-media-filter and mounts the SVG filter definitions, since a filter referenced but
|
|
* never defined silently does nothing.
|
|
*
|
|
* `forceColorScheme` matters as much as the token table, and was missing. Mantine keys its
|
|
* own rules on data-mantine-color-scheme, which ThemeProvider sets from the theme -- night and
|
|
* amber ride on dark. Without it every theme rendered against Mantine's LIGHT scheme here, so
|
|
* a per-theme test could pass on a page that was broken in the app: the toast title, which
|
|
* Mantine paints with `--mantine-color-white` in dark scheme only, resolved correctly by
|
|
* accident and its contrast test could not fail.
|
|
*/
|
|
Cypress.Commands.add('mountUI', (component, options = {}) => {
|
|
const {theme = 'light', mediaFilter, ...mountOptions} = options;
|
|
|
|
const html = document.documentElement;
|
|
html.dataset.theme = theme;
|
|
if (mediaFilter) html.dataset.mediaFilter = mediaFilter;
|
|
else delete html.dataset.mediaFilter;
|
|
|
|
return cy.mount(
|
|
<MantineProvider
|
|
theme={mantineTheme}
|
|
cssVariablesResolver={cssVariablesResolver}
|
|
forceColorScheme={isDarkTheme(theme) ? 'dark' : 'light'}
|
|
>
|
|
{mediaFilter ? <MediaFilterDefs/> : null}
|
|
{component}
|
|
</MantineProvider>,
|
|
mountOptions
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Fail unless two elements are laid out side by side.
|
|
*
|
|
* Reads real geometry, so it catches an element painted over another regardless of how the
|
|
* overlap came about -- an invalid length, a collapsed flex row, a stale absolute position.
|
|
*/
|
|
Cypress.Commands.add('shouldNotOverlap', {prevSubject: false}, (firstSelector, secondSelector) => {
|
|
cy.get(firstSelector).then(($first) => {
|
|
cy.get(secondSelector).then(($second) => {
|
|
const a = $first[0].getBoundingClientRect();
|
|
const b = $second[0].getBoundingClientRect();
|
|
const overlapsHorizontally = (a.right > b.left + 0.5) && (b.right > a.left + 0.5);
|
|
const overlapsVertically = (a.bottom > b.top + 0.5) && (b.bottom > a.top + 0.5);
|
|
const overlaps = overlapsHorizontally && overlapsVertically;
|
|
expect(
|
|
overlaps,
|
|
`${firstSelector} [${a.left}, ${a.right}] must not overlap `
|
|
+ `${secondSelector} [${b.left}, ${b.right}]`,
|
|
).to.equal(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Contrast ratio between an element's text and the surface actually behind it, per WCAG.
|
|
*
|
|
* "The surface actually behind it" is the point. A transparent element -- which is what a
|
|
* tag becomes in night mode -- has no background of its own to compare against, so a naive
|
|
* check reads `rgba(0,0,0,0)` and concludes anything is legible. This walks up to the first
|
|
* ancestor that paints something, which is what the eye sees.
|
|
*/
|
|
const parseRgb = (value) => {
|
|
const parts = (value.match(/[\d.]+/g) || []).map(Number);
|
|
return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1};
|
|
};
|
|
|
|
const relativeLuminance = ({r, g, b}) => {
|
|
const channel = (v) => {
|
|
const s = v / 255;
|
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
};
|
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
};
|
|
|
|
const effectiveBackground = (el) => {
|
|
let node = el;
|
|
while (node && node !== document.documentElement) {
|
|
const bg = parseRgb(getComputedStyle(node).backgroundColor);
|
|
if (bg.a > 0) return bg;
|
|
node = node.parentElement;
|
|
}
|
|
return parseRgb(getComputedStyle(document.documentElement).backgroundColor);
|
|
};
|
|
|
|
Cypress.Commands.add('contrastRatio', {prevSubject: false}, (selector) => {
|
|
return cy.get(selector).then(($el) => {
|
|
const el = $el[0];
|
|
const text = relativeLuminance(parseRgb(getComputedStyle(el).color));
|
|
const behind = relativeLuminance(effectiveBackground(el));
|
|
const lighter = Math.max(text, behind);
|
|
const darker = Math.min(text, behind);
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
});
|
|
});
|
|
|
|
Cypress.Commands.add('mountWithTags', (component, options) => {
|
|
cy.fixture('tagsMock.json').then((mockTags) => {
|
|
cy.intercept('GET', '**/api/tag', {
|
|
statusCode: 200,
|
|
body: mockTags
|
|
}).as('getTags');
|
|
});
|
|
return cy.mountWithRouter(
|
|
<TagsProvider>
|
|
{component}
|
|
</TagsProvider>,
|
|
options
|
|
);
|
|
});
|