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

288
test/README.md Normal file
View File

@@ -0,0 +1,288 @@
# Testing
Thanks for writing tests! Here's a quick run-down on our current setup.
## Getting started
1. Add a unit test to `packages/*/src/TheUnitInQuestion/TheUnitInQuestion.test.js` or an integration test `packages/*/test/`.
2. Run `pnpm t TheUnitInQuestion`.
3. Implement the tested behavior
4. Open a PR once the test passes or if you want somebody to review your work
## Tools we use
- [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/)
- [Chai](https://www.chaijs.com/)
- [Sinon](https://sinonjs.org/)
- [Vitest](https://vitest.dev/)
- [Playwright](https://playwright.dev/)
- [jsdom](https://github.com/jsdom/jsdom)
## Writing tests
For all unit tests, please use the return value from `@mui/internal-test-utils/createRenderer`.
It prepares the test suite and returns a function with the same interface as
[`render` from `@testing-library/react`](https://testing-library.com/docs/react-testing-library/api#render).
```js
describe('test suite', () => {
const { render } = createRenderer();
test('first', () => {
render(<input />);
});
});
```
For new tests please use `expect` from the BDD testing approach. Prefer to use as expressive [matchers](https://www.chaijs.com/api/bdd/) as possible. This keeps
the tests readable, and, more importantly, the message if they fail as descriptive as possible.
In addition to the core matchers from `chai` we also use matchers from [`chai-dom`](https://github.com/nathanboktae/chai-dom#readme).
Deciding where to put a test is (like naming things) a hard problem:
- When in doubt, put the new test case directly in the unit test file for that component, for example `packages/mui-material/src/Button/Button.test.js`.
- If your test requires multiple components from the library create a new integration test.
- If you find yourself using a lot of `data-testid` attributes or you're accessing
a lot of styles consider adding a component (that doesn't require any interaction)
to `test/regressions/tests/`, for example `test/regressions/tests/List/ListWithSomeStyleProp`
- If you have to dispatch and compose many different DOM events prefer end-to-end tests (Checkout the [end-to-end testing readme](./e2e/README.md) for more information.)
### Unexpected calls to `console.error` or `console.warn`
By default, our test suite fails if any test recorded `console.error` or `console.warn` calls that are unexpected.
The failure message includes the full test name (suite names + test name).
This should help locating the test in case the top of the stack can't be read due to excessive error messages.
The error includes the logged message as well as the stacktrace of that message.
You can explicitly [expect no console calls](#writing-a-test-for-consoleerror-or-consolewarn) for when you're adding a regression test.
This makes the test more readable and properly fails the test in watchmode if the test had unexpected `console` calls.
### Writing a test for `console.error` or `console.warn`
If you add a new warning via `console.error` or `console.warn` you should add tests that expect this message.
For tests that expect a call you can use our custom `toWarnDev` or `toErrorDev` matchers.
The expected messages must be a subset of the actual messages and match the casing.
The order of these messages must match as well.
Example:
```jsx
function SomeComponent({ variant }) {
if (process.env.NODE_ENV !== 'production') {
if (variant === 'unexpected') {
console.error("That variant doesn't make sense.");
}
if (variant !== undefined) {
console.error('`variant` is deprecated.');
}
}
return <div />;
}
expect(() => {
render(<SomeComponent variant="unexpected" />);
}).toErrorDev(["That variant doesn't make sense.", '`variant` is deprecated.']);
```
```js
function SomeComponent({ variant }) {
if (process.env.NODE_ENV !== 'production') {
if (variant === 'unexpected') {
console.error("That variant doesn't make sense.");
}
if (variant !== undefined) {
console.error('`variant` is deprecated.');
}
}
return <div />;
}
expect(() => {
render(<SomeComponent />);
}).not.toErrorDev();
```
## Commands
We uses a wide range of tests approach as each of them comes with a different
trade-off, mainly completeness vs. speed.
### React API level
#### Debugging tests
If you want to debug tests with the, for example Chrome inspector (chrome://inspect) you can run `pnpm t <testFilePattern> --debug`.
Note that the test will not get executed until you start code execution in the inspector.
We have a dedicated task to use VS Code's integrated debugger to debug the currently opened test file.
Open the test you want to run and press F5 (launch "Test Current File").
#### Run the core unit/integration test suite
To run all of the unit and integration tests run `pnpm test:unit`. You can scope down to one or more specific files with
```bash
pnpm test:unit <file name pattern>
```
If you want to `grep` for certain tests by name add `-t STRING_TO_GREP`
#### Watch the core unit/integration test suite
`pnpm t <testFilePattern>`
First, we have the **unit test** suite.
It uses [vitest](https://vitest.dev) and a thin wrapper around `@testing-library/react`.
Here is an [example](https://github.com/mui/material-ui/blob/6d9f42a637184a3b3cb552d2591e2cf39653025d/packages/mui-material/src/Dialog/Dialog.test.js#L60-L69) with the `Dialog` component.
Next, we have the **integration** tests. They are mostly used for components that
act as composite widgets like `Select` or `Menu`.
Here is an [example](https://github.com/mui/material-ui/blob/814fb60bbd8e500517b2307b6a297a638838ca89/packages/material-ui/test/integration/Menu.test.js#L98-L108) with the `Menu` component.
#### Create HTML coverage reports
`pnpm test:node --coverage`
```bash
# browser tests
pnpm test:browser run --coverage --coverage.reporter html
# node tests
pnpm test:node run --coverage --coverage.reporter html
# all tests
pnpm test:unit run --coverage --coverage.reporter html
```
When running this command you should get under `coverage/index.html` a full coverage report in HTML format. This is created using [Istanbul](https://istanbul.js.org)'s HTML reporter and gives good data such as line, branch and function coverage.
### DOM API level
#### Run the browser test suit
`pnpm test:browser`
Testing the components at the React level isn't enough;
we need to make sure they will behave as expected with a **real DOM**.
To solve that problem we use [vitest browser mode](https://vitest.dev/guide/browser/).
Our tests run on different browsers to increase the coverage:
- [Headless Chrome](https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md)
- Headless Firefox
- Webkit
You can run on other browsers using `VITEST_BROWSERS=firefox,webkit pnpm test:browser`.
### Browser API level
In the end, components are going to be used in a real browser.
The DOM is just one dimension of that environment,
so we also need to take into account the rendering engine.
#### Visual regression tests
Check out the [visual regression testing readme](./regressions/README.md) for more information.
#### end-to-end tests
Checkout the [end-to-end testing readme](./e2e/README.md) for more information.
##### Development
When working on the visual regression tests you can run `pnpm test:regressions:dev` in the background to constantly rebuild the views used for visual regression testing.
To actually take the screenshots you can then run `pnpm test:regressions:run`.
You can pass the same arguments as you could to `vitest`.
For example, `pnpm test:regressions:run -t "docs-system-basic"` to take new screenshots of every demo in `docs/src/pages/system/basic`.
You can view the screenshots in `test/regressions/screenshots/chrome`.
Alternatively, you might want to open `http://localhost:5001` (while `pnpm test:regressions:dev` is running) to view individual views separately.
### Caveats
#### Accessibility tree exclusion
Our tests also explicitly document which parts of the queried element are included in
the accessibility (a11y) tree and which are excluded.
This check is fairly expensive which is why it is disabled when tests are run locally by default.
The rationale being that in almost all cases including or excluding elements from a query-set depending on their a11y-tree membership makes no difference.
The queries where this does make a difference explicitly include checking for a11y tree inclusion, for example `getByRole('button', { hidden: false })` (see [byRole documentation](https://testing-library.com/docs/dom-testing-library/api-queries#byrole) for more information).
To see if your test (`test:unit`) behaves the same between CI and local environment, set the environment variable `CI` to `'true'`.
Not considering a11y tree exclusion is a common cause of "Unable to find an accessible element with the role" or "Found multiple elements with the role".
### Performance monitoring
We have a dedicated CI task that profiles our core test suite.
Since this task is fairly expensive and not relevant to most day-to-day work it has to be started manually.
The CircleCI docs explain [how to start a pipeline manually](https://circleci.com/docs/api/v2/#operation/triggerPipeline) in detail.
Example:
With an environment variable `$CIRCLE_TOKEN` containing a [CircleCI personal access token](https://app.circleci.com/settings/user/tokens).
The following command triggers the `profile` workflow for the pull request #24289.
```bash
curl --request POST \
--url https://circleci.com/api/v2/project/gh/mui/material-ui/pipeline \
--header 'content-type: application/json' \
--header 'Circle-Token: $CIRCLE_TOKEN' \
--data-raw '{"branch":"pull/24289/head","parameters":{"workflow":"profile"}}'
```
To analyze this profile run you can use https://frontend-public.mui.com/test-profile/:job-number.
To find out the job number you can start with the response of the previous CircleCI API request which includes the created pipeline id.
You then have to search in the [CircleCI UI](https://app.circleci.com/pipelines/github/mui/material-ui) for the job number of `test_profile` that is part of the started pipeline.
The job number can be extracted from the URL of a particular CircleCI job.
For example, in https://app.circleci.com/pipelines/github/mui/material-ui/32796/workflows/23f946de-328e-49b7-9c94-bfe0a0248a12/jobs/211258 `jobs/211258` points to the job number which is in this case `211258` which means you want to visit https://frontend-public.mui.com/test-profile/211258 to analyze the profile.
### Testing multiple versions of React
You can check integration of different versions of React (for example different [release channels](https://react.dev/community/versioning-policy) or PRs to React) by running:
```bash
pnpm use-react-version <version>
```
Possible values for `version`:
- default: `stable` (minimum supported React version)
- a tag on npm, for example `next`, `experimental` or `latest`
- an older version, for example `^17.0.0`
#### CI
##### Circle CI web interface
There are two workflows that can be triggered for any given PR manually in the CircleCI web interface:
- `react-next`
- `react-17`
Follow these steps:
1. Go to https://app.circleci.com/pipelines/github/mui/material-ui?branch=pull/PR_NUMBER/head and replace `PR_NUMBER` with the PR number you want to test.
2. Click `Trigger Pipeline` button.
3. Expand `Add parameters (optional)` and add the following parameter:
| Parameter type | Name | Value |
| :------------- | :--------- | :------------------------- |
| `string` | `workflow` | `react-next` or `react-17` |
4. Click `Trigger Pipeline` button.
##### API request
You can pass the same `version` to our CircleCI pipeline as well:
With the following API request we're triggering a run of the default workflow in
PR #24289 for `react@next`
```bash
curl --request POST \
--url https://circleci.com/api/v2/project/gh/mui/material-ui/pipeline \
--header 'content-type: application/json' \
--header 'Circle-Token: $CIRCLE_TOKEN' \
--data-raw '{"branch":"pull/24289/head","parameters":{"react-version":"next"}}'
```

0
test/assets/fake.png Normal file
View File

0
test/assets/fake2.png Normal file
View File

View File

@@ -0,0 +1,53 @@
/**
* @file Configuration file for bundle-size-checker
*
* This file determines which packages and components will have their bundle sizes measured.
*/
import path from 'path';
import glob from 'fast-glob';
import { defineConfig } from '@mui/internal-bundle-size-checker';
const rootDir = path.resolve(import.meta.dirname, '../..');
/**
* Generates the entrypoints configuration by scanning the project structure.
*/
export default defineConfig(async () => {
// Discover Material UI components
const materialPackagePath = path.join(rootDir, 'packages/mui-material/build');
const materialFiles = await glob(path.join(materialPackagePath, '([A-Z])*/index.js'));
const materialComponents = materialFiles.map((componentPath) => {
const componentName = path.basename(path.dirname(componentPath));
return `@mui/material/${componentName}`;
});
// Discover Lab components
const labPackagePath = path.join(rootDir, 'packages/mui-lab/build');
const labFiles = await glob(path.join(labPackagePath, '([A-Z])*/index.js'));
const labComponents = labFiles.map((componentPath) => {
const componentName = path.basename(path.dirname(componentPath));
return `@mui/lab/${componentName}`;
});
// Return the complete entrypoints configuration
return {
entrypoints: [
'@mui/material',
...materialComponents,
'@mui/lab',
...labComponents,
'@mui/private-theming',
'@mui/system',
'@mui/system/createBox',
'@mui/system/createStyled',
'@mui/material/styles#createTheme',
'@mui/system/colorManipulator',
'@mui/lab/useAutocomplete',
'@mui/material/useMediaQuery',
'@mui/material/useScrollTrigger',
'@mui/utils',
],
upload: !!process.env.CI,
comment: false,
};
});

View File

@@ -0,0 +1,18 @@
{
"private": true,
"description": "Bundle size measurement workspace for MUI packages",
"scripts": {
"check": "NODE_OPTIONS=\"--max-old-space-size=4096\" bundle-size-checker --output ../size-snapshot.json"
},
"devDependencies": {
"fast-glob": "^3.3.3",
"path": "^0.12.7"
},
"dependencies": {
"@mui/material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/private-theming": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*"
}
}

53
test/bundling/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Bundle fixtures
A collection of "smoke"-test that verify that the package layout is correct.
`createFixture` is used to create new or update existing fixtures.
The created file might need some manual adjustment since not every edge case is covered.
## Run a fixture
### To test a Pull Request
1. Checkout branch
1. `pnpm install`
1. `pnpm lerna run build --scope "@mui/*"`
1. `pnpm release:pack`
1. Navigate into the fixture you want to test (where the `package.json` is located)
1. `pnpm install --ignore-workspace`
1. `pnpm start`
### To test a published npm dist tag
_For example: `latest` or `next` on npm or a pkg.pr.new published version_
1. Navigate into the fixture you want to test (where the `package.json` is located)
1. Adjust `pnpm.overrides` of the `package.json` file to point to the desired version
1. `pnpm install --ignore-workspace`
1. `pnpm start`
### In CI
You have to run our CircleCI pipeline with the `workflow` parameter set to `bundling`.
With the following API request we're triggering a run of the bundling workflow in
PR #24289:
```bash
curl --request POST \
--url https://circleci.com/api/v2/project/gh/mui/material-ui/pipeline \
--header 'content-type: application/json' \
--header 'Circle-Token: $CIRCLE_TOKEN' \
--data-raw '{"branch":"pull/24289/head","parameters":{"workflow":"bundling"}}'
```
`$CIRCLE_TOKEN` must be set as an environment variable created from https://app.circleci.com/settings/user/tokens.
## Add a new fixture
1. Create a folder in `test/fixtures/bundling`
1. Add the necessary dependencies
1. Re-use the entries for `dependencies` and `pnpm.overrides` for `@mui/*` packages from the other fixtures
1. Create a template
1. Write a factory that fills the template in `test/bundling/scripts/createFixture`
1. Add an entry into the `bundling` CircleCI pipeline (`.circleci/config.yml`)

View File

@@ -0,0 +1,3 @@
{{{imports}}}
{{{usage}}}

View File

@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Vite App</title>
</head>
<body>
<script type="module" src="/build/esbuild.fixture.js"></script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
{
"name": "esbuild",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js esbuild",
"start": "pnpm build && concurrently --success first --kill-others \"pnpm server\" \"node testEsbuildIntegration\"",
"build": "esbuild esbuild.fixture.js --bundle --outfile=build/esbuild.fixture.js --tsconfig=tsconfig.json",
"server": "serve -p 5001 -s build"
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"esbuild": "0.25.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-is": "18.2.0"
},
"devDependencies": {
"concurrently": "7.4.0",
"playwright": "1.55.1",
"serve": "14.2.4"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,58 @@
const { chromium } = require('@playwright/test');
/**
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns {boolean}
*/
async function attemptGoto(page, url) {
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;
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (consoleMessage) => {
throw new Error(
`Expected no console messages but got ${consoleMessage.type()}: '${consoleMessage.text()}' `,
);
});
await attemptGoto(page, 'http://localhost:5001/');
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,4 @@
{
"compilerOptions": {},
"exclude": ["node_modules", "build"]
}

View File

@@ -0,0 +1,2 @@
defaults and fully supports es6-module
maintained node versions

View File

@@ -0,0 +1,6 @@
module.exports = {
siteMetadata: {
title: 'gatsby',
},
plugins: [],
};

View File

@@ -0,0 +1,7 @@
{{{imports}}}
{{{usage}}}
export default function Page() {
return null;
}

View File

@@ -0,0 +1,38 @@
{
"name": "gatsby",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js gatsby",
"start": "pnpm gatsby build && concurrently --success first --kill-others \"pnpm gatsby serve\" \"node testGatsbyIntegration\""
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"gatsby": "5.13.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-is": "18.2.0"
},
"devDependencies": {
"concurrently": "7.4.0",
"@playwright/test": "1.54.1"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import AccessibilityIcon from '@mui/icons-material/Accessibility';
export default function Development() {
return (
<div>
<IconButton>
<AccessibilityIcon />
</IconButton>
</div>
);
}

View File

@@ -0,0 +1,59 @@
const { chromium } = require('@playwright/test');
/**
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns {boolean}
*/
async function attemptGoto(page, url) {
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;
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (consoleMessage) => {
throw new Error(
`Expected no console messages but got ${consoleMessage.type()}: '${consoleMessage.text()}' `,
);
});
await attemptGoto(page, 'http://localhost:9000/gatsby.fixture');
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,7 @@
{{{imports}}}
{{{usage}}}
export default function Page() {
return null;
}

View File

@@ -0,0 +1,13 @@
export default {
webpack5: false,
eslint: {
ignoreDuringBuilds: true,
},
webpack(config, { defaultLoaders }) {
config.module.rules.push({
test: /\/node_modules\/@mui\//,
use: [defaultLoaders.babel],
});
return config;
},
};

View File

@@ -0,0 +1,38 @@
{
"name": "next-webpack4",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js next-webpack4",
"start": "NODE_OPTIONS=--openssl-legacy-provider pnpm next build && concurrently --success first --kill-others \"pnpm next start\" \"node testNextWebpack4Integration\""
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"next": "14.2.30",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-is": "^17.0.2"
},
"devDependencies": {
"concurrently": "7.4.0",
"@playwright/test": "1.54.1"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import AccessibilityIcon from '@mui/icons-material/Accessibility';
export default function Development() {
return (
<div>
<IconButton>
<AccessibilityIcon />
</IconButton>
</div>
);
}

View File

@@ -0,0 +1,59 @@
const { chromium } = require('@playwright/test');
/**
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns {boolean}
*/
async function attemptGoto(page, url) {
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;
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (consoleMessage) => {
throw new Error(
`Expected no console messages but got ${consoleMessage.type()}: '${consoleMessage.text()}' `,
);
});
await attemptGoto(page, 'http://localhost:5001/next-webpack.fixture');
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,7 @@
{{{imports}}}
{{{usage}}}
export default function Page() {
return null;
}

View File

@@ -0,0 +1,5 @@
export default {
eslint: {
ignoreDuringBuilds: true,
},
};

View File

@@ -0,0 +1,36 @@
{
"name": "next-webpack5",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"start": "pnpm next build && concurrently --success first --kill-others \"pnpm next start\" \"node testNextWebpack5Integration\""
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/utils": "workspace:*",
"next": "14.2.30",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-is": "18.2.0"
},
"devDependencies": {
"concurrently": "7.4.0",
"@playwright/test": "1.54.1"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import AccessibilityIcon from '@mui/icons-material/Accessibility';
export default function Development() {
return (
<div>
<IconButton>
<AccessibilityIcon />
</IconButton>
</div>
);
}

View File

@@ -0,0 +1,59 @@
const { chromium } = require('@playwright/test');
/**
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns {boolean}
*/
async function attemptGoto(page, url) {
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;
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (consoleMessage) => {
throw new Error(
`Expected no console messages but got ${consoleMessage.type()}: '${consoleMessage.text()}' `,
);
});
await attemptGoto(page, 'http://localhost:5001/next-webpack.fixture');
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,5 @@
import * as ReactIs from 'react-is';
import Accordion2 from '@mui/material/Accordion';
// eslint-disable-next-line no-console
console.assert(ReactIs.isValidElementType(Accordion2));

View File

@@ -0,0 +1,3 @@
{{{requires}}}
{{{usage}}}

View File

@@ -0,0 +1,32 @@
{
"name": "node-esm",
"version": "1.0.0",
"main": "index.js",
"type": "commonjs",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js node-cjs",
"start": "node node-cjs.fixture.js"
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"react-is": "18.2.0"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,5 @@
import * as ReactIs from 'react-is';
import Accordion2 from '@mui/material/Accordion';
// eslint-disable-next-line no-console
console.assert(ReactIs.isValidElementType(Accordion2));

View File

@@ -0,0 +1,3 @@
{{{imports}}}
{{{usage}}}

View File

@@ -0,0 +1,32 @@
{
"name": "node-esm",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js node-esm",
"start": "node node-esm.fixture.js"
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"react-is": "18.2.0"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,40 @@
{
"name": "snowpack",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js snowpack",
"start": "pnpm snowpack build && concurrently --success first --kill-others \"pnpm server\" \"node testSnowpackIntegration\"",
"server": "serve -p 5001 -s build"
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"snowpack": "3.8.8",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-is": "18.2.0"
},
"devDependencies": {
"concurrently": "7.4.0",
"@playwright/test": "1.54.1",
"serve": "14.2.4"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Starter Snowpack App" />
<title>Starter Snowpack App</title>
<script type="module" src="/build/snowpack.fixture.js"></script>
</head>
<body>
<noscript>Needs JavaScript to test fixture</noscript>
</body>
</html>

View File

@@ -0,0 +1,7 @@
/** @type {import("snowpack").SnowpackUserConfig } */
module.exports = {
mount: {
public: { url: '/', static: true },
src: { url: '/build' },
},
};

View File

@@ -0,0 +1,3 @@
{{{imports}}}
{{{usage}}}

View File

@@ -0,0 +1,69 @@
const { chromium } = require('@playwright/test');
/**
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns {boolean}
*/
async function attemptGoto(page, url) {
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;
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (consoleMessage) => {
// Unclear why snowpack bundles the development build of react-dom
// Unable to reproduce this locally
const isReactDevtoolsMessage =
consoleMessage.type() === 'info' &&
consoleMessage
.text()
.includes('Download the React DevTools for a better development experience:');
if (!isReactDevtoolsMessage) {
throw new Error(
`Expected no console messages but got ${consoleMessage.type()}: '${consoleMessage.text()}' `,
);
}
});
await attemptGoto(page, 'http://localhost:5001/');
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Vite App</title>
</head>
<body>
<script type="module" src="/vite.fixture.js"></script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{
"name": "vite",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"prestart": "node ../../scripts/createFixture.js vite",
"start": "pnpm vite build && concurrently --success first --kill-others \"pnpm server\" \"node testViteIntegration\"",
"server": "serve -p 5001 -s build"
},
"dependencies": {
"@emotion/core": "11.0.0",
"@emotion/react": "11.10.4",
"@emotion/styled": "11.10.4",
"@mui/material": "workspace:*",
"@mui/icons-material": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/styled-engine": "workspace:*",
"@mui/system": "workspace:*",
"@mui/utils": "workspace:*",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-is": "18.2.0",
"vite": "5.4.21"
},
"devDependencies": {
"concurrently": "7.4.0",
"@playwright/test": "1.54.1",
"serve": "14.2.4"
},
"pnpm": {
"overrides": {
"@mui/material": "file:../../../../packed/@mui/material.tgz",
"@mui/icons-material": "file:../../../../packed/@mui/icons-material.tgz",
"@mui/lab": "file:../../../../packed/@mui/lab.tgz",
"@mui/styled-engine": "file:../../../../packed/@mui/styled-engine.tgz",
"@mui/system": "file:../../../../packed/@mui/system.tgz",
"@mui/utils": "file:../../../../packed/@mui/utils.tgz"
}
}
}

View File

@@ -0,0 +1,59 @@
const { chromium } = require('@playwright/test');
/**
* @param {number} timeoutMS
* @returns {Promise<void>}
*/
function sleep(duration) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
/**
* Attempts page.goto with retries
*
* @remarks The server and runner can be started up simultaneously
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns {boolean}
*/
async function attemptGoto(page, url) {
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;
}
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', (consoleMessage) => {
throw new Error(
`Expected no console messages but got ${consoleMessage.type()}: '${consoleMessage.text()}' `,
);
});
await attemptGoto(page, 'http://localhost:5001/');
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,11 @@
/**
* @type {import('vite').UserConfig}
*/
const config = {
mode: 'production',
build: {
outDir: 'build',
},
};
export default config;

View File

@@ -0,0 +1,3 @@
{{{imports}}}
{{{usage}}}

View File

@@ -0,0 +1,221 @@
import { promises as fs } from 'fs';
import { URL } from 'url';
import * as process from 'process';
/**
* @typedef {object} FixtureContext
* @property {URL} fixtureUrl
* @property {object} fixtureTemplateValues
*/
/**
* @param {URL} destinationUrl
* @param {string} templateSource
* @param {Record<string, string>} templateValues
*/
async function writeFromTemplate(destinationUrl, templateSource, templateValues) {
const source = Object.entries(templateValues).reduce((partialCode, [name, value]) => {
return partialCode.replace(`{{{${name}}}}`, value);
}, templateSource);
await fs.writeFile(destinationUrl, source);
}
/**
* @param {FixtureContext} context
*/
async function writeNodeESMFixture(context) {
const { fixtureUrl, fixtureTemplateValues } = context;
const destinationPath = new URL('./node-esm.fixture.js', fixtureUrl);
const templateSource = await fs.readFile(new URL('node-esm.template', fixtureUrl), {
encoding: 'utf8',
});
await writeFromTemplate(destinationPath, templateSource, fixtureTemplateValues);
}
/**
* @param {FixtureContext} context
*/
async function writeNodeCJSFixture(context) {
const { fixtureUrl, fixtureTemplateValues } = context;
const destinationPath = new URL('./node-cjs.fixture.js', fixtureUrl);
const templateSource = await fs.readFile(new URL('node-cjs.template', fixtureUrl), {
encoding: 'utf8',
});
await writeFromTemplate(destinationPath, templateSource, fixtureTemplateValues);
}
/**
* @param {FixtureContext} context
*/
async function writeNextWebpackFixture(context) {
const { fixtureUrl, fixtureTemplateValues } = context;
const destinationUrl = new URL('./pages/next-webpack.fixture.js', fixtureUrl);
const templateSource = await fs.readFile(new URL('./next-webpack.template', fixtureUrl), {
encoding: 'utf8',
});
await writeFromTemplate(destinationUrl, templateSource, fixtureTemplateValues);
}
/**
* @param {FixtureContext} context
*/
async function writeSnowpackFixture(context) {
const { fixtureUrl, fixtureTemplateValues } = context;
const destinationUrl = new URL('./src/snowpack.fixture.js', fixtureUrl);
await fs.mkdir(new URL('.', destinationUrl), { recursive: true });
const templateSource = await fs.readFile(new URL('snowpack.template', fixtureUrl), {
encoding: 'utf8',
});
await writeFromTemplate(destinationUrl, templateSource, fixtureTemplateValues);
}
/**
* @param {FixtureContext} context
*/
async function writeViteFixture(context) {
const { fixtureUrl: fixturePath, fixtureTemplateValues } = context;
const destinationPath = new URL('./vite.fixture.js', fixturePath);
const templateSource = await fs.readFile(new URL('vite.template', fixturePath), {
encoding: 'utf8',
});
await writeFromTemplate(destinationPath, templateSource, fixtureTemplateValues);
}
/**
* @param {FixtureContext} context
*/
async function writeEsbuildFixture(context) {
const { fixtureUrl, fixtureTemplateValues } = context;
const destinationPath = new URL('./esbuild.fixture.js', fixtureUrl);
const templateSource = await fs.readFile(new URL('esbuild.template', fixtureUrl), {
encoding: 'utf8',
});
await writeFromTemplate(destinationPath, templateSource, fixtureTemplateValues);
}
/**
* @param {FixtureContext} context
*/
async function writeGatsbyFixture(context) {
const { fixtureUrl: fixturePath, fixtureTemplateValues } = context;
const destinationPath = new URL('./src/pages/gatsby.fixture.js', fixturePath);
const templateSource = await fs.readFile(new URL('gatsby.template', fixturePath), {
encoding: 'utf8',
});
await writeFromTemplate(destinationPath, templateSource, fixtureTemplateValues);
}
async function readFixtureTemplateValues(fileUrl) {
const code = await fs.readFile(fileUrl, { encoding: 'utf8' });
const importsMatch = code.match(/\/\/ #region imports(.+?)\/\/ #endregion/s);
const [imports] = importsMatch;
const lines = imports.split(/\n+/).filter((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*');
});
const requires = lines
.map((line) => {
const [, specifier, module] = /import (.*) from ['"](.*)['"]/.exec(line);
if (specifier.startsWith('*')) {
return `const ${specifier.replace('* as ', '')} = require('${module}')`;
}
if (specifier.startsWith('{')) {
return `const ${specifier.replace(' as ', ': ')} = require('${module}')`;
}
return `const { default: ${specifier} } = require('${module}')`;
})
.join('\n');
const usageMatch = code.match(/\/\/ #region usage(.+?)\/\/ #endregion/s);
const [usage] = usageMatch;
return { imports, usage, requires };
}
function resolveFixtureUrl(fixtureName) {
return new URL(`../fixtures/${fixtureName}/`, import.meta.url);
}
/**
* @param {object} context
* @param {string} context.fixture
*/
async function run(context) {
const { fixture } = context;
if (fixture === undefined) {
throw new Error(`Usage: ${process.argv[1]} <fixture>`);
}
const fixtureTemplateValues = await readFixtureTemplateValues(
new URL('./fixtureTemplateValues.js', import.meta.url),
);
switch (fixture) {
case 'node-cjs':
await writeNodeCJSFixture({
fixtureUrl: resolveFixtureUrl('node-cjs'),
fixtureTemplateValues,
});
break;
case 'node-esm':
await writeNodeESMFixture({
fixtureUrl: resolveFixtureUrl('node-esm'),
fixtureTemplateValues,
});
break;
case 'next-webpack4':
await writeNextWebpackFixture({
fixtureUrl: resolveFixtureUrl('next-webpack4'),
fixtureTemplateValues,
});
break;
case 'next-webpack5':
await writeNextWebpackFixture({
fixtureUrl: resolveFixtureUrl('next-webpack5'),
fixtureTemplateValues,
});
break;
case 'snowpack':
await writeSnowpackFixture({
fixtureUrl: resolveFixtureUrl('snowpack'),
fixtureTemplateValues,
});
break;
case 'vite':
await writeViteFixture({
fixtureUrl: resolveFixtureUrl('vite'),
fixtureTemplateValues,
});
break;
case 'esbuild':
await writeEsbuildFixture({
fixtureUrl: resolveFixtureUrl('esbuild'),
fixtureTemplateValues,
});
break;
// TODO remove, no longer relevant since https://github.com/mui/material-ui/pull/38567
case 'gatsby':
await writeGatsbyFixture({
fixtureUrl: resolveFixtureUrl('gatsby'),
fixtureTemplateValues,
});
break;
default:
throw new TypeError(`Can't handle fixture '${fixture}'`);
}
}
run({ fixture: process.argv[2] }).catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,138 @@
import * as process from 'process';
import packages from './packages.js';
function isComponent(identifier) {
// Components start with Uppercase letter.
return /^[A-Z]/.test(identifier);
}
function isNamespace(identifier) {
return ['colors', 'styles', 'utils'].includes(identifier);
}
function getMuiLocal(imported, source) {
return `${imported}_${source.split('/')[1]}`;
}
/**
* @param {object} context
* @param {boolean} context.asNamedImport
* @param {string} context.local
* @param {string} context.imported
* @param {string} context.source
*/
function createImport(context) {
const { specifier, imported, local = imported, source } = context;
if (specifier === 'named') {
return `import { ${imported} as ${local} } from '${source}';`;
}
if (specifier === 'namespace') {
return `import * as ${local} from '${source}';`;
}
return `import ${local} from '${source}';`;
}
/**
* @param {NodeJS.WritableStream} outStream
*/
function writeImports(outStream) {
outStream.write(
`${createImport({
local: 'ReactIs',
modules: false,
source: 'react-is',
specifier: 'namespace',
})}\n`,
);
outStream.write('// #region imports\n');
outStream.write('/* eslint-disable import/no-duplicates */\n');
Object.entries(packages).forEach(([packageName, topLevelPackages]) => {
topLevelPackages.forEach((topLevelPackageName) => {
if (isNamespace(topLevelPackageName)) {
outStream.write(
`${createImport({
specifier: 'namespace',
local: `${getMuiLocal(topLevelPackageName, packageName)}__pathImport`,
imported: topLevelPackageName,
source: `${packageName}/${topLevelPackageName}`,
})}\n`,
);
} else {
outStream.write(
`${createImport({
specifier: 'named',
local: getMuiLocal(topLevelPackageName, packageName),
imported: topLevelPackageName,
source: packageName,
})}\n`,
);
outStream.write(
`${createImport({
specifier: 'default',
local: `${getMuiLocal(topLevelPackageName, packageName)}__pathImport`,
source: `${packageName}/${topLevelPackageName}`,
})}\n`,
);
}
});
});
outStream.write('/* eslint-enable import/no-duplicates */\n');
outStream.write('// #endregion\n');
}
function getComponentValidator(localIdentifier) {
return `ReactIs.isValidElementType(${localIdentifier})`;
}
function getNamespaceValidator(localIdentifier) {
return `${localIdentifier} !== null && typeof ${localIdentifier} === 'object'`;
}
function getUnknownValidator(localIdentifier) {
return `${localIdentifier} !== undefined`;
}
/**
* @param {NodeJS.WritableStream} outStream
*/
function writeUsage(outStream) {
outStream.write('\n// #region usage\n');
outStream.write('\n/* eslint-disable no-console */');
Object.entries(packages).forEach(([packageName, topLevelPackages]) => {
topLevelPackages.forEach((topLevelPackageName) => {
let getValidator = getUnknownValidator;
if (isNamespace(topLevelPackageName)) {
getValidator = getNamespaceValidator;
} else if (isComponent(topLevelPackageName)) {
getValidator = getComponentValidator;
}
if (!isNamespace(topLevelPackageName)) {
outStream.write(
`console.assert(${getValidator(
getMuiLocal(topLevelPackageName, packageName),
)}, '${topLevelPackageName} named import is not consumable.');\n`,
);
}
outStream.write(
`console.assert(${getValidator(
`${getMuiLocal(topLevelPackageName, packageName)}__pathImport`,
)}, '${topLevelPackageName} path import is not consumable.');\n`,
);
});
});
outStream.write('/* eslint-enable no-console */\n');
outStream.write('// #endregion\n');
}
async function main() {
const outStream = process.stdout;
writeImports(outStream);
writeUsage(outStream);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

206
test/bundling/scripts/packages.js generated Normal file
View File

@@ -0,0 +1,206 @@
export default {
'@mui/material': [
'Accordion',
'AccordionActions',
'AccordionDetails',
'AccordionSummary',
'Alert',
'AlertTitle',
'AppBar',
'Autocomplete',
'Avatar',
'Backdrop',
'Badge',
'BottomNavigation',
'BottomNavigationAction',
'Box',
'Breadcrumbs',
'Button',
'ButtonBase',
'ButtonGroup',
'Card',
'CardActionArea',
'CardActions',
'CardContent',
'CardHeader',
'CardMedia',
'Checkbox',
'Chip',
'CircularProgress',
'ClickAwayListener',
'Collapse',
'colors',
'Container',
'CssBaseline',
'Dialog',
'DialogActions',
'DialogContent',
'DialogContentText',
'DialogTitle',
'Divider',
'Drawer',
'Fab',
'Fade',
'FilledInput',
'FormControl',
'FormControlLabel',
'FormGroup',
'FormHelperText',
'FormLabel',
'Grid',
'Grow',
'Icon',
'IconButton',
'ImageList',
'ImageListItem',
'ImageListItemBar',
'Input',
'InputAdornment',
'InputBase',
'InputLabel',
'LinearProgress',
'Link',
'List',
'ListItem',
'ListItemAvatar',
'ListItemIcon',
'ListItemSecondaryAction',
'ListItemText',
'ListSubheader',
'Menu',
'MenuItem',
'MenuList',
'MobileStepper',
'Modal',
'NativeSelect',
'NoSsr',
'OutlinedInput',
'Pagination',
'PaginationItem',
'Paper',
'Popover',
'Popper',
'Portal',
'Radio',
'RadioGroup',
'Rating',
'ScopedCssBaseline',
'Select',
'Skeleton',
'Slide',
'Slider',
'Snackbar',
'SnackbarContent',
'SpeedDial',
'SpeedDialAction',
'SpeedDialIcon',
'Step',
'StepButton',
'StepConnector',
'StepContent',
'StepIcon',
'StepLabel',
'Stepper',
'styles',
'SvgIcon',
'SwipeableDrawer',
'Switch',
'Tab',
'Table',
'TableBody',
'TableCell',
'TableContainer',
'TableFooter',
'TableHead',
'TablePagination',
'TablePaginationActions',
'TableRow',
'TableSortLabel',
'Tabs',
'TabScrollButton',
'TextareaAutosize',
'TextField',
'ToggleButton',
'ToggleButtonGroup',
'Toolbar',
'Tooltip',
'Typography',
'Unstable_TrapFocus',
'useAutocomplete',
'useMediaQuery',
'usePagination',
'useScrollTrigger',
'utils',
'Zoom',
],
'@mui/icons-material': [
// Icons are generated.
// So the behavior of a single item should be equivalent to all icons.
'Accessibility',
],
'@mui/lab': [
'Alert',
'AlertTitle',
'Autocomplete',
'AvatarGroup',
'LoadingButton',
'Pagination',
'PaginationItem',
'Rating',
'Skeleton',
'SpeedDial',
'SpeedDialAction',
'SpeedDialIcon',
'TabContext',
'TabList',
'TabPanel',
// types only
// 'themeAugmentation',
'Timeline',
'TimelineConnector',
'TimelineContent',
'TimelineDot',
'TimelineItem',
'TimelineOppositeContent',
'TimelineSeparator',
'ToggleButton',
'ToggleButtonGroup',
'TreeItem',
'TreeView',
'useAutocomplete',
],
'@mui/system': [
'borders',
'breakpoints',
'compose',
'display',
'flexbox',
'grid',
// private
// 'memoize',
// 'merge',
'palette',
'positions',
// private
// 'responsivePropType',
'shadows',
'sizing',
'spacing',
'style',
// private
// 'styleFunctionSx',
'typography',
],
'@mui/utils': [
'chainPropTypes',
'deepmerge',
'elementAcceptingRef',
'elementTypeAcceptingRef',
'exactProp',
'formatMuiErrorMessage',
'getDisplayName',
'HTMLElementType',
'ponyfillGlobal',
'refType',
],
};

126
test/cli.js Normal file
View File

@@ -0,0 +1,126 @@
const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');
const glob = require('fast-glob');
const { default: yargs } = require('yargs');
const { hideBin } = require('yargs/helpers');
async function run(argv) {
const workspaceRoot = path.resolve(__dirname, '../');
const gitignore = fs.readFileSync(path.join(workspaceRoot, '.gitignore'), { encoding: 'utf8' });
const ignore = gitignore
.split(/\r?\n/)
.filter((pattern) => {
return pattern.length > 0 && !pattern.startsWith('#');
})
.map((line) => {
if (line.startsWith('/')) {
// "/" marks the cwd of the ignore file.
// Since we declare the dirname of the gitignore the cwd we can prepend "." as a shortcut.
return `.${line}`;
}
return line;
});
const globPattern = `**/*${argv.testFilePattern.replace(/\\/g, '/')}*`;
const spec = glob
.sync(globPattern, {
cwd: workspaceRoot,
ignore,
followSymbolicLinks: false,
})
.filter((relativeFile) => {
return /\.test\.(js|ts|tsx)$/.test(relativeFile);
});
if (spec.length === 0) {
throw new Error(`Could not find any file test files matching '${globPattern}'`);
}
const args = ['mocha'].concat(spec);
if (argv.bail) {
args.push('--bail');
}
if (argv.debug || argv.inspecting) {
args.push('--timeout 0');
}
if (argv.debug) {
args.push('--inspect-brk');
}
if (!argv.single) {
args.push('--watch');
}
if (argv.testNamePattern !== undefined) {
args.push(`--grep '${argv.testNamePattern}'`);
}
const mochaProcess = childProcess.spawn('pnpm', args, {
env: {
...process.env,
BABEL_ENV: 'test',
NODE_ENV: argv.production ? 'production' : 'test',
},
shell: true,
stdio: ['inherit', 'inherit', 'inherit'],
});
mochaProcess.once('exit', (signal) => {
process.exit(signal !== null ? signal : undefined);
});
process.on('SIGINT', () => {
// Forward interrupt.
// Otherwise cli.js exits and the you get dangling console output from mocha.
// "dangling" meaning that you get mocha output in the new terminal input.
mochaProcess.kill('SIGINT');
});
}
yargs()
.command({
command: '$0 <testFilePattern>',
description: 'Test cli for developing',
builder: (command) => {
return command
.positional('testFilePattern', {
description: 'Only test files match "**/*{testFilePattern}*.test.{js,ts,tsx}"',
type: 'string',
})
.option('bail', {
alias: 'b',
description: 'Stop on first error.',
type: 'boolean',
})
.option('debug', {
alias: 'd',
description:
'Allows attaching a debugger and waits for the debugger to start code execution.',
type: 'boolean',
})
.option('inspecting', {
description: 'In case you expect to hit breakpoints that may interrupt a test.',
type: 'boolean',
})
.option('production', {
alias: 'p',
description:
'Run tests in production environment. WARNING: Will not work with most tests.',
type: 'boolean',
})
.option('single', {
alias: 's',
description: 'Run only once i.e. not in watch mode.',
type: 'boolean',
})
.option('testNamePattern', {
alias: 't',
description: 'Limit tests by their name given a pattern.',
type: 'string',
});
},
handler: run,
})
.help()
.strict(true)
.version(false)
.parse(hideBin(process.argv));

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,12 @@
# Docs end-to-end testing
## Running locally
1. Run `pnpm docs:dev` to start the development docs server.
2. Run `pnpm test:e2e-website` in a separate terminal to run the test suites (`*.spec.ts`) inside `test/e2e-website` folder.
> use --headed to run tests in headed browsers, check out [Playwright CLI](https://playwright.dev/docs/intro#command-line) for more options
## CI
After Netlify deploys the preview site, the `netlify/functions/deploy-succeeded.js` hook calls CircleCI API to run the `e2e-website` workflow against the deployed URL.

View File

@@ -0,0 +1,82 @@
import { test as base, expect } from '@playwright/test';
import { TestFixture } from './playwright.config';
const test = base.extend<TestFixture>({});
test.describe('Demo docs', () => {
test('mode toggle demos should work', async ({ page }) => {
await page.goto('/experiments/docs/demos/');
await expect(page.locator('div:has(> [data-element="demo-mode-toggle-paper"])')).toHaveClass(
/light/,
);
await expect(page.locator('[data-element="demo-mode-toggle-paper"]')).toHaveCSS(
'background-color',
'rgb(255, 255, 255)',
);
// Toggle dark mode
await page
.getByRole('radiogroup', { name: /^demo-mode-toggle$/ })
.locator('label:nth-child(3)')
.click();
await expect(page.locator('div:has(> [data-element="demo-mode-toggle-paper"])')).toHaveClass(
/dark/,
);
await expect(page.locator('[data-element="demo-mode-toggle-paper"]')).toHaveCSS(
'background-color',
'rgb(18, 18, 18)',
);
});
test('mode toggle custom theme demos should work', async ({ page }) => {
await page.goto('/experiments/docs/demos/');
await expect(
page.locator('div:has(> [data-element="demo-mode-toggle-custom-theme-paper"])'),
).toHaveClass(/light/);
await expect(page.locator('[data-element="demo-mode-toggle-custom-theme-paper"]')).toHaveCSS(
'background-color',
'rgb(239, 154, 154)',
);
// Toggle dark mode
await page
.getByRole('radiogroup', { name: /^demo-mode-toggle-custom-theme$/ })
.locator('label:nth-child(3)')
.click();
await expect(
page.locator('div:has(> [data-element="demo-mode-toggle-custom-theme-paper"])'),
).toHaveClass(/dark/);
await expect(page.locator('[data-element="demo-mode-toggle-custom-theme-paper"]')).toHaveCSS(
'background-color',
'rgb(183, 28, 28)',
);
});
test('mode toggle iframe demos should work', async ({ page }) => {
await page.goto('/experiments/docs/demos/');
const iframe = page.locator('iframe[title*="DemoModeToggleIframe"]').contentFrame();
await expect(iframe.locator('html')).toHaveClass(/light/);
await expect(iframe.locator('[data-element="demo-mode-toggle-iframe-paper"]')).toHaveCSS(
'background-color',
'rgb(255, 255, 255)',
);
// Toggle dark mode
await iframe
.getByRole('radiogroup', { name: /^demo-mode-toggle-iframe$/ })
.locator('label:nth-child(3)')
.click();
await expect(iframe.locator('html')).toHaveClass(/dark/);
await expect(iframe.locator('[data-element="demo-mode-toggle-iframe-paper"]')).toHaveCSS(
'background-color',
'rgb(18, 18, 18)',
);
});
});

View File

@@ -0,0 +1,29 @@
import { test as base, expect } from '@playwright/test';
import { kebabCase } from 'es-toolkit/string';
import { TestFixture } from './playwright.config';
const test = base.extend<TestFixture>({});
test.describe('Joy docs', () => {
test('should have correct link with hash in the TOC', async ({ page }) => {
await page.goto('/joy-ui/getting-started/installation/');
const anchors = page.locator('[aria-label="Page table of contents"] ul a');
const firstAnchor = anchors.first();
const textContent = await firstAnchor.textContent();
await expect(firstAnchor).toHaveAttribute(
'href',
`/joy-ui/getting-started/installation/#${kebabCase(textContent || '')}`,
);
});
test('should be able to see demos', async ({ page }) => {
await page.goto('/joy-ui/react-button/');
const headline = page.locator('main h1');
await expect(headline).toHaveText('Button');
});
});

View File

@@ -0,0 +1,21 @@
import { test as base, expect } from '@playwright/test';
import FEATURE_TOGGLE from 'docs/src/featureToggle';
import { TestFixture } from './playwright.config';
const test = base.extend<TestFixture>({});
test('should be able to change color without crash', async ({ page }) => {
await page.goto('/material-ui/customization/color/#playground', { waitUntil: 'networkidle' });
await page.fill('#primary', ''); // clear the input
await page.type('#primary', '#e91e63');
await page.fill('#secondary', ''); // clear the input
await page.type('#secondary', '#ffc400');
await page.click('button:has-text("Set Docs Colors")');
await page.click('#mui-version-selector'); // can open any menu, just to make sure that it does not break
await expect(page.locator('#mui-version-menu')).toBeVisible();
});

View File

@@ -0,0 +1,194 @@
import { test as base, expect, Page } from '@playwright/test';
import { kebabCase } from 'es-toolkit/string';
import { TestFixture } from './playwright.config';
const test = base.extend<TestFixture>({});
test.describe('Material docs', () => {
test('should have correct link with hash in the TOC', async ({ page }) => {
await page.goto('/material-ui/getting-started/installation/');
const anchors = page.locator('[aria-label="Page table of contents"] ul a');
const firstAnchor = anchors.first();
const textContent = await firstAnchor.textContent();
await expect(firstAnchor).toHaveAttribute(
'href',
`/material-ui/getting-started/installation/#${kebabCase(textContent || '')}`,
);
});
test('[zh] should have correct link with hash in the TOC', async ({ page }) => {
test.skip(
(process.env.CIRCLE_BRANCH || '').startsWith('pull'),
'There is no languages on the deploy preview',
);
await page.goto('/zh/material-ui/getting-started/installation/');
const anchors = page.locator('main nav ul a');
const firstAnchor = anchors.first();
const textContent = await firstAnchor.textContent();
await expect(firstAnchor).toHaveAttribute(
'href',
`/zh/material-ui/getting-started/installation/#${kebabCase(textContent || '')}`,
);
});
test.describe('Demo page', () => {
test('should have correct link for API section', async ({ page }) => {
await page.goto('/material-ui/react-card/');
const anchors = page.locator('div > h2#api ~ ul a');
const firstAnchor = anchors.first();
const textContent = await firstAnchor.textContent();
await expect(firstAnchor).toHaveAttribute(
'href',
`/material-ui/api/${kebabCase(textContent || '')}/`,
);
});
test('should have correct link for sidebar anchor', async ({ page }) => {
await page.goto('/material-ui/react-card/');
const anchor = page.locator('nav[aria-label="documentation"] .app-drawer-active');
await expect(anchor).toHaveAttribute('href', '/material-ui/react-card/');
await expect(anchor).toHaveText('Card');
});
test('should have plural url for Tabs', async ({ page }) => {
await page.goto('/material-ui/react-tabs/');
const anchor = page.locator('nav[aria-label="documentation"] .app-drawer-active');
await expect(anchor).toHaveAttribute('href', '/material-ui/react-tabs/');
await expect(anchor).toHaveText('Tabs');
});
test('should have plural url for Breadcrumbs', async ({ page }) => {
await page.goto('/material-ui/react-breadcrumbs/');
const anchor = page.locator('nav[aria-label="documentation"] .app-drawer-active');
await expect(anchor).toHaveAttribute('href', '/material-ui/react-breadcrumbs/');
await expect(anchor).toHaveText('Breadcrumbs');
});
test('should not have react- prefix for icons', async ({ page }) => {
await page.goto('/material-ui/icons/');
const anchor = page.locator('nav[aria-label="documentation"] .app-drawer-active');
await expect(anchor).toHaveAttribute('href', '/material-ui/icons/');
await expect(anchor).toHaveText('Icons');
});
test('should not have react- prefix for material-icons', async ({ page }) => {
await page.goto('/material-ui/material-icons/');
const anchor = page.locator('nav[aria-label="documentation"] .app-drawer-active');
await expect(anchor).toHaveAttribute('href', '/material-ui/material-icons/');
await expect(anchor).toHaveText('Material Icons');
});
test('should have correct API links when name of components conflicts with Base UI', async ({
page,
}) => {
await page.goto('/material-ui/react-button/');
const anchors = page.locator('div > h2#api ~ ul a');
const firstAnchor = anchors.first();
const textContent = await firstAnchor.textContent();
await expect(textContent).toEqual('<Button />');
await expect(firstAnchor).toHaveAttribute('href', '/material-ui/api/button/');
});
});
test.describe('API page', () => {
test('should have correct link for sidebar anchor', async ({ page }) => {
await page.goto('/material-ui/api/card/');
const anchor = page.locator('nav[aria-label="documentation"] ul a:text-is("Card")');
await expect(anchor).toHaveClass(/app-drawer-active/);
await expect(anchor).toHaveAttribute('href', '/material-ui/api/card/');
});
test('all the links in the main content should have correct prefix', async ({ page }) => {
await page.goto('/material-ui/api/card/');
const anchors = page.locator('div#main-content a');
const handles = await anchors.elementHandles();
const links = await Promise.all(handles.map((elm) => elm.getAttribute('href')));
links.forEach((link) => {
if (
link &&
['/getting-started', '/customization', '/guides', '/discover-more'].some((path) =>
link.includes(path),
)
) {
expect(link).toMatch(/^\/(material-ui|system)/);
}
expect(link).not.toMatch(/\/components/); // there should be no `/components` in the url anymore
if (link && link.startsWith('/system')) {
expect(link.startsWith('/system')).toBeTruthy();
expect(link.match(/\/system{1}/g)).toHaveLength(1); // should not have repeated `/system/system/*`
}
});
});
});
test.describe('Search', () => {
const retryToggleSearch = async (page: Page, count = 3) => {
try {
await page.keyboard.press('Meta+k');
await page.waitForSelector('input#docsearch-input', { timeout: 2000 });
} catch (error) {
if (count === 0) {
throw error;
}
await retryToggleSearch(page, count - 1);
}
};
test('should have correct link when searching component', async ({ page }) => {
await page.goto('/material-ui/getting-started/installation/');
await page.waitForLoadState('networkidle'); // wait for docsearch
await retryToggleSearch(page);
await page.type('input#docsearch-input', 'card', { delay: 50 });
const anchor = page.locator('.DocSearch-Hits a:has-text("Card")');
await expect(anchor.first()).toHaveAttribute('href', '/material-ui/react-card/');
});
test('should have correct link when searching API', async ({ page }) => {
await page.goto('/material-ui/getting-started/installation/');
await page.waitForLoadState('networkidle'); // wait for docsearch
await retryToggleSearch(page);
await page.type('input#docsearch-input', 'card api', { delay: 50 });
const anchor = page.locator('.DocSearch-Hits a:has-text("Card API")');
await expect(anchor.first()).toHaveAttribute('href', '/material-ui/api/card/');
});
});
});

View File

@@ -0,0 +1,10 @@
import { test as base, expect } from '@playwright/test';
import { TestFixture } from './playwright.config';
const test = base.extend<TestFixture>({});
test('should see the selected icon popup that match the query', async ({ page }) => {
await page.goto('/material-ui/material-icons/?selected=AcUnit');
await expect(page.locator('.MuiDialog-container h2:has-text("AcUnit")')).toBeVisible();
});

View File

@@ -0,0 +1,15 @@
import { PlaywrightTestConfig } from '@playwright/test';
export type TestFixture = {};
const config: PlaywrightTestConfig<TestFixture> = {
reportSlowTests: {
max: 1,
threshold: 60 * 1000, // 1min
},
use: {
baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'https://mui.com',
},
};
export default config;

View File

@@ -0,0 +1,29 @@
import { test as base, expect } from '@playwright/test';
import FEATURE_TOGGLE from 'docs/src/featureToggle';
import { TestFixture } from './playwright.config';
const test = base.extend<TestFixture>({});
test('able to navigate between products', async ({ page }) => {
await page.goto('/material-ui/getting-started/installation/');
await page.click('#mui-product-selector');
await expect(page.locator('#mui-product-menu')).toBeVisible();
await expect(
page.locator('#mui-product-menu a[href^="/material-ui/getting-started/"]'),
).toBeVisible();
await expect(page.locator('#mui-product-menu a[href^="/system/"]')).toHaveAttribute(
'href',
'/system/getting-started/',
);
await expect(page.locator('#mui-product-menu a[href^="/x/react-data-grid/"]')).toBeVisible();
await expect(page.locator('#mui-product-menu a[href^="/x/react-date-pickers/"]')).toBeVisible();
await expect(
page.locator('#mui-product-menu a[href^="/base-ui/getting-started/"]'),
).toBeVisible();
});

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',
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,792 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import createStyled from '@mui/system/createStyled';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { createRenderer, screen } from '@mui/internal-test-utils';
describe('createStyled', () => {
const { render } = createRenderer();
// These tests rely on implementation details (namely `displayName`)
// Ideally we'd just test if the proper name appears in a React warning.
// But React warnings are deduplicated during module lifetime.
// We would need to reset modules to make the tests work in watchmode.
describe.skipIf(process.env.NODE_ENV === 'production')('displayName', () => {
it('uses the `componentName` if set', () => {
const styled = createStyled({});
const SomeMuiComponent = styled('div', { name: 'SomeMuiComponent' })({});
expect(SomeMuiComponent).to.have.property('displayName', 'SomeMuiComponent');
});
it('falls back to the decorated tag name', () => {
const styled = createStyled({});
const SomeMuiComponent = styled('div')({});
expect(SomeMuiComponent).to.have.property('displayName', 'Styled(div)');
});
it('falls back to the decorated computed displayName', () => {
const styled = createStyled({});
const SomeMuiComponent = styled(function SomeMuiComponent() {
return null;
})({});
expect(SomeMuiComponent).to.have.property('displayName', 'Styled(SomeMuiComponent)');
});
it('has a fallback name if the display name cannot be computed', () => {
const styled = createStyled({});
const SomeMuiComponent = styled(() => null)({});
expect(SomeMuiComponent).to.have.property('displayName', 'Styled(Component)');
});
});
describe('composition', () => {
it('should call styleFunctionSx once', () => {
const styled = createStyled();
const spySx = spy();
const Child = styled('div')({});
render(<Child sx={spySx} />);
expect(spySx.callCount).to.equal(2); // React 18 renders twice in strict mode.
});
it('should still call styleFunctionSx once', () => {
const styled = createStyled();
const spySx = spy();
const Child = styled('div')({});
const Parent = styled(Child)({});
render(<Parent sx={spySx} />);
expect(spySx.callCount).to.equal(2); // React 18 renders twice in strict mode.
});
it('both child and parent still accept `sx` prop', () => {
const styled = createStyled();
const Child = styled('div')({});
const Parent = styled(Child)({});
render(
<React.Fragment>
<Parent data-testid="parent" sx={{ color: 'rgb(0, 0, 255)' }} />
<Child data-testid="child" sx={{ color: 'rgb(255, 0, 0)' }} />
</React.Fragment>,
);
expect(screen.getByRole('parent')).toHaveComputedStyle({ color: 'rgb(0, 0, 255)' });
expect(screen.getByRole('child')).toHaveComputedStyle({ color: 'rgb(255, 0, 0)' });
});
});
it('default overridesResolver', () => {
const styled = createStyled({});
const Button = styled('button', {
name: 'MuiButton',
slot: 'root',
})({
display: 'flex',
});
render(
<ThemeProvider
theme={createTheme({
components: {
MuiButton: {
styleOverrides: {
root: {
width: '300px',
height: '200px',
},
},
},
},
})}
>
<Button data-testid="button" color="primary" variant="contained" className="Mui-disabled">
Hello
</Button>
</ThemeProvider>,
);
expect(screen.getByTestId('button')).toHaveComputedStyle({
width: '300px',
height: '200px',
});
});
describe('styles', () => {
it('styles of pseudo classes of variants are merged', () => {
const theme = createTheme({
components: {
MuiButton: {
variants: [
{
props: { variant: 'contained' },
style: {
'&.Mui-disabled': {
width: '300px',
},
},
},
{
props: { variant: 'contained', color: 'primary' },
style: {
'&.Mui-disabled': {
height: '200px',
},
},
},
],
},
},
});
const styled = createStyled({});
const Button = styled('button', {
shouldForwardProp: (prop) => prop !== 'color' && prop !== 'contained',
name: 'MuiButton',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})({
display: 'flex',
});
render(
<ThemeProvider theme={theme}>
<Button data-testid="button" color="primary" variant="contained" className="Mui-disabled">
Hello
</Button>
</ThemeProvider>,
);
expect(screen.getByTestId('button')).toHaveComputedStyle({
width: '300px',
height: '200px',
});
});
});
describe('styleOverrides callback', () => {
const styled = createStyled({});
const ButtonRoot = styled('button', {
name: 'MuiButton',
slot: 'Root',
overridesResolver: (props, styles) => [
styles.root,
{ [`& .MuiButton-avatar`]: styles.avatar },
],
})({});
const ButtonIcon = styled('span', {
name: 'MuiButton',
slot: 'Icon',
overridesResolver: (props, styles) => styles.icon,
})({});
function Button({ children, startIcon, endIcon, color = 'primary', ...props }) {
const ownerState = { startIcon, endIcon, color, ...props };
return (
<ButtonRoot data-testid="button" ownerState={ownerState}>
{startIcon && <ButtonIcon ownerState={ownerState}>{startIcon}</ButtonIcon>}
{children}
{endIcon && <ButtonIcon ownerState={ownerState}>{endIcon}</ButtonIcon>}
</ButtonRoot>
);
}
it('spread ownerState as props to the slot styleOverrides', () => {
const finalTheme = createTheme({
components: {
MuiButton: {
styleOverrides: {
avatar: () => {
return {
width: '100px',
};
},
},
},
},
});
render(
<ThemeProvider theme={finalTheme}>
<Button>
<div className="MuiButton-avatar" data-testid="button-avatar" />
Hello
</Button>
</ThemeProvider>,
);
expect(screen.getByTestId('button-avatar')).toHaveComputedStyle({
width: '100px',
});
});
it('support slot as nested class', () => {
const finalTheme = createTheme({
typography: {
button: {
fontSize: '20px',
},
},
components: {
MuiButton: {
styleOverrides: {
root: ({ ownerState, theme }) => {
const { color, variant } = ownerState;
const styles = [];
if (color === 'primary') {
styles.push({
width: 120,
height: 48,
});
}
if (variant === 'contained') {
styles.push(theme.typography.button);
}
return styles;
},
icon: ({ ownerState }) => [
ownerState.startIcon && { marginRight: 8 },
ownerState.endIcon && { marginLeft: 8 },
],
},
},
},
});
const { container } = render(
<ThemeProvider theme={finalTheme}>
<Button color="primary" variant="contained" startIcon="foo">
Hello
</Button>
</ThemeProvider>,
);
expect(screen.getByRole('button')).toHaveComputedStyle({
width: '120px',
height: '48px',
fontSize: '20px',
});
expect(
container.firstChild.firstChild, // startIcon
).toHaveComputedStyle({
marginRight: '8px',
});
});
it('support object return from the callback', () => {
const finalTheme = createTheme({
components: {
MuiButton: {
styleOverrides: {
root: () => ({
width: '300px',
}),
},
},
},
});
const { container } = render(
<ThemeProvider theme={finalTheme}>
<Button>Hello</Button>
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
width: '300px',
});
});
it('support template string return from the callback', () => {
const finalTheme = createTheme({
components: {
MuiButton: {
styleOverrides: {
root: () => `
width: 300px;
`,
},
},
},
});
const { container } = render(
<ThemeProvider theme={finalTheme}>
<Button>Hello</Button>
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
width: '300px',
});
});
it('works with sx', () => {
const finalTheme = createTheme({
components: {
MuiButton: {
styleOverrides: {
root: ({ theme }) =>
theme.unstable_sx({
pt: 10,
}),
icon: ({ ownerState, theme }) => [
ownerState.color === 'primary' &&
theme.unstable_sx({
mr: 10,
}),
],
},
},
},
});
const { container } = render(
<ThemeProvider theme={finalTheme}>
<Button startIcon="✅">Hello</Button>
</ThemeProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
paddingTop: '80px',
});
expect(container.firstChild.firstChild).toHaveComputedStyle({
marginRight: '80px',
});
});
});
it('does not spread `sx` prop to DOM', () => {
const styled = createStyled({});
const Button = styled('button')({});
render(
<Button data-testid="button" sx={{ bgcolor: 'red' }}>
Link
</Button>,
);
expect(screen.getByTestId('button')).not.to.have.attribute('sx');
});
it('does not forward `ownerState` prop to DOM', () => {
const styled = createStyled({});
const Button = styled('button')({});
render(<Button data-testid="button" ownerState={{}} />);
expect(screen.getByTestId('button')).not.to.have.attribute('ownerState');
});
describe('default behaviors', () => {
it('does not forward invalid props to DOM if no `slot` specified', () => {
// This scenario is usually used by library consumers
const styled = createStyled({});
const Button = styled('button')({});
render(
<Button data-testid="button" color="red" shouldBeRemoved data-foo="bar">
Link
</Button>,
);
const button = screen.getByTestId('button');
expect(button.getAttribute('data-foo')).to.equal('bar');
expect(button.getAttribute('color')).to.equal('red'); // color is for Safari mask-icon link
expect(button.getAttribute('shouldBeRemoved')).not.to.equal('true');
});
it('can use `as` prop', () => {
const styled = createStyled({});
const Button = styled('button')({});
render(<Button data-testid="button" as="a" href="/" />);
expect(screen.getByTestId('button')).to.have.tagName('a');
expect(screen.getByTestId('button')).to.have.attribute('href', '/');
});
it('able to pass props to `as` styled component', () => {
const styled = createStyled({});
const ChildRoot = styled('div')({});
function Child({ component }) {
return <ChildRoot as={component}>content</ChildRoot>;
}
const Button = styled('button')({});
const { container } = render(<Button as={Child} component="span" />);
expect(container.firstChild).to.have.tagName('span');
});
});
describe('variants key', () => {
it('should accept variants in object style arg', () => {
const styled = createStyled({});
const Test = styled('div')({
variants: [
{
props: { color: 'blue', variant: 'filled' },
style: {
backgroundColor: 'rgb(0,0,255)',
},
},
{
props: { color: 'blue', variant: 'text' },
style: {
color: 'rgb(0,0,255)',
},
},
],
});
render(
<React.Fragment>
<Test data-testid="filled" color="blue" variant="filled">
Filled
</Test>
<Test data-testid="text" color="blue" variant="text">
Text
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('filled')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('text')).toHaveComputedStyle({ color: 'rgb(0, 0, 255)' });
});
it('should accept variants in function style arg', () => {
const styled = createStyled({ defaultTheme: { colors: { blue: 'rgb(0, 0, 255)' } } });
const Test = styled('div')(({ theme }) => ({
variants: [
{
props: { color: 'blue', variant: 'filled' },
style: {
backgroundColor: theme.colors.blue,
},
},
{
props: { color: 'blue', variant: 'text' },
style: {
color: theme.colors.blue,
},
},
],
}));
render(
<React.Fragment>
<Test data-testid="filled" color="blue" variant="filled">
Filled
</Test>
<Test data-testid="text" color="blue" variant="text">
Text
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('filled')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('text')).toHaveComputedStyle({ color: 'rgb(0, 0, 255)' });
});
it('should accept variants in function style arg with props usage', () => {
const styled = createStyled({
defaultTheme: {
colors: { blue: 'rgb(0, 0, 255)', red: 'rgb(255, 0, 0)', green: 'rgb(0, 255, 0)' },
},
});
const Test = styled('div')(({ theme, color }) => ({
variants: [
{
props: (props) => props.color !== 'blue',
style: {
backgroundColor: theme.colors[color],
},
},
],
}));
render(
<React.Fragment>
<Test data-testid="red" color="red">
Filled
</Test>
<Test data-testid="green" color="green">
Text
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('green')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 255, 0)',
});
expect(screen.getByTestId('red')).toHaveComputedStyle({ backgroundColor: 'rgb(255, 0, 0)' });
});
it('should merge props and ownerState in props callback', () => {
const styled = createStyled({
defaultTheme: {
colors: { blue: 'rgb(0, 0, 255)', red: 'rgb(255, 0, 0)', green: 'rgb(0, 255, 0)' },
},
});
const Test = styled('div')(({ theme, color }) => ({
variants: [
{
props: (props) => props.color === 'green' || props.color === 'red',
style: {
backgroundColor: theme.colors[color],
},
},
],
}));
render(
<React.Fragment>
<Test data-testid="red" ownerState={{ color: 'red' }}>
Red
</Test>
<Test data-testid="green" ownerState={{ color: 'green' }}>
Green
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('green')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 255, 0)',
});
expect(screen.getByTestId('red')).toHaveComputedStyle({ backgroundColor: 'rgb(255, 0, 0)' });
});
it('should accept variants in arrays', () => {
const styled = createStyled({ defaultTheme: { colors: { blue: 'rgb(0, 0, 255)' } } });
const Test = styled('div')(
({ theme }) => ({
variants: [
{
props: { color: 'blue', variant: 'filled' },
style: {
backgroundColor: theme.colors.blue,
},
},
{
props: { color: 'blue', variant: 'text' },
style: {
color: theme.colors.blue,
},
},
],
}),
{
variants: [
{
props: { color: 'blue', variant: 'outlined' },
style: {
borderTopColor: 'rgb(0,0,255)',
},
},
// This is overriding the previous definition
{
props: { color: 'blue', variant: 'text' },
style: {
color: 'rgb(0,0,220)',
},
},
],
},
);
render(
<React.Fragment>
<Test data-testid="filled" color="blue" variant="filled">
Filled
</Test>
<Test data-testid="text" color="blue" variant="text">
Text
</Test>
<Test data-testid="outlined" color="blue" variant="outlined">
Outlined
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('filled')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('text')).toHaveComputedStyle({ color: 'rgb(0, 0, 220)' });
expect(screen.getByTestId('outlined')).toHaveComputedStyle({
borderTopColor: 'rgb(0, 0, 255)',
});
});
it('theme variants should override styled variants', () => {
const styled = createStyled({});
const Test = styled('div', { name: 'Test' })({
variants: [
{
props: { color: 'blue', variant: 'filled' },
style: {
backgroundColor: 'rgb(0,0,255)',
},
},
// This is overriding the previous definition
{
props: { color: 'blue', variant: 'text' },
style: {
color: 'rgb(0,0,255)',
},
},
],
});
render(
<ThemeProvider
theme={{
components: {
Test: {
variants: [
{
props: { variant: 'text', color: 'blue' },
style: {
color: 'rgb(0,0,220)',
},
},
],
},
},
}}
>
<Test data-testid="filled" color="blue" variant="filled">
Filled
</Test>
<Test data-testid="text" color="blue" variant="text">
Text
</Test>
</ThemeProvider>,
);
expect(screen.getByTestId('filled')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('text')).toHaveComputedStyle({ color: 'rgb(0, 0, 220)' });
});
it('should accept variants in function props arg', () => {
const styled = createStyled({ defaultTheme: { colors: { blue: 'rgb(0, 0, 255)' } } });
const Test = styled('div')(({ theme }) => ({
variants: [
{
props: (props) => props.color === 'blue' && props.variant === 'filled',
style: {
backgroundColor: theme.colors.blue,
},
},
{
props: (props) => props.color === 'blue' && props.variant === 'text',
style: {
color: theme.colors.blue,
},
},
],
}));
render(
<React.Fragment>
<Test data-testid="filled" color="blue" variant="filled">
Filled
</Test>
<Test data-testid="text" color="blue" variant="text">
Text
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('filled')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('text')).toHaveComputedStyle({ color: 'rgb(0, 0, 255)' });
});
it('should accept variants with both object and function props arg', () => {
const styled = createStyled({ defaultTheme: { colors: { blue: 'rgb(0, 0, 255)' } } });
const Test = styled('div')(({ theme }) => ({
variants: [
{
props: (props) => props.color === 'blue' && props.variant === 'filled',
style: {
backgroundColor: theme.colors.blue,
},
},
{
props: { color: 'blue', variant: 'outlined' },
style: {
borderColor: theme.colors.blue,
},
},
{
props: (props) => props.color === 'blue' && props.variant === 'text',
style: {
color: theme.colors.blue,
},
},
],
}));
render(
<React.Fragment>
<Test data-testid="filled" color="blue" variant="filled">
Filled
</Test>
<Test data-testid="outlined" color="blue" variant="outlined">
Outlined
</Test>
<Test data-testid="text" color="blue" variant="text">
Text
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('filled')).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('outlined')).toHaveComputedStyle({
borderTopColor: 'rgb(0, 0, 255)',
});
expect(screen.getByTestId('text')).toHaveComputedStyle({ color: 'rgb(0, 0, 255)' });
});
it('should not consume values from nested ownerState', () => {
const styled = createStyled({ defaultTheme: { colors: { blue: 'rgb(0, 0, 255)' } } });
const Test = styled('div')(({ theme }) => ({
variants: [
{
props: ({ ownerState }) => ownerState.color === 'blue',
style: {
backgroundColor: theme.colors.blue,
},
},
],
}));
const ownerState = { color: 'blue' };
render(
<React.Fragment>
<Test data-testid="blue" ownerState={ownerState}>
Blue
</Test>
<Test data-testid="nested" ownerState={{ ownerState }}>
Nested ownerState
</Test>
</React.Fragment>,
);
expect(screen.getByTestId('blue')).toHaveComputedStyle({ backgroundColor: 'rgb(0, 0, 255)' });
expect(screen.getByTestId('nested')).not.toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
});
});
});

View File

@@ -0,0 +1,67 @@
import { expect } from 'chai';
import styleFunctionSx from '@mui/system/styleFunctionSx';
import {
private_createMixins as createMixins,
private_createTypography as createTypography,
} from '@mui/material/styles';
import { private_createBreakpoints as createBreakpoints } from '@mui/system/createTheme';
describe('styleFunctionSx', () => {
describe('breakpoints', () => {
it('writes breakpoints in correct order if default toolbar mixin is present in theme', () => {
const breakpoints = createBreakpoints({});
const result = styleFunctionSx({
theme: {
mixins: createMixins(breakpoints),
breakpoints,
},
sx: (themeParam) => themeParam.mixins.toolbar,
});
// Test the order
expect(Object.keys(result)).to.deep.equal([
'@media (min-width:0px)',
'@media (min-width:600px)',
'minHeight',
]);
});
});
it('resolves inherit typography properties', () => {
const result = styleFunctionSx({
theme: { typography: createTypography({}, {}) },
sx: {
fontFamily: 'inherit',
fontWeight: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit',
},
});
expect(result).deep.equal({
fontFamily: 'inherit',
fontWeight: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit',
});
});
it('resolves theme typography properties', () => {
const result = styleFunctionSx({
theme: { typography: createTypography({}, {}) },
sx: {
fontFamily: 'default',
fontWeight: 'fontWeightMedium',
fontSize: 'fontSize',
},
});
expect(result).deep.equal({
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeight: 500,
fontSize: 14,
});
});
});

View File

@@ -0,0 +1,160 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { createRenderer, screen } from '@mui/internal-test-utils';
import { ThemeContext } from '@mui/styled-engine';
import * as material from '@mui/material';
import * as joy from '@mui/joy';
// simulate 3rd-party library like Theme-UI, Chakra-UI, or Mantine
interface LibTheme {
palette: { brand: string };
vars: { palette: { brand: string } };
}
function LibThemeProvider({ children }: React.PropsWithChildren<{}>) {
const theme = React.useMemo<LibTheme>(
() => ({ palette: { brand: '#ff5252' }, vars: { palette: { brand: 'var(--palette-brand)' } } }),
[],
);
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
function LibComponent() {
const theme = React.useContext(ThemeContext as unknown as React.Context<LibTheme>);
return <div style={{ color: theme.palette.brand }} />;
}
const joyTheme = joy.extendTheme({
components: {
JoyButton: {
defaultProps: {
variant: 'outlined',
},
styleOverrides: {
root: ({ theme }) => ({
color: theme.vars.palette.text.primary,
mixBlendMode: 'darken',
}),
},
},
},
});
const CustomJoy = joy.styled('div')(({ theme }) => ({
fontSize: theme.vars.fontSize.md,
}));
const materialTheme = material.createTheme({
components: {
MuiButton: {
defaultProps: {
variant: 'outlined',
},
styleOverrides: {
root: ({ theme }) => ({
color: theme.palette.text.primary,
mixBlendMode: 'darken',
}),
},
},
},
});
const CustomMaterial = material.styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadius,
}));
describe('Multiple nested theme providers', () => {
const { render } = createRenderer();
let originalMatchmedia: any;
let storage: Record<string, string> = {};
const createMatchMedia = (matches: boolean) => () => ({
matches,
// Keep mocking legacy methods because @mui/material v5 still uses them
addListener: () => {},
addEventListener: () => {},
removeListener: () => {},
removeEventListener: () => {},
});
beforeEach(() => {
originalMatchmedia = window.matchMedia;
// Create mocks of localStorage getItem and setItem functions
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: spy((key) => storage[key]),
setItem: spy((key, value) => {
storage[key] = value;
}),
},
configurable: true,
});
// clear the localstorage
storage = {};
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
});
afterEach(() => {
window.matchMedia = originalMatchmedia;
});
it('[docs] Material UI + Joy UI', () => {
render(
<joy.CssVarsProvider theme={{ [joy.THEME_ID]: joyTheme }}>
<material.ThemeProvider theme={materialTheme}>
<joy.Button
sx={(theme) => ({
// test `sx`
bgcolor: theme.vars.palette.neutral[100],
})}
>
Joy
</joy.Button>
<material.Button
sx={(theme) => ({
bgcolor: theme.palette.secondary.light,
})}
>
Material
</material.Button>
</material.ThemeProvider>
</joy.CssVarsProvider>,
);
// these test if `useThemeProps` works with theme scoping
expect(screen.getByText('Joy')).to.have.class(joy.buttonClasses.variantOutlined);
expect(screen.getByText('Joy')).toHaveComputedStyle({ mixBlendMode: 'darken' });
expect(screen.getByText('Material')).to.have.class(material.buttonClasses.outlinedPrimary);
expect(screen.getByText('Material')).toHaveComputedStyle({ mixBlendMode: 'darken' });
});
it('Material UI works with 3rd-party lib', () => {
render(
<LibThemeProvider>
<material.ThemeProvider theme={{ [material.THEME_ID]: materialTheme }}>
<material.Button>Material</material.Button>
<CustomMaterial /> {/* styled() should work with theme scoping */}
<LibComponent />{' '}
{/* still able to render even though it is wrapped in Material UI ThemeProvider */}
</material.ThemeProvider>
</LibThemeProvider>,
);
expect(screen.getByText('Material')).to.have.class(material.buttonClasses.outlinedPrimary);
});
it('Joy UI works with 3rd-party lib', () => {
render(
<LibThemeProvider>
<joy.ThemeProvider theme={{ [joy.THEME_ID]: joyTheme }}>
<joy.Button>Joy</joy.Button>
<CustomJoy /> {/* styled() should work with theme scoping */}
<LibComponent />{' '}
{/* still able to render even though it is wrapped in Material UI ThemeProvider */}
</joy.ThemeProvider>
</LibThemeProvider>,
);
expect(screen.getByText('Joy')).to.have.class(joy.buttonClasses.variantOutlined);
});
});

View File

@@ -0,0 +1,5 @@
# TypeScript `moduleResolution` Tests
This suite uses workspace linking to resolve build folders and runs the TypeScript compiler with various `moduleResolution` settings.
You can only run `pnpm typescript:all` after the project has been built with `pnpm release:build`.

View File

@@ -0,0 +1,14 @@
{
"name": "@mui-internal/test-module-resolution",
"private": true,
"scripts": {
"typescript:all": "pnpm typescript:node && pnpm typescript:node16",
"typescript:node": "tsc -p tsconfig.node.json",
"typescript:node16": "tsc -p tsconfig.node16.json"
},
"devDependencies": {},
"dependencies": {
"@mui/icons-material": "workspace:^",
"@mui/material": "workspace:^"
}
}

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
import { AccessAlarm } from '@mui/icons-material';
import AccessAlarmOutlined from '@mui/icons-material/AccessAlarmOutlined';
export default function Test() {
return (
<React.Fragment>
<AccessAlarm />
<AccessAlarmOutlined />
</React.Fragment>
);
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
// rely on workspace linking
"paths": {},
"target": "es5",
"lib": ["es6", "dom"],
"jsx": "react",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"noErrorTruncation": true,
"rootDir": "./src",
"skipLibCheck": true,
"types": []
},
"exclude": ["**/node_modules/"]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// <ts@next uncomment> "ignoreDeprecations": "6.0",
"module": "commonjs",
// This will stop functioning under typescript 7.0. We can probably just remove this test by then
"moduleResolution": "node"
}
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16"
}
}

47
test/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "@mui-internal/tests",
"private": true,
"scripts": {
"typescript": "tsc -p tsconfig.json"
},
"devDependencies": {
"@babel/runtime": "^7.28.4",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@mui/base": "5.0.0-beta.70",
"@mui/icons-material": "workspace:^",
"@mui/internal-test-utils": "workspace:^",
"@mui/joy": "workspace:*",
"@mui/lab": "workspace:*",
"@mui/material": "workspace:^",
"@mui/stylis-plugin-rtl": "workspace:^",
"@mui/system": "workspace:^",
"@mui/utils": "workspace:^",
"@playwright/test": "1.56.1",
"@testing-library/dom": "^10.4.1",
"@types/chai": "^5.2.3",
"@types/react": "^19.2.7",
"@types/react-is": "^19.2.0",
"@types/sinon": "^17.0.4",
"chai": "^6.0.1",
"docs": "workspace:^",
"es-toolkit": "^1.39.10",
"fast-glob": "^3.3.3",
"html-webpack-plugin": "^5.6.3",
"prop-types": "^15.8.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-is": "^19.2.1",
"react-router": "^7.9.6",
"react-window": "^2.2.2",
"sinon": "^21.0.0",
"styled-components": "^6.1.19",
"stylis": "4.2.0",
"webfontloader": "^1.6.28",
"webpack": "^5.101.0",
"yargs": "^17.7.2"
},
"dependencies": {
"@react-spring/web": "^10.0.3"
}
}

View File

@@ -0,0 +1,56 @@
# Visual regression testing
Visual regression tests 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 either:
1. located inside a separate file in `./fixtures` and written as a React component.
Here is an [example](https://github.com/mui/material-ui/blob/814fb60bbd8e500517b2307b6a297a638838ca89/test/regressions/tests/Menu/SimpleMenuList.js#L6-L16) with the `Menu` component.
2. a demo from `docs/data`
By default all demos are included.
We exclude demos if they are redundant or flaky etc.
The logic for this exclusion is handled (like the composition) in `./index.js`
If you introduce new behavior, prefer adding a demo to the documentation to solve documentation and testing with one file.
If you're adding a new test prefer a new component instead of editing existing files since that might unknowingly alter existing tests.
## Instrumentation
### Manual
`pnpm test:regressions:dev` will build all fixtures and render an overview page that lists all fixtures.
This can be used to debug individual fixtures.
By default, a devtools-like view is shown that can be disabled by appending `#no-dev` to the URL, for example `http://localhost:5001/docs-customization-typography/CustomResponsiveFontSizes#no-dev` or forced by appending `#dev` to the URL, for example `http://localhost:5001/docs-customization-typography/CustomResponsiveFontSizes#dev`.
### Automatic
We're using [`playwright`](https://playwright.dev) to iterate over each fixture and take a screenshot.
It allows catching regressions like this one:
![before](/test/docs-regressions-before.png)
![diff](/test/docs-regressions-diff.png)
Screenshots are saved in `./screenshots/$BROWSER_NAME/`.
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:regressions:dev` and `pnpm test:regressions:run --watch` in separate terminals is recommended.
| command | description |
| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------- |
| `pnpm test:regressions` | Full run |
| `pnpm test:regressions:dev` | Prepares the fixtures to be able to test in watchmode |
| `pnpm test:regressions:run` | Runs the tests (requires `pnpm test:regressions:dev` or `pnpm test:regressions:build`+`pnpm test:regressions:server`) |
| `pnpm test:regressions:build` | Builds the vite bundle for viewing the fixtures |
| `pnpm test:regressions:server` | Serves the fixture bundle. |

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import Box from '@mui/material/Box';
import GlobalStyles from '@mui/material/GlobalStyles';
import JoyBox from '@mui/joy/Box';
import { CssVarsProvider } from '@mui/joy/styles';
function TestViewer(props) {
const { children, path } = 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(() => {
function handleFontsEvent(event) {
if (event.type === 'loading') {
setReady(false);
} else if (event.type === 'loadingdone') {
// Don't know if there could be multiple loaded events after we started loading multiple times.
// So make sure we're only ready if fonts are actually ready.
if (document.fonts.status === 'loaded') {
setReady(true);
}
}
}
document.fonts.addEventListener('loading', handleFontsEvent);
document.fonts.addEventListener('loadingdone', handleFontsEvent);
// In case the child triggered font fetching we're not ready yet.
// The fonts event handler will mark the test as ready on `loadingdone`
if (document.fonts.status === 'loaded') {
setReady(true);
}
return () => {
document.fonts.removeEventListener('loading', handleFontsEvent);
document.fonts.removeEventListener('loadingdone', handleFontsEvent);
};
}, []);
const viewerBoxSx = {
display: 'block',
p: 1,
};
return (
<React.Fragment>
<GlobalStyles
styles={{
html: {
WebkitFontSmoothing: 'antialiased', // Antialiasing.
MozOsxFontSmoothing: 'grayscale', // Antialiasing.
// Do the opposite of the docs in order to help catching issues.
boxSizing: 'content-box',
},
'*, *::before, *::after': {
boxSizing: 'inherit',
// Disable transitions to avoid flaky screenshots
transition: 'none !important',
animation: 'none !important',
},
body: {
margin: 0,
overflowX: 'hidden',
},
}}
/>
{path.startsWith('/docs-joy') ? (
<CssVarsProvider>
<JoyBox
aria-busy={!ready}
data-testid="testcase"
data-testpath={path}
sx={{ bgcolor: 'background.body', ...viewerBoxSx }}
>
{children}
</JoyBox>
</CssVarsProvider>
) : (
<Box
aria-busy={!ready}
data-testid="testcase"
data-testpath={path}
sx={{ bgcolor: 'background.default', ...viewerBoxSx }}
>
{children}
</Box>
)}
</React.Fragment>
);
}
TestViewer.propTypes = {
children: PropTypes.node.isRequired,
path: PropTypes.string.isRequired,
};
export default TestViewer;

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import { Box } from '@mui/material';
export default function MultilineAlertWithAction() {
return (
<Box sx={{ width: 500 }}>
<Alert
action={
<Button color="inherit" size="small">
UNDO
</Button>
}
>
<Typography as="div">{'a'.repeat(500)}</Typography>
</Alert>
</Box>
);
}

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
export default function MultilineAlertWithAction() {
return (
<Alert
action={
<Button color="inherit" size="small">
UNDO
</Button>
}
>
<Typography as="div">This is the first line.</Typography>
<Typography as="div">This is the second line.</Typography>
</Alert>
);
}

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import { AppBar, Typography } from '@mui/material';
import { ThemeProvider, createTheme } from '@mui/material/styles';
const darkTheme = createTheme({
palette: {
mode: 'dark',
},
});
export default function Demo() {
return (
<div style={{ backgroundColor: '#ffefd5' }}>
<AppBar position="static">
<Typography>Light | Default</Typography>
</AppBar>
<ThemeProvider theme={darkTheme}>
<AppBar position="static">
<Typography>Dark | Default</Typography>
</AppBar>
</ThemeProvider>
<AppBar position="static" color="transparent">
<Typography>Light | Transparent</Typography>
</AppBar>
<ThemeProvider theme={darkTheme}>
<AppBar position="static" color="transparent">
<Typography>Dark | Transparent</Typography>
</AppBar>
</ThemeProvider>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import { CssVarsProvider } from '@mui/joy/styles';
import AspectRatio from '@mui/joy/AspectRatio';
import Box from '@mui/joy/Box';
export default function VariantColorJoy() {
return (
<CssVarsProvider>
<Box sx={{ p: 2, bgcolor: 'red' }}>
<AspectRatio sx={{ borderRadius: 'xl', minWidth: 200 }} />
</Box>
</CssVarsProvider>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
export default function Demo() {
return (
<div style={{ width: 300 }}>
<Autocomplete
options={[]}
renderInput={(params) => (
<TextField
{...params}
variant="filled"
hiddenLabel
placeholder="Filled variant with hiddenLabel"
/>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import * as React from 'react';
import Stack from '@mui/material/Stack';
import Chip from '@mui/material/Chip';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
const movies = [
{
title: 'The Lord of the Rings: The Two Towers',
year: 2002,
},
];
export default function Sizes() {
return (
<Stack spacing={2} sx={{ width: 300 }}>
<Autocomplete
id="size-small-outlined"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={movies[0]}
disableClearable
renderInput={(params) => <TextField {...params} label="Movie" placeholder="Favorites" />}
/>
<Autocomplete
multiple
id="size-small-outlined-multi"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={[movies[0]]}
disableClearable
renderInput={(params) => <TextField {...params} label="Movie" placeholder="Favorites" />}
/>
<Autocomplete
id="size-small-outlined"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={movies[0]}
renderInput={(params) => <TextField {...params} label="Movie" placeholder="Favorites" />}
/>
<Autocomplete
multiple
id="size-small-outlined-multi"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={[movies[0]]}
renderInput={(params) => <TextField {...params} label="Movie" placeholder="Favorites" />}
/>
<Autocomplete
id="size-small-standard"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={movies[0]}
disableClearable
renderInput={(params) => (
<TextField {...params} variant="standard" label="Movies" placeholder="Favorites" />
)}
/>
<Autocomplete
multiple
id="size-small-standard-multi"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={[movies[0]]}
disableClearable
renderInput={(params) => (
<TextField {...params} variant="standard" label="Movies" placeholder="Favorites" />
)}
/>
<Autocomplete
id="size-small-filled"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={movies[0]}
disableClearable
renderInput={(params) => (
<TextField {...params} variant="filled" label="Movies" placeholder="Favorites" />
)}
/>
<Autocomplete
multiple
id="size-small-filled-multi"
size="small"
options={movies}
getOptionLabel={(option) => option.title}
defaultValue={[movies[0]]}
disableClearable
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...other } = getTagProps({ index });
return (
<Chip key={key} variant="outlined" label={option.title} size="small" {...other} />
);
})
}
renderInput={(params) => (
<TextField {...params} variant="filled" label="Movies" placeholder="Favorites" />
)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import SvgIcon from '@mui/material/SvgIcon';
import TextField from '@mui/material/TextField';
const movies = [
{
label: 'The Lord of the Rings: The Two Towers',
year: 2002,
},
];
export default function SmallAutocompleteWithStartAdornment() {
return (
<Autocomplete
options={movies}
value={movies[0]}
sx={{ width: 120, mt: 2 }}
renderInput={(params) => (
<TextField
{...params}
label="Autocomplete"
slotProps={{
input: {
...params.InputProps,
startAdornment: (
<SvgIcon>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12a7.5 7.5 0 0015 0m-15 0a7.5 7.5 0 1115 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077l1.41-.513m14.095-5.13l1.41-.513M5.106 17.785l1.15-.964m11.49-9.642l1.149-.964M7.501 19.795l.75-1.3m7.5-12.99l.75-1.3m-6.063 16.658l.26-1.477m2.605-14.772l.26-1.477m0 17.726l-.26-1.477M10.698 4.614l-.26-1.477M16.5 19.794l-.75-1.299M7.5 4.205L12 12m6.894 5.785l-1.149-.964M6.256 7.178l-1.15-.964m15.352 8.864l-1.41-.513M4.954 9.435l-1.41-.514M12.002 12l-3.75 6.495"
/>
</svg>
</SvgIcon>
),
},
}}
/>
)}
/>
);
}

View File

@@ -0,0 +1,16 @@
import * as React from 'react';
import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete';
export default function StandardAutocomplete() {
return (
<div style={{ width: 300 }}>
<Autocomplete
options={[]}
renderInput={(params) => (
<TextField {...params} label="Standard autocomplete" variant="standard" />
)}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete';
export default function StandardAutocomplete() {
return (
<div style={{ height: 220 }}>
<Autocomplete
multiple
limitTags={2}
options={['One', 'Two', 'Three']}
defaultValue={['One', 'Two', 'Three']}
renderInput={(params) => <TextField {...params} />}
sx={{ width: 300 }}
/>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import TextField from '@mui/material/TextField';
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete';
import useMediaQuery from '@mui/material/useMediaQuery';
import ListSubheader from '@mui/material/ListSubheader';
import Popper from '@mui/material/Popper';
import { useTheme, styled } from '@mui/material/styles';
import { List, useListRef } from 'react-window';
import Typography from '@mui/material/Typography';
const LISTBOX_PADDING = 8; // px
function RowComponent({ index, itemData, style }) {
const dataSet = itemData[index];
const inlineStyle = {
...style,
top: (style.top ?? 0) + LISTBOX_PADDING,
};
if ('group' in dataSet) {
return (
<ListSubheader key={dataSet.key} component="div" style={inlineStyle}>
{dataSet.group}
</ListSubheader>
);
}
const { key, ...optionProps } = dataSet[0];
return (
<Typography key={key} component="li" {...optionProps} noWrap style={inlineStyle}>
{`#${dataSet[2] + 1} - ${dataSet[1]}`}
</Typography>
);
}
// Adapter for react-window v2
RowComponent.propTypes = {
index: PropTypes.number.isRequired,
itemData: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.element, PropTypes.number, PropTypes.string]).isRequired,
),
PropTypes.shape({
children: PropTypes.node,
group: PropTypes.string.isRequired,
key: PropTypes.number.isRequired,
}),
]).isRequired,
).isRequired,
style: PropTypes.object.isRequired,
};
const ListboxComponent = React.forwardRef(function ListboxComponent(props, ref) {
const { children, internalListRef, onItemsBuilt, ...other } = props;
const itemData = [];
const optionIndexMap = React.useMemo(() => new Map(), []);
children.forEach((item) => {
itemData.push(item);
if ('children' in item && Array.isArray(item.children)) {
itemData.push(...item.children);
}
});
// Map option values to their indices in the flattened array
itemData.forEach((item, index) => {
if (Array.isArray(item) && item[1]) {
optionIndexMap.set(item[1], index);
}
});
React.useEffect(() => {
if (onItemsBuilt) {
onItemsBuilt(optionIndexMap);
}
}, [onItemsBuilt, optionIndexMap]);
const theme = useTheme();
const smUp = useMediaQuery(theme.breakpoints.up('sm'), {
noSsr: true,
});
const itemCount = itemData.length;
const itemSize = smUp ? 36 : 48;
const getChildSize = (child) => {
if (child.hasOwnProperty('group')) {
return 48;
}
return itemSize;
};
const getHeight = () => {
if (itemCount > 8) {
return 8 * itemSize;
}
return itemData.map(getChildSize).reduce((a, b) => a + b, 0);
};
// Separate className for List, other props for wrapper div (ARIA, handlers)
const { className, style, ...otherProps } = other;
return (
<div ref={ref} {...otherProps}>
<List
className={className}
listRef={internalListRef}
key={itemCount}
rowCount={itemCount}
rowHeight={(index) => getChildSize(itemData[index])}
rowComponent={RowComponent}
rowProps={{ itemData }}
style={{
height: getHeight() + 2 * LISTBOX_PADDING,
width: '100%',
}}
overscanCount={5}
tagName="ul"
/>
</div>
);
});
ListboxComponent.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
internalListRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({
current: PropTypes.shape({
element: PropTypes.object,
scrollToRow: PropTypes.func.isRequired,
}),
}),
]),
onItemsBuilt: PropTypes.func.isRequired,
style: PropTypes.object,
};
const StyledPopper = styled(Popper)({
[`& .${autocompleteClasses.listbox}`]: {
boxSizing: 'border-box',
'& ul': {
padding: 0,
margin: 0,
},
},
});
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const OPTIONS = [...Array(100).keys()]
.map(
(number) =>
`${characters[number % characters.length].repeat(10)}${Math.floor(number / characters.length)}`,
)
.sort((a, b) => a.toUpperCase().localeCompare(b.toUpperCase()));
export default function Virtualize() {
// Use react-window v2's useListRef hook for imperative API access
const internalListRef = useListRef(null);
const optionIndexMapRef = React.useRef(new Map());
const handleItemsBuilt = React.useCallback((optionIndexMap) => {
optionIndexMapRef.current = optionIndexMap;
}, []);
// Handle keyboard navigation by scrolling to highlighted option
const handleHighlightChange = (event, option) => {
if (option && internalListRef.current) {
const index = optionIndexMapRef.current.get(option);
if (index !== undefined) {
internalListRef.current.scrollToRow({ index, align: 'auto' });
}
}
};
return (
<div style={{ height: 400 }}>
<Autocomplete
disableCloseOnSelect
sx={{ width: 300 }}
disableListWrap
options={OPTIONS}
groupBy={(option) => option[0].toUpperCase()}
renderInput={(params) => <TextField {...params} label="100 options" />}
renderOption={(props, option, state) => [props, option, state.index]}
renderGroup={(params) => params}
onHighlightChange={handleHighlightChange}
slots={{
popper: StyledPopper,
}}
slotProps={{
listbox: {
component: ListboxComponent,
internalListRef,
onItemsBuilt: handleItemsBuilt,
},
}}
/>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More