mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
GitHub Actions runs every job CircleCI did (#789), so CircleCI only duplicated them. The README badge now shows the backend tests on master. Co-authored-by: Claude Opus 5.5 <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 CI 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
|
|
);
|
|
});
|