Skip to content

Commit

Permalink
[test] Port e2e infra back to Base UI
Browse files Browse the repository at this point in the history
  • Loading branch information
oliviertassinari committed May 4, 2024
1 parent f5f2f88 commit 0a92d32
Show file tree
Hide file tree
Showing 15 changed files with 604 additions and 1 deletion.
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>
);
}
29 changes: 29 additions & 0 deletions test/e2e/fixtures/TextareaAutosize/TextareaAutosizeSuspense.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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

0 comments on commit 0a92d32

Please sign in to comment.