diff --git a/.circleci/config.yml b/.circleci/config.yml index 5b1cfae099..47b978d259 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -392,6 +392,19 @@ jobs: - run: name: Upload screenshots to Argos CI command: pnpm test:argos + test_e2e: + <<: *default-job + docker: + - image: mcr.microsoft.com/playwright:v1.43.1-focal + environment: + NODE_ENV: development # Needed if playwright is in `devDependencies` + steps: + - checkout + - install_js: + browsers: true + - run: + name: pnpm test:e2e + command: pnpm test:e2e workflows: version: 2 pipeline: @@ -424,7 +437,10 @@ workflows: <<: *default-context requires: - checkout - + - test_e2e: + <<: *default-context + requires: + - checkout profile: when: equal: [profile, << pipeline.parameters.workflow >>] diff --git a/package.json b/package.json index 82e0649a82..2f33fb77f6 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,11 @@ "test:coverage": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc --reporter=text mocha 'packages/**/*.test.{js,ts,tsx}' 'docs/**/*.test.{js,ts,tsx}'", "test:coverage:ci": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc --reporter=lcov mocha 'packages/**/*.test.{js,ts,tsx}' 'docs/**/*.test.{js,ts,tsx}'", "test:coverage:html": "cross-env NODE_ENV=test BABEL_ENV=coverage nyc --reporter=html mocha 'packages/**/*.test.{js,ts,tsx}' 'docs/**/*.test.{js,ts,tsx}'", + "test:e2e": "cross-env NODE_ENV=production pnpm test:e2e:build && concurrently --success first --kill-others \"pnpm test:e2e:run\" \"pnpm test:e2e:server\"", + "test:e2e:build": "webpack --config test/e2e/webpack.config.js", + "test:e2e:dev": "concurrently \"pnpm test:e2e:build --watch\" \"pnpm test:e2e:server\"", + "test:e2e:run": "mocha --config test/e2e/.mocharc.js 'test/e2e/**/*.test.{js,ts,tsx}'", + "test:e2e:server": "serve test/e2e -p 5001", "test:karma": "cross-env NODE_ENV=test karma start test/karma.conf.js", "test:karma:profile": "cross-env NODE_ENV=test karma start test/karma.conf.profile.js", "test:regressions": "cross-env NODE_ENV=production pnpm test:regressions:build && concurrently --success first --kill-others \"pnpm test:regressions:run\" \"pnpm test:regressions:server\"", diff --git a/test/e2e/.mocharc.js b/test/e2e/.mocharc.js new file mode 100644 index 0000000000..95bb343e11 --- /dev/null +++ b/test/e2e/.mocharc.js @@ -0,0 +1,8 @@ +module.exports = { + extension: ['js', 'ts', 'tsx'], + recursive: true, + slow: 500, + timeout: (process.env.CIRCLECI === 'true' ? 4 : 2) * 1000, // Circle CI has low-performance CPUs. + reporter: 'dot', + require: ['@mui/internal-test-utils/setupBabelPlaywright'], +}; diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000000..ce3a9c9978 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,30 @@ +# end-to-end testing + +End-to-end tests (short e2e) are split into two parts: + +1. The rendered UI (short: fixture) +2. Instrumentation of that UI + +## Rendered UI + +The composition of all tests happens in `./index.js`. +The rendered UI is located inside a separate file in `./fixtures` and written as a React component. +If you're adding a new test prefer a new component instead of editing existing files since that might unknowingly alter existing tests. + +## Instrumentation + +We're using [`playwright`](https://playwright.dev) to replay user actions. +Each test tests only a single fixture. +A fixture can be loaded with `await renderFixture(fixturePath)`, for example `renderFixture('FocusTrap/OpenFocusTrap')`. + +## Commands + +For development `pnpm test:e2e:dev` and `pnpm test:e2e:run --watch` in separate terminals is recommended. + +| command | description | +| :--------------------- | :-------------------------------------------------------------------------------------------- | +| `pnpm test:e2e` | Full run | +| `pnpm test:e2e:dev` | Prepares the fixtures to be able to test in watchmode | +| `pnpm test:e2e:run` | Runs the tests (requires `pnpm test:e2e:dev` or `pnpm test:e2e:build`+`pnpm test:e2e:server`) | +| `pnpm test:e2e:build` | Builds the Webpack bundle for viewing the fixtures | +| `pnpm test:e2e:server` | Serves the fixture bundle. | diff --git a/test/e2e/TestViewer.js b/test/e2e/TestViewer.js new file mode 100644 index 0000000000..09c21fd4d7 --- /dev/null +++ b/test/e2e/TestViewer.js @@ -0,0 +1,28 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; + +function TestViewer(props) { + const { children } = props; + + // We're simulating `act(() => ReactDOM.render(children))` + // In the end children passive effects should've been flushed. + // React doesn't have any such guarantee outside of `act()` so we're approximating it. + const [ready, setReady] = React.useState(false); + React.useEffect(() => { + setReady(true); + }, []); + + return ( + }> +
+ {children} +
+
+ ); +} + +TestViewer.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default TestViewer; diff --git a/test/e2e/fixtures/FocusTrap/ClosedFocusTrap.tsx b/test/e2e/fixtures/FocusTrap/ClosedFocusTrap.tsx new file mode 100644 index 0000000000..e79d2280ec --- /dev/null +++ b/test/e2e/fixtures/FocusTrap/ClosedFocusTrap.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { FocusTrap } from '@base_ui/react/FocusTrap'; + +export default function ClosedFocusTrap() { + return ( + + + +
+ +
+
+ +
+ ); +} diff --git a/test/e2e/fixtures/FocusTrap/DefaultOpenLazyFocusTrap.tsx b/test/e2e/fixtures/FocusTrap/DefaultOpenLazyFocusTrap.tsx new file mode 100644 index 0000000000..4576cacb35 --- /dev/null +++ b/test/e2e/fixtures/FocusTrap/DefaultOpenLazyFocusTrap.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { FocusTrap } from '@base_ui/react/FocusTrap'; + +export default function BaseFocusTrap() { + const [open, close] = React.useReducer(() => false, true); + + return ( + + + true} open={open} disableAutoFocus> +
+
Title
+ + +
+
+
+ ); +} diff --git a/test/e2e/fixtures/FocusTrap/DisableEnforceFocusFocusTrap.tsx b/test/e2e/fixtures/FocusTrap/DisableEnforceFocusFocusTrap.tsx new file mode 100644 index 0000000000..f51b7a0eb6 --- /dev/null +++ b/test/e2e/fixtures/FocusTrap/DisableEnforceFocusFocusTrap.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { FocusTrap } from '@base_ui/react/FocusTrap'; + +export default function disableEnforceFocusFocusTrap() { + return ( + + + +
+ +
+
+
+ ); +} diff --git a/test/e2e/fixtures/FocusTrap/OpenFocusTrap.tsx b/test/e2e/fixtures/FocusTrap/OpenFocusTrap.tsx new file mode 100644 index 0000000000..835dae6fed --- /dev/null +++ b/test/e2e/fixtures/FocusTrap/OpenFocusTrap.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { FocusTrap } from '@base_ui/react/FocusTrap'; + +export default function BaseFocusTrap() { + return ( + + + true} open> +
+
Title
+ + + +
+
+
+ ); +} diff --git a/test/e2e/fixtures/TextareaAutosize/TextareaAutosizeSuspense.tsx b/test/e2e/fixtures/TextareaAutosize/TextareaAutosizeSuspense.tsx new file mode 100644 index 0000000000..433fd461cf --- /dev/null +++ b/test/e2e/fixtures/TextareaAutosize/TextareaAutosizeSuspense.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { TextareaAutosize } from '@base_ui/react/TextareaAutosize'; + +function LazyRoute() { + const [isDone, setIsDone] = React.useState(false); + + if (!isDone) { + // Force React to show fallback suspense + throw new Promise((resolve) => { + setTimeout(resolve, 1); + setIsDone(true); + }); + } + + return
; +} + +export default function TextareaAutosizeSuspense() { + const [showRoute, setShowRoute] = React.useState(false); + + return ( + + + + {showRoute ? : } + + + ); +} diff --git a/test/e2e/index.js b/test/e2e/index.js new file mode 100644 index 0000000000..e0c3ff8a1a --- /dev/null +++ b/test/e2e/index.js @@ -0,0 +1,127 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as ReactDOMClient from 'react-dom/client'; +import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import * as DomTestingLibrary from '@testing-library/dom'; +import TestViewer from './TestViewer'; + +const fixtures = []; + +const importFixtures = require.context('./fixtures', true, /\.(js|ts|tsx)$/, 'lazy'); +importFixtures.keys().forEach((path) => { + // require.context contains paths for module alias imports and relative imports + if (!path.startsWith('.')) { + return; + } + const [suite, name] = path + .replace('./', '') + .replace(/\.\w+$/, '') + .split('/'); + fixtures.push({ + path, + suite: `e2e/${suite}`, + name, + Component: React.lazy(() => importFixtures(path)), + }); +}); + +function App() { + function computeIsDev() { + if (window.location.hash === '#dev') { + return true; + } + if (window.location.hash === '#no-dev') { + return false; + } + return process.env.NODE_ENV === 'development'; + } + const [isDev, setDev] = React.useState(computeIsDev); + React.useEffect(() => { + function handleHashChange() { + setDev(computeIsDev()); + } + window.addEventListener('hashchange', handleHashChange); + + return () => { + window.removeEventListener('hashchange', handleHashChange); + }; + }, []); + + function computePath(fixture) { + return `/${fixture.suite}/${fixture.name}`; + } + + return ( + + + {fixtures.map((fixture) => { + const path = computePath(fixture); + const FixtureComponent = fixture.Component; + if (FixtureComponent === undefined) { + console.warn('Missing `Component` ', fixture); + return null; + } + + return ( + + + + } + /> + ); + })} + + + + ); +} + +const container = document.getElementById('react-root'); +const children = ; +if (typeof ReactDOM.unstable_createRoot === 'function') { + const root = ReactDOM.unstable_createRoot(container); + root.render(children); +} else { + const root = ReactDOMClient.createRoot(container); + root.render(children); +} + +window.DomTestingLibrary = DomTestingLibrary; +window.elementToString = function elementToString(element) { + if ( + element != null && + (element.nodeType === element.ELEMENT_NODE || element.nodeType === element.DOCUMENT_NODE) + ) { + return window.DomTestingLibrary.prettyDOM(element, undefined, { + highlight: true, + maxDepth: 1, + }); + } + return String(element); +}; diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts new file mode 100644 index 0000000000..d147a68bbd --- /dev/null +++ b/test/e2e/index.test.ts @@ -0,0 +1,215 @@ +import { expect } from 'chai'; +import * as playwright from 'playwright'; +import type { + ByRoleMatcher, + ByRoleOptions, + Matcher, + MatcherOptions, + SelectorMatcherOptions, +} from '@testing-library/dom'; +import '@mui/internal-test-utils/initMatchers'; +import '@mui/internal-test-utils/initPlaywrightMatchers'; + +function sleep(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); + }); +} + +interface PlaywrightScreen { + getByLabelText: ( + labelText: Matcher, + options?: SelectorMatcherOptions, + ) => Promise>; + getByRole: ( + role: ByRoleMatcher, + options?: ByRoleOptions, + ) => Promise>; + getByTestId: ( + testId: string, + options?: MatcherOptions, + ) => Promise>; + getByText: ( + text: Matcher, + options?: SelectorMatcherOptions, + ) => Promise>; +} + +/** + * Attempts page.goto with retries + * + * @remarks The server and runner can be started up simultaneously + * @param page + * @param url + */ +async function attemptGoto(page: playwright.Page, url: string): Promise { + const maxAttempts = 10; + const retryTimeoutMS = 250; + + let didNavigate = false; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop + await page.goto(url); + didNavigate = true; + } catch (error) { + // eslint-disable-next-line no-await-in-loop + await sleep(retryTimeoutMS); + } + } + + return didNavigate; +} + +describe('e2e', () => { + const baseUrl = 'http://localhost:5001'; + let browser: playwright.Browser; + let page: playwright.Page; + const screen: PlaywrightScreen = { + getByLabelText: (...inputArgs) => { + return page.evaluateHandle( + (args) => window.DomTestingLibrary.getByLabelText(document.body, ...args), + inputArgs, + ); + }, + getByRole: (...inputArgs) => { + return page.evaluateHandle( + (args) => window.DomTestingLibrary.getByRole(document.body, ...args), + inputArgs, + ); + }, + getByText: (...inputArgs) => { + return page.evaluateHandle( + (args) => window.DomTestingLibrary.getByText(document.body, ...args), + inputArgs, + ); + }, + getByTestId: (...inputArgs) => { + return page.evaluateHandle( + (args) => window.DomTestingLibrary.getByTestId(document.body, ...args), + inputArgs, + ); + }, + }; + + async function renderFixture(fixturePath: string) { + await page.goto(`${baseUrl}/e2e/${fixturePath}#no-dev`); + await page.waitForSelector('[data-testid="testcase"]:not([aria-busy="true"])'); + } + + before(async function beforeHook() { + this.timeout(20000); + + browser = await playwright.chromium.launch({ + headless: true, + }); + page = await browser.newPage(); + const isServerRunning = await attemptGoto(page, `${baseUrl}#no-dev`); + if (!isServerRunning) { + throw new Error( + `Unable to navigate to ${baseUrl} after multiple attempts. Did you forget to run \`pnpm test:e2e:server\` and \`pnpm test:e2e:build\`?`, + ); + } + }); + + after(async () => { + await browser.close(); + }); + + describe('', () => { + it('should loop the tab key', async () => { + await renderFixture('FocusTrap/OpenFocusTrap'); + + await expect(screen.getByTestId('root')).toHaveFocus(); + + await page.keyboard.press('Tab'); + await expect(screen.getByText('x')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('cancel')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('ok')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('x')).toHaveFocus(); + + await screen.getByTestId('initial-focus').then(($element) => $element.focus()); + await expect(screen.getByTestId('root')).toHaveFocus(); + await screen.getByText('x').then(($element) => $element.focus()); + await page.keyboard.press('Shift+Tab'); + await expect(screen.getByText('ok')).toHaveFocus(); + }); + + it('should loop the tab key after activation', async () => { + await renderFixture('FocusTrap/DefaultOpenLazyFocusTrap'); + + await expect(screen.getByTestId('initial-focus')).toHaveFocus(); + + await page.keyboard.press('Tab'); + await expect(screen.getByText('close')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('noop')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('close')).toHaveFocus(); + await page.keyboard.press('Enter'); + await expect(screen.getByTestId('initial-focus')).toHaveFocus(); + }); + + it('should focus on first focus element after last has received a tab click', async () => { + await renderFixture('FocusTrap/OpenFocusTrap'); + + await page.keyboard.press('Tab'); + await expect(screen.getByText('x')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('cancel')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('ok')).toHaveFocus(); + }); + + it('should be able to be tabbed straight through when rendered closed', async () => { + await renderFixture('FocusTrap/ClosedFocusTrap'); + + await expect(screen.getByText('initial focus')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('inside focusable')).toHaveFocus(); + await page.keyboard.press('Tab'); + await expect(screen.getByText('final tab target')).toHaveFocus(); + }); + + it('should not trap focus when clicking outside when disableEnforceFocus is set', async () => { + await renderFixture('FocusTrap/DisableEnforceFocusFocusTrap'); + + // initial focus is on the button outside of the trap focus + await expect(screen.getByTestId('initial-focus')).toHaveFocus(); + + // focus the button inside the trap focus + await page.keyboard.press('Tab'); + await expect(screen.getByTestId('inside-trap-focus')).toHaveFocus(); + + // the focus is now trapped inside + await page.keyboard.press('Tab'); + await expect(screen.getByTestId('inside-trap-focus')).toHaveFocus(); + + const initialFocus = (await screen.getByTestId('initial-focus'))!; + await initialFocus.click(); + + await expect(screen.getByTestId('initial-focus')).toHaveFocus(); + }); + }); + + describe('', () => { + // https://github.com/mui/material-ui/issues/32640 + it('should handle suspense without error', async () => { + const pageErrors: string[] = []; + page.on('pageerror', (err) => pageErrors.push(err.name)); + + await renderFixture('TextareaAutosize/TextareaAutosizeSuspense'); + expect(await page.isVisible('textarea')).to.equal(true); + await page.click('button'); + expect(await page.isVisible('textarea')).to.equal(false); + await page.waitForTimeout(200); // Wait for debounce to fire (166) + + expect(pageErrors.length).to.equal(0); + }); + }); +}); diff --git a/test/e2e/serve.json b/test/e2e/serve.json new file mode 100644 index 0000000000..ef9da9b562 --- /dev/null +++ b/test/e2e/serve.json @@ -0,0 +1,4 @@ +{ + "public": "build", + "rewrites": [{ "source": "**", "destination": "index.html" }] +} diff --git a/test/e2e/template.html b/test/e2e/template.html new file mode 100644 index 0000000000..fe0f2392e5 --- /dev/null +++ b/test/e2e/template.html @@ -0,0 +1,16 @@ + + + + Playwright end-to-end test + + + + + +
+ + diff --git a/test/e2e/webpack.config.js b/test/e2e/webpack.config.js new file mode 100644 index 0000000000..dd10685c68 --- /dev/null +++ b/test/e2e/webpack.config.js @@ -0,0 +1,45 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const webpackBaseConfig = require('../../webpackBaseConfig'); + +module.exports = { + ...webpackBaseConfig, + entry: path.resolve(__dirname, 'index.js'), + mode: process.env.NODE_ENV || 'development', + optimization: { + // Helps debugging and build perf. + // Bundle size is irrelevant for local serving + minimize: false, + }, + output: { + path: path.resolve(__dirname, './build'), + publicPath: '/', + filename: 'tests.js', + }, + plugins: [ + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, './template.html'), + }), + ], + module: { + ...webpackBaseConfig.module, + rules: [ + { + test: /\.(js|ts|tsx)$/, + exclude: /node_modules/, + loader: 'babel-loader', + options: { + cacheDirectory: true, + configFile: path.resolve(__dirname, '../../babel.config.js'), + envName: 'regressions', + }, + }, + { + test: /\.(jpg|gif|png)$/, + type: 'asset/inline', + }, + ], + }, + // TODO: 'browserslist:modern' + target: 'web', +};