Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[test] Port e2e infra back to Base UI #395

Merged
merged 1 commit into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -424,7 +437,10 @@ workflows:
<<: *default-context
requires:
- checkout

- test_e2e:
<<: *default-context
requires:
- checkout
profile:
when:
equal: [profile, << pipeline.parameters.workflow >>]
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/.mocharc.js
Original file line number Diff line number Diff line change
@@ -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'],
};
30 changes: 30 additions & 0 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# end-to-end testing

End-to-end tests (short <abbr title="end-to-end">e2e</abbr>) 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. |
28 changes: 28 additions & 0 deletions test/e2e/TestViewer.js
Original file line number Diff line number Diff line change
@@ -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 (
<React.Suspense fallback={<div aria-busy />}>
<div aria-busy={!ready} data-testid="testcase">
{children}
</div>
</React.Suspense>
);
}

TestViewer.propTypes = {
children: PropTypes.node.isRequired,
};

export default TestViewer;
18 changes: 18 additions & 0 deletions test/e2e/fixtures/FocusTrap/ClosedFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';
import { FocusTrap } from '@base_ui/react/FocusTrap';

export default function ClosedFocusTrap() {
return (
<React.Fragment>
<button type="button" autoFocus>
initial focus
</button>
<FocusTrap open={false}>
<div data-testid="root">
<button type="button">inside focusable</button>
</div>
</FocusTrap>
<button type="button">final tab target</button>
</React.Fragment>
);
}
23 changes: 23 additions & 0 deletions test/e2e/fixtures/FocusTrap/DefaultOpenLazyFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<React.Fragment>
<button type="button" autoFocus data-testid="initial-focus">
initial focus
</button>
<FocusTrap isEnabled={() => true} open={open} disableAutoFocus>
<div data-testid="root">
<div>Title</div>
<button type="button" onClick={close}>
close
</button>
<button type="button">noop</button>
</div>
</FocusTrap>
</React.Fragment>
);
}
19 changes: 19 additions & 0 deletions test/e2e/fixtures/FocusTrap/DisableEnforceFocusFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react';
import { FocusTrap } from '@base_ui/react/FocusTrap';

export default function disableEnforceFocusFocusTrap() {
return (
<React.Fragment>
<button data-testid="initial-focus" type="button" autoFocus>
initial focus
</button>
<FocusTrap open disableEnforceFocus disableAutoFocus>
<div data-testid="root">
<button data-testid="inside-trap-focus" type="button">
inside focusable
</button>
</div>
</FocusTrap>
</React.Fragment>
);
}
20 changes: 20 additions & 0 deletions test/e2e/fixtures/FocusTrap/OpenFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';
import { FocusTrap } from '@base_ui/react/FocusTrap';

export default function BaseFocusTrap() {
return (
<React.Fragment>
<button type="button" autoFocus data-testid="initial-focus">
initial focus
</button>
<FocusTrap isEnabled={() => true} open>
<div tabIndex={-1} data-testid="root">
<div>Title</div>
<button type="button">x</button>
<button type="button">cancel</button>
<button type="button">ok</button>
</div>
</FocusTrap>
</React.Fragment>
);
}
31 changes: 31 additions & 0 deletions test/e2e/fixtures/TextareaAutosize/TextareaAutosizeSuspense.tsx
Original file line number Diff line number Diff line change
@@ -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 <div />;
}

export default function TextareaAutosizeSuspense() {
const [showRoute, setShowRoute] = React.useState(false);

return (
<React.Fragment>
<button type="button" onClick={() => setShowRoute((r) => !r)}>
Toggle view
</button>
<React.Suspense fallback={null}>
{showRoute ? <LazyRoute /> : <TextareaAutosize />}
</React.Suspense>
</React.Fragment>
);
}
127 changes: 127 additions & 0 deletions test/e2e/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<Router>
<Routes>
{fixtures.map((fixture) => {
const path = computePath(fixture);
const FixtureComponent = fixture.Component;
if (FixtureComponent === undefined) {
console.warn('Missing `Component` ', fixture);
return null;
}

return (
<Route
key={path}
exact
path={path}
element={
<TestViewer>
<FixtureComponent />
</TestViewer>
}
/>
);
})}
</Routes>
<div hidden={!isDev}>
<p>
Devtools can be enabled by appending <code>#dev</code> in the addressbar or disabled by
appending <code>#no-dev</code>.
</p>
<a href="#no-dev">Hide devtools</a>
<details>
<summary id="my-test-summary">nav for all tests</summary>
<nav id="tests">
<ol>
{fixtures.map((test) => {
const path = computePath(test);
return (
<li key={path}>
<Link to={path}>{path}</Link>
</li>
);
})}
</ol>
</nav>
</details>
</div>
</Router>
);
}

const container = document.getElementById('react-root');
const children = <App />;
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);
};
Loading