Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions test/regressions/MarketingWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react';
import { BrandingProvider } from '@mui/internal-core-docs/branding';

// `docs/src/components/product*/*.tsx` composites are authored to run inside
// the Next.js docs site and read the docs branding theme
// (`palette.primaryDark`, `palette.gradients`, `applyDarkStyles`, ...).
// Supply it so they render in the regression fixture. `mode="light"` matches
// the live `/material-ui` / `/x` pages' default.
export default function MarketingWrapper({ children }: { children: React.ReactNode }) {
return (
<BrandingProvider mode="light">
<div style={{ width: '100%' }}>{children}</div>
</BrandingProvider>
);
}
13 changes: 13 additions & 0 deletions test/regressions/demoMeta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ describe('parseRoute', () => {
demo: 'BasicButtons',
});
});

it('parses a docs-product route into the matching product*/ docs/src path', () => {
expect(parseRoute('/docs-product-material/MaterialHero')).to.deep.equal({
path: 'docs/src/components/productMaterial/MaterialHero',
slug: 'material',
demo: 'MaterialHero',
});
expect(parseRoute('/docs-product-x/XGridFullDemo')).to.deep.equal({
path: 'docs/src/components/productX/XGridFullDemo',
slug: 'x',
demo: 'XGridFullDemo',
});
});
});

describe('getConfig', () => {
Expand Down
43 changes: 35 additions & 8 deletions test/regressions/demoMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* screenshots, one for axe — so editing one tool can never stomp on the
* other. Each list is evaluated last-match-wins (no inheritance: an override
* rule must restate every field it cares about) against the docs path
* `docs/data/material/components/{slug}/{Demo}`.
* `docs/data/material/components/{slug}/{Demo}` (component demos) or
* `docs/src/components/product{Product}/{Name}` (landing-page composites).
*
* Whole-slug exclusions where *no* tool wants anything live in the
* `index.jsx` glob — dropping them from the bundle entirely, not just from
Expand All @@ -12,12 +13,17 @@

import { minimatch } from 'minimatch';

/** Default playwright viewport when no `ScreenshotRule.viewport` matches. */
export const DEFAULT_VIEWPORT = { width: 1000, height: 700 };

export interface ScreenshotRule {
/** Minimatch glob against `docs/data/material/components/{slug}/{Demo}`. */
/** Minimatch glob against the docs path (see file-level comment). */
test: string;
enabled?: boolean;
/** Playwright waits for this selector after navigation, before axe + screenshot. */
waitForSelector?: string;
/** Per-route playwright viewport override. Defaults to {@link DEFAULT_VIEWPORT}. */
viewport?: { width: number; height: number };
}

export interface A11yRule {
Expand Down Expand Up @@ -98,6 +104,14 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [
test: 'docs/data/material/components/table/ReactVirtualizedTable',
waitForSelector: '[data-index="1"]',
}, // Wait for virtualized rows to render

// Landing-page composites under `docs/src/components/product*/` use
// desktop breakpoints (`md`+) and look clipped at the 1000×700 default.
{ test: 'docs/src/components/product*/**', viewport: { width: 1280, height: 800 } },
// X composites (large grids, dense charts) want a wider canvas to match
// their live-docs desktop layout. Last-match-wins so this overrides the
// broader product*/** viewport above.
{ test: 'docs/src/components/productX/**', viewport: { width: 1440, height: 900 } },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc playwright has a fullPage option for its screenshots. Something we can leverage over hardcoding the height?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The screenshots here are element-scoped - screenshotTarget.screenshot() on the [data-testid="testcase"] element rather than page.screenshot({ fullPage: true }) - and an element screenshot already captures the element's full rendered height regardless of viewport. So the hardcoded height in these rules isn't really doing the work; it's the width that matters (the composites key off desktop breakpoints at 1280/1440).

Rather than introduce page-level fullPage (different code path from how every other demo is shot, and it'd pull in chrome/scrollbars), would you be okay with dropping the magic height and keeping these as width-only overrides? That removes the hardcoded height while keeping the element-screenshot consistency.

];

/**
Expand All @@ -117,19 +131,32 @@ export interface ParsedRoute {
demo: string;
}

const ROUTE_REGEX = /^\/docs-components-([^/]+)\/(.+)$/;
const COMPONENT_ROUTE_REGEX = /^\/docs-components-([^/]+)\/(.+)$/;
const COMPOSITE_ROUTE_REGEX = /^\/docs-product-([^/]+)\/(.+)$/;

/**
* Map a VRT route to its docs path + slug + demo, or `null` for non-component
* routes (regression fixtures).
*
* Recognises two route shapes:
* - `/docs-components-{slug}/{Demo}` → `docs/data/material/components/{slug}/{Demo}`
* - `/docs-product-{product}/{Name}` → `docs/src/components/product{Product}/{Name}`
*/
export function parseRoute(route: string): ParsedRoute | null {
const match = route.match(ROUTE_REGEX);
if (!match) {
return null;
const componentMatch = route.match(COMPONENT_ROUTE_REGEX);
if (componentMatch) {
const [, slug, demo] = componentMatch;
return { path: `docs/data/material/components/${slug}/${demo}`, slug, demo };
}
const compositeMatch = route.match(COMPOSITE_ROUTE_REGEX);
if (compositeMatch) {
const [, product, demo] = compositeMatch;
// Re-capitalize the single-word product segment from `index.jsx`'s glob
// (`material` → `Material`, `x` → `X`) to rebuild the directory name.
const dir = `product${product.charAt(0).toUpperCase()}${product.slice(1)}`;
return { path: `docs/src/components/${dir}/${demo}`, slug: product, demo };
}
const [, slug, demo] = match;
return { path: `docs/data/material/components/${slug}/${demo}`, slug, demo };
return null;
}

/**
Expand Down
46 changes: 46 additions & 0 deletions test/regressions/fakeDateSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Override `Date` so `new Date()` and `Date.now()` return a stable instant for
// Argos — otherwise the `today` in date-aware composites (XHero /
// XDateRangeDemo build picker defaults from `dayjs()`) shifts the baseline
// every day.
//
// This MUST be imported before any module that reads `Date` at module scope.
// `index.jsx` imports it on its first line, ahead of `./fixtures` (which hosts
// the eager `import.meta.glob` calls). Eager globs *prepend* their imports to
// the module that contains them rather than appending (see mui/base-ui#4370),
// so the globs live in a separate `./fixtures` module that is imported after
// this one — guaranteeing the demos' top-level `dayjs()` runs after the
// override is installed.
//
// `Reflect.construct` preserves the native `Date.prototype` (timezone-aware
// date libraries enumerate its own methods) and forwards `new.target` for
// correct subclass construction. Follows mui/base-ui#4337. The playwright page
// also pins `timezoneId: 'UTC'` (see `index.test.js`) so the frozen instant
// renders identically regardless of the machine's local timezone.

const fakeNow = new Date('2025-06-15T09:00:00.000Z').valueOf();

const OriginalDate = Date;
const offset = fakeNow - OriginalDate.now();

function FakeDate(...args: any[]): Date | string {
if (new.target) {
return args.length === 0
? Reflect.construct(OriginalDate, [OriginalDate.now() + offset], new.target)
: Reflect.construct(OriginalDate, args, new.target);
}
return new OriginalDate(OriginalDate.now() + offset).toString();
}
Comment thread
LukasTy marked this conversation as resolved.

FakeDate.prototype = OriginalDate.prototype;
// Static methods can't be assigned to a plain function type under `strict`, so
// copy them through `Object.assign` (base-ui's version relies on a looser
// tsconfig; this is the typecheck-clean equivalent).
Object.assign(FakeDate, {
parse: OriginalDate.parse,
UTC: OriginalDate.UTC,
now: () => OriginalDate.now() + offset,
});

(globalThis as { Date: unknown }).Date = FakeDate;

export {};
207 changes: 207 additions & 0 deletions test/regressions/fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Fixture discovery for the visual-regression bundle.
//
// This module exists to host the eager `import.meta.glob` calls in isolation.
// Eager globs *prepend* their resolved imports to the top of the module that
// contains them, ahead of that module's own `import` statements (see
// mui/base-ui#4370). If these globs lived in `index.jsx`, the demo modules —
// including their top-level `dayjs()` calls — would evaluate before
// `index.jsx`'s first import (`./fakeDateSetup`) could install the frozen
// clock. Keeping them here, imported *after* `./fakeDateSetup`, guarantees the
// override is in place first.

// Get all the fixtures specifically written for preventing visual regressions.
const importRegressionFixtures = import.meta.glob(['./fixtures/**/*.(js|ts|tsx)'], {
import: 'default',
eager: true,
});

const regressionFixtures = [];

Object.keys(importRegressionFixtures).forEach((path) => {
const [suite, name] = path
.replace('./fixtures/', '')
.replace(/\.\w+$/, '')
.split('/');

// TODO: Why does webpack include a key for the absolute and relative path?
// We just want the relative path
if (path.startsWith('./')) {
regressionFixtures.push({
path,
suite: `regression-${suite}`,
name,
Component: importRegressionFixtures[path],
});
}
}, []);

// Also use some of the demos to avoid code duplication.
//
// Two exclusion layers:
// - Slug-level (whole slug has no tool consumer, or path can't be imported) lives here,
// dropping the demo from the bundle entirely.
// - Per-demo (a specific demo inside an otherwise-enrolled slug is skipped by one tool
// or the other) lives in `demoMeta.ts`, so screenshot-specific reasons
// ("Redundant", "Flaky image loading") don't also drop a11y coverage.
//
// Enrolling a new component for a11y: un-negate the slug glob below if needed,
// then add an `A11Y_RULES` entry in `demoMeta.ts`
// (e.g. `{ test: 'docs/data/material/components/foo/{BasicFoo,FooVariants}', enabled: true }`).
const importDemos = import.meta.glob(
[
'docs/data/**/[A-Z]*.js',
'docs/data/base/**/[A-Z]*/css/index.js',
'docs/data/base/**/[A-Z]*/tailwind/index.js',
'docs/data/base/**/[A-Z]*/system/index.js',
// ================== Structural — cannot be imported safely ==================
'!docs/data/experiments',
'!docs/data/material/**/*NoSnap.*',
// Templates — not demos
'!docs/data/material/getting-started/templates/blog/components',
'!docs/data/material/getting-started/templates/checkout/components',
'!docs/data/material/getting-started/templates/crud-dashboard/components',
'!docs/data/material/getting-started/templates/crud-dashboard/theme/customizations',
'!docs/data/material/getting-started/templates/crud-dashboard/hooks',
'!docs/data/material/getting-started/templates/crud-dashboard/context',
'!docs/data/material/getting-started/templates/dashboard/components',
'!docs/data/material/getting-started/templates/dashboard/internals/components',
'!docs/data/material/getting-started/templates/dashboard/theme/customizations',
'!docs/data/material/getting-started/templates/marketing-page/components',
'!docs/data/material/getting-started/templates/marketing-page/MarketingPage',
'!docs/data/material/getting-started/templates/shared-theme',
'!docs/data/material/getting-started/templates/sign-in/components',
'!docs/data/material/getting-started/templates/sign-in-side/components',
'!docs/data/material/getting-started/templates/sign-up/components',
// Customization demos — not component pages
'!docs/data/material/customization/breakpoints',
'!docs/data/material/customization/color',
'!docs/data/material/customization/container-queries/ResizableDemo',
'!docs/data/material/customization/default-theme',
'!docs/data/material/customization/density/DensityTool',
'!docs/data/material/customization/right-to-left/RtlDemo',
'!docs/data/material/customization/transitions/TransitionHover',
'!docs/data/material/customization/typography/ResponsiveFontSizesChart',
// Other non-demo subtrees
'!docs/data/material/components/menubar/components', // Source subdir, not demos
'!docs/data/material/getting-started/supported-components/MaterialUIComponents',
'!docs/data/material/guides',
'!docs/data/base/getting-started/quickstart/BaseButtonTailwind',
'!docs/data/base/guides/working-with-tailwind-css/PlayerFinal',
'!docs/data/premium-themes',
// ================== Slug-level — no tool consumer ==================
'!docs/data/material/components/backdrop', // Needs interaction
'!docs/data/material/components/click-away-listener', // Needs interaction
'!docs/data/material/components/container', // Can't see the impact
'!docs/data/material/components/dialogs', // Needs interaction
'!docs/data/material/components/image-list', // Images don't load
'!docs/data/material/components/material-icons/SearchIcons', // Heavy icon grid
'!docs/data/material/components/menus', // Needs interaction
'!docs/data/material/components/popper', // Needs interaction
'!docs/data/material/components/progress', // Flaky
'!docs/data/material/components/speed-dial', // Needs interaction
'!docs/data/material/components/textarea-autosize', // Superseded by a dedicated regression test
'!docs/data/material/components/tooltips', // Needs interaction
'!docs/data/material/components/transitions', // Needs interaction
'!docs/data/material/components/use-media-query', // Need to dynamically resize to test
'!docs/data/material/customization/breakpoints', // Need to dynamically resize to test
'!docs/data/material/customization/color', // Escape viewport
'!docs/data/material/customization/container-queries/ResizableDemo', // No public components
'!docs/data/material/customization/default-theme', // Redux isolation
'!docs/data/material/customization/density/DensityTool', // Redux isolation
'!docs/data/material/customization/right-to-left/RtlDemo',
'!docs/data/material/customization/transitions/TransitionHover', // Need interaction
'!docs/data/material/customization/typography/ResponsiveFontSizesChart',
'!docs/data/material/getting-started/supported-components/MaterialUIComponents', // No public components
'!docs/data/material/guides',
'!docs/data/base/getting-started/quickstart/BaseButtonTailwind', // CodeSandbox
'!docs/data/base/guides/working-with-tailwind-css/PlayerFinal', // No public components
'!docs/data/premium-themes',
'!docs/data/material/getting-started/versions/LatestVersions', // not a component
'!docs/data/material/getting-started/versions/ReleasedVersions', // not a component
],
{
import: 'default',
eager: true,
},
);

const demoFixtures = [];
Object.keys(importDemos).forEach((path) => {
const [name, ...suiteArray] = path
.replace('../../docs/data/', '')
.replace('.js', '')
.split('/')
.reverse();
const suite = `docs-${suiteArray
.reverse()
.join('-')
.replace(/^material-/, '')}`;

demoFixtures.push({
path,
suite,
name,
Component: importDemos[path],
});
}, []);

// Bespoke composites under `docs/src/components/product*/` that assemble the
// product landing pages (`/material-ui`, `/x`). They aren't covered by the
// `docs/data/**` glob above. See `test/regressions/MarketingWrapper.tsx` for
// the branding wrapper and `test/regressions/stubs/` for the `next/*` shims
// that let composites with transitive `next/router` imports render outside
// the Next.js docs host.
//
// This is an explicit allowlist rather than a `product*/[A-Z]*.tsx` glob: most
// composites are marketing chrome (heroes with links, pricing tables, Figma
// kit promos, template thumbnails) with no meaningful component integration to
// guard. We only cover the ones that render real MUI / MUI X components in a
// composed layout — where a CSS/theme regression would actually show up.
const importComposites = import.meta.glob(
[
// Material UI — component showcases under the docs branding theme.
'docs/src/components/productMaterial/MaterialHero.tsx',
'docs/src/components/productMaterial/MaterialStyling.tsx',
'docs/src/components/productMaterial/MaterialTheming.tsx',
// MUI X — one isolated demo per product (the switcher composite
// `XComponents` renders these same demos behind tabs; isolating them
// drops the tab chrome and avoids screenshotting one product at a time).
'docs/src/components/productX/XHero.tsx',
'docs/src/components/productX/XGridFullDemo.tsx',
'docs/src/components/productX/XDateRangeDemo.tsx',
'docs/src/components/productX/XChartsDemo.tsx',
'docs/src/components/productX/XTreeViewDemo.tsx',
'docs/src/components/productX/XTheming.tsx',
],
{
import: 'default',
eager: true,
},
);

const compositeFixtures = [];
Object.keys(importComposites).forEach((path) => {
const productMatch = path.match(/components\/product([^/]+)\//);
if (!productMatch) {
return;
}
// Only single-word products are covered (`productMaterial`, `productX`), so
// the suite name is just the lower-cased segment (`material`, `x`).
// `parseRoute` in `demoMeta.ts` re-capitalizes it to rebuild the docs path.
const product = productMatch[1].toLowerCase();
const name = path
.split('/')
.pop()
.replace(/\.tsx$/, '');
compositeFixtures.push({
path,
suite: `docs-product-${product}`,
name,
Component: importComposites[path],
isComposite: true,
});
}, []);

const fixtures = regressionFixtures.concat(demoFixtures).concat(compositeFixtures);

export default fixtures;
Loading
Loading