init project
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

30
test/e2e/README.md Normal file
View File

@@ -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. |

26
test/e2e/TestViewer.js Normal file
View File

@@ -0,0 +1,26 @@
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 (
<div aria-busy={!ready} data-testid="testcase">
{children}
</div>
);
}
TestViewer.propTypes = {
children: PropTypes.node.isRequired,
};
export default TestViewer;

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import Autocomplete from '@mui/joy/Autocomplete';
function HoverJoyAutocomplete() {
return (
<Autocomplete
open
options={['one', 'two', 'three', 'four', 'five']}
sx={{ width: 300 }}
slotProps={{ listbox: { sx: { height: '100px' } } }}
/>
);
}
export default HoverJoyAutocomplete;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
function HoverMaterialAutocomplete() {
return (
<Autocomplete
open
options={['one', 'two', 'three', 'four', 'five']}
sx={{ width: 300 }}
ListboxProps={{ sx: { height: '100px' } }}
renderInput={(params) => <TextField {...params} />}
/>
);
}
export default HoverMaterialAutocomplete;

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import FocusTrap from '@mui/material/Unstable_TrapFocus';
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>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import FocusTrap from '@mui/material/Unstable_TrapFocus';
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>
);
}

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import FocusTrap from '@mui/material/Unstable_TrapFocus';
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>
);
}

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import FocusTrap from '@mui/material/Unstable_TrapFocus';
export default function OpenFocusTrap() {
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">confirm</button>
<button type="button">cancel</button>
<button type="button">ok</button>
</div>
</FocusTrap>
</React.Fragment>
);
}

View File

@@ -0,0 +1,6 @@
import * as React from 'react';
import Rating from '@mui/material/Rating';
export default function BasicRating() {
return <Rating name="rating-test" defaultValue={1} />;
}

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import TextField from '@mui/material/TextField';
export default function OutlinedTextFieldOnClick() {
const [isClicked, setIsClicked] = React.useState(false);
return (
<TextField
id="outlined-basic"
label="Outlined"
error={isClicked}
variant="outlined"
onClick={() => {
setIsClicked(true);
}}
/>
);
}

View File

@@ -0,0 +1,8 @@
import * as React from 'react';
import TextareaAutosize from '@mui/material/TextareaAutosize';
function BasicTextareaAutosize() {
return <TextareaAutosize data-testid="textarea" />;
}
export default BasicTextareaAutosize;

View File

@@ -0,0 +1,30 @@
import TextareaAutosize from '@mui/material/TextareaAutosize';
import Button from '@mui/material/Button';
import * as React from 'react';
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 onClick={() => setShowRoute((r) => !r)}>Toggle view</Button>
<React.Suspense fallback={null}>
{showRoute ? <LazyRoute /> : <TextareaAutosize />}
</React.Suspense>
</React.Fragment>
);
}

121
test/e2e/index.js Normal file
View File

@@ -0,0 +1,121 @@
import * as React from 'react';
import * as ReactDOMClient from 'react-dom/client';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router';
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 />;
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);
};

267
test/e2e/index.test.ts Normal file
View File

@@ -0,0 +1,267 @@
import { Page, Browser, chromium, expect } from '@playwright/test';
import { describe, it, beforeAll } from 'vitest';
import '@mui/internal-test-utils/initPlaywrightMatchers';
const BASE_URL = 'http://localhost:5001';
function sleep(duration: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param page
* @param url
*/
async function attemptGoto(page: Page, url: string): Promise<boolean> {
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', () => {
let browser: Browser;
let page: Page;
async function renderFixture(fixturePath: string) {
await page.goto(`${BASE_URL}/e2e/${fixturePath}#no-dev`);
await page.waitForSelector('[data-testid="testcase"]:not([aria-busy="true"])');
}
beforeAll(async function beforeHook() {
browser = await chromium.launch({
headless: true,
});
page = await browser.newPage();
const isServerRunning = await attemptGoto(page, `${BASE_URL}#no-dev`);
if (!isServerRunning) {
throw new Error(
`Unable to navigate to ${BASE_URL} after multiple attempts. Did you forget to run \`pnpm test:e2e:server\` and \`pnpm test:e2e:build\`?`,
);
}
}, 20000);
afterAll(async () => {
await browser.close();
});
describe('<FocusTrap />', () => {
it('should loop the tab key', async () => {
await renderFixture('FocusTrap/OpenFocusTrap');
await expect(page.getByTestId('root')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('confirm')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('cancel')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('ok')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('confirm')).toBeFocused();
await page.getByTestId('initial-focus').focus();
await expect(page.getByTestId('root')).toBeFocused();
await page.getByText('confirm').focus();
await page.keyboard.press('Shift+Tab');
await expect(page.getByText('ok')).toBeFocused();
});
it('should loop the tab key after activation', async () => {
await renderFixture('FocusTrap/DefaultOpenLazyFocusTrap');
await expect(page.getByTestId('initial-focus')).toBeFocused();
const close = page.getByRole('button', { name: 'close' });
await page.keyboard.press('Tab');
await expect(close).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('noop')).toBeFocused();
await page.keyboard.press('Tab');
await expect(close).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByTestId('initial-focus')).toBeFocused();
});
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(page.getByText('confirm')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('cancel')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('ok')).toBeFocused();
});
it('should be able to be tabbed straight through when rendered closed', async () => {
await renderFixture('FocusTrap/ClosedFocusTrap');
await expect(page.getByText('initial focus')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('inside focusable')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByText('final tab target')).toBeFocused();
});
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(page.getByTestId('initial-focus')).toBeFocused();
// focus the button inside the trap focus
await page.keyboard.press('Tab');
await expect(page.getByTestId('inside-trap-focus')).toBeFocused();
// the focus is now trapped inside
await page.keyboard.press('Tab');
await expect(page.getByTestId('inside-trap-focus')).toBeFocused();
const initialFocus = (await page.getByTestId('initial-focus'))!;
await initialFocus.click();
await expect(page.getByTestId('initial-focus')).toBeFocused();
});
});
describe('<Rating />', () => {
it('should loop the arrow key', async () => {
await renderFixture('Rating/BasicRating');
const activeEl = page.locator(':focus');
await page.focus('input[name="rating-test"]:checked');
await expect(activeEl).toHaveAttribute('value', '1');
await page.keyboard.press('ArrowLeft');
await expect(activeEl).toHaveAttribute('value', '');
await page.keyboard.press('ArrowLeft');
await expect(activeEl).toHaveAttribute('value', '5');
});
});
describe('<Autocomplete/>', () => {
it('[Material Autocomplete] should highlight correct option when initial navigation through options starts from mouse move', async () => {
await renderFixture('Autocomplete/HoverMaterialAutocomplete');
const combobox = (await page.getByRole('combobox'))!;
await combobox.click();
const firstOption = (await page.getByText('one'))!;
const dimensions = (await firstOption.boundingBox())!;
await page.mouse.move(dimensions.x + 10, dimensions.y + 10); // moves to 1st option
await page.keyboard.down('ArrowDown'); // moves to 2nd option
await page.keyboard.down('ArrowDown'); // moves to 3rd option
await page.keyboard.down('ArrowDown'); // moves to 4th option
const listbox = await page.getByRole('listbox');
const focusedOption = listbox.locator('.Mui-focused');
const focusedOptionText = await focusedOption.innerHTML();
expect(focusedOptionText).toEqual('four');
});
it('[Joy Autocomplete] should highlight correct option when initial navigation through options starts from mouse move', async () => {
await renderFixture('Autocomplete/HoverJoyAutocomplete');
const combobox = (await page.getByRole('combobox'))!;
await combobox.click();
const firstOption = (await page.getByText('one'))!;
const dimensions = (await firstOption.boundingBox())!;
await page.mouse.move(dimensions.x + 10, dimensions.y + 10); // moves to 1st option
await page.keyboard.down('ArrowDown'); // moves to 2nd option
await page.keyboard.down('ArrowDown'); // moves to 3rd option
await page.keyboard.down('ArrowDown'); // moves to 4th option
const listbox = await page.getByRole('listbox');
const focusedOption = listbox.locator('.Mui-focused');
const focusedOptionText = await focusedOption.innerHTML();
expect(focusedOptionText).toEqual('four');
});
});
describe('<TextareaAutosize />', () => {
// 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')).toEqual(true);
await page.click('button');
expect(await page.isVisible('textarea')).toEqual(false);
await page.waitForTimeout(200); // Wait for debounce to fire (166)
expect(pageErrors.length).toEqual(0);
});
it('should not glitch when resizing', async () => {
await renderFixture('TextareaAutosize/BasicTextareaAutosize');
const textarea = await page.getByTestId('textarea')!;
// Get the element's dimensions
const { x, y, width, height } = (await textarea.boundingBox())!;
// Calculate coordinates of bottom-right corner
const bottomRightX = x + width;
const bottomRightY = y + height;
// Get the initial height of textarea as a number
const initialHeight = await textarea.evaluate((textareaElement) =>
parseFloat(textareaElement.style.height),
);
// Move the mouse to the bottom-right corner, adjusting slightly to grab the resize handle
await page.mouse.move(bottomRightX - 5, bottomRightY - 5);
// Hold the mouse down without releasing the mouse button (mouseup) to grab the resize handle
await page.mouse.down();
// Move the mouse to resize the textarea
await page.mouse.move(bottomRightX + 50, bottomRightY + 50);
// Assert that the textarea height has increased after resizing
expect(
await textarea.evaluate((textareaElement) => parseFloat(textareaElement.style.height)),
).toBeGreaterThan(initialHeight);
});
});
describe('<TextField />', () => {
it('should fire `onClick` when clicking on the focused label position', async () => {
await renderFixture('TextField/OutlinedTextFieldOnClick');
// execute the click on the focused label position
await page.getByRole('textbox').click({ position: { x: 10, y: 10 } });
const errorSelector = page.locator('.MuiInputBase-root.Mui-error');
await errorSelector.waitFor();
});
});
});

4
test/e2e/serve.json Normal file
View File

@@ -0,0 +1,4 @@
{
"public": "build",
"rewrites": [{ "source": "**", "destination": "index.html" }]
}

16
test/e2e/template.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<title>Playwright end-to-end test</title>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<style>
body {
background-color: white;
}
</style>
</head>
<body>
<div id="react-root"></div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});

View File

@@ -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.mjs'),
envName: 'regressions',
},
},
{
test: /\.(jpg|gif|png)$/,
type: 'asset/inline',
},
],
},
// TODO: 'browserslist:modern'
target: 'web',
};