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 (
+
+
+
+ }
+ />
+ );
+ })}
+
+
+
+ Devtools can be enabled by appending #dev
in the addressbar or disabled by
+ appending #no-dev
.
+
+
Hide devtools
+
+ nav for all tests
+
+
+
+
+ );
+}
+
+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',
+};