Skip to content
131 changes: 131 additions & 0 deletions test/regressions/AppSearchFixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { getTheme } from '@mui/internal-core-docs/branding';
import { UserLanguageProvider } from '@mui/internal-core-docs/i18n';
import PageContext from '@mui/internal-core-docs/PageContext';
import { AppSearch } from '@mui/internal-core-docs/AppLayout/components/AppSearch';

// DocSearch ships its own stylesheet, and every release is free to restyle the
// markup our `AppSearch` overrides target. Two regressions slipped through the
// v4 -> v5 bump that way, and the v3 -> v4 one broke dark mode, so both schemes
// are covered. `fixtures/AppSearch/` holds one route per scheme.
//
// `index.test.js` opens the modal (see the `AppSearch` block) rather than these
// fixtures doing it on mount: the route loop screenshots the testcase element,
// and a fixed-position overlay would paint over it. That block also declares
// the `docsearch` layer order, so opening a fixture by hand in
// `test:regressions:dev` shows the modal with DocSearch's own styling.

const INDEX_NAME = process.env.SEARCH_INDEX!;

// Shaped like what DocSearch persists: a hit minus `_highlightResult` and
// `_snippetResult`, plus the `pathname`/`as`/`userLanguage` that `AppSearch`'s
// `transformItems` adds. Seeding these renders the hit list without a network
// round-trip, which is where the section headings and the result cards have to
// line up.
const RECENT_SEARCHES = [
{
objectID: 'regression-card',
content: null,
url: 'https://mui.com/material-ui/react-card/',
url_without_anchor: 'https://mui.com/material-ui/react-card/',
type: 'lvl1',
anchor: null,
hierarchy: {
lvl0: 'Components',
lvl1: 'Card',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
productId: 'material-ui',
productCategoryId: 'core',
pathname: '/material-ui/react-card/',
as: '/material-ui/react-card/',
userLanguage: 'en',
},
{
objectID: 'regression-breakpoints',
content: null,
url: 'https://mui.com/material-ui/customization/breakpoints/',
url_without_anchor: 'https://mui.com/material-ui/customization/breakpoints/',
type: 'lvl2',
anchor: null,
hierarchy: {
lvl0: 'Customization',
lvl1: 'Breakpoints',
lvl2: 'Default breakpoints',
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
productId: 'material-ui',
productCategoryId: 'core',
pathname: '/material-ui/customization/breakpoints/',
as: '/material-ui/customization/breakpoints/',
userLanguage: 'en',
},
];

// `AppSearch` reads `productId`/`productCategoryId` off the context, and
// `PageContext` has no default value.
const pageContext = {
activePage: null,
pages: [],
productId: 'material-ui',
productCategoryId: 'core',
productIdentifier: { metadata: '', name: 'Material UI', versions: [] },
activePageParents: [],
} as unknown as React.ContextType<typeof PageContext>;

export default function AppSearchFixture({ mode }: { mode: 'light' | 'dark' }) {
// Seed before `AppSearch` mounts: DocSearch reads the stored searches once,
// when the modal first renders.
const [seeded, setSeeded] = React.useState(false);
React.useLayoutEffect(() => {
localStorage.setItem(
`__DOCSEARCH_RECENT_SEARCHES__${INDEX_NAME}`,
JSON.stringify(RECENT_SEARCHES),
);
localStorage.setItem(`__DOCSEARCH_FAVORITE_SEARCHES__${INDEX_NAME}`, '[]');
setSeeded(true);
}, []);

return (
// The docs theme, so `AppSearch` styles against `theme.vars` and its dark
// block keys off `[data-mui-color-scheme="dark"]` exactly as in production.
// The storage key is per scheme: the route loop reuses one page, and a mode
// persisted by one route would otherwise carry into the other.
<ThemeProvider
theme={getTheme('ltr')}
defaultMode={mode}
modeStorageKey={`docsearch-regression-${mode}`}
disableTransitionOnChange
>
{/*
`TestViewer` sets `box-sizing: content-box` on purpose, to catch
components that rely on the docs' reset. DocSearch is one: it sets
`border-box` on the modal subtree, and with the layer order corrected
our `content-box` now outranks it and the modal overflows. The docs
render `CssBaseline`, so render it here too.
*/}
<CssBaseline />
{/* `useUserLanguage` defaults to an empty string, which makes every
`t(...)` call fall back to an ellipsis. */}
<UserLanguageProvider defaultUserLanguage="en">
<PageContext.Provider value={pageContext}>
{/* `TestViewer`'s own surface comes from the default theme, so the
dark button would otherwise sit on a light background. */}
<Box sx={{ bgcolor: 'background.default', p: 1 }}>
{seeded ? <AppSearch sx={{ minWidth: { sm: 160 } }} /> : null}
</Box>
</PageContext.Provider>
</UserLanguageProvider>
</ThemeProvider>
);
}
133 changes: 133 additions & 0 deletions test/regressions/algoliaSearchStub.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Canned Algolia response for the `AppSearch/SearchModal*` fixtures.
//
// The results screen is where the hit markup lives -- highlighted matches,
// breadcrumbs, the tree connector between a section and its children -- so it
// needs hits. Serving them from a stub rather than the live index keeps the
// screenshots from churning every time the docs are recrawled, and keeps the
// suite off the network.
//
// Shaped like a real `/1/indexes/*/queries` response: `_snippetResult` carries
// the `<mark>` tags DocSearch renders, `_highlightResult.hierarchy.lvl0` is what
// it groups the sections by, and `productId`/`productCategoryId` are the extra
// attributes `AppSearch` requests for its product chip.
const hit = ({ objectID, type, hierarchy, content = null, snippet, url }) => ({
objectID,
type,
content,
url,
url_without_anchor: url.split('#')[0],
anchor: null,
hierarchy: {
lvl0: null,
lvl1: null,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
...hierarchy,
},
productId: 'material-ui',
productCategoryId: 'core',
_highlightResult: {
hierarchy: { lvl0: { value: hierarchy.lvl0, matchLevel: 'none', matchedWords: [] } },
},
_snippetResult: snippet,
});

const HITS = [
hit({
objectID: 'components-card',
type: 'lvl1',
hierarchy: { lvl0: 'Components', lvl1: 'Card' },
snippet: { hierarchy: { lvl1: { value: '<mark>Card</mark>', matchLevel: 'full' } } },
url: 'https://mui.com/material-ui/react-card/',
}),
hit({
objectID: 'components-card-basics',
type: 'lvl2',
hierarchy: { lvl0: 'Components', lvl1: 'Card', lvl2: 'Basic card' },
snippet: { hierarchy: { lvl2: { value: 'Basic <mark>card</mark>', matchLevel: 'full' } } },
url: 'https://mui.com/material-ui/react-card/#basic-card',
}),
hit({
objectID: 'components-card-media',
type: 'lvl2',
hierarchy: { lvl0: 'Components', lvl1: 'Card', lvl2: 'Media' },
snippet: { hierarchy: { lvl2: { value: 'Media', matchLevel: 'none' } } },
url: 'https://mui.com/material-ui/react-card/#media',
}),
hit({
objectID: 'api-card',
type: 'lvl1',
hierarchy: { lvl0: 'Component API', lvl1: 'Card API' },
snippet: { hierarchy: { lvl1: { value: '<mark>Card</mark> API', matchLevel: 'full' } } },
url: 'https://mui.com/material-ui/api/card/',
}),
hit({
objectID: 'api-card-props',
type: 'content',
hierarchy: { lvl0: 'Component API', lvl1: 'Card API' },
content: 'The content of the card. Use raised to render a raised card.',
snippet: {
content: {
value:
'The content of the <mark>card</mark>. Use raised to render a raised <mark>card</mark>.',
matchLevel: 'full',
},
},
url: 'https://mui.com/material-ui/api/card/#props',
}),
];

const RESPONSE = {
results: [
{
hits: HITS,
nbHits: HITS.length,
page: 0,
nbPages: 1,
hitsPerPage: 40,
exhaustiveNbHits: true,
query: 'card',
params: '',
index: 'material-ui-regressions',
processingTimeMS: 1,
queryID: 'regression',
},
],
};

const CORS = {
'access-control-allow-origin': '*',
'access-control-allow-headers': '*',
'access-control-allow-methods': 'GET,POST,OPTIONS',
};

const isAlgolia = (url) =>
url.hostname.endsWith('.algolia.net') || url.hostname.endsWith('.algolianet.com');

/**
* Answer the search request the modal fires, so the results screen renders the
* same hits on every run.
*
* @param {import('@playwright/test').Page} page
*/
export async function stubAlgoliaSearch(page) {
await page.route(isAlgolia, (route) =>
route.fulfill(
route.request().method() === 'OPTIONS'
? { status: 204, headers: CORS }
: {
status: 200,
headers: { ...CORS, 'content-type': 'application/json' },
body: JSON.stringify(RESPONSE),
},
),
);
}

/** @param {import('@playwright/test').Page} page */
export async function unstubAlgoliaSearch(page) {
await page.unroute(isAlgolia);
}
6 changes: 6 additions & 0 deletions test/regressions/fixtures/AppSearch/SearchModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react';
import AppSearchFixture from '../../AppSearchFixture';

export default function SearchModal() {
return <AppSearchFixture mode="light" />;
}
6 changes: 6 additions & 0 deletions test/regressions/fixtures/AppSearch/SearchModalDark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react';
import AppSearchFixture from '../../AppSearchFixture';

export default function SearchModalDark() {
return <AppSearchFixture mode="dark" />;
}
50 changes: 50 additions & 0 deletions test/regressions/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { chromium } from '@playwright/test';
import { test as base } from 'vitest';
import { recordA11y, WCAG_TAGS, GLOBAL_DISABLED_RULES } from './a11y/axe';
import { A11Y_RULES, DEFAULT_VIEWPORT, SCREENSHOT_RULES, getConfig, parseRoute } from './demoMeta';
import { stubAlgoliaSearch, unstubAlgoliaSearch } from './algoliaSearchStub';

const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url));
const AXE_SCRIPT = path.resolve(currentDirectory, '../../node_modules/axe-core/axe.min.js');
Expand Down Expand Up @@ -268,6 +269,55 @@ async function main() {
});
});

describe.each(['SearchModal', 'SearchModalDark'])('AppSearch/%s', (fixture) => {
test('should render the DocSearch modal correctly', async ({ pooled }) => {
Comment thread
LukasTy marked this conversation as resolved.
const { page } = pooled;
await renderFixture(page, `/regression-AppSearch/${fixture}`);
// The modal portals to `document.body`, so it lands outside the
// testcase element and has to be screenshotted on its own.
await page.getByRole('button', { name: /search/i }).click();
const modal = await page.waitForSelector('.DocSearch-Modal');
// `useLazyCSS` fetches the DocSearch stylesheet and injects it as a
// `<style data-href>`. Without it the modal is unstyled.
await page.waitForFunction(() =>
Boolean(document.querySelector('style[data-href*="docsearch"]')),
);
// Rank `docsearch` below `mui`, the way the layer order that
// `BrandingCssVarsProvider` declares does in the docs. Without it the
// DocSearch stylesheet wins and none of the `AppSearch` overrides
// apply. It has to happen here rather than in the fixture: a layer's
// position is fixed by where it is first named, emotion prepends its
// tags above everything in `<head>`, and it keeps doing so as
// components mount — so the only stable point is once the modal has
// finished rendering.
await page.evaluate(() => {
const style = document.createElement('style');
style.textContent = '@layer docsearch, mui;';
Comment thread
LukasTy marked this conversation as resolved.
Outdated
document.head.prepend(style);
});
await takeScreenshot(page, {
testcase: modal,
route: `/regression-AppSearch/${fixture}Open`,
});

// The results screen carries the markup the start screen never shows:
// highlighted matches, breadcrumbs, and the tree connector between a
// section and its children.
try {
await stubAlgoliaSearch(page);
await page.locator('.DocSearch-Input').fill('card');
await page.waitForSelector('.DocSearch-Hit mark');
await takeScreenshot(page, {
testcase: modal,
route: `/regression-AppSearch/${fixture}Results`,
});
} finally {
// Pages are pooled, so leave the route table as we found it.
await unstubAlgoliaSearch(page);
}
});
});

describe('Autocomplete', () => {
test('should not close immediately when textbox expands', async ({ pooled }) => {
const { page } = pooled;
Expand Down
3 changes: 3 additions & 0 deletions test/regressions/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export default defineConfig({
],
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
// `AppSearch` reads it for the Algolia index name, which also keys the
// stored recent searches the `AppSearch/SearchModal` fixture seeds.
'process.env.SEARCH_INDEX': JSON.stringify('material-ui-regressions'),
// Seed `@mui/x-data-grid-generator`'s Chance instances deterministically so
// the Data Grid composites (XHero/XGridFullDemo/XDataGrid/XTheming via
// `useDemoData`) render identical rows on every load. Without this the
Expand Down
Loading