mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Three things the file browser made obvious.
The header was not a surface. Mantine gives thead no background at all, so
the header painted whatever was behind the table -- which is exactly what an
unstriped body row paints. Against a striped body it read as one more stripe,
and bold type was the only thing distinguishing it. It now carries `--head`,
the token that already means "recessed surface", with a full-strength 2px rule
under it: the header/body division is the one line in a table that carries
meaning, so it is the one allowed to be loud.
Column borders are gone. A line around every cell is a lot of ink to repeat
what the column headings already say, and it was half of why the header
disappeared -- a bordered header cell looks like a cell in a grid.
Row rules were `--border`, the same weight as the outline around the table, so
a long table read as a stack of boxes. They now use `--table-line`, a new
token per theme sitting between the surface and `--border`. The table outline
is not a row rule and keeps `--border`; Mantine draws both from
`--table-border-color`, so that had to be restated.
In the file browser an icon and a filename are inline siblings in one cell. An
inline SVG sits on the text baseline, so a 24px glyph beside 13px text hung
above it -- the icon looked centred in the row and the name looked like it was
resting on the icon's floor. `vertical-align: middle` centres it, and the
margin is a gap that was never there at all: the folder icon touched the first
letter of its own name.
Cypress now loads App.css, as the app does. Without it a spec covering the
file browser rows measures a component the app never renders.
One jest test and eleven browser tests, each checked red against a planted
regression. Two notes for the next person:
- `html .mantine-Table-table` is specificity, not decoration. Mantine
declares --table-border-color on its own hashed class, which ties with
`.mantine-Table-table` and wins on source order.
- the "quieter than the outline" test measures both lines against `--panel`.
Nothing paints <html> under a mounted component, so measuring against the
root background compares them to black, which ranks the *paler* line as the
louder one -- the assertion passed backwards until it was pointed at the
surface the rules actually sit on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
8.1 KiB
JavaScript
195 lines
8.1 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 colour 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 {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.
|
|
*/
|
|
return cy.mount(
|
|
<MemoryRouter initialEntries={initialEntries}>
|
|
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
|
|
<QueryProvider>
|
|
<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>
|
|
</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
|
|
);
|
|
});
|