diff --git a/test/regressions/AppSearchFixture.tsx b/test/regressions/AppSearchFixture.tsx new file mode 100644 index 00000000000000..ca89dd11f7564c --- /dev/null +++ b/test/regressions/AppSearchFixture.tsx @@ -0,0 +1,64 @@ +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` drives the rest (see the `AppSearch` block): it seeds the +// stored recent searches, opens the modal, stubs the search request and +// declares the `docsearch` layer order. Opening a fixture by hand in +// `test:regressions:dev` therefore shows the closed button only. + +// `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; + +export default function AppSearchFixture({ mode }: { mode: 'light' | 'dark' }) { + 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. + + {/* + `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. + */} + + {/* `useUserLanguage` defaults to an empty string, which makes every + `t(...)` call fall back to an ellipsis. */} + + + {/* `TestViewer`'s own surface comes from the default theme, so the + dark button would otherwise sit on a light background. */} + + + + + + + ); +} diff --git a/test/regressions/algoliaSearchStub.js b/test/regressions/algoliaSearchStub.js new file mode 100644 index 00000000000000..7d83a50ff9320a --- /dev/null +++ b/test/regressions/algoliaSearchStub.js @@ -0,0 +1,137 @@ +// 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 `` 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: 'Card', 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 card', 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: 'Card 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' }, + // Long on purpose. Below 768px DocSearch lets the title and the path wrap + // instead of truncating, and a string that fits on one line would render + // the same either way, leaving the narrow capture with nothing to catch. + content: + 'The content of the card, which renders inside a Paper surface and accepts any children you pass to it, including a card header, card media and card actions.', + snippet: { + content: { + value: + 'The content of the card, which renders inside a Paper surface and accepts any children you pass to it, including a card header, card media and card actions.', + 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); +} diff --git a/test/regressions/docsearchFixtureData.js b/test/regressions/docsearchFixtureData.js new file mode 100644 index 00000000000000..5cd3a12b2e23fc --- /dev/null +++ b/test/regressions/docsearchFixtureData.js @@ -0,0 +1,59 @@ +// Shared by the `AppSearch/SearchModal*` fixtures, `index.test.js` and +// `vite.config.mts`, so the index name that keys DocSearch's storage is +// declared once. +export const INDEX_NAME = 'material-ui-regressions'; + +export const recentSearchesKey = `__DOCSEARCH_RECENT_SEARCHES__${INDEX_NAME}`; +export const favoriteSearchesKey = `__DOCSEARCH_FAVORITE_SEARCHES__${INDEX_NAME}`; + +// Shaped like what DocSearch persists: a hit minus `_highlightResult` and +// `_snippetResult`, plus the `pathname`/`as`/`userLanguage` that `AppSearch`'s +// `transformItems` adds. Seeding these gives the start screen a hit list +// without a network round-trip, which is where the section headings and the +// result cards have to line up. +export 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', + }, +]; diff --git a/test/regressions/fixtures/AppSearch/SearchModal.tsx b/test/regressions/fixtures/AppSearch/SearchModal.tsx new file mode 100644 index 00000000000000..68efe104a8871c --- /dev/null +++ b/test/regressions/fixtures/AppSearch/SearchModal.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import AppSearchFixture from '../../AppSearchFixture'; + +export default function SearchModal() { + return ; +} diff --git a/test/regressions/fixtures/AppSearch/SearchModalDark.tsx b/test/regressions/fixtures/AppSearch/SearchModalDark.tsx new file mode 100644 index 00000000000000..5660a789bdcd4f --- /dev/null +++ b/test/regressions/fixtures/AppSearch/SearchModalDark.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import AppSearchFixture from '../../AppSearchFixture'; + +export default function SearchModalDark() { + return ; +} diff --git a/test/regressions/index.test.js b/test/regressions/index.test.js index 02e6cb24a65fed..4fbecb17840c3e 100644 --- a/test/regressions/index.test.js +++ b/test/regressions/index.test.js @@ -5,6 +5,8 @@ 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'; +import { favoriteSearchesKey, recentSearchesKey, RECENT_SEARCHES } from './docsearchFixtureData'; const currentDirectory = url.fileURLToPath(new URL('.', import.meta.url)); const AXE_SCRIPT = path.resolve(currentDirectory, '../../node_modules/axe-core/axe.min.js'); @@ -268,6 +270,98 @@ async function main() { }); }); + describe.each(['SearchModal', 'SearchModalDark'])('AppSearch/%s', (fixture) => { + test('should render the DocSearch modal correctly', async ({ pooled }) => { + const { page } = pooled; + // Seed from here rather than from the fixture. The `pooled` fixture + // clears storage on acquisition, and navigating to a route the pooled + // page already rendered does not remount it, so a seed written on mount + // would be gone by the time the modal reads it. + await page.evaluate( + ([key, favoriteKey, hits]) => { + localStorage.setItem(key, JSON.stringify(hits)); + localStorage.setItem(favoriteKey, '[]'); + }, + [recentSearchesKey, favoriteSearchesKey, RECENT_SEARCHES], + ); + 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 + // `