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

View File

@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Initial release as an npm package.

View File

@@ -0,0 +1,9 @@
# @mui/internal-test-utils
This package contains test runner initialization functions and common tests shared between MUI packages.
This is an internal package not meant for general use.
## Release
1. Build the project: `pnpm build`
2. Publish the build artifacts to npm: `pnpm release:publish`

View File

@@ -0,0 +1,66 @@
{
"name": "@mui/internal-test-utils",
"version": "2.0.16",
"author": "MUI Team",
"description": "Utilities for MUI tests. This is an internal package not meant for general use.",
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages-internal/test-utils"
},
"exports": {
".": "./src/index.ts",
"./createDescribe": "./src/createDescribe.ts",
"./createRenderer": "./src/createRenderer.tsx",
"./init": "./src/init.js",
"./initMatchers": "./src/initMatchers.ts",
"./initPlaywrightMatchers": "./src/initPlaywrightMatchers.ts",
"./chaiPlugin": "./src/chaiPlugin.ts",
"./setupVitest": "./src/setupVitest.ts"
},
"scripts": {
"prebuild": "rimraf ./build",
"build": "code-infra build",
"typescript": "tsc -p tsconfig.json",
"release:publish": "pnpm publish --tag latest",
"release:publish:dry-run": "pnpm publish --tag latest --registry=\"http://localhost:4873/\""
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"assertion-error": "^2.0.1",
"chai-dom": "^1.12.1",
"dom-accessibility-api": "^0.7.1",
"es-toolkit": "^1.42.0",
"format-util": "^1.0.5",
"jsdom": "^26.1.0",
"prop-types": "^15.8.1",
"sinon": "^21.0.0"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/chai": "^5.2.3",
"@types/chai-dom": "^1.11.3",
"@types/format-util": "^1.0.4",
"@types/jsdom": "^21.1.7",
"@types/prop-types": "^15.7.15",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/sinon": "^17.0.4",
"typescript": "^5.9.2"
},
"peerDependencies": {
"@playwright/test": "^1.53.1",
"chai": "^4.5.0 || ^5.0.0 || ^6.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"publishConfig": {
"access": "public",
"directory": "build"
}
}

View File

@@ -0,0 +1,107 @@
export {};
// https://stackoverflow.com/a/46755166/3406963
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Chai {
interface Assertion {
/**
* Checks `expectedStyle` is a subset of the elements inline style i.e. `element.style`.
* @example expect(element).toHaveInlineStyle({ width: '200px' })
*/
toHaveInlineStyle(
expectedStyle: Partial<
Record<
Exclude<
keyof CSSStyleDeclaration,
| 'getPropertyPriority'
| 'getPropertyValue'
| 'item'
| 'removeProperty'
| 'setProperty'
| number
>,
string
>
>,
): void;
/**
* Checks `expectedStyle` is a subset of the elements computed style i.e. `window.getComputedStyle(element)`.
* @example expect(element).toHaveComputedStyle({ width: '200px' })
*/
toHaveComputedStyle(
expectedStyle: Partial<
Record<
Exclude<
keyof CSSStyleDeclaration,
| 'getPropertyPriority'
| 'getPropertyValue'
| 'item'
| 'removeProperty'
| 'setProperty'
| number
>,
string
>
>,
): void;
/**
* Check if an element's [`visibility`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/visibility) is not `hidden` or `collapsed`.
*/
toBeVisible(): void;
/**
* Check if an element's [`visibility`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/visibility) is `hidden` or `collapsed`.
*/
toBeHidden(): void;
/**
* Checks if the element is inaccessible.
*
* Elements are considered inaccessible if they either:
* - have [`visibility`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/visibility) `hidden`
* - have [`display`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/display) `none`
* - have `aria-hidden` `true` or any of their parents
*
* @see [Excluding Elements from the Accessibility Tree](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion)
*/
toBeInaccessible(): void;
toHaveAccessibleDescription(description: string): void;
/**
* Checks if the accessible name computation (according to `accname` spec)
* matches the expectation.
*
* @see https://www.w3.org/TR/accname-1.2/
* @param name
*/
toHaveAccessibleName(name: string): void;
/**
* Checks if the element is actually focused i.e. `document.activeElement` is equal to the actual element.
*/
toHaveFocus(): void;
/**
* Checks if the element is the active-descendant of the active element.
*/
toHaveVirtualFocus(): void;
/**
* Matches calls to `console.warn` in the asserted callback.
*
* @example expect(() => render()).not.toWarnDev()
* @example expect(() => render()).toWarnDev('single message')
* @example expect(() => render()).toWarnDev(['first warning', 'then the second'])
*/
toWarnDev(messages?: string | readonly (string | boolean)[]): void;
/**
* Matches calls to `console.error` in the asserted callback.
*
* @example expect(() => render()).not.toErrorDev()
* @example expect(() => render()).toErrorDev('single message')
* @example expect(() => render()).toErrorDev(['first warning', 'then the second'])
*/
toErrorDev(messages?: string | readonly (string | boolean)[]): void;
/**
* Asserts that the given callback throws an error matching the given message in development (process.env.NODE_ENV !== 'production').
* In production it expects a minified error.
*/
toThrowMinified(message: string | RegExp): void;
}
}
}

View File

@@ -0,0 +1,497 @@
import { isInaccessible } from '@testing-library/dom';
import { prettyDOM } from '@testing-library/react/pure';
import * as chai from 'chai';
import { computeAccessibleDescription, computeAccessibleName } from 'dom-accessibility-api';
import formatUtil from 'format-util';
import { kebabCase } from 'es-toolkit/string';
import { AssertionError } from 'assertion-error';
import './chai.types';
import { isJsdom } from './env';
// chai#utils.elToString that looks like stringified elements in testing-library
function elementToString(element: Element | null | undefined) {
if (typeof element?.nodeType === 'number') {
return prettyDOM(element, undefined, { highlight: true, maxDepth: 1 });
}
return String(element);
}
const chaiPlugin: Parameters<typeof chai.use>[0] = (chaiAPI, utils) => {
const blockElements = new Set([
'html',
'address',
'blockquote',
'body',
'dd',
'div',
'dl',
'dt',
'fieldset',
'form',
'frame',
'frameset',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'noframes',
'ol',
'p',
'ul',
'center',
'dir',
'hr',
'menu',
'pre',
]);
function pretendVisibleGetComputedStyle(element: Element): CSSStyleDeclaration {
// `CSSStyleDeclaration` is not constructable
// https://stackoverflow.com/a/52732909/3406963
// this is not equivalent to the declaration from `getComputedStyle`
// for example `getComputedStyle` would return a readonly declaration
// let's hope this doesn't get passed around until it's no longer clear where it comes from
const declaration = document.createElement('span').style;
// initial values
declaration.content = '';
// technically it's `inline`. We partially apply the default user agent sheet (chrome) here
// we're only interested in elements that use block
declaration.display = blockElements.has(element.tagName) ? 'block' : 'inline';
declaration.visibility = 'visible';
return declaration;
}
// better diff view for expect(element).to.equal(document.activeElement)
chaiAPI.Assertion.addMethod('toHaveFocus', function elementIsFocused() {
const element = utils.flag(this, 'object');
this.assert(
element === document.activeElement,
`expected element to have focus`,
`expected element to NOT have focus \n${elementToString(element)}`,
elementToString(element),
elementToString(document.activeElement),
);
});
chaiAPI.Assertion.addMethod('toHaveVirtualFocus', function elementIsVirtuallyFocused() {
const element = utils.flag(this, 'object');
const id = element.getAttribute('id');
const virtuallyFocusedElementId = document.activeElement!.getAttribute('aria-activedescendant');
this.assert(
virtuallyFocusedElementId === id,
`expected element to be virtually focused\nexpected id #{exp}\n${
virtuallyFocusedElementId === null
? `activeElement: ${elementToString(document.activeElement)}`
: 'actual id: #{act}'
}`,
'expected element to NOT to be virtually focused',
id,
virtuallyFocusedElementId,
virtuallyFocusedElementId !== null,
);
});
chaiAPI.Assertion.addMethod('toBeInaccessible', function elementIsAccessible() {
const element = utils.flag(this, 'object');
const inaccessible = isInaccessible(element);
this.assert(
inaccessible === true,
`expected \n${elementToString(element)} to be inaccessible but it was accessible`,
`expected \n${elementToString(element)} to be accessible but it was inaccessible`,
// Not interested in a diff but the typings require the 4th parameter.
undefined,
);
});
chaiAPI.Assertion.addMethod('toHaveAccessibleName', function hasAccessibleName(expectedName) {
const root = utils.flag(this, 'object');
// make sure it's an Element
new chaiAPI.Assertion(root.nodeType, `Expected an Element but got '${String(root)}'`).to.equal(
1,
);
const actualName = computeAccessibleName(root, {
computedStyleSupportsPseudoElements: !isJsdom(),
// in local development we pretend to be visible. full getComputedStyle is
// expensive and reserved for CI
getComputedStyle: process.env.CI ? undefined : pretendVisibleGetComputedStyle,
});
this.assert(
actualName === expectedName,
`expected \n${elementToString(root)} to have accessible name #{exp} but got #{act} instead.`,
`expected \n${elementToString(root)} not to have accessible name #{exp}.`,
expectedName,
actualName,
);
});
chaiAPI.Assertion.addMethod(
'toHaveAccessibleDescription',
function hasAccessibleDescription(expectedDescription) {
const root = utils.flag(this, 'object');
// make sure it's an Element
new chaiAPI.Assertion(
root.nodeType,
`Expected an Element but got '${String(root)}'`,
).to.equal(1);
const actualDescription = computeAccessibleDescription(root, {
// in local development we pretend to be visible. full getComputedStyle is
// expensive and reserved for CI
getComputedStyle: process.env.CI ? undefined : pretendVisibleGetComputedStyle,
});
const possibleDescriptionComputationMessage = root.hasAttribute('title')
? ' computeAccessibleDescription can be misleading when a `title` attribute is used. This might be a bug in `dom-accessibility-api`.'
: '';
this.assert(
actualDescription === expectedDescription,
`expected \n${elementToString(
root,
)} to have accessible description #{exp} but got #{act} instead.${possibleDescriptionComputationMessage}`,
`expected \n${elementToString(
root,
)} not to have accessible description #{exp}.${possibleDescriptionComputationMessage}`,
expectedDescription,
actualDescription,
);
},
);
/**
* Correct name for `to.be.visible`
*/
chaiAPI.Assertion.addMethod('toBeVisible', function toBeVisible() {
// eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-unused-expressions
new chaiAPI.Assertion(this._obj).to.be.visible;
});
/**
* Correct name for `not.to.be.visible`
*/
chaiAPI.Assertion.addMethod('toBeHidden', function toBeHidden() {
// eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-unused-expressions
new chaiAPI.Assertion(this._obj).not.to.be.visible;
});
function assertMatchingStyles(
this: Chai.AssertionStatic,
actualStyleDeclaration: CSSStyleDeclaration,
expectedStyleUnnormalized: Record<string, string>,
options: { styleTypeHint: string },
): void {
const { styleTypeHint } = options;
// Compare objects using hyphen case.
// This is closer to actual CSS and required for getPropertyValue anyway.
const expectedStyle: Record<string, string> = {};
Object.keys(expectedStyleUnnormalized).forEach((cssProperty) => {
const hyphenCasedPropertyName = kebabCase(cssProperty);
const isVendorPrefixed = /^(moz|ms|o|webkit)-/.test(hyphenCasedPropertyName);
const propertyName = isVendorPrefixed
? `-${hyphenCasedPropertyName}`
: hyphenCasedPropertyName;
expectedStyle[propertyName] = expectedStyleUnnormalized[cssProperty];
});
const shorthandProperties = new Set([
'all',
'animation',
'background',
'border',
'border-block-end',
'border-block-start',
'border-bottom',
'border-color',
'border-image',
'border-inline-end',
'border-inline-start',
'border-left',
'border-radius',
'border-right',
'border-style',
'border-top',
'border-width',
'column-rule',
'columns',
'flex',
'flex-flow',
'font',
'gap',
'grid',
'grid-area',
'grid-column',
'grid-row',
'grid-template',
'list-style',
'margin',
'mask',
'offset',
'outline',
'overflow',
'padding',
'place-content',
'place-items',
'place-self',
'scroll-margin',
'scroll-padding',
'text-decoration',
'text-emphasis',
'transition',
]);
const usedShorthandProperties = Object.keys(expectedStyle).filter((cssProperty) => {
return shorthandProperties.has(cssProperty);
});
if (usedShorthandProperties.length > 0) {
throw new Error(
[
`Shorthand properties are not supported in ${styleTypeHint} styles matchers since browsers can compute them differently. `,
'Use longhand properties instead for the follow shorthand properties:\n',
usedShorthandProperties
.map((cssProperty) => {
return `- https://developer.mozilla.org/en-US/docs/Web/CSS/${cssProperty}#constituent_properties`;
})
.join('\n'),
].join(''),
);
}
const actualStyle: Record<string, string> = {};
Object.keys(expectedStyle).forEach((cssProperty) => {
actualStyle[cssProperty] = actualStyleDeclaration.getPropertyValue(cssProperty);
});
const jsdomHint =
'Styles in JSDOM e.g. from `test:unit` are often misleading since JSDOM does not implement the Cascade nor actual CSS property value computation. ' +
"If results differ between real browsers and JSDOM, skip the test in JSDOM e.g. `it.skipIf(isJsdom())('...`";
const shorthandHint =
'Browsers can compute shorthand properties differently. Prefer longhand properties e.g. `borderTopColor`, `borderRightColor` etc. instead of `border` or `border-color`.';
const messageHint = `${jsdomHint}\n${shorthandHint}`;
this.assert(
// TODO Fix upstream docs/types
(utils as any).eql(actualStyle, expectedStyle),
`expected #{this} to have ${styleTypeHint} style #{exp} \n\n${messageHint}`,
`expected #{this} not to have ${styleTypeHint} style #{exp}${messageHint}`,
expectedStyle,
actualStyle,
true,
);
}
chaiAPI.Assertion.addMethod(
'toHaveInlineStyle',
function toHaveInlineStyle(expectedStyleUnnormalized: Record<string, string>) {
const element = utils.flag(this, 'object') as HTMLElement;
if (element?.nodeType !== 1) {
// Same pre-condition for negated and unnegated assertion
throw new AssertionError(`Expected an Element but got ${String(element)}`);
}
assertMatchingStyles.call(this, element.style, expectedStyleUnnormalized, {
styleTypeHint: 'inline',
});
},
);
chaiAPI.Assertion.addMethod(
'toHaveComputedStyle',
function toHaveComputedStyle(expectedStyleUnnormalized: Record<string, string>) {
const element = utils.flag(this, 'object') as HTMLElement;
if (element?.nodeType !== 1) {
// Same pre-condition for negated and unnegated assertion
throw new AssertionError(`Expected an Element but got ${String(element)}`);
}
const computedStyle = element.ownerDocument.defaultView!.getComputedStyle(element);
assertMatchingStyles.call(this, computedStyle, expectedStyleUnnormalized, {
styleTypeHint: 'computed',
});
},
);
chaiAPI.Assertion.addMethod('toThrowMinified', function toThrowMinified(expectedDevMessage) {
// TODO: Investigate if `as any` can be removed after https://github.com/DefinitelyTyped/DefinitelyTyped/issues/48634 is resolved.
if (process.env.NODE_ENV !== 'production') {
(this as any).to.throw(expectedDevMessage);
} else {
utils.flag(
this,
'message',
"Looks like the error was not minified. This can happen if the error code hasn't been generated yet. Run `pnpm extract-error-codes` and try again.",
);
// TODO: Investigate if `as any` can be removed after https://github.com/DefinitelyTyped/DefinitelyTyped/issues/48634 is resolved.
(this as any).to.throw('Minified MUI error', 'helper');
}
});
function addConsoleMatcher(matcherName: string, methodName: 'error' | 'warn') {
function matcher(
this: Chai.AssertionStatic,
expectedMessagesInput: readonly (string | false | RegExp)[] = [],
) {
// documented pattern to get the actual value of the assertion
// eslint-disable-next-line no-underscore-dangle
const callback = this._obj;
if (process.env.NODE_ENV !== 'production') {
const expectedMessages = Array.isArray(expectedMessagesInput)
? expectedMessagesInput.slice()
: [expectedMessagesInput];
const unexpectedMessages: Error[] = [];
// TODO Remove type once MUI X enables noImplicitAny
let caughtError: unknown | null = null;
this.assert(
expectedMessages.length > 0,
`Expected to call console.${methodName} but didn't provide messages. ` +
`If you don't expect any messages prefer \`expect().not.${matcherName}();\`.`,
`Expected no call to console.${methodName} while also expecting messages.` +
'Expected no call to console.error but provided messages. ' +
"If you want to make sure a certain message isn't logged prefer the positive. " +
'By expecting certain messages you automatically expect that no other messages are logged',
// Not interested in a diff but the typings require the 4th parameter.
undefined,
);
// Ignore skipped messages in e.g. `[condition && 'foo']`
const remainingMessages = expectedMessages.filter((messageOrFalse) => {
return messageOrFalse !== false;
});
// eslint-disable-next-line no-console
const originalMethod = console[methodName];
let messagesMatched = 0;
const consoleMatcher = (format: string, ...args: readonly unknown[]) => {
// Ignore legacy root deprecation warnings
// TODO: Remove once we no longer use legacy roots.
if (
format.includes('Use createRoot instead.') ||
format.includes('Use hydrateRoot instead.')
) {
return;
}
const actualMessage = formatUtil(format, ...args);
const expectedMessage = remainingMessages.shift();
messagesMatched += 1;
// TODO Remove type once MUI X enables noImplicitAny
let message: string | null = null;
if (expectedMessage === undefined) {
message = `Expected no more error messages but got:\n"${actualMessage}"`;
} else if (
(typeof expectedMessage === 'string' && !actualMessage.includes(expectedMessage)) ||
(expectedMessage instanceof RegExp && !expectedMessage.test(actualMessage))
) {
message = `Expected #${messagesMatched} "${expectedMessage}" to be included in \n"${actualMessage}"`;
}
if (message !== null) {
const error = new Error(message);
const { stack: fullStack } = error;
const fullStacktrace = fullStack!.replace(`Error: ${message}\n`, '').split('\n');
const usefulStacktrace = fullStacktrace
//
// first line points to this frame which is irrelevant for the tester
.slice(1);
const usefulStack = `${message}\n${usefulStacktrace.join('\n')}`;
error.stack = usefulStack;
unexpectedMessages.push(error);
}
};
// eslint-disable-next-line no-console
console[methodName] = consoleMatcher;
try {
callback();
} catch (error) {
caughtError = error;
} finally {
// eslint-disable-next-line no-console
console[methodName] = originalMethod;
// unexpected thrown error takes precedence over unexpected console call
if (caughtError !== null) {
// not the same pattern as described in the block because we don't rethrow in the catch
// eslint-disable-next-line no-unsafe-finally
throw caughtError;
}
const formatMessages = (messages: ReadonlyArray<Error | string>) => {
const formattedMessages = messages.map((message) => {
if (typeof message === 'string') {
return `"${message}"`;
}
// full Error
return `${message.stack}`;
});
return `\n\n - ${formattedMessages.join('\n\n- ')}`;
};
const shouldHaveWarned = utils.flag(this, 'negate') !== true;
// unreachable from expect().not.toWarnDev(messages)
if (unexpectedMessages.length > 0) {
const unexpectedMessageRecordedMessage = `Recorded unexpected console.${methodName} calls: ${formatMessages(
unexpectedMessages,
)}`;
// chai will duplicate the stack frames from the unexpected calls in their assertion error
// it's not ideal but the test failure is located the second to last stack frame
// and the origin of the call is the second stackframe in the stack
this.assert(
// force chai to always trigger an assertion error
!shouldHaveWarned,
unexpectedMessageRecordedMessage,
unexpectedMessageRecordedMessage,
// Not interested in a diff but the typings require the 4th parameter.
undefined,
);
}
if (shouldHaveWarned) {
this.assert(
remainingMessages.length === 0,
`Could not match the following console.${methodName} calls. ` +
`Make sure previous actions didn't call console.${methodName} by wrapping them in expect(() => {}).not.${matcherName}(): ${formatMessages(
remainingMessages,
)}`,
`Impossible state reached in \`expect().${matcherName}()\`. ` +
`This is a bug in the matcher.`,
// Not interested in a diff but the typings require the 4th parameter.
undefined,
);
}
}
} else {
// nothing to do in prod
// If there are still console calls than our test setup throws.
callback();
}
}
chaiAPI.Assertion.addMethod(matcherName, matcher);
}
/**
* @example expect(() => render()).toWarnDev('single message')
* @example expect(() => render()).toWarnDev(['first warning', 'then the second'])
*/
addConsoleMatcher('toWarnDev', 'warn');
addConsoleMatcher('toErrorDev', 'error');
};
export default chaiPlugin;

View File

@@ -0,0 +1,61 @@
import * as React from 'react';
import PropTypes from 'prop-types';
/**
* A basic error boundary that can be used to assert thrown errors in render.
* @example <ErrorBoundary ref={errorRef}><MyComponent /></ErrorBoundary>;
* expect(errorRef.current.errors).to.have.length(0);
*/
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }> {
static propTypes = {
children: PropTypes.node.isRequired,
};
state = {
error: null,
};
/**
* @public
*/
errors: unknown[] = [];
static getDerivedStateFromError(error: unknown) {
return { error };
}
componentDidCatch(error: unknown) {
this.errors.push(error);
}
render() {
if (this.state.error) {
return null;
}
return this.props.children;
}
}
/**
* Allows counting how many times the owner of `RenderCounter` rendered or
* a component within the RenderCounter tree "commits" an update.
* @example <RenderCounter ref={getRenderCountRef}>...</RenderCounter>
* getRenderCountRef.current() === 2
*/
export const RenderCounter = React.forwardRef<() => number, { children: React.ReactNode }>(
function RenderCounter({ children }, ref) {
const getRenderCountRef = React.useRef(0);
React.useImperativeHandle(ref, () => () => getRenderCountRef.current);
return (
<React.Profiler
id="render-counter"
onRender={() => {
getRenderCountRef.current += 1;
}}
>
{children}
</React.Profiler>
);
},
);

View File

@@ -0,0 +1,9 @@
// there's probably a broader solution e.g. levering DOMWindow from 'jsdom'
// interface Window extends DOMWindow doesn't work because jsdom typings use
// triple slash directives. Technical dom.lib.d.ts should already have these properties
interface Window {
DragEvent: typeof DragEvent;
Event: typeof Event;
HTMLButtonElement: HTMLButtonElement;
HTMLParagraphElement: HTMLParagraphElement;
}

View File

@@ -0,0 +1,67 @@
const { JSDOM } = require('jsdom');
// We can use jsdom-global at some point if maintaining these lists is a burden.
const whitelist = [
// used by React's experimental cache API
// Always including it to reduce churn when switching between React builds
'AbortController',
// required for fake getComputedStyle
'CSSStyleDeclaration',
'Element',
'Event',
'TouchEvent',
'Image',
'HTMLElement',
'HTMLInputElement',
'Node',
'Performance',
'document',
'DocumentFragment',
];
const blacklist = ['sessionStorage', 'localStorage'];
function createDOM() {
const dom = new JSDOM('', {
pretendToBeVisual: true,
url: 'http://localhost',
});
globalThis.window = dom.window;
// Not yet supported: https://github.com/jsdom/jsdom/issues/2152
class Touch {
constructor(instance) {
this.instance = instance;
}
get identifier() {
return this.instance.identifier;
}
get pageX() {
return this.instance.pageX;
}
get pageY() {
return this.instance.pageY;
}
get clientX() {
return this.instance.clientX;
}
get clientY() {
return this.instance.clientY;
}
}
globalThis.window.Touch = Touch;
Object.keys(dom.window)
.filter((key) => !blacklist.includes(key))
.concat(whitelist)
.forEach((key) => {
if (typeof globalThis[key] === 'undefined') {
globalThis[key] = dom.window[key];
}
});
}
module.exports = createDOM;

View File

@@ -0,0 +1,31 @@
import { describe } from 'vitest';
type MUIDescribe<P extends any[]> = {
(...args: P): void;
skip: (...args: P) => void;
only: (...args: P) => void;
};
export default <P extends any[]>(
message: string,
callback: (...args: P) => void,
): MUIDescribe<P> => {
const muiDescribe = (...args: P) => {
describe(message, () => {
callback(...args);
});
};
muiDescribe.skip = (...args: P) => {
describe.skip(message, () => {
callback(...args);
});
};
muiDescribe.only = (...args: P) => {
describe.only(message, () => {
callback(...args);
});
};
return muiDescribe;
};

View File

@@ -0,0 +1,31 @@
import { expect } from 'chai';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { createRenderer } from './createRenderer';
describe('createRenderer', () => {
const { render } = createRenderer();
it('allows querying descriptions', () => {
function Component() {
return (
<React.Fragment>
<div id="target" aria-describedby="r:1 r:2 r:3">
I have a description.
</div>
{ReactDOM.createPortal(<div id="r:1">Description 1</div>, document.body)}
{/* The ID format is important here. It would fail `querySelectorAll('#r:2')` and ensures we use `getElementById` */}
<div id="r:2">Description 2</div>
<div id="r:3">Description 3</div>
</React.Fragment>
);
}
const { getAllDescriptionsOf } = render(<Component />);
const descriptions = getAllDescriptionsOf(document.getElementById('target'));
expect(descriptions).to.have.length(3);
expect(descriptions[0]).to.have.property('id', 'r:1');
expect(descriptions[1]).to.have.property('id', 'r:2');
expect(descriptions[2]).to.have.property('id', 'r:3');
});
});

View File

@@ -0,0 +1,598 @@
/* eslint-disable compat/compat -- Test environment */
import createEmotionCache from '@emotion/cache';
import { CacheProvider as EmotionCacheProvider } from '@emotion/react';
import {
buildQueries,
cleanup,
prettyDOM,
queries,
RenderResult,
act as rtlAct,
fireEvent as rtlFireEvent,
screen as rtlScreen,
Screen,
render as testingLibraryRender,
RenderOptions as TestingLibraryRenderOptions,
within,
} from '@testing-library/react/pure';
import { userEvent } from '@testing-library/user-event';
import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import { useFakeTimers } from 'sinon';
import { beforeEach, afterEach, beforeAll, vi, expect } from 'vitest';
import reactMajor from './reactMajor';
function queryAllDescriptionsOf(baseElement: HTMLElement, element: Element): HTMLElement[] {
const ariaDescribedBy = element.getAttribute('aria-describedby');
if (ariaDescribedBy === null) {
return [];
}
return ariaDescribedBy
.split(' ')
.map((id) => {
return document.getElementById(id);
})
.filter((maybeElement): maybeElement is HTMLElement => {
return maybeElement !== null && baseElement.contains(maybeElement);
});
}
const [
queryDescriptionOf,
getAllDescriptionsOf,
getDescriptionOf,
findAllDescriptionsOf,
findDescriptionOf,
] = buildQueries<[Element]>(
queryAllDescriptionsOf,
function getMultipleError() {
return `Found multiple descriptions.`;
},
function getMissingError() {
return `Found no describing element.`;
},
);
const customQueries = {
queryDescriptionOf,
queryAllDescriptionsOf,
getDescriptionOf,
getAllDescriptionsOf,
findDescriptionOf,
findAllDescriptionsOf,
};
interface RenderConfiguration extends Pick<TestingLibraryRenderOptions, 'reactStrictMode'> {
/**
* https://testing-library.com/docs/react-testing-library/api#container
*/
container?: HTMLElement;
/**
* if true does not cleanup before mount
*/
disableUnmount?: boolean;
/**
* wrap in React.StrictMode?
*/
strict?: boolean;
/**
* Set to `true` if the test fails due to [Strict Effects](https://github.com/reactwg/react-18/discussions/19).
*/
strictEffects?: boolean;
wrapper: React.JSXElementConstructor<{ children?: React.ReactNode }>;
}
interface ClientRenderConfiguration extends RenderConfiguration {
/**
* https://testing-library.com/docs/react-testing-library/api#hydrate
*/
hydrate: boolean;
}
interface ServerRenderConfiguration extends RenderConfiguration {
container: HTMLElement;
}
export type RenderOptions = Omit<Partial<RenderConfiguration>, 'reactStrictMode'>;
export interface MuiRenderResult extends RenderResult<typeof queries & typeof customQueries> {
user: ReturnType<typeof userEvent.setup>;
forceUpdate(): void;
/**
* convenience helper. Better than repeating all props.
*/
setProps(props: object): void;
}
export interface MuiRenderToStringResult {
container: HTMLElement;
hydrate(): MuiRenderResult;
}
interface DataAttributes {
[key: `data-${string}`]: string;
}
function render(
element: React.ReactElement<DataAttributes>,
configuration: ClientRenderConfiguration,
): MuiRenderResult {
const { container, hydrate, wrapper, reactStrictMode } = configuration;
const testingLibraryRenderResult = testingLibraryRender(element, {
container,
hydrate,
queries: { ...queries, ...customQueries },
wrapper,
reactStrictMode,
});
const result: MuiRenderResult = {
...testingLibraryRenderResult,
user: userEvent.setup({ document }),
forceUpdate() {
testingLibraryRenderResult.rerender(
React.cloneElement(element, {
'data-force-update': String(Math.random()),
}),
);
},
setProps(props) {
testingLibraryRenderResult.rerender(React.cloneElement(element, props));
},
};
return result;
}
function renderToString(
element: React.ReactElement<DataAttributes>,
configuration: ServerRenderConfiguration,
): { container: HTMLElement; hydrate(): MuiRenderResult } {
const { container, wrapper: Wrapper } = configuration;
container.innerHTML = ReactDOMServer.renderToString(<Wrapper>{element}</Wrapper>);
return {
container,
hydrate() {
return render(element, { ...configuration, hydrate: true });
},
};
}
export interface Clock {
/**
* Runs all timers until there are no more remaining.
* WARNING: This may cause an infinite loop if a timeout constantly schedules another timeout.
* Prefer to to run only pending timers with `runToLast` and unmount your component directly.
*/
runAll(): void;
/**
* Runs only the currently pending timers.
*/
runToLast(): void;
/**
* Tick the clock ahead `timeoutMS` milliseconds.
* @param timeoutMS
*/
tick(timeoutMS: number): void;
/**
* Returns true if we're running with "real" i.e. native timers.
*/
isReal(): boolean;
/**
* Runs the current test suite (i.e. `describe` block) with fake timers.
*/
withFakeTimers(): void;
/**
* Restore the real timer
*/
restore(): void;
}
export type ClockConfig = undefined | number | Date;
function createClock(
defaultMode: 'fake' | 'real',
config: ClockConfig,
options: Exclude<Parameters<typeof useFakeTimers>[0], number | Date>,
): Clock {
if (defaultMode === 'fake') {
beforeEach(() => {
vi.useFakeTimers({
now: config,
// useIsFocusVisible schedules a global timer that needs to persist regardless of whether components are mounted or not.
// Technically we'd want to reset all modules between tests but we don't have that technology.
// In the meantime just continue to clear native timers like we did for the past years when using `sinon` < 8.
shouldClearNativeTimers: true,
toFake: [
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
'requestAnimationFrame',
'cancelAnimationFrame',
'performance',
'Date',
],
...options,
});
if (config) {
vi.setSystemTime(config);
}
});
} else {
beforeEach(() => {
if (config) {
vi.setSystemTime(config);
}
});
}
afterEach(async () => {
if (vi.isFakeTimers()) {
await rtlAct(async () => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
}
});
return {
withFakeTimers: () => {
if (vi.isFakeTimers()) {
return;
}
beforeEach(() => {
vi.useFakeTimers({
now: config,
// useIsFocusVisible schedules a global timer that needs to persist regardless of whether components are mounted or not.
// Technically we'd want to reset all modules between tests but we don't have that technology.
// In the meantime just continue to clear native timers like we did for the past years when using `sinon` < 8.
shouldClearNativeTimers: true,
toFake: [
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
'requestAnimationFrame',
'cancelAnimationFrame',
'performance',
'Date',
],
...options,
});
if (config) {
vi.setSystemTime(config);
}
});
},
runToLast: () => {
rtlAct(() => {
vi.runOnlyPendingTimers();
});
},
isReal() {
return !vi.isFakeTimers();
},
restore() {
vi.useRealTimers();
},
tick(timeoutMS: number) {
rtlAct(() => {
vi.advanceTimersByTime(timeoutMS);
});
},
runAll() {
rtlAct(() => {
vi.runAllTimers();
});
},
};
}
export interface Renderer {
clock: Clock;
render(element: React.ReactElement<DataAttributes>, options?: RenderOptions): MuiRenderResult;
renderToString(
element: React.ReactElement<DataAttributes>,
options?: RenderOptions,
): MuiRenderToStringResult;
}
export interface CreateRendererOptions extends Pick<RenderOptions, 'strict' | 'strictEffects'> {
/**
* @default 'real'
*/
clock?: 'fake' | 'real';
clockConfig?: ClockConfig;
clockOptions?: Parameters<typeof createClock>[2];
}
export function createRenderer(globalOptions: CreateRendererOptions = {}): Renderer {
const {
clock: clockMode = 'real',
clockConfig,
strict: globalStrict = true,
strictEffects: globalStrictEffects = globalStrict,
clockOptions,
} = globalOptions;
// save stack to re-use in test-hooks
const { stack: createClientRenderStack } = new Error();
const clock = createClock(clockMode, clockConfig, clockOptions);
/**
* Flag whether `createRenderer` was called in a suite i.e. describe() block.
* For legacy reasons `createRenderer` might accidentally be called in a beforeAll(Each) hook.
*/
let wasCalledInSuite = false;
beforeAll(function beforeHook() {
wasCalledInSuite = true;
});
let emotionCache: import('@emotion/cache').EmotionCache = null!;
/**
* target container for SSR
*/
let serverContainer: HTMLElement;
/**
* Flag whether all setup for `configuredClientRender` was completed.
* For legacy reasons `configuredClientRender` might accidentally be called in a beforeAll(Each) hook.
*/
let prepared = false;
beforeEach(function beforeEachHook() {
if (!wasCalledInSuite) {
const error = new Error(
'Unable to run `before` hook for `createRenderer`. This usually indicates that `createRenderer` was called in a `before` hook instead of in a `describe()` block.',
);
error.stack = createClientRenderStack;
throw error;
}
const id = expect.getState().currentTestName;
if (!id) {
throw new Error(
'Unable to find the currently running test. This is a bug with the client-renderer. Please report this issue to a maintainer.',
);
}
emotionCache = createEmotionCache({ key: 'emotion-client-render' });
serverContainer = document.createElement('div');
document.body.appendChild(serverContainer);
prepared = true;
});
afterEach(() => {
if (!clock.isReal()) {
const error = new Error(
"Can't cleanup before fake timers are restored.\n" +
'Be sure to:\n' +
' 1. Only use `clock` from `createRenderer`.\n' +
' 2. Call `createRenderer` in a suite and not any test hook (for example `beforeEach`) or test itself (for example `it`).',
);
// Use saved stack otherwise the stack trace will not include the test location.
error.stack = createClientRenderStack;
throw error;
}
cleanup();
emotionCache.sheet.tags.forEach((styleTag) => {
styleTag.remove();
});
emotionCache = null!;
serverContainer.remove();
serverContainer = null!;
});
function createWrapper(options: Pick<RenderOptions, 'wrapper'>) {
const { wrapper: InnerWrapper = React.Fragment } = options;
return function Wrapper({ children }: { children?: React.ReactNode }) {
return (
<EmotionCacheProvider value={emotionCache}>
<InnerWrapper>{children}</InnerWrapper>
</EmotionCacheProvider>
);
};
}
return {
clock,
render(element: React.ReactElement<DataAttributes>, options: RenderOptions = {}) {
if (!prepared) {
throw new Error(
'Unable to finish setup before `render()` was called. ' +
'This usually indicates that `render()` was called in a `beforeAll()` or `beforeEach` hook. ' +
'Move the call into each `it()`. Otherwise you cannot run a specific test and we cannot isolate each test.',
);
}
const usesLegacyRoot = reactMajor < 18;
const reactStrictMode =
(options.strict ?? globalStrict) &&
((options.strictEffects ?? globalStrictEffects) || usesLegacyRoot);
return render(element, {
...options,
reactStrictMode,
hydrate: false,
wrapper: createWrapper(options),
});
},
renderToString(element: React.ReactElement<DataAttributes>, options: RenderOptions = {}) {
if (!prepared) {
throw new Error(
'Unable to finish setup before `render()` was called. ' +
'This usually indicates that `render()` was called in a `beforeAll()` or `beforeEach` hook. ' +
'Move the call into each `it()`. Otherwise you cannot run a specific test and we cannot isolate each test.',
);
}
const { container = serverContainer, ...localOptions } = options;
return renderToString(element, {
...localOptions,
container,
wrapper: createWrapper(options),
});
},
};
}
const fireEvent = ((target, event, ...args) => {
return rtlFireEvent(target, event, ...args);
}) as typeof rtlFireEvent;
Object.keys(rtlFireEvent).forEach(
// @ts-expect-error
(eventType: keyof typeof rtlFireEvent) => {
fireEvent[eventType] = (...args) => rtlFireEvent[eventType](...args);
},
);
const originalFireEventKeyDown = rtlFireEvent.keyDown;
fireEvent.keyDown = (desiredTarget, options = {}) => {
const element = desiredTarget as Element | Document;
// `element` shouldn't be `document` but we catch this later anyway
const document = element.ownerDocument || element;
const target = document.activeElement || document.body || document.documentElement;
if (target !== element) {
// see https://www.w3.org/TR/uievents/#keydown
const error = new Error(
`\`keydown\` events can only be targeted at the active element which is ${prettyDOM(
target,
undefined,
{ maxDepth: 1 },
)}`,
);
// We're only interested in the callsite of fireEvent.keyDown
error.stack = error
.stack!.split('\n')
.filter((line) => !/at Function.key/.test(line))
.join('\n');
throw error;
}
return originalFireEventKeyDown(element, options);
};
const originalFireEventKeyUp = rtlFireEvent.keyUp;
fireEvent.keyUp = (desiredTarget, options = {}) => {
const element = desiredTarget as Element | Document;
// `element` shouldn't be `document` but we catch this later anyway
const document = element.ownerDocument || element;
const target = document.activeElement || document.body || document.documentElement;
if (target !== element) {
// see https://www.w3.org/TR/uievents/#keyup
const error = new Error(
`\`keyup\` events can only be targeted at the active element which is ${prettyDOM(
target,
undefined,
{ maxDepth: 1 },
)}`,
);
// We're only interested in the callsite of fireEvent.keyUp
error.stack = error
.stack!.split('\n')
.filter((line) => !/at Function.key/.test(line))
.join('\n');
throw error;
}
return originalFireEventKeyUp(element, options);
};
export function fireTouchChangedEvent(
target: Element,
type: 'touchstart' | 'touchmove' | 'touchend',
options: { changedTouches: Array<Pick<TouchInit, 'clientX' | 'clientY'>> },
): void {
const { changedTouches } = options;
const originalGetBoundingClientRect = target.getBoundingClientRect;
target.getBoundingClientRect = () => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON() {
return {
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
};
},
});
const event = new window.TouchEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
changedTouches: changedTouches.map(
(opts) =>
new window.Touch({
target,
identifier: 0,
...opts,
}),
),
});
fireEvent(target, event);
target.getBoundingClientRect = originalGetBoundingClientRect;
}
function act<T>(callback: () => T | Promise<T>): Promise<T>;
function act(callback: () => void): void;
function act<T>(callback: () => void | T | Promise<T>) {
return rtlAct(callback);
}
const bodyBoundQueries = within(document.body, { ...queries, ...customQueries });
export {
renderHook,
waitFor,
within,
createEvent,
type RenderHookResult,
type EventType,
} from '@testing-library/react/pure';
export { act, fireEvent };
export const screen: Screen & typeof bodyBoundQueries = { ...rtlScreen, ...bodyBoundQueries };
export async function flushEffects(): Promise<void> {
await act(async () => {});
}
/**
* returns true when touch is suported and can be mocked
*/
export function supportsTouch() {
// only run in supported browsers
if (typeof Touch === 'undefined') {
return false;
}
try {
// eslint-disable-next-line no-new
new Touch({ identifier: 0, target: window, pageX: 0, pageY: 0 });
} catch {
// Touch constructor not supported
return false;
}
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
/* eslint-disable import/prefer-default-export */
export function isJsdom() {
return window.navigator.userAgent.includes('jsdom');
}

View File

@@ -0,0 +1,76 @@
import { configure, fireEvent, getConfig } from '@testing-library/react/pure';
import reactMajor from './reactMajor';
const noWrapper = (callback: () => void) => callback();
/**
* @param {() => void} callback
* @returns {void}
*/
function withMissingActWarningsIgnored(callback: () => void) {
if (reactMajor >= 18) {
callback();
return;
}
const originalConsoleError = console.error;
console.error = function silenceMissingActWarnings(message, ...args) {
const isMissingActWarning = /not wrapped in act\(...\)/.test(message);
if (!isMissingActWarning) {
originalConsoleError.call(console, message, ...args);
}
};
const originalConfig = getConfig();
configure({
eventWrapper: noWrapper,
});
try {
callback();
} finally {
configure(originalConfig);
console.error = originalConsoleError;
}
}
// -----------------------------------------
// WARNING ⚠️ WARNING ⚠️ WARNING ⚠️ WARNING
//
// Do not add events here because you want to ignore "missing act()" warnings.
// Only add events if you made sure that React actually considers these as "discrete".
// Be aware that "discrete events" are an implementation detail of React.
// To test discrete events we cannot use `fireEvent` from `@testing-library/react` because they are all wrapped in `act`.
// `act` overrides the "discrete event" semantics with "batching" semantics: https://github.com/facebook/react/blob/3fbd47b86285b6b7bdeab66d29c85951a84d4525/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L1061-L1064
// Note that using `fireEvent` from `@testing-library/dom` would not work since /react configures both `fireEvent` to use `act` as a wrapper.
// -----------------------------------------
export function click(...args: Parameters<(typeof fireEvent)['click']>) {
return withMissingActWarningsIgnored(() => {
fireEvent.click(...args);
});
}
export function keyDown(...args: Parameters<(typeof fireEvent)['keyDown']>) {
return withMissingActWarningsIgnored(() => {
fireEvent.keyDown(...args);
});
}
export function keyUp(...args: Parameters<(typeof fireEvent)['keyUp']>) {
return withMissingActWarningsIgnored(() => {
fireEvent.keyUp(...args);
});
}
export function mouseDown(...args: Parameters<(typeof fireEvent)['mouseDown']>) {
return withMissingActWarningsIgnored(() => {
fireEvent.mouseDown(...args);
});
}
export function mouseUp(...args: Parameters<(typeof fireEvent)['mouseUp']>) {
return withMissingActWarningsIgnored(() => {
fireEvent.mouseUp(...args);
});
}

View File

@@ -0,0 +1,5 @@
import { act } from './createRenderer';
export default async function flushMicrotasks() {
await act(async () => {});
}

View File

@@ -0,0 +1,34 @@
import { act, fireEvent } from './createRenderer';
export default function focusVisible(element: HTMLElement) {
act(() => {
element.blur();
});
fireEvent.keyDown(document.body, { key: 'Tab' });
act(() => {
element.focus();
});
}
export function simulatePointerDevice() {
// first focus on a page triggers focus visible until a pointer event
// has been dispatched
fireEvent.pointerDown(document.body);
}
export function simulateKeyboardDevice() {
fireEvent.keyDown(document.body, { key: 'TAB' });
}
/**
* See https://issues.chromium.org/issues/40719291 for more details.
*/
export function programmaticFocusTriggersFocusVisible(): boolean {
try {
// So far this only applies to Chrome 86 beta which is the only tested browser supporting this pseudo class.
document.createElement('button').matches(':focus-visible');
return true;
} catch (error) {
return false;
}
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
export * from './components';
export { default as describeConformance } from './describeConformance';
export * from './describeConformance';
export { default as createDescribe } from './createDescribe';
export * from './createRenderer';
export {
default as focusVisible,
simulatePointerDevice,
simulateKeyboardDevice,
programmaticFocusTriggersFocusVisible,
} from './focusVisible';
export {} from './initMatchers';
export * as fireDiscreteEvent from './fireDiscreteEvent';
export { default as flushMicrotasks } from './flushMicrotasks';
export { default as reactMajor } from './reactMajor';
export * from './env';
/**
* Set to true if console logs during [lifecycles that are invoked twice in `React.StrictMode`](https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects) are suppressed.
* Useful for asserting on `console.warn` or `console.error` via `toErrorDev()`.
* TODO: Refactor to use reactMajor when fixing the React 17 cron test.
* https://github.com/mui/material-ui/issues/43153
*/
export const strictModeDoubleLoggingSuppressed = React.version.startsWith('17');

View File

@@ -0,0 +1,11 @@
import * as testingLibrary from '@testing-library/react/pure';
import './initMatchers';
// checking if an element is hidden is quite expensive
// this is only done in CI as a fail safe. It can still explicitly be checked
// in the test files which helps documenting what is part of the DOM but hidden
// from assistive technology
const defaultHidden = !process.env.CI;
// adds verbosity for something that might be confusing
console.warn(`${defaultHidden ? 'including' : 'excluding'} inaccessible elements by default`);
testingLibrary.configure({ defaultHidden });

View File

@@ -0,0 +1,124 @@
import { expect } from 'chai';
import { createSandbox } from 'sinon';
describe('custom matchers', () => {
const consoleSandbox = createSandbox();
beforeEach(() => {
// otherwise our global setup throws on unexpected calls in afterEach
consoleSandbox.stub(console, 'warn');
consoleSandbox.stub(console, 'error');
});
afterEach(() => {
consoleSandbox.restore();
});
describe('toErrorDev()', () => {
it('passes if the message is exactly the same', () => {
expect(() => console.error('expected message')).toErrorDev('expected message');
});
it('passes if the message is a subset', () => {
expect(() => console.error('expected message')).toErrorDev('pected messa');
});
it('passes if multiple messages are expected', () => {
expect(() => {
console.error('expected message');
console.error('another message');
}).toErrorDev(['expected message', 'another message']);
});
it("fails if an expected console.error call wasn't recorded with a useful stacktrace", () => {
let caughtError;
try {
console.error('expected message');
expect(() => {}).toErrorDev('expected message');
} catch (error) {
caughtError = error;
}
expect(caughtError).to.have.property('stack');
expect(caughtError.stack).to.include(
'Could not match the following console.error calls. ' +
"Make sure previous actions didn't call console.error by wrapping them in expect(() => {}).not.toErrorDev(): \n\n" +
' - "expected message"\n',
);
// check that the top stackframe points to this test
// if this test is moved to another file the next assertion fails
expect(caughtError.stack).to.match(
/- "expected message"\s+at .*\/initMatchers\.test\.js:\d+:\d+/,
);
});
it('is case sensitive', () => {
let caughtError;
try {
expect(() => console.error('expected Message')).toErrorDev('expected message');
} catch (error) {
caughtError = error;
}
expect(caughtError).to.have.property('stack');
expect(caughtError.stack).to.include(
'Recorded unexpected console.error calls: \n\n' +
' - Expected #1 "expected message" to be included in \n' +
'"expected Message"\n',
);
// check that the top stackframe points to this test
// if this test is moved to another file the next assertion fails
expect(caughtError.stack).to.match(
/"expected Message"\s+at .*\/initMatchers\.test\.js:\d+:\d+/,
);
});
it('fails if the order of calls does not match', () => {
expect(() => {
expect(() => {
console.error('another message');
console.error('expected message');
}).toErrorDev(['expected message', 'another message']);
}).to.throw('Recorded unexpected console.error calls');
});
it('fails if there are fewer messages than expected', () => {
expect(() => {
expect(() => {
console.error('expected message');
}).toErrorDev(['expected message', 'another message']);
}).to.throw('Could not match the following console.error calls');
});
it('passes if no messages were recorded if expected', () => {
expect(() => {}).not.toErrorDev();
expect(() => {}).not.toErrorDev([]);
});
it('fails if no arguments are used as a way of negating', () => {
expect(() => {
expect(() => {}).toErrorDev();
}).to.throw(
"Expected to call console.error but didn't provide messages. " +
"If you don't expect any messages prefer `expect().not.toErrorDev();",
);
});
it('fails if arguments are passed when negated', () => {
expect(() => {
expect(() => {}).not.toErrorDev('not unexpected?');
}).to.throw(
'Expected no call to console.error but provided messages. ' +
"If you want to make sure a certain message isn't logged prefer the positive. " +
'By expecting certain messages you automatically expect that no other messages are logged',
);
});
it('ignores `false` messages', () => {
const isReact16 = false;
expect(() => {
expect(() => {}).toErrorDev([isReact16 && 'some legacy error message']);
}).not.to.throw();
});
});
});

View File

@@ -0,0 +1,7 @@
import * as chai from 'chai';
import chaiDom from 'chai-dom';
import './chai.types';
import chaiPlugin from './chaiPlugin';
chai.use(chaiDom);
chai.use(chaiPlugin);

View File

@@ -0,0 +1,98 @@
import * as chai from 'chai';
import * as DomTestingLibrary from '@testing-library/dom';
import type { ElementHandle } from '@playwright/test';
import { AssertionError } from 'assertion-error';
// https://stackoverflow.com/a/46755166/3406963
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Chai {
interface Assertion {
/**
* Checks if the element handle is actually focused i.e. the element handle is pointing to `document.activeElement`.
*/
toHaveFocus(): Promise<void>;
/**
* Checks if the element handle has the given attribute.
* @example expect($element).toHaveAttribute('aria-expanded') is like `[aria-expanded]` CSS selector
* @example expect($element).toHaveAttribute('aria-expanded', 'true') is like `[aria-expanded="true"]` CSS selector
*/
toHaveAttribute(attributeName: string, attributeValue?: string): Promise<void>;
}
}
interface Window {
DomTestingLibrary: typeof DomTestingLibrary;
/**
* @example $element.evaluate(element => window.pageElementToString(element))
*/
elementToString(element: Node | null | undefined): string | false;
}
}
chai.use((chaiAPI, utils) => {
chai.Assertion.addMethod('toHaveFocus', async function elementHandleIsFocused() {
const $elementOrHandle: ElementHandle | Promise<ElementHandle> = utils.flag(this, 'object');
if ($elementOrHandle == null) {
throw new AssertionError(`Expected an element handle but got ${String($elementOrHandle)}.`);
}
const $element =
typeof ($elementOrHandle as Promise<any>).then === 'function'
? await ($elementOrHandle as Promise<ElementHandle>)
: ($elementOrHandle as ElementHandle);
const { isFocused, stringifiedActiveElement, stringifiedElement } = await $element.evaluate(
(element) => {
const activeElement =
element.ownerDocument !== null ? element.ownerDocument.activeElement : null;
return {
isFocused: activeElement === element,
stringifiedElement: window.elementToString(element),
stringifiedActiveElement: window.elementToString(activeElement),
};
},
);
this.assert(
isFocused,
`expected element to have focus`,
`expected element to NOT have focus \n${stringifiedElement}`,
stringifiedElement,
stringifiedActiveElement,
);
});
chai.Assertion.addMethod(
'toHaveAttribute',
async function elementHandleHasAttribute(attributeName: string, attributeValue?: string) {
const $elementOrHandle: ElementHandle | Promise<ElementHandle> = utils.flag(this, 'object');
if ($elementOrHandle == null) {
throw new AssertionError(`Expected an element handle but got ${String($elementOrHandle)}.`);
}
const $element =
typeof ($elementOrHandle as Promise<any>).then === 'function'
? await ($elementOrHandle as Promise<ElementHandle>)
: ($elementOrHandle as ElementHandle);
const actualAttributeValue = await $element.getAttribute(attributeName);
if (attributeValue === undefined) {
this.assert(
actualAttributeValue !== null,
`expected element to have attribute \`${attributeName}\``,
`expected element to NOT have attribute \`${attributeName}\``,
null,
null,
);
} else {
this.assert(
actualAttributeValue === attributeValue,
`expected element to have attribute \`${attributeName}="${attributeValue}"\``,
`expected element to NOT have attribute \`${attributeName}="${attributeValue}"\``,
attributeValue,
actualAttributeValue,
);
}
},
);
});

View File

@@ -0,0 +1,3 @@
import * as React from 'react';
export default parseInt(React.version, 10);

View File

@@ -0,0 +1,10 @@
const testingLibrary = require('@testing-library/dom');
const createDOM = require('./createDOM');
createDOM();
require('./init');
testingLibrary.configure({
// JSDOM logs errors otherwise on `getComputedStyle(element, pseudoElement)` calls.
computedStyleSupportsPseudoElements: false,
});

View File

@@ -0,0 +1,36 @@
import failOnConsole from 'vitest-fail-on-console';
import * as chai from 'chai';
import './chai.types';
import chaiPlugin from './chaiPlugin';
chai.use(chaiPlugin);
failOnConsole({
silenceMessage: (message: string) => {
if (process.env.NODE_ENV === 'production') {
// TODO: mock scheduler
if (message.includes('act(...) is not supported in production builds of React')) {
return true;
}
}
if (message.includes('Warning: useLayoutEffect does nothing on the server')) {
// Controversial warning that is commonly ignored by switching to `useEffect` on the server.
// https://github.com/facebook/react/issues/14927
// However, this switch doesn't work since it relies on environment sniffing and we test SSR in a browser environment.
return true;
}
// Unclear why this is an issue for the current occurrences of this warning.
// TODO: Revisit once https://github.com/facebook/react/issues/22796 is resolved
if (
message.includes(
'Detected multiple renderers concurrently rendering the same context provider.',
)
) {
return true;
}
return false;
},
});

View File

@@ -0,0 +1,39 @@
import * as chai from 'chai';
import chaiDom from 'chai-dom';
chai.use(chaiDom);
// Enable missing act warnings: https://github.com/reactwg/react-18/discussions/102
(globalThis as any).jest = null;
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
if (window.navigator.userAgent.includes('jsdom')) {
// Not yet supported: https://github.com/jsdom/jsdom/issues/2152
(globalThis as any).window.Touch = class Touch {
instance: any;
constructor(instance: any) {
this.instance = instance;
}
get identifier() {
return this.instance.identifier;
}
get pageX() {
return this.instance.pageX;
}
get pageY() {
return this.instance.pageY;
}
get clientX() {
return this.instance.clientX;
}
get clientY() {
return this.instance.clientY;
}
};
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": false,
"composite": true,
"tsBuildInfoFile": "./build/.tsbuildinfo",
"target": "ES2020",
"types": ["node"],
"allowJs": true
}
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": ["es2020", "dom"],
"noEmit": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"isolatedModules": true,
"jsx": "react",
// Remove when https://github.com/thomasbrodusch/vitest-fail-on-console/issues/160 is resolved
"skipLibCheck": true
},
"include": ["./src/**/*"]
}

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line import/no-relative-packages
import sharedConfig from '../../vitest.shared.mts';
export default sharedConfig(import.meta.url, { jsdom: true });