From 372fef5520cb1bd543e2654f7cee441b3ec03a34 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 28 May 2026 17:45:58 +0300 Subject: [PATCH 01/14] [test] Cover docs landing-page composites with Argos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Argos screenshot coverage for the ~27 bespoke composites under `docs/src/components/product*/*.tsx` that assemble the product landing pages (`/material-ui`, `/x`, `/core`, `/design-kits`, `/templates`). The existing fixture glob walks `docs/data/**` only, so these composites have historically regressed silently until someone opened the page. Ports the approach from mui/mui-x#22283 with one material-ui-specific addition: a tiny `next/*` stub layer in the regression bundle's Vite config. Nearly every product composite directly or transitively imports `Link` from `@mui/internal-core-docs/Link`, which calls `useRouter()` from `next/router`; without the stub, render either crashes at runtime or fails to bundle (the `AppLayout` barrel pulls `next/router` in via `AppLayoutHead`/`AppLayoutDocs`/`AppSearch`). - `stubs/{next-router,next-link,next-head}.{ts,tsx}` (~30 lines total): `useRouter` returns safe defaults, `next/link` renders a plain anchor (visually equivalent for Argos), `next/head` is `() => null`. - `vite.config.mts`: alias `next/router`, `next/link`, `next/head` to the stubs. - `MarketingWrapper.tsx`: wraps composite fixtures with `BrandingProvider` so they can read docs branding tokens (`palette.primaryDark`, `palette.gradients`, `applyDarkStyles`). - `index.jsx`: second `import.meta.glob` for `docs/src/components/product*/[A-Z]*.tsx`; PascalCase → kebab-case for the suite name (`productDesignKit` → suite `docs-product-design-kit`); composites get wrapped in `MarketingWrapper`. - `demoMeta.ts`: adds a `viewport` field on `ScreenshotRule`, exports `DEFAULT_VIEWPORT`, extends `parseRoute` to recognise composite routes and round-trip the kebab-case back to a PascalCase directory, and appends two viewport rules — `product*/**` → 1280×800, `productX/**` → 1440×900 (last-match-wins). - `index.test.js`: per-route `page.setViewportSize(rule?.viewport ?? DEFAULT_VIEWPORT)` before each fixture render. - `demoMeta.test.ts`: new cases for the composite route regex, including the kebab-case → PascalCase round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/regressions/MarketingWrapper.tsx | 14 +++++++ test/regressions/demoMeta.test.ts | 19 +++++++++ test/regressions/demoMeta.ts | 46 ++++++++++++++++++---- test/regressions/index.jsx | 56 ++++++++++++++++++++++++--- test/regressions/index.test.js | 20 ++++++++-- test/regressions/stubs/next-head.tsx | 8 ++++ test/regressions/stubs/next-link.tsx | 41 ++++++++++++++++++++ test/regressions/stubs/next-router.ts | 30 ++++++++++++++ test/regressions/vite.config.mts | 18 ++++++++- 9 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 test/regressions/MarketingWrapper.tsx create mode 100644 test/regressions/stubs/next-head.tsx create mode 100644 test/regressions/stubs/next-link.tsx create mode 100644 test/regressions/stubs/next-router.ts diff --git a/test/regressions/MarketingWrapper.tsx b/test/regressions/MarketingWrapper.tsx new file mode 100644 index 00000000000000..149f8d79f82485 --- /dev/null +++ b/test/regressions/MarketingWrapper.tsx @@ -0,0 +1,14 @@ +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. +export default function MarketingWrapper({ children }: { children: React.ReactNode }) { + return ( + +
{children}
+
+ ); +} diff --git a/test/regressions/demoMeta.test.ts b/test/regressions/demoMeta.test.ts index 514d39704f92cd..4c7bd96dea61bf 100644 --- a/test/regressions/demoMeta.test.ts +++ b/test/regressions/demoMeta.test.ts @@ -14,6 +14,25 @@ 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', + }); + // Multi-segment slug: kebab-case round-trips to PascalCase directory. + expect(parseRoute('/docs-product-design-kit/DesignKitHero')).to.deep.equal({ + path: 'docs/src/components/productDesignKit/DesignKitHero', + slug: 'design-kit', + demo: 'DesignKitHero', + }); + }); }); describe('getConfig', () => { diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts index a31673b5dad1eb..ade894e6e070ea 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -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 @@ -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 { @@ -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 } }, ]; /** @@ -117,19 +131,35 @@ 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; + // Reverse the kebab-case conversion applied by the glob in `index.jsx`: + // `design-kit` → `DesignKit` → directory `productDesignKit`. + const pascal = product + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(''); + return { path: `docs/src/components/product${pascal}/${demo}`, slug: product, demo }; } - const [, slug, demo] = match; - return { path: `docs/data/material/components/${slug}/${demo}`, slug, demo }; + return null; } /** diff --git a/test/regressions/index.jsx b/test/regressions/index.jsx index d514daf40c6742..e54e8a053dc483 100644 --- a/test/regressions/index.jsx +++ b/test/regressions/index.jsx @@ -5,6 +5,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react import webfontloader from 'webfontloader'; import { Globals } from '@react-spring/web'; import TestViewer from './TestViewer'; +import MarketingWrapper from './MarketingWrapper'; import './global.css'; // Skip charts annimation for screen shots @@ -154,7 +155,39 @@ Object.keys(importDemos).forEach((path) => { }); }, []); -function FixtureRenderer({ component: FixtureComponent, path }) { +// Bespoke composites under `docs/src/components/product*/` that assemble the +// product landing pages (`/material-ui`, `/x`, `/core`, ...). 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. +const importComposites = import.meta.glob(['docs/src/components/product*/[A-Z]*.tsx'], { + import: 'default', + eager: true, +}); + +const compositeFixtures = []; +Object.keys(importComposites).forEach((path) => { + const productMatch = path.match(/components\/product([^/]+)\//); + if (!productMatch) { + return; + } + // Convert PascalCase to kebab-case so the suite name follows the existing + // `docs-{kebab-case}` convention (e.g. `productDesignKit` → + // `design-kit` → suite `docs-product-design-kit`). `parseRoute` in + // `demoMeta.ts` reverses this when reconstructing the docs path. + const product = productMatch[1].replace(/(?<=[a-z])(?=[A-Z])/g, '-').toLowerCase(); + const name = path.split('/').pop().replace(/\.tsx$/, ''); + compositeFixtures.push({ + path, + suite: `docs-product-${product}`, + name, + Component: importComposites[path], + isComposite: true, + }); +}, []); + +function FixtureRenderer({ component: FixtureComponent, path, isComposite }) { React.useEffect(() => { const viewerRoot = document.getElementById('test-viewer'); const testRoot = document.createElement('div'); @@ -163,7 +196,13 @@ function FixtureRenderer({ component: FixtureComponent, path }) { React.startTransition(() => { reactRoot.render( - + {isComposite ? ( + + + + ) : ( + + )} , ); }); @@ -175,13 +214,14 @@ function FixtureRenderer({ component: FixtureComponent, path }) { viewerRoot.removeChild(testRoot); }; - }, [FixtureComponent, path]); + }, [FixtureComponent, path, isComposite]); return null; } FixtureRenderer.propTypes = { component: PropTypes.elementType, + isComposite: PropTypes.bool, path: PropTypes.string.isRequired, }; @@ -260,7 +300,13 @@ function App(props) { key={path} exact path={path} - element={} + element={ + + } /> ); })} @@ -305,7 +351,7 @@ App.propTypes = { const container = document.getElementById('react-root'); const children = ( - + ); const reactRoot = ReactDOMClient.createRoot(container); diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js index b14ee71bbf6a29..221cbfc339144b 100644 --- a/test/regressions/index.test.js +++ b/test/regressions/index.test.js @@ -3,7 +3,13 @@ import * as path from 'path'; import * as fs from 'node:fs/promises'; import { chromium } from '@playwright/test'; import { recordA11y, WCAG_TAGS, GLOBAL_DISABLED_RULES } from './a11y/axe'; -import { A11Y_RULES, SCREENSHOT_RULES, getConfig, parseRoute } from './demoMeta'; +import { + A11Y_RULES, + DEFAULT_VIEWPORT, + SCREENSHOT_RULES, + getConfig, + parseRoute, +} from './demoMeta'; const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); const AXE_SCRIPT = path.resolve(currentDirectory, '../../node_modules/axe-core/axe.min.js'); @@ -17,10 +23,11 @@ async function main() { // otherwise the loaded google Roboto font isn't applied headless: false, }); - // reuse viewport from `vrtest` - // https://github.com/nathanmarks/vrtest/blob/1185b852a6c1813cedf5d81f6d6843d9a241c1ce/src/server/runner.js#L44 + // Default viewport reused from `vrtest` + // (https://github.com/nathanmarks/vrtest/blob/1185b852a6c1813cedf5d81f6d6843d9a241c1ce/src/server/runner.js#L44). + // Per-route overrides are applied below via `screenshotRule.viewport`. const page = await browser.newPage({ - viewport: { width: 1000, height: 700 }, + viewport: DEFAULT_VIEWPORT, reducedMotion: 'reduce', }); @@ -136,6 +143,11 @@ async function main() { this?.timeout?.(0); } + // Apply per-route viewport before rendering so the composite renders + // at its desktop breakpoint. Routes without a matching rule keep the + // default 1000x700. + await page.setViewportSize(screenshotRule?.viewport ?? DEFAULT_VIEWPORT); + const testcase = await renderFixture(route); if (screenshotRule?.waitForSelector) { diff --git a/test/regressions/stubs/next-head.tsx b/test/regressions/stubs/next-head.tsx new file mode 100644 index 00000000000000..95b81bfc0b6170 --- /dev/null +++ b/test/regressions/stubs/next-head.tsx @@ -0,0 +1,8 @@ +// `AppLayoutHead` (a sibling of the `Frame`/`Item`/`Highlighter` exports we +// import from `@mui/internal-core-docs/AppLayout`) imports `next/head` at +// module scope. Stub it defensively so the barrel evaluates cleanly even if +// Vite doesn't tree-shake `AppLayoutHead` out. + +export default function Head() { + return null; +} diff --git a/test/regressions/stubs/next-link.tsx b/test/regressions/stubs/next-link.tsx new file mode 100644 index 00000000000000..cb8bc19c701093 --- /dev/null +++ b/test/regressions/stubs/next-link.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; + +// Stub for `next/link`. Renders a plain anchor — visually equivalent to the +// Next.js Link for Argos purposes (link text + styling captured). +interface NextLinkStubProps extends React.AnchorHTMLAttributes { + href: string | { pathname?: string }; + as?: string; + prefetch?: boolean; + replace?: boolean; + scroll?: boolean; + shallow?: boolean; + passHref?: boolean; + locale?: string | false; + legacyBehavior?: boolean; +} + +const NextLink = React.forwardRef(function NextLink( + { + href, + as: _as, + prefetch: _prefetch, + replace: _replace, + scroll: _scroll, + shallow: _shallow, + passHref: _passHref, + locale: _locale, + legacyBehavior: _legacyBehavior, + children, + ...rest + }, + ref, +) { + const url = typeof href === 'string' ? href : (href?.pathname ?? ''); + return ( + + {children} + + ); +}); + +export default NextLink; diff --git a/test/regressions/stubs/next-router.ts b/test/regressions/stubs/next-router.ts new file mode 100644 index 00000000000000..f57984d98848da --- /dev/null +++ b/test/regressions/stubs/next-router.ts @@ -0,0 +1,30 @@ +// Stub for `next/router` so docs composites that import `useRouter` +// (directly or transitively via `@mui/internal-core-docs/Link`) render +// without a Next.js host. Returns safe defaults; nothing in the regression +// bundle actually needs to navigate. + +const router = { + pathname: '/', + asPath: '/', + route: '/', + query: {}, + basePath: '', + isLocaleDomain: false, + isReady: true, + isPreview: false, + isFallback: false, + push: () => Promise.resolve(true), + replace: () => Promise.resolve(true), + prefetch: () => Promise.resolve(), + back: () => {}, + forward: () => {}, + reload: () => {}, + beforePopState: () => {}, + events: { on: () => {}, off: () => {}, emit: () => {} }, +}; + +export function useRouter() { + return router; +} + +export default { useRouter }; diff --git a/test/regressions/vite.config.mts b/test/regressions/vite.config.mts index 3f958831024b69..0beaaa28bd65a1 100644 --- a/test/regressions/vite.config.mts +++ b/test/regressions/vite.config.mts @@ -1,9 +1,13 @@ +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; import { defineConfig, transformWithEsbuild } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; // eslint-disable-next-line import/no-relative-packages import { alias } from '../../vitest.shared.mts'; +const stubsDir = resolve(fileURLToPath(new URL('.', import.meta.url)), 'stubs'); + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -35,7 +39,19 @@ export default defineConfig({ 'process.env.NODE_ENV': JSON.stringify('production'), }, resolve: { - alias, + alias: [ + // The existing `alias` from vitest.shared.mts is an object map; spread + // it as { find, replacement } entries so the next/* regex aliases below + // coexist with it. + ...Object.entries(alias).map(([find, replacement]) => ({ find, replacement })), + // Stub `next/*` modules so docs composites under `docs/src/components/` + // that transitively import `next/router` (via `@mui/internal-core-docs/Link`) + // render outside the Next.js docs host. See `stubs/next-router.ts` for + // the rationale. + { find: /^next\/router$/, replacement: `${stubsDir}/next-router.ts` }, + { find: /^next\/link$/, replacement: `${stubsDir}/next-link.tsx` }, + { find: /^next\/head$/, replacement: `${stubsDir}/next-head.tsx` }, + ], }, optimizeDeps: { esbuildOptions: { From e7691db5ca27e24dd7cc140d2646811f93704af7 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 28 May 2026 17:52:52 +0300 Subject: [PATCH 02/14] [test] Fix prettier formatting Co-Authored-By: Claude Opus 4.7 (1M context) --- test/regressions/index.jsx | 5 ++++- test/regressions/index.test.js | 8 +------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/test/regressions/index.jsx b/test/regressions/index.jsx index e54e8a053dc483..6a56d09b3a67a5 100644 --- a/test/regressions/index.jsx +++ b/test/regressions/index.jsx @@ -177,7 +177,10 @@ Object.keys(importComposites).forEach((path) => { // `design-kit` → suite `docs-product-design-kit`). `parseRoute` in // `demoMeta.ts` reverses this when reconstructing the docs path. const product = productMatch[1].replace(/(?<=[a-z])(?=[A-Z])/g, '-').toLowerCase(); - const name = path.split('/').pop().replace(/\.tsx$/, ''); + const name = path + .split('/') + .pop() + .replace(/\.tsx$/, ''); compositeFixtures.push({ path, suite: `docs-product-${product}`, diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js index 221cbfc339144b..a35dc08c0909c5 100644 --- a/test/regressions/index.test.js +++ b/test/regressions/index.test.js @@ -3,13 +3,7 @@ import * as path from 'path'; import * as fs from 'node:fs/promises'; import { chromium } from '@playwright/test'; import { recordA11y, WCAG_TAGS, GLOBAL_DISABLED_RULES } from './a11y/axe'; -import { - A11Y_RULES, - DEFAULT_VIEWPORT, - SCREENSHOT_RULES, - getConfig, - parseRoute, -} from './demoMeta'; +import { A11Y_RULES, DEFAULT_VIEWPORT, SCREENSHOT_RULES, getConfig, parseRoute } from './demoMeta'; const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); const AXE_SCRIPT = path.resolve(currentDirectory, '../../node_modules/axe-core/axe.min.js'); From ec5d9f94751cb706e1f4712d843981f28035c09a Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 01:16:26 +0300 Subject: [PATCH 03/14] [test] Fix TypeScript errors surfaced by composite coverage - `stubs/next-link.tsx`: `NextLinkStubProps` was extending `AnchorHTMLAttributes` but widening `href` from `string | undefined` to `string | { pathname?: string }`. Omit `href` from the base before redeclaring it. - `MarketingWrapper.tsx`: importing `BrandingProvider` from `@mui/internal-core-docs/branding` pulled in the package's barrel, whose sibling modules use extensionless relative imports (`from './brandingTheme'`). That's fine under the package's own `moduleResolution: 'bundler'`, but the `test/tsconfig.json` typecheck runs under `nodenext` and surfaced them as TS2835. Deep-import `brandingLightTheme` from the leaf module (which has zero relative imports) and inline the minimal light-mode `ThemeProvider` wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/regressions/MarketingWrapper.tsx | 19 +++++++++++++++---- test/regressions/stubs/next-link.tsx | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/test/regressions/MarketingWrapper.tsx b/test/regressions/MarketingWrapper.tsx index 149f8d79f82485..2102234d446def 100644 --- a/test/regressions/MarketingWrapper.tsx +++ b/test/regressions/MarketingWrapper.tsx @@ -1,14 +1,25 @@ import * as React from 'react'; -import { BrandingProvider } from '@mui/internal-core-docs/branding'; +import { ThemeProvider } from '@mui/material/styles'; +// Deep-import the theme directly rather than going through +// `@mui/internal-core-docs/branding`'s barrel. The barrel re-exports a few +// sibling modules whose relative imports lack `.js` extensions — fine under +// the package's own `moduleResolution: 'bundler'`, but the test typecheck +// runs under `nodenext` and would surface those as errors. Importing the +// leaf module (which has no relative imports) sidesteps the chain. +// eslint-disable-next-line import/no-relative-packages +import { brandingLightTheme } from '../../packages-internal/core-docs/src/branding/brandingTheme'; // `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. +// Supply it so they render in the regression fixture. Inlines the minimal +// light-mode subset of `BrandingProvider` (a thin `ThemeProvider` wrapper); +// composites that read `theme.palette.mode` get `'light'`, which matches +// the live `/material-ui` / `/x` pages' default. export default function MarketingWrapper({ children }: { children: React.ReactNode }) { return ( - +
{children}
-
+ ); } diff --git a/test/regressions/stubs/next-link.tsx b/test/regressions/stubs/next-link.tsx index cb8bc19c701093..57d1c8909bf128 100644 --- a/test/regressions/stubs/next-link.tsx +++ b/test/regressions/stubs/next-link.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; // Stub for `next/link`. Renders a plain anchor — visually equivalent to the // Next.js Link for Argos purposes (link text + styling captured). -interface NextLinkStubProps extends React.AnchorHTMLAttributes { +// +// `href` widens past the plain-anchor `string | undefined` to accept +// Next.js's `UrlObject` shape, so we omit it from the base interface and +// redeclare it here. +interface NextLinkStubProps extends Omit, 'href'> { href: string | { pathname?: string }; as?: string; prefetch?: boolean; From 93d0d933954aaf44e08cae58e1e1c6a572df8dbe Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 10:46:15 +0300 Subject: [PATCH 04/14] [test] Dedupe pnpm lockfile Caught by CI's `pnpm dedupe --check`. The `eslint-plugin-import` peer info had a nested `eslint-import-resolver-typescript` resolver specifier that pnpm dedupe simplifies to the flat form. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44c4a19e6b0158..ec6d7ccedd94ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13663,7 +13663,7 @@ snapshots: eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)) eslint-plugin-compat: 7.0.1(eslint@10.3.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@10.3.0(jiti@2.6.1)) eslint-plugin-mdx: 3.7.0(eslint@10.3.0(jiti@2.6.1)) eslint-plugin-mocha: 11.2.0(eslint@10.3.0(jiti@2.6.1)) @@ -17478,7 +17478,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.9.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -17528,7 +17528,7 @@ snapshots: lodash: 4.18.1 pkg-dir: 5.0.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)))(eslint@10.3.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@10.3.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@10.3.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 From 46b620305ee53c8850172714ebe3fe02a3f5c378 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 10:49:54 +0300 Subject: [PATCH 05/14] [test] Insulate test typecheck from branding theme augmentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `test_types` job (which runs `tsc -p test/tsconfig.json` under `moduleResolution: 'nodenext'`) was failing to resolve `@mui/material/themeCssVarsAugmentation` and friends when typechecking walked into `packages-internal/core-docs/src/branding/brandingTheme.ts` via `MarketingWrapper.tsx`'s deep import. The augmentations resolve fine in the package's own typecheck (`bundler` resolution) and in the Vite bundle, but not in `test/`'s nodenext context against the test package's node_modules layout. Workaround: route the import through a local `brandingThemeShim` — the `.d.ts` declares the theme as a `@mui/material/styles` `Theme`, and the `.js` does the runtime re-export. TS picks the `.d.ts` and never walks into the augmentation source; Vite picks the `.js` and resolves it normally. Also added `regressions/brandingThemeShim.js` to `test/tsconfig.json`'s exclude as belt-and-suspenders so TS doesn't accidentally include the runtime shim and start chasing imports through it. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/regressions/MarketingWrapper.tsx | 15 +++++++-------- test/regressions/brandingThemeShim.d.ts | 14 ++++++++++++++ test/regressions/brandingThemeShim.js | 5 +++++ test/tsconfig.json | 3 ++- 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 test/regressions/brandingThemeShim.d.ts create mode 100644 test/regressions/brandingThemeShim.js diff --git a/test/regressions/MarketingWrapper.tsx b/test/regressions/MarketingWrapper.tsx index 2102234d446def..4ca85a45d3d653 100644 --- a/test/regressions/MarketingWrapper.tsx +++ b/test/regressions/MarketingWrapper.tsx @@ -1,13 +1,12 @@ import * as React from 'react'; import { ThemeProvider } from '@mui/material/styles'; -// Deep-import the theme directly rather than going through -// `@mui/internal-core-docs/branding`'s barrel. The barrel re-exports a few -// sibling modules whose relative imports lack `.js` extensions — fine under -// the package's own `moduleResolution: 'bundler'`, but the test typecheck -// runs under `nodenext` and would surface those as errors. Importing the -// leaf module (which has no relative imports) sidesteps the chain. -// eslint-disable-next-line import/no-relative-packages -import { brandingLightTheme } from '../../packages-internal/core-docs/src/branding/brandingTheme'; +// Go through a local `.d.ts` shim rather than importing the docs branding +// theme directly. The theme source file declares `@mui/material/*` module +// augmentations that resolve fine in the package's own typecheck and in the +// Vite bundle, but fail under `test/tsconfig.json`'s `nodenext` resolution. +// The runtime side is in `brandingThemeShim.js`, excluded from the test +// typecheck. +import { brandingLightTheme } from './brandingThemeShim'; // `docs/src/components/product*/*.tsx` composites are authored to run inside // the Next.js docs site and read the docs branding theme diff --git a/test/regressions/brandingThemeShim.d.ts b/test/regressions/brandingThemeShim.d.ts new file mode 100644 index 00000000000000..d04be204f4b25d --- /dev/null +++ b/test/regressions/brandingThemeShim.d.ts @@ -0,0 +1,14 @@ +import type { Theme } from '@mui/material/styles'; + +// Declaration-only shim for the docs branding theme. +// +// The runtime export lives in the matching `.js` file (excluded from the +// test typecheck). We declare the shape here so `MarketingWrapper.tsx` can +// consume it without TS transitively pulling in +// `packages-internal/core-docs/src/branding/brandingTheme.ts`. That source +// file declares augmentations to `@mui/material/styles` and friends, which +// resolve fine in the package's own `bundler`-resolution typecheck and in +// the Vite build, but fail under `test/tsconfig.json`'s `nodenext` +// resolution (`@mui/material/themeCssVarsAugmentation` can't be found in +// the test package's node_modules layout). +export const brandingLightTheme: Theme; diff --git a/test/regressions/brandingThemeShim.js b/test/regressions/brandingThemeShim.js new file mode 100644 index 00000000000000..c0d0f1ef42b75c --- /dev/null +++ b/test/regressions/brandingThemeShim.js @@ -0,0 +1,5 @@ +// Runtime re-export of the docs branding theme. See `brandingThemeShim.d.ts` +// for the rationale — this file is excluded from `test/tsconfig.json` so TS +// uses the declaration; Vite resolves the import here at bundle time. +// eslint-disable-next-line import/no-relative-packages +export { brandingLightTheme } from '../../packages-internal/core-docs/src/branding/brandingTheme'; diff --git a/test/tsconfig.json b/test/tsconfig.json index 838698ac0ea236..ad87dba5f08b1b 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -14,6 +14,7 @@ "regressions/build/**/*", "vitest.config.ts", "e2e/vite.config.mts", - "regressions/vite.config.mts" + "regressions/vite.config.mts", + "regressions/brandingThemeShim.js" ] } From 3c05e153080ae9e096773cd1959787ea7cedc670 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 17:02:43 +0300 Subject: [PATCH 06/14] [test] Narrow composite coverage to meaningful integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broad `product*/[A-Z]*.tsx` glob pulled in all 27 composites, but most are marketing chrome — heroes that are mostly links/images, pricing tables, Figma-kit promos, template thumbnails — with no real component integration for Argos to guard. Replace it with an explicit allowlist of the 9 composites that render real MUI / MUI X components in a composed layout, where a CSS/theme regression would actually show. Kept: - productMaterial: MaterialHero, MaterialStyling, MaterialTheming - productX: XHero, XGridFullDemo, XDateRangeDemo, XChartsDemo, XTreeViewDemo, XTheming For MUI X, each product is covered by its isolated demo rather than via the `XComponents` switcher composite (which renders these same demos behind tabs) — that drops the tab chrome and the duplicate grid section (`XDataGrid`), giving one clean screenshot per product. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/index.jsx | 41 +++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/test/regressions/index.jsx b/test/regressions/index.jsx index 6a56d09b3a67a5..2946747c1c47c8 100644 --- a/test/regressions/index.jsx +++ b/test/regressions/index.jsx @@ -156,15 +156,38 @@ Object.keys(importDemos).forEach((path) => { }, []); // Bespoke composites under `docs/src/components/product*/` that assemble the -// product landing pages (`/material-ui`, `/x`, `/core`, ...). 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. -const importComposites = import.meta.glob(['docs/src/components/product*/[A-Z]*.tsx'], { - import: 'default', - eager: true, -}); +// 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) => { From 437d6636afaa1f6ee1696b1976dee0bf8ea7a0a9 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 17:02:48 +0300 Subject: [PATCH 07/14] [test] Freeze the clock for date-dependent composites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XHero and XDateRangeDemo build their picker default values from `dayjs()` at *module scope* (e.g. `const startDate = dayjs().date(10)`), which runs when the eager fixture glob loads the bundle — before any render-time or per-test mock could intervene. Without a fixed clock the calendars (and any date columns in the grid demos) would shift every run and churn the Argos baseline. Install a fixed `Date` via an inline script in `index.html`, ahead of the module bundle, so it is in place before any composite module evaluates. Native `new Date(arg)`, `Date.parse`, `Date.UTC` and `instanceof Date` keep working; only the "now" reading is pinned. Mirrors the fixed-date approach used by mui-x's regression bundle. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/index.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/regressions/index.html b/test/regressions/index.html index 11d30901325150..d009756f1557a3 100644 --- a/test/regressions/index.html +++ b/test/regressions/index.html @@ -1,6 +1,27 @@ + Visual regression tests From 29dab5a0315708a97658f7f2a425772a615f663e Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 18:02:07 +0300 Subject: [PATCH 08/14] [test] Align date freeze with base-ui's regression approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous inline `Date` override was hand-rolled and I incorrectly described it as mirroring mui-x (mui-x uses sinon `useFakeTimers`; this bundle does not). Replace the shim with the reviewed approach from mui/base-ui#4337: `Reflect.construct` preserves the native `Date.prototype` (timezone-aware date libraries enumerate its own methods) and forwards `new.target`. Also pin `timezoneId: 'UTC'` on the playwright page — without it the frozen instant would render in the CI machine's local timezone and still churn the baseline. This was missing before. Kept as an inline classic Visual regression tests diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js index 5d54ba82ef193a..3e849bf6c3ac54 100644 --- a/test/regressions/index.test.js +++ b/test/regressions/index.test.js @@ -34,6 +34,9 @@ async function main() { const page = await _browser.newPage({ viewport: DEFAULT_VIEWPORT, reducedMotion: 'reduce', + // Pin the timezone so the frozen `Date` (see `index.html`) renders the + // same instant regardless of the CI machine's local timezone. + timezoneId: 'UTC', }); // Block images since they slow down tests (need download). From 7e01d9a40b643ec31f8590fa103388be3a8d4ac5 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 29 May 2026 18:02:11 +0300 Subject: [PATCH 09/14] [test] Simplify composite route mapping to single-word products With coverage narrowed to `productMaterial` and `productX`, the kebab-case <-> PascalCase round-trip (added for multi-word products like `productDesignKit`) is dead code. Replace it with a plain lower-case / re-capitalize of the single-word product segment in both `index.jsx` (suite name) and `demoMeta.ts` (`parseRoute`), and drop the now-irrelevant multi-segment test case. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/demoMeta.test.ts | 6 ------ test/regressions/demoMeta.ts | 11 ++++------- test/regressions/index.jsx | 9 ++++----- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/test/regressions/demoMeta.test.ts b/test/regressions/demoMeta.test.ts index 4c7bd96dea61bf..e751a21e83723c 100644 --- a/test/regressions/demoMeta.test.ts +++ b/test/regressions/demoMeta.test.ts @@ -26,12 +26,6 @@ describe('parseRoute', () => { slug: 'x', demo: 'XGridFullDemo', }); - // Multi-segment slug: kebab-case round-trips to PascalCase directory. - expect(parseRoute('/docs-product-design-kit/DesignKitHero')).to.deep.equal({ - path: 'docs/src/components/productDesignKit/DesignKitHero', - slug: 'design-kit', - demo: 'DesignKitHero', - }); }); }); diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts index ade894e6e070ea..a9c67869eb541c 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -151,13 +151,10 @@ export function parseRoute(route: string): ParsedRoute | null { const compositeMatch = route.match(COMPOSITE_ROUTE_REGEX); if (compositeMatch) { const [, product, demo] = compositeMatch; - // Reverse the kebab-case conversion applied by the glob in `index.jsx`: - // `design-kit` → `DesignKit` → directory `productDesignKit`. - const pascal = product - .split('-') - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(''); - return { path: `docs/src/components/product${pascal}/${demo}`, slug: product, demo }; + // 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 }; } return null; } diff --git a/test/regressions/index.jsx b/test/regressions/index.jsx index 2946747c1c47c8..7f89ef202d316c 100644 --- a/test/regressions/index.jsx +++ b/test/regressions/index.jsx @@ -195,11 +195,10 @@ Object.keys(importComposites).forEach((path) => { if (!productMatch) { return; } - // Convert PascalCase to kebab-case so the suite name follows the existing - // `docs-{kebab-case}` convention (e.g. `productDesignKit` → - // `design-kit` → suite `docs-product-design-kit`). `parseRoute` in - // `demoMeta.ts` reverses this when reconstructing the docs path. - const product = productMatch[1].replace(/(?<=[a-z])(?=[A-Z])/g, '-').toLowerCase(); + // 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() From 0be52a02e08d18b093a064e3cb05751f08d458d0 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 2 Jun 2026 12:47:36 +0300 Subject: [PATCH 10/14] [test] Move date freeze into a module, matching base-ui's structure Follows mui/base-ui#4337 + #4370 fully, instead of the earlier inline ` Visual regression tests diff --git a/test/regressions/index.jsx b/test/regressions/index.jsx index 7f89ef202d316c..11f58aff11795c 100644 --- a/test/regressions/index.jsx +++ b/test/regressions/index.jsx @@ -1,3 +1,8 @@ +// `./fakeDateSetup` MUST stay first: it installs the frozen `Date` before any +// other module — notably the demo modules pulled in by `./fixtures`'s eager +// globs — reads `Date` at module scope. See `fakeDateSetup.ts` for why this +// ordering (and the separate `./fixtures` module) is required. +import './fakeDateSetup'; import * as React from 'react'; import PropTypes from 'prop-types'; import * as ReactDOMClient from 'react-dom/client'; @@ -6,6 +11,7 @@ import webfontloader from 'webfontloader'; import { Globals } from '@react-spring/web'; import TestViewer from './TestViewer'; import MarketingWrapper from './MarketingWrapper'; +import allFixtures from './fixtures'; import './global.css'; // Skip charts annimation for screen shots @@ -19,199 +25,6 @@ window.muiFixture = { }, }; -// 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, - }); -}, []); - function FixtureRenderer({ component: FixtureComponent, path, isComposite }) { React.useEffect(() => { const viewerRoot = document.getElementById('test-viewer'); @@ -376,7 +189,7 @@ App.propTypes = { const container = document.getElementById('react-root'); const children = ( - + ); const reactRoot = ReactDOMClient.createRoot(container); From 2ac0f9292670bb976ee9f85f37f4c83c1d7bebe8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 3 Jun 2026 10:31:05 +0300 Subject: [PATCH 11/14] [test] Fix next/router stub default export The real `next/router` default export is the router singleton (`import Router from 'next/router'`), used by `packages-internal/core-docs/src/Link/MarkdownLinks.ts` for `Router.events`. The stub defaulted to `{ useRouter }`, so that path would throw. Export the `router` object as the default; keep the named `useRouter` hook for the other call sites. Addresses a review comment from Copilot. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/stubs/next-router.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/regressions/stubs/next-router.ts b/test/regressions/stubs/next-router.ts index f57984d98848da..f508da20163df4 100644 --- a/test/regressions/stubs/next-router.ts +++ b/test/regressions/stubs/next-router.ts @@ -1,7 +1,12 @@ -// Stub for `next/router` so docs composites that import `useRouter` -// (directly or transitively via `@mui/internal-core-docs/Link`) render -// without a Next.js host. Returns safe defaults; nothing in the regression -// bundle actually needs to navigate. +// Stub for `next/router` so docs composites that import it (directly or +// transitively via `@mui/internal-core-docs/Link`) render without a Next.js +// host. Returns safe defaults; nothing in the regression bundle actually +// needs to navigate. +// +// The real `next/router` exposes the router as BOTH the default export (the +// singleton, `import Router from 'next/router'` — used by +// `MarkdownLinks.ts` for `Router.events`) and via the named `useRouter` +// hook. Mirror both so either consumer works. const router = { pathname: '/', @@ -27,4 +32,4 @@ export function useRouter() { return router; } -export default { useRouter }; +export default router; From 430d93e41c9ae177f2825ead840a94a9ec75d650 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 3 Jun 2026 10:31:19 +0300 Subject: [PATCH 12/14] [test] Resolve test types like a bundler, drop branding shim `test/tsconfig.json` used `moduleResolution: nodenext`, which can't resolve subpath exports like `@mui/material/themeCssVarsAugmentation` the way the Vite/vitest bundler does. That mismatch forced a `brandingThemeShim.{d.ts,js}` indirection so `MarketingWrapper` could import the docs branding theme without the typecheck choking on its module augmentations. Resolve modules like a bundler instead (matching the root tsconfig and the actual regression bundle), then: - delete both `brandingThemeShim` files, - import `BrandingProvider` directly from `@mui/internal-core-docs/branding` in `MarketingWrapper`, - drop the now-needless shim exclude from `test/tsconfig.json`. The `nodenext` setting predates this PR (added in #48248); flipping it to `bundler` changes how the whole `test/` package typechecks (e2e, integration, regressions). Verified `pnpm typescript` (all 19 projects) and `pnpm test:regressions:build` still pass. Addresses a review comment from @Janpot. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/MarketingWrapper.tsx | 17 ++++------------- test/regressions/brandingThemeShim.d.ts | 15 --------------- test/regressions/brandingThemeShim.js | 7 ------- test/tsconfig.json | 11 +++++++---- 4 files changed, 11 insertions(+), 39 deletions(-) delete mode 100644 test/regressions/brandingThemeShim.d.ts delete mode 100644 test/regressions/brandingThemeShim.js diff --git a/test/regressions/MarketingWrapper.tsx b/test/regressions/MarketingWrapper.tsx index 4a79fb0d01e720..8ba90c48297f21 100644 --- a/test/regressions/MarketingWrapper.tsx +++ b/test/regressions/MarketingWrapper.tsx @@ -1,24 +1,15 @@ import * as React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -// Go through a local `.d.ts` shim rather than importing the docs branding -// theme directly. The theme source file declares `@mui/material/*` module -// augmentations that resolve fine in the package's own typecheck and in the -// Vite bundle, but fail under `test/tsconfig.json`'s `nodenext` resolution. -// The runtime side is in `brandingThemeShim.js`, excluded from the test -// typecheck. -import brandingLightTheme from './brandingThemeShim'; +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. Inlines the minimal -// light-mode subset of `BrandingProvider` (a thin `ThemeProvider` wrapper); -// composites that read `theme.palette.mode` get `'light'`, which matches +// 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 ( - +
{children}
-
+ ); } diff --git a/test/regressions/brandingThemeShim.d.ts b/test/regressions/brandingThemeShim.d.ts deleted file mode 100644 index 2702cdf30974a0..00000000000000 --- a/test/regressions/brandingThemeShim.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Theme } from '@mui/material/styles'; - -// Declaration-only shim for the docs branding theme. -// -// The runtime export lives in the matching `.js` file (excluded from the -// test typecheck). We declare the shape here so `MarketingWrapper.tsx` can -// consume it without TS transitively pulling in -// `packages-internal/core-docs/src/branding/brandingTheme.ts`. That source -// file declares augmentations to `@mui/material/styles` and friends, which -// resolve fine in the package's own `bundler`-resolution typecheck and in -// the Vite build, but fail under `test/tsconfig.json`'s `nodenext` -// resolution (`@mui/material/themeCssVarsAugmentation` can't be found in -// the test package's node_modules layout). -declare const brandingLightTheme: Theme; -export default brandingLightTheme; diff --git a/test/regressions/brandingThemeShim.js b/test/regressions/brandingThemeShim.js deleted file mode 100644 index 587bbb50a54f0f..00000000000000 --- a/test/regressions/brandingThemeShim.js +++ /dev/null @@ -1,7 +0,0 @@ -// Runtime re-export of the docs branding theme. See `brandingThemeShim.d.ts` -// for the rationale — this file is excluded from `test/tsconfig.json` so TS -// uses the declaration; Vite resolves the import here at bundle time. -// eslint-disable-next-line import/no-relative-packages -import { brandingLightTheme } from '../../packages-internal/core-docs/src/branding/brandingTheme'; - -export default brandingLightTheme; diff --git a/test/tsconfig.json b/test/tsconfig.json index ad87dba5f08b1b..9c47150ab42c2e 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -4,8 +4,12 @@ "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, - "module": "nodenext", - "moduleResolution": "nodenext", + // Resolve modules the way the Vite/vitest bundler does (matching the root + // tsconfig), so typechecking sees the same module graph the regression + // bundle builds. `nodenext` here diverged from the bundler and couldn't + // resolve subpath exports like `@mui/material/themeCssVarsAugmentation`. + "module": "esnext", + "moduleResolution": "bundler", "types": ["vitest/globals"] }, "include": ["e2e/**/*", "integration/**/*", "regressions/**/*"], @@ -14,7 +18,6 @@ "regressions/build/**/*", "vitest.config.ts", "e2e/vite.config.mts", - "regressions/vite.config.mts", - "regressions/brandingThemeShim.js" + "regressions/vite.config.mts" ] } From 96203d80c3a2822490e3d9ec935b0c9065438098 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 3 Jun 2026 13:58:02 +0300 Subject: [PATCH 13/14] [test] Make composite viewport overrides width-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-route viewport override hardcoded both width and height, but screenshots target the testcase element (`screenshotTarget.screenshot()`), which captures its full rendered height regardless of viewport — so only the width ever affected the result (composites key off desktop breakpoints). Replace `viewport: { width, height }` with `viewportWidth`; the runner keeps the default viewport height. Removes the misleading hardcoded heights (1280x800 / 1440x900 -> widths 1280 / 1440). Addresses a review comment from @Janpot. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/demoMeta.ts | 19 +++++++++++++------ test/regressions/index.test.js | 15 ++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/test/regressions/demoMeta.ts b/test/regressions/demoMeta.ts index a9c67869eb541c..1d11c6a5f70380 100644 --- a/test/regressions/demoMeta.ts +++ b/test/regressions/demoMeta.ts @@ -22,8 +22,15 @@ export interface ScreenshotRule { 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 }; + /** + * Per-route viewport width override (px). Defaults to + * {@link DEFAULT_VIEWPORT}'s width. Only the width is configurable: the + * screenshot targets the testcase element, which captures its full rendered + * height regardless of viewport, so width is the only axis that affects the + * result (composites key off desktop breakpoints). The viewport height stays + * at the default. + */ + viewportWidth?: number; } export interface A11yRule { @@ -106,12 +113,12 @@ export const SCREENSHOT_RULES: ScreenshotRule[] = [ }, // 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 } }, + // desktop breakpoints (`md`+) and look clipped at the default width. + { test: 'docs/src/components/product*/**', viewportWidth: 1280 }, // 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 } }, + // broader product*/** width above. + { test: 'docs/src/components/productX/**', viewportWidth: 1440 }, ]; /** diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js index 3e849bf6c3ac54..a3d69f923d2cb5 100644 --- a/test/regressions/index.test.js +++ b/test/regressions/index.test.js @@ -171,11 +171,16 @@ async function main() { { timeout: process.env.PWDEBUG ? 0 : undefined }, async ({ pooled, task }) => { const { page } = pooled; - // Apply per-route viewport before rendering so the composite - // renders at its desktop breakpoint. Routes without a matching - // rule fall back to DEFAULT_VIEWPORT — important when the page - // comes from a pool and may have been resized by a prior test. - await page.setViewportSize(screenshotRule?.viewport ?? DEFAULT_VIEWPORT); + // Apply per-route viewport width before rendering so the composite + // renders at its desktop breakpoint. Only the width varies per + // rule (the screenshot captures the testcase element's full height + // regardless); the height stays at the default. Routes without a + // matching rule fall back to DEFAULT_VIEWPORT — important when the + // page comes from a pool and may have been resized by a prior test. + await page.setViewportSize({ + width: screenshotRule?.viewportWidth ?? DEFAULT_VIEWPORT.width, + height: DEFAULT_VIEWPORT.height, + }); const testcase = await renderFixture(page, route); if (screenshotRule?.waitForSelector) { From af9d0643e1f54c3820943f5197d4197983d4b728 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 3 Jun 2026 13:58:13 +0300 Subject: [PATCH 14/14] [test] Make next/link stub honor `as` and object href query/hash The stub ignored the `as` prop and dropped `query`/`hash` when `href` was a `UrlObject`, so internal docs call sites that pass `{ pathname, query, hash }` (AppNavDrawer) or use `as` (AppSearch) would render incorrect anchor hrefs if they ended up in the bundle. Prefer `as` when present, and serialize the object form (`pathname` + `?query` + `#hash`) the way Next does. Addresses a review comment from Copilot. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/regressions/stubs/next-link.tsx | 33 +++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/test/regressions/stubs/next-link.tsx b/test/regressions/stubs/next-link.tsx index 57d1c8909bf128..05978941776556 100644 --- a/test/regressions/stubs/next-link.tsx +++ b/test/regressions/stubs/next-link.tsx @@ -6,8 +6,15 @@ import * as React from 'react'; // `href` widens past the plain-anchor `string | undefined` to accept // Next.js's `UrlObject` shape, so we omit it from the base interface and // redeclare it here. +interface UrlObject { + pathname?: string; + query?: Record | string; + hash?: string; +} + interface NextLinkStubProps extends Omit, 'href'> { - href: string | { pathname?: string }; + href: string | UrlObject; + /** Next's `as` is the URL actually shown in the address bar — prefer it. */ as?: string; prefetch?: boolean; replace?: boolean; @@ -18,10 +25,28 @@ interface NextLinkStubProps extends Omit(function NextLink( { href, - as: _as, + as, prefetch: _prefetch, replace: _replace, scroll: _scroll, @@ -34,7 +59,9 @@ const NextLink = React.forwardRef(function }, ref, ) { - const url = typeof href === 'string' ? href : (href?.pathname ?? ''); + // `as` (when present) is the URL Next actually renders; otherwise derive it + // from `href`, serializing the object form. + const url = as ?? (typeof href === 'string' ? href : urlObjectToHref(href)); return ( {children}