+ `);
+
+ const field = queryByTestId(input);
+ expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000');
+ });
+ });
+
+ // These tests for the `.not` use cases will help us cover our bases and complete test coverage
+ describe('Negated Test Cases', () => {
+ it('Passes the test if an invalid `id` is provided for the target element\'s `aria-errormessage`', () => {
+ const secondId = 'id2';
+ const secondError = 'LISTEN TO ME!!!';
+
+ const { queryByTestId } = render(`
+
+ `);
+
+ const field = queryByTestId('input');
+ expect(field).not.toHaveAccessibleErrorMessage();
+ expect(field).not.toHaveAccessibleErrorMessage(error);
+ expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]));
+ expect(field).not.toHaveAccessibleErrorMessage(secondError);
+ expect(field).not.toHaveAccessibleErrorMessage(
+ new RegExp(secondError[0]),
+ );
+ });
+
+ it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => {
+ const { queryByTestId } = render(`
+
+ `);
+
+ const field = queryByTestId(input);
+ expect(field).not.toHaveAccessibleErrorMessage();
+ expect(field).not.toHaveAccessibleErrorMessage(error);
+ expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]));
+ });
+ });
+});
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-name.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-name.js
new file mode 100644
index 0000000000..9d02b670c0
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-name.js
@@ -0,0 +1,322 @@
+import { render } from './helpers/test-utils';
+
+describe('.toHaveAccessibleName', () => {
+ it('recognizes an element\'s content as its label when appropriate', () => {
+ const { queryByTestId } = render(`
+
+
+
First element
+
Second element
+
+
+
+
+ `);
+
+ const list = queryByTestId('my-list');
+ expect(list).not.toHaveAccessibleName();
+ expect(() => {
+ expect(list).toHaveAccessibleName();
+ }).toThrow(/expected element to have accessible name/i);
+
+ expect(queryByTestId('first')).toHaveAccessibleName('First element');
+ expect(queryByTestId('second')).toHaveAccessibleName('Second element');
+
+ const button = queryByTestId('my-button');
+ expect(button).toHaveAccessibleName();
+ expect(button).toHaveAccessibleName('Continue to the next step');
+ expect(button).toHaveAccessibleName(/continue to the next step/i);
+ expect(button).toHaveAccessibleName(
+ expect.stringContaining('Continue to the next'),
+ );
+ expect(button).not.toHaveAccessibleName('Next step');
+ expect(() => {
+ expect(button).toHaveAccessibleName('Next step');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(button).not.toHaveAccessibleName('Continue to the next step');
+ }).toThrow(/expected element not to have accessible name/i);
+ expect(() => {
+ expect(button).not.toHaveAccessibleName();
+ }).toThrow(/expected element not to have accessible name/i);
+ });
+
+ it('works with label elements', () => {
+ const { queryByTestId } = render(`
+
+
+
+
+
+
+ `);
+
+ const firstNameField = queryByTestId('first-name-field');
+ expect(firstNameField).toHaveAccessibleName('First name');
+ expect(queryByTestId('first-name-field')).toHaveAccessibleName(
+ /first name/i,
+ );
+ expect(firstNameField).toHaveAccessibleName(
+ expect.stringContaining('First'),
+ );
+ expect(() => {
+ expect(firstNameField).toHaveAccessibleName('Last name');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(firstNameField).not.toHaveAccessibleName('First name');
+ }).toThrow(/expected element not to have accessible name/i);
+
+ const checkboxField = queryByTestId('checkbox-field');
+ expect(checkboxField).toHaveAccessibleName('Accept terms and conditions');
+ expect(checkboxField).toHaveAccessibleName(/accept terms/i);
+ expect(checkboxField).toHaveAccessibleName(
+ expect.stringContaining('Accept terms'),
+ );
+ expect(() => {
+ expect(checkboxField).toHaveAccessibleName(
+ 'Accept our terms and conditions',
+ );
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(checkboxField).not.toHaveAccessibleName(
+ 'Accept terms and conditions',
+ );
+ }).toThrow(/expected element not to have accessible name/i);
+ });
+
+ it('works with aria-label attributes', () => {
+ const { queryByTestId } = render(`
+
+
+
+
+
+
+
+
+ `);
+
+ const firstNameField = queryByTestId('first-name-field');
+ expect(firstNameField).not.toHaveAccessibleName('First name');
+ expect(firstNameField).toHaveAccessibleName('Enter your name');
+ expect(firstNameField).toHaveAccessibleName(/enter your name/i);
+ expect(firstNameField).toHaveAccessibleName(
+ expect.stringContaining('your name'),
+ );
+ expect(() => {
+ expect(firstNameField).toHaveAccessibleName('First name');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(firstNameField).not.toHaveAccessibleName('Enter your name');
+ }).toThrow(/expected element not to have accessible name/i);
+
+ const checkboxField = queryByTestId('checkbox-field');
+ expect(checkboxField).not.toHaveAccessibleName(
+ 'Accept terms and conditions',
+ );
+ expect(checkboxField).toHaveAccessibleName(
+ 'Accept our terms and conditions',
+ );
+ expect(checkboxField).toHaveAccessibleName(/accept our terms/i);
+ expect(checkboxField).toHaveAccessibleName(
+ expect.stringContaining('terms'),
+ );
+ expect(() => {
+ expect(checkboxField).toHaveAccessibleName('Accept terms and conditions');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(checkboxField).not.toHaveAccessibleName(
+ 'Accept our terms and conditions',
+ );
+ }).toThrow(/expected element not to have accessible name/i);
+
+ const submitButton = queryByTestId('submit-button');
+ expect(submitButton).not.toHaveAccessibleName('Continue');
+ expect(submitButton).toHaveAccessibleName('Submit this form');
+ expect(submitButton).toHaveAccessibleName(/submit this form/i);
+ expect(submitButton).toHaveAccessibleName(expect.stringContaining('form'));
+ expect(() => {
+ expect(submitButton).toHaveAccessibleName('Continue');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(submitButton).not.toHaveAccessibleName('Submit this form');
+ }).toThrow(/expected element not to have accessible name/i);
+ });
+
+ it('works with aria-labelledby attributes', () => {
+ const { queryByTestId } = render(`
+
+
+
+
Enter your name
+
+
+
Accept our terms and conditions
+
+
+
Submit this form
+
+ `);
+
+ const firstNameField = queryByTestId('first-name-field');
+ expect(firstNameField).not.toHaveAccessibleName('First name');
+ expect(firstNameField).toHaveAccessibleName('Enter your name');
+ expect(firstNameField).toHaveAccessibleName(/enter your name/i);
+ expect(firstNameField).toHaveAccessibleName(
+ expect.stringContaining('your name'),
+ );
+ expect(() => {
+ expect(firstNameField).toHaveAccessibleName('First name');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(firstNameField).not.toHaveAccessibleName('Enter your name');
+ }).toThrow(/expected element not to have accessible name/i);
+
+ const checkboxField = queryByTestId('checkbox-field');
+ expect(checkboxField).not.toHaveAccessibleName(
+ 'Accept terms and conditions',
+ );
+ expect(checkboxField).toHaveAccessibleName(
+ 'Accept our terms and conditions',
+ );
+ expect(checkboxField).toHaveAccessibleName(/accept our terms/i);
+ expect(checkboxField).toHaveAccessibleName(
+ expect.stringContaining('terms'),
+ );
+ expect(() => {
+ expect(checkboxField).toHaveAccessibleName('Accept terms and conditions');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(checkboxField).not.toHaveAccessibleName(
+ 'Accept our terms and conditions',
+ );
+ }).toThrow(/expected element not to have accessible name/i);
+
+ const submitButton = queryByTestId('submit-button');
+ expect(submitButton).not.toHaveAccessibleName('Continue');
+ expect(submitButton).toHaveAccessibleName('Submit this form');
+ expect(submitButton).toHaveAccessibleName(/submit this form/i);
+ expect(submitButton).toHaveAccessibleName(expect.stringContaining('form'));
+ expect(() => {
+ expect(submitButton).toHaveAccessibleName('Continue');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(submitButton).not.toHaveAccessibleName('Submit this form');
+ }).toThrow(/expected element not to have accessible name/i);
+ });
+
+ it('works with image alt attributes', () => {
+ const { queryByTestId } = render(`
+
+
+
+
+ `);
+
+ const logoImage = queryByTestId('logo-img');
+ expect(logoImage).toHaveAccessibleName('Company logo');
+ expect(logoImage).toHaveAccessibleName(/company logo/i);
+ expect(logoImage).toHaveAccessibleName(expect.stringContaining('logo'));
+ expect(() => {
+ expect(logoImage).toHaveAccessibleName('Our company logo');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(logoImage).not.toHaveAccessibleName('Company logo');
+ }).toThrow(/expected element not to have accessible name/i);
+
+ const closeButton = queryByTestId('close-button');
+ expect(closeButton).toHaveAccessibleName('Close modal');
+ expect(closeButton).toHaveAccessibleName(/close modal/i);
+ expect(closeButton).toHaveAccessibleName(expect.stringContaining('modal'));
+ expect(() => {
+ expect(closeButton).toHaveAccessibleName('Close window');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(closeButton).not.toHaveAccessibleName('Close modal');
+ }).toThrow(/expected element not to have accessible name/i);
+ });
+
+ it('works with svg title attributes', () => {
+ const { queryByTestId } = render(`
+
+ `);
+
+ const svgElement = queryByTestId('svg-title');
+ expect(svgElement).toHaveAccessibleName('Test title');
+ expect(svgElement).toHaveAccessibleName(/test title/i);
+ expect(svgElement).toHaveAccessibleName(expect.stringContaining('Test'));
+ expect(() => {
+ expect(svgElement).toHaveAccessibleName('Another title');
+ }).toThrow(/expected element to have accessible name/i);
+ expect(() => {
+ expect(svgElement).not.toHaveAccessibleName('Test title');
+ }).toThrow(/expected element not to have accessible name/i);
+ });
+
+ it('works as in the examples in the README', () => {
+ const { queryByTestId: getByTestId } = render(`
+
+ `);
+
+ const continueButton = queryByTestId('continue-button');
+
+ expect(continueButton).not.toHaveRole('listitem');
+ expect(continueButton).toHaveRole('button');
+
+ expect(() => {
+ expect(continueButton).toHaveRole('listitem');
+ }).toThrow(/expected element to have role/i);
+ expect(() => {
+ expect(continueButton).not.toHaveRole('button');
+ }).toThrow(/expected element not to have role/i);
+ });
+
+ it('matches explicit role', () => {
+ const { queryByTestId } = render(`
+
+
Continue
+
+ `);
+
+ const continueButton = queryByTestId('continue-button');
+
+ expect(continueButton).not.toHaveRole('listitem');
+ expect(continueButton).toHaveRole('button');
+
+ expect(() => {
+ expect(continueButton).toHaveRole('listitem');
+ }).toThrow(/expected element to have role/i);
+ expect(() => {
+ expect(continueButton).not.toHaveRole('button');
+ }).toThrow(/expected element not to have role/i);
+ });
+
+ it('matches multiple explicit roles', () => {
+ const { queryByTestId } = render(`
+
+
Continue
+
+ `);
+
+ const continueButton = queryByTestId('continue-button');
+
+ expect(continueButton).not.toHaveRole('listitem');
+ expect(continueButton).toHaveRole('button');
+ expect(continueButton).toHaveRole('switch');
+
+ expect(() => {
+ expect(continueButton).toHaveRole('listitem');
+ }).toThrow(/expected element to have role/i);
+ expect(() => {
+ expect(continueButton).not.toHaveRole('button');
+ }).toThrow(/expected element not to have role/i);
+ expect(() => {
+ expect(continueButton).not.toHaveRole('switch');
+ }).toThrow(/expected element not to have role/i);
+ });
+
+ // At this point, we might be testing the details of getImplicitAriaRoles, but
+ // it's good to have a gut check
+ it('handles implicit roles with multiple conditions', () => {
+ const { queryByTestId } = render(`
+
+ `);
+ expect(container.querySelector('.label')).not.toHaveStyle({
+ width: '100%',
+ height: '',
+ });
+ expect(container.querySelector('.label')).not.toHaveStyle({
+ width: '',
+ height: '',
+ });
+ });
+ });
+});
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-text-content.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-text-content.js
new file mode 100644
index 0000000000..7dfb9e273e
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-text-content.js
@@ -0,0 +1,109 @@
+import { render } from './helpers/test-utils';
+
+describe('.toHaveTextContent', () => {
+ test('handles positive test cases', () => {
+ const { queryByTestId } = render(
+ `2`,
+ );
+
+ expect(queryByTestId('count-value')).toHaveTextContent('2');
+ expect(queryByTestId('count-value')).toHaveTextContent(2);
+ expect(queryByTestId('count-value')).toHaveTextContent(/2/);
+ expect(queryByTestId('count-value')).not.toHaveTextContent('21');
+ });
+
+ test('handles text nodes', () => {
+ const { container } = render(`example`);
+
+ expect(container.querySelector('span').firstChild).toHaveTextContent(
+ 'example',
+ );
+ });
+
+ test('handles fragments', () => {
+ const { asFragment } = render(`example`);
+
+ expect(asFragment()).toHaveTextContent('example');
+ });
+
+ test('handles negative test cases', () => {
+ const { queryByTestId } = render(
+ `2`,
+ );
+
+ expect(() => expect(queryByTestId('count-value2')).toHaveTextContent('2'))
+ .toThrowError();
+
+ expect(() => expect(queryByTestId('count-value')).toHaveTextContent('3'))
+ .toThrowError();
+ expect(() =>
+ expect(queryByTestId('count-value')).not.toHaveTextContent('2')
+ ).toThrowError();
+ });
+
+ test('normalizes whitespace by default', () => {
+ const { container } = render(`
+
+ Step
+ 1
+ of
+ 4
+
+ `);
+
+ expect(container.querySelector('span')).toHaveTextContent('Step 1 of 4');
+ });
+
+ test('allows whitespace normalization to be turned off', () => {
+ const { container } = render(` Step 1 of 4`);
+
+ expect(container.querySelector('span')).toHaveTextContent(' Step 1 of 4', {
+ normalizeWhitespace: false,
+ });
+ });
+
+ test('can handle multiple levels', () => {
+ const { container } = render(`Step 1
+
+ of 4`);
+
+ expect(container.querySelector('#parent')).toHaveTextContent('Step 1 of 4');
+ });
+
+ test('can handle multiple levels with content spread across decendants', () => {
+ const { container } = render(`
+
+ Step
+ 1
+ of
+
+
+ 4
+
+ `);
+
+ expect(container.querySelector('#parent')).toHaveTextContent('Step 1 of 4');
+ });
+
+ test('does not throw error with empty content', () => {
+ const { container } = render(``);
+ expect(container.querySelector('span')).toHaveTextContent('');
+ });
+
+ test('is case-sensitive', () => {
+ const { container } = render('Sensitive text');
+
+ expect(container.querySelector('span')).toHaveTextContent('Sensitive text');
+ expect(container.querySelector('span')).not.toHaveTextContent(
+ 'sensitive text',
+ );
+ });
+
+ test('when matching with empty string and element with content, suggest using toBeEmptyDOMElement instead', () => {
+ // https://github.com/testing-library/jest-dom/issues/104
+ const { container } = render('not empty');
+
+ expect(() => expect(container.querySelector('span')).toHaveTextContent(''))
+ .toThrowError(/toBeEmptyDOMElement\(\)/);
+ });
+});
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-value.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-value.js
new file mode 100644
index 0000000000..f134677d82
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-value.js
@@ -0,0 +1,220 @@
+import { render } from './helpers/test-utils';
+
+describe('.toHaveValue', () => {
+ test('handles value of text input', () => {
+ const { queryByTestId } = render(`
+
+
+
+ `);
+
+ expect(queryByTestId('value')).toHaveValue('foo');
+ expect(queryByTestId('value')).toHaveValue();
+ expect(queryByTestId('value')).not.toHaveValue('bar');
+ expect(queryByTestId('value')).not.toHaveValue('');
+
+ expect(queryByTestId('empty')).toHaveValue('');
+ expect(queryByTestId('empty')).not.toHaveValue();
+ expect(queryByTestId('empty')).not.toHaveValue('foo');
+
+ expect(queryByTestId('without')).toHaveValue('');
+ expect(queryByTestId('without')).not.toHaveValue();
+ expect(queryByTestId('without')).not.toHaveValue('foo');
+ queryByTestId('without').value = 'bar';
+ expect(queryByTestId('without')).toHaveValue('bar');
+ });
+
+ test('handles value of number input', () => {
+ const { queryByTestId } = render(`
+
+
+
+ `);
+
+ expect(queryByTestId('number')).toHaveValue(5);
+ expect(queryByTestId('number')).toHaveValue();
+ expect(queryByTestId('number')).not.toHaveValue(4);
+ expect(queryByTestId('number')).not.toHaveValue('5');
+
+ expect(queryByTestId('empty')).toHaveValue(null);
+ expect(queryByTestId('empty')).not.toHaveValue();
+ expect(queryByTestId('empty')).not.toHaveValue('5');
+
+ expect(queryByTestId('without')).toHaveValue(null);
+ expect(queryByTestId('without')).not.toHaveValue();
+ expect(queryByTestId('without')).not.toHaveValue('10');
+ queryByTestId('without').value = 10;
+ expect(queryByTestId('without')).toHaveValue(10);
+ });
+
+ test('handles value of select element', () => {
+ const { queryByTestId } = render(`
+
+
+
+
+
+ `);
+
+ expect(queryByTestId('single')).toHaveValue('second');
+ expect(queryByTestId('single')).toHaveValue();
+
+ expect(queryByTestId('multiple')).toHaveValue(['second', 'third']);
+ expect(queryByTestId('multiple')).toHaveValue();
+
+ expect(queryByTestId('not-selected')).not.toHaveValue();
+ expect(queryByTestId('not-selected')).toHaveValue('');
+
+ queryByTestId('single').children[0].setAttribute('selected', true);
+ expect(queryByTestId('single')).toHaveValue('first');
+ });
+
+ test('handles value of textarea element', () => {
+ const { queryByTestId } = render(`
+
+ `);
+ expect(queryByTestId('textarea')).toHaveValue('text value');
+ });
+
+ test('throws when passed checkbox or radio', () => {
+ const { queryByTestId } = render(`
+
+
+ `);
+
+ expect(() => {
+ expect(queryByTestId('checkbox')).toHaveValue('');
+ }).toThrow();
+
+ expect(() => {
+ expect(queryByTestId('radio')).toHaveValue('');
+ }).toThrow();
+ });
+
+ test('throws when the expected input value does not match', () => {
+ const { container } = render(``);
+ const input = container.firstChild;
+ let errorMessage;
+ try {
+ expect(input).toHaveValue('something else');
+ } catch (error) {
+ errorMessage = error.message;
+ }
+
+ expect(errorMessage).toMatchInlineSnapshot(`
+expect(>element>).toHaveValue(>something else>)>
+
+Expected the element to have value:
+ something else>
+Received:
+ foo>
+`);
+ });
+
+ test('throws with type information when the expected text input value has loose equality with received value', () => {
+ const { container } = render(``);
+ const input = container.firstChild;
+ let errorMessage;
+ try {
+ expect(input).toHaveValue(8);
+ } catch (error) {
+ errorMessage = error.message;
+ }
+
+ expect(errorMessage).toMatchInlineSnapshot(`
+expect(>element>).toHaveValue(>8>)>
+
+Expected the element to have value:
+ 8 (number)>
+Received:
+ 8 (string)>
+`);
+ });
+
+ test('throws when using not but the expected input value does match', () => {
+ const { container } = render(``);
+ const input = container.firstChild;
+ let errorMessage;
+
+ try {
+ expect(input).not.toHaveValue('foo');
+ } catch (error) {
+ errorMessage = error.message;
+ }
+ expect(errorMessage).toMatchInlineSnapshot(`
+expect(>element>).not.toHaveValue(>foo>)>
+
+Expected the element not to have value:
+ foo>
+Received:
+ foo>
+`);
+ });
+
+ test('throws when the form has no a value but a value is expected', () => {
+ const { container } = render(``);
+ const input = container.firstChild;
+ let errorMessage;
+
+ try {
+ expect(input).toHaveValue();
+ } catch (error) {
+ errorMessage = error.message;
+ }
+ expect(errorMessage).toMatchInlineSnapshot(`
+expect(>element>).toHaveValue(>expected>)>
+
+Expected the element to have value:
+ (any)>
+Received:
+
+`);
+ });
+
+ test('throws when the form has a value but none is expected', () => {
+ const { container } = render(``);
+ const input = container.firstChild;
+ let errorMessage;
+
+ try {
+ expect(input).not.toHaveValue();
+ } catch (error) {
+ errorMessage = error.message;
+ }
+ expect(errorMessage).toMatchInlineSnapshot(`
+expect(>element>).not.toHaveValue(>expected>)>
+
+Expected the element not to have value:
+ (any)>
+Received:
+ foo>
+`);
+ });
+
+ test('handles value of aria-valuenow', () => {
+ const valueToCheck = 70;
+ const { queryByTestId } = render(`
+
+
+ `);
+
+ expect(queryByTestId('meter')).toHaveValue(valueToCheck);
+ expect(queryByTestId('meter')).not.toHaveValue(valueToCheck + 1);
+
+ // Role that does not support aria-valuenow
+ expect(queryByTestId('textbox')).not.toHaveValue(70);
+ });
+});
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/utils.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/utils.js
new file mode 100644
index 0000000000..e28070abe0
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/utils.js
@@ -0,0 +1,213 @@
+import {
+ deprecate,
+ checkHtmlElement,
+ checkNode,
+ HtmlElementTypeError,
+ NodeTypeError,
+ toSentence,
+} from '../utils';
+import document from './helpers/document';
+
+test('deprecate', () => {
+ const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ const name = 'test';
+ const replacement = 'test';
+ const message =
+ `Warning: ${name} has been deprecated and will be removed in future updates.`;
+
+ deprecate(name, replacement);
+ expect(spy).toHaveBeenCalledWith(message, replacement);
+
+ deprecate(name);
+ expect(spy).toHaveBeenCalledWith(message, undefined);
+
+ spy.mockRestore();
+});
+
+describe('checkHtmlElement', () => {
+ let assertionContext;
+ beforeAll(() => {
+ expect.extend({
+ fakeMatcher() {
+ assertionContext = this;
+
+ return { pass: true };
+ },
+ });
+
+ expect(true).fakeMatcher(true);
+ });
+ it('does not throw an error for correct html element', () => {
+ expect(() => {
+ const element = document.createElement('p');
+ checkHtmlElement(element, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('does not throw an error for correct svg element', () => {
+ expect(() => {
+ const element = document.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'rect',
+ );
+ checkHtmlElement(element, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('does not throw for body', () => {
+ expect(() => {
+ checkHtmlElement(document.body, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('throws for undefined', () => {
+ expect(() => {
+ checkHtmlElement(undefined, () => {}, assertionContext);
+ }).toThrow(HtmlElementTypeError);
+ });
+
+ it('throws for document', () => {
+ expect(() => {
+ checkHtmlElement(document, () => {}, assertionContext);
+ }).toThrow(HtmlElementTypeError);
+ });
+
+ it('throws for function', () => {
+ expect(() => {
+ checkHtmlElement(
+ () => {},
+ () => {},
+ assertionContext,
+ );
+ }).toThrow(HtmlElementTypeError);
+ });
+
+ it('throws for almost element-like objects', () => {
+ class FakeObject {}
+ expect(() => {
+ checkHtmlElement(
+ {
+ ownerDocument: {
+ defaultView: { HTMLElement: FakeObject, SVGElement: FakeObject },
+ },
+ },
+ () => {},
+ assertionContext,
+ );
+ }).toThrow(HtmlElementTypeError);
+ });
+});
+
+describe('checkNode', () => {
+ let assertionContext;
+ beforeAll(() => {
+ expect.extend({
+ fakeMatcher() {
+ assertionContext = this;
+
+ return { pass: true };
+ },
+ });
+
+ expect(true).fakeMatcher(true);
+ });
+ it('does not throw an error for correct html element', () => {
+ expect(() => {
+ const element = document.createElement('p');
+ checkNode(element, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('does not throw an error for correct svg element', () => {
+ expect(() => {
+ const element = document.createElementNS(
+ 'http://www.w3.org/2000/svg',
+ 'rect',
+ );
+ checkNode(element, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('does not throw an error for Document fragments', () => {
+ expect(() => {
+ const fragment = document.createDocumentFragment();
+ checkNode(fragment, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('does not throw an error for text nodes', () => {
+ expect(() => {
+ const text = document.createTextNode('foo');
+ checkNode(text, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('does not throw for body', () => {
+ expect(() => {
+ checkNode(document.body, () => {}, assertionContext);
+ }).not.toThrow();
+ });
+
+ it('throws for undefined', () => {
+ expect(() => {
+ checkNode(undefined, () => {}, assertionContext);
+ }).toThrow(NodeTypeError);
+ });
+
+ it('throws for document', () => {
+ expect(() => {
+ checkNode(document, () => {}, assertionContext);
+ }).toThrow(NodeTypeError);
+ });
+
+ it('throws for function', () => {
+ expect(() => {
+ checkNode(
+ () => {},
+ () => {},
+ assertionContext,
+ );
+ }).toThrow(NodeTypeError);
+ });
+
+ it('throws for almost element-like objects', () => {
+ class FakeObject {}
+ expect(() => {
+ checkNode(
+ {
+ ownerDocument: {
+ defaultView: { Node: FakeObject, SVGElement: FakeObject },
+ },
+ },
+ () => {},
+ assertionContext,
+ );
+ }).toThrow(NodeTypeError);
+ });
+});
+
+describe('toSentence', () => {
+ it('turns array into string of comma separated list with default last word connector', () => {
+ expect(toSentence(['one', 'two', 'three'])).toBe('one, two and three');
+ });
+
+ it('supports custom word connector', () => {
+ expect(toSentence(['one', 'two', 'three'], { wordConnector: '; ' })).toBe(
+ 'one; two and three',
+ );
+ });
+
+ it('supports custom last word connector', () => {
+ expect(
+ toSentence(['one', 'two', 'three'], { lastWordConnector: ' or ' }),
+ ).toBe('one, two or three');
+ });
+
+ it('turns one element array into string containing first element', () => {
+ expect(toSentence(['one'])).toBe('one');
+ });
+
+ it('turns empty array into empty string', () => {
+ expect(toSentence([])).toBe('');
+ });
+});
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/index.js b/packages/testing-library/lynx-dom-jest-matchers/src/index.js
new file mode 100644
index 0000000000..5d3cf42351
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/index.js
@@ -0,0 +1,3 @@
+import * as extensions from './matchers';
+
+expect.extend(extensions);
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/jest-globals.js b/packages/testing-library/lynx-dom-jest-matchers/src/jest-globals.js
new file mode 100644
index 0000000000..4df6bf5e71
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/jest-globals.js
@@ -0,0 +1,6 @@
+/* istanbul ignore file */
+
+import { expect } from '@jest/globals';
+import * as extensions from './matchers';
+
+expect.extend(extensions);
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/matchers.js b/packages/testing-library/lynx-dom-jest-matchers/src/matchers.js
new file mode 100644
index 0000000000..7d9a05599e
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/matchers.js
@@ -0,0 +1,27 @@
+// export {toBeInTheDOM} from './to-be-in-the-dom'
+export { toBeInTheDocument } from './to-be-in-the-document';
+// export {toBeEmpty} from './to-be-empty'
+// export {toBeEmptyDOMElement} from './to-be-empty-dom-element'
+// export {toContainElement} from './to-contain-element'
+// export {toContainHTML} from './to-contain-html'
+export { toHaveTextContent } from './to-have-text-content';
+// export {toHaveAccessibleDescription} from './to-have-accessible-description'
+// export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage'
+// export {toHaveRole} from './to-have-role'
+// export {toHaveAccessibleName} from './to-have-accessible-name'
+// export {toHaveAttribute} from './to-have-attribute'
+// export {toHaveClass} from './to-have-class'
+// export {toHaveStyle} from './to-have-style'
+// export {toHaveFocus} from './to-have-focus'
+// export {toHaveFormValues} from './to-have-form-values'
+// export {toBeVisible} from './to-be-visible'
+// export {toBeDisabled, toBeEnabled} from './to-be-disabled'
+// export {toBeRequired} from './to-be-required'
+// export {toBeInvalid, toBeValid} from './to-be-invalid'
+// export {toHaveValue} from './to-have-value'
+// export {toHaveDisplayValue} from './to-have-display-value'
+// export {toBeChecked} from './to-be-checked'
+// export {toBePartiallyChecked} from './to-be-partially-checked'
+// export {toHaveDescription} from './to-have-description'
+// export {toHaveErrorMessage} from './to-have-errormessage'
+// export {toHaveSelection} from './to-have-selection'
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-checked.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-checked.js
new file mode 100644
index 0000000000..8cda0436a1
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-checked.js
@@ -0,0 +1,65 @@
+import { roles } from 'aria-query';
+import { checkHtmlElement, toSentence } from './utils';
+
+export function toBeChecked(element) {
+ checkHtmlElement(element, toBeChecked, this);
+
+ const isValidInput = () => {
+ return (
+ element.tagName.toLowerCase() === 'input'
+ && ['checkbox', 'radio'].includes(element.type)
+ );
+ };
+
+ const isValidAriaElement = () => {
+ return (
+ roleSupportsChecked(element.getAttribute('role'))
+ && ['true', 'false'].includes(element.getAttribute('aria-checked'))
+ );
+ };
+
+ if (!isValidInput() && !isValidAriaElement()) {
+ return {
+ pass: false,
+ message: () =>
+ `only inputs with type="checkbox" or type="radio" or elements with ${supportedRolesSentence()} and a valid aria-checked attribute can be used with .toBeChecked(). Use .toHaveValue() instead`,
+ };
+ }
+
+ const isChecked = () => {
+ if (isValidInput()) return element.checked;
+ return element.getAttribute('aria-checked') === 'true';
+ };
+
+ return {
+ pass: isChecked(),
+ message: () => {
+ const is = isChecked() ? 'is' : 'is not';
+ return [
+ this.utils.matcherHint(
+ `${this.isNot ? '.not' : ''}.toBeChecked`,
+ 'element',
+ '',
+ ),
+ '',
+ `Received element ${is} checked:`,
+ ` ${this.utils.printReceived(element.cloneNode(false))}`,
+ ].join('\n');
+ },
+ };
+}
+
+function supportedRolesSentence() {
+ return toSentence(
+ supportedRoles().map(role => `role="${role}"`),
+ { lastWordConnector: ' or ' },
+ );
+}
+
+function supportedRoles() {
+ return roles.keys().filter(roleSupportsChecked);
+}
+
+function roleSupportsChecked(role) {
+ return roles.get(role)?.props['aria-checked'] !== undefined;
+}
diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-disabled.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-disabled.js
new file mode 100644
index 0000000000..5975ed4e79
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-disabled.js
@@ -0,0 +1,116 @@
+import { checkHtmlElement, getTag } from './utils';
+
+// form elements that support 'disabled'
+const FORM_TAGS = [
+ 'fieldset',
+ 'input',
+ 'select',
+ 'optgroup',
+ 'option',
+ 'button',
+ 'textarea',
+];
+
+/*
+ * According to specification:
+ * If
is disabled, the form controls that are its descendants,
+ * except descendants of its first optional
+ *
+ *
+ *
+ * expect(getByTestId('img-alt')).toHaveAccessibleName('Test alt')
+ * expect(getByTestId('img-empty-alt')).not.toHaveAccessibleName()
+ * expect(getByTestId('svg-title')).toHaveAccessibleName('Test title')
+ * expect(getByTestId('button-img-alt')).toHaveAccessibleName()
+ * expect(getByTestId('img-paragraph')).not.toHaveAccessibleName()
+ * expect(getByTestId('svg-button')).toHaveAccessibleName()
+ * expect(getByTestId('svg-without-title')).not.toHaveAccessibleName()
+ * expect(getByTestId('input-title')).toHaveAccessibleName()
+ * @see
+ * [testing-library/jest-dom#tohaveaccessiblename](https://github.com/testing-library/jest-dom#tohaveaccessiblename)
+ */
+ toHaveAccessibleName(text?: string | RegExp | E): R;
+ /**
+ * @description
+ * This allows you to assert that an element has the expected
+ * [role](https://www.w3.org/TR/html-aria/#docconformance).
+ *
+ * This is useful in cases where you already have access to an element via
+ * some query other than the role itself, and want to make additional
+ * assertions regarding its accessibility.
+ *
+ * The role can match either an explicit role (via the `role` attribute), or
+ * an implicit one via the [implicit ARIA
+ * semantics](https://www.w3.org/TR/html-aria/).
+ *
+ * Note: roles are matched literally by string equality, without inheriting
+ * from the ARIA role hierarchy. As a result, querying a superclass role
+ * like 'checkbox' will not include elements with a subclass role like
+ * 'switch'.
+ *
+ * @example
+ *
+ *
Continue
+ *
+ * About
+ * Invalid link
+ *
+ * expect(getByTestId('button')).toHaveRole('button')
+ * expect(getByTestId('button-explicit')).toHaveRole('button')
+ * expect(getByTestId('button-explicit-multiple')).toHaveRole('button')
+ * expect(getByTestId('button-explicit-multiple')).toHaveRole('switch')
+ * expect(getByTestId('link')).toHaveRole('link')
+ * expect(getByTestId('link-invalid')).not.toHaveRole('link')
+ * expect(getByTestId('link-invalid')).toHaveRole('generic')
+ *
+ * @see
+ * [testing-library/jest-dom#tohaverole](https://github.com/testing-library/jest-dom#tohaverole)
+ */
+ toHaveRole(
+ // Get autocomplete for ARIARole union types, while still supporting another string
+ // Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-567871939
+ role: ARIARole | (string & {}),
+ ): R;
+ /**
+ * @description
+ * This allows you to check whether the given element is partially checked.
+ * It accepts an input of type checkbox and elements with a role of checkbox
+ * with a aria-checked="mixed", or input of type checkbox with indeterminate
+ * set to true
+ *
+ * @example
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * const ariaCheckboxMixed = getByTestId('aria-checkbox-mixed')
+ * const inputCheckboxChecked = getByTestId('input-checkbox-checked')
+ * const inputCheckboxUnchecked = getByTestId('input-checkbox-unchecked')
+ * const ariaCheckboxChecked = getByTestId('aria-checkbox-checked')
+ * const ariaCheckboxUnchecked = getByTestId('aria-checkbox-unchecked')
+ * const inputCheckboxIndeterminate = getByTestId('input-checkbox-indeterminate')
+ *
+ * expect(ariaCheckboxMixed).toBePartiallyChecked()
+ * expect(inputCheckboxChecked).not.toBePartiallyChecked()
+ * expect(inputCheckboxUnchecked).not.toBePartiallyChecked()
+ * expect(ariaCheckboxChecked).not.toBePartiallyChecked()
+ * expect(ariaCheckboxUnchecked).not.toBePartiallyChecked()
+ *
+ * inputCheckboxIndeterminate.indeterminate = true
+ * expect(inputCheckboxIndeterminate).toBePartiallyChecked()
+ * @see
+ * [testing-library/jest-dom#tobepartiallychecked](https://github.com/testing-library/jest-dom#tobepartiallychecked)
+ */
+ toBePartiallyChecked(): R;
+ /**
+ * @deprecated
+ * since v5.17.0
+ *
+ * @description
+ * Check whether the given element has an [ARIA error message](https://www.w3.org/TR/wai-aria/#aria-errormessage) or not.
+ *
+ * Use the `aria-errormessage` attribute to reference another element that contains
+ * custom error message text. Multiple ids is **NOT** allowed. Authors MUST use
+ * `aria-invalid` in conjunction with `aria-errormessage`. Learn more from the
+ * [`aria-errormessage` spec](https://www.w3.org/TR/wai-aria/#aria-errormessage).
+ *
+ * Whitespace is normalized.
+ *
+ * When a `string` argument is passed through, it will perform a whole
+ * case-sensitive match to the error message text.
+ *
+ * To perform a case-insensitive match, you can use a `RegExp` with the `/i`
+ * modifier.
+ *
+ * To perform a partial match, you can pass a `RegExp` or use
+ * expect.stringContaining("partial string")`.
+ *
+ * @example
+ *
+ *
+ *
+ * Invalid time: the time must be between 9:00 AM and 5:00 PM"
+ *
+ *
+ * const timeInput = getByLabel('startTime')
+ *
+ * expect(timeInput).toHaveErrorMessage(
+ * 'Invalid time: the time must be between 9:00 AM and 5:00 PM',
+ * )
+ * expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
+ * expect(timeInput).toHaveErrorMessage(expect.stringContaining('Invalid time')) // to partially match
+ * expect(timeInput).not.toHaveErrorMessage('Pikachu!')
+ * @see
+ * [testing-library/jest-dom#tohaveerrormessage](https://github.com/testing-library/jest-dom#tohaveerrormessage)
+ */
+ toHaveErrorMessage(text?: string | RegExp | E): R;
+ /**
+ * @description
+ * This allows to assert that an element has a
+ * [text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection).
+ *
+ * This is useful to check if text or part of the text is selected within an
+ * element. The element can be either an input of type text, a textarea, or any
+ * other element that contains text, such as a paragraph, span, div etc.
+ *
+ * NOTE: the expected selection is a string, it does not allow to check for
+ * selection range indeces.
+ *
+ * @example
+ *
+ *
+ *
+ *
prev
+ *
text selected text
+ *
next
+ *
+ *
+ * getByTestId('text').setSelectionRange(5, 13)
+ * expect(getByTestId('text')).toHaveSelection('selected')
+ *
+ * getByTestId('textarea').setSelectionRange(0, 5)
+ * expect('textarea').toHaveSelection('text ')
+ *
+ * const selection = document.getSelection()
+ * const range = document.createRange()
+ * selection.removeAllRanges()
+ * selection.empty()
+ * selection.addRange(range)
+ *
+ * // selection of child applies to the parent as well
+ * range.selectNodeContents(getByTestId('child'))
+ * expect(getByTestId('child')).toHaveSelection('selected')
+ * expect(getByTestId('parent')).toHaveSelection('selected')
+ *
+ * // selection that applies from prev all, parent text before child, and part child.
+ * range.setStart(getByTestId('prev'), 0)
+ * range.setEnd(getByTestId('child').childNodes[0], 3)
+ * expect(queryByTestId('prev')).toHaveSelection('prev')
+ * expect(queryByTestId('child')).toHaveSelection('sel')
+ * expect(queryByTestId('parent')).toHaveSelection('text sel')
+ * expect(queryByTestId('next')).not.toHaveSelection()
+ *
+ * // selection that applies from part child, parent text after child and part next.
+ * range.setStart(getByTestId('child').childNodes[0], 3)
+ * range.setEnd(getByTestId('next').childNodes[0], 2)
+ * expect(queryByTestId('child')).toHaveSelection('ected')
+ * expect(queryByTestId('parent')).toHaveSelection('ected text')
+ * expect(queryByTestId('prev')).not.toHaveSelection()
+ * expect(queryByTestId('next')).toHaveSelection('ne')
+ *
+ * @see
+ * [testing-library/jest-dom#tohaveselection](https://github.com/testing-library/jest-dom#tohaveselection)
+ */
+ toHaveSelection(selection?: string): R;
+ }
+}
+
+// Needs to extend Record to be accepted by expect.extend()
+// as it requires a string index signature.
+declare const matchers:
+ & matchers.TestingLibraryMatchers
+ & Record;
+export = matchers;
diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/vitest.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/vitest.d.ts
new file mode 100644
index 0000000000..798f26712d
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/types/vitest.d.ts
@@ -0,0 +1,17 @@
+import 'vitest';
+import { type TestingLibraryMatchers } from './matchers';
+
+declare module 'vitest' {
+ interface Assertion extends
+ TestingLibraryMatchers<
+ any,
+ T
+ >
+ {}
+ interface AsymmetricMatchersContaining extends
+ TestingLibraryMatchers<
+ any,
+ any
+ >
+ {}
+}
diff --git a/packages/testing-library/lynx-dom-jest-matchers/vitest.d.ts b/packages/testing-library/lynx-dom-jest-matchers/vitest.d.ts
new file mode 100644
index 0000000000..1b17a0d4ab
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/vitest.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/testing-library/lynx-dom-jest-matchers/vitest.js b/packages/testing-library/lynx-dom-jest-matchers/vitest.js
new file mode 100644
index 0000000000..7b4bcc6dd9
--- /dev/null
+++ b/packages/testing-library/lynx-dom-jest-matchers/vitest.js
@@ -0,0 +1,4 @@
+import { expect } from 'vitest';
+import * as extensions from './dist/matchers';
+
+expect.extend(extensions);
diff --git a/packages/testing-library/lynx-dom-testing-library/LICENSE b/packages/testing-library/lynx-dom-testing-library/LICENSE
new file mode 100644
index 0000000000..4c43675bc4
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/LICENSE
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+Copyright (c) 2017 Kent C. Dodds
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/testing-library/lynx-dom-testing-library/README.md b/packages/testing-library/lynx-dom-testing-library/README.md
new file mode 100644
index 0000000000..efa735f3ea
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/README.md
@@ -0,0 +1,4 @@
+# @lynx-js/lynx-dom-testing-library
+
+Lynx equivalent of
+[dom-testing-library](https://github.com/testing-library/dom-testing-library)
diff --git a/packages/testing-library/lynx-dom-testing-library/jest.config.js b/packages/testing-library/lynx-dom-testing-library/jest.config.js
new file mode 100644
index 0000000000..5906e1bbd9
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/jest.config.js
@@ -0,0 +1,24 @@
+const {
+ collectCoverageFrom,
+ coveragePathIgnorePatterns,
+ coverageThreshold,
+ watchPlugins,
+} = require('kcd-scripts/jest');
+
+module.exports = {
+ collectCoverageFrom,
+ coveragePathIgnorePatterns: [
+ ...coveragePathIgnorePatterns,
+ '/__tests__/',
+ '/__node_tests__/',
+ ],
+ coverageThreshold,
+ watchPlugins: [
+ ...watchPlugins,
+ require.resolve('jest-watch-select-projects'),
+ ],
+ projects: [
+ require.resolve('./tests/jest.config.dom.js'),
+ require.resolve('./tests/jest.config.node.js'),
+ ],
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/package.json b/packages/testing-library/lynx-dom-testing-library/package.json
new file mode 100644
index 0000000000..832d215463
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@lynx-js/lynx-dom-testing-library",
+ "version": "0.0.0",
+ "description": "Simple and complete DOM testing utilities that encourage good testing practices.",
+ "keywords": [
+ "testing",
+ "ui",
+ "dom",
+ "jsdom",
+ "unit",
+ "integration",
+ "functional",
+ "end-to-end",
+ "e2e"
+ ],
+ "homepage": "https://github.com/testing-library/dom-testing-library#readme",
+ "bugs": {
+ "url": "https://github.com/testing-library/dom-testing-library/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/testing-library/dom-testing-library"
+ },
+ "license": "MIT",
+ "author": "Kent C. Dodds (https://kentcdodds.com)",
+ "main": "dist/index.js",
+ "umd:main": "dist/@testing-library/dom.umd.js",
+ "module": "dist/@testing-library/dom.esm.js",
+ "source": "src/index.js",
+ "types": "types/index.d.ts",
+ "files": [
+ "dist",
+ "types/*.d.ts"
+ ],
+ "scripts": {
+ "build": "rslib build",
+ "dev": "rslib build --watch"
+ },
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "devDependencies": {
+ "@rslib/core": "^0.3.1",
+ "@testing-library/jest-dom": "^5.11.6",
+ "jest-in-case": "^1.0.2",
+ "jest-snapshot-serializer-ansi": "^1.0.0",
+ "jest-watch-select-projects": "^2.0.0",
+ "jsdom": "20.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/rollup.config.js b/packages/testing-library/lynx-dom-testing-library/rollup.config.js
new file mode 100644
index 0000000000..00b1867d07
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/rollup.config.js
@@ -0,0 +1,5 @@
+const rollupConfig = require('kcd-scripts/dist/config/rollup.config');
+
+// the exports in this library should always be named for all formats.
+rollupConfig.output[0].exports = 'named';
+module.exports = rollupConfig;
diff --git a/packages/testing-library/lynx-dom-testing-library/rslib.config.ts b/packages/testing-library/lynx-dom-testing-library/rslib.config.ts
new file mode 100644
index 0000000000..47e3874488
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/rslib.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from '@rslib/core';
+
+export default defineConfig({
+ source: {
+ entry: {
+ index: './src/**',
+ },
+ },
+ lib: [
+ {
+ bundle: false,
+ format: 'cjs',
+ syntax: 'es2021',
+ },
+ ],
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/DOMElementFilter.ts b/packages/testing-library/lynx-dom-testing-library/src/DOMElementFilter.ts
new file mode 100644
index 0000000000..64e5638a0b
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/DOMElementFilter.ts
@@ -0,0 +1,266 @@
+/**
+ * Source: https://github.com/facebook/jest/blob/e7bb6a1e26ffab90611b2593912df15b69315611/packages/pretty-format/src/plugins/DOMElement.ts
+ */
+/* eslint-disable -- trying to stay as close to the original as possible */
+/* istanbul ignore file */
+import type { Config, NewPlugin, Printer, Refs } from 'pretty-format';
+
+function escapeHTML(str: string): string {
+ return str.replace(//g, '>');
+}
+// Return empty string if keys is empty.
+const printProps = (
+ keys: Array,
+ props: Record,
+ config: Config,
+ indentation: string,
+ depth: number,
+ refs: Refs,
+ printer: Printer,
+): string => {
+ const indentationNext = indentation + config.indent;
+ const colors = config.colors;
+ return keys
+ .map(key => {
+ const value = props[key];
+ let printed = printer(value, config, indentationNext, depth, refs);
+
+ if (typeof value !== 'string') {
+ if (printed.indexOf('\n') !== -1) {
+ printed = config.spacingOuter
+ + indentationNext
+ + printed
+ + config.spacingOuter
+ + indentation;
+ }
+ printed = '{' + printed + '}';
+ }
+
+ return (
+ config.spacingInner
+ + indentation
+ + colors.prop.open
+ + key
+ + colors.prop.close
+ + '='
+ + colors.value.open
+ + printed
+ + colors.value.close
+ );
+ })
+ .join('');
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants
+const NodeTypeTextNode = 3;
+
+// Return empty string if children is empty.
+const printChildren = (
+ children: Array,
+ config: Config,
+ indentation: string,
+ depth: number,
+ refs: Refs,
+ printer: Printer,
+): string =>
+ children
+ .map(child => {
+ const printedChild = typeof child === 'string'
+ ? printText(child, config)
+ : printer(child, config, indentation, depth, refs);
+
+ if (
+ printedChild === ''
+ && typeof child === 'object'
+ && child !== null
+ && (child as Node).nodeType !== NodeTypeTextNode
+ ) {
+ // A plugin serialized this Node to '' meaning we should ignore it.
+ return '';
+ }
+ return config.spacingOuter + indentation + printedChild;
+ })
+ .join('');
+
+const printText = (text: string, config: Config): string => {
+ const contentColor = config.colors.content;
+ return contentColor.open + escapeHTML(text) + contentColor.close;
+};
+
+const printComment = (comment: string, config: Config): string => {
+ const commentColor = config.colors.comment;
+ return (
+ commentColor.open
+ + ''
+ + commentColor.close
+ );
+};
+
+// Separate the functions to format props, children, and element,
+// so a plugin could override a particular function, if needed.
+// Too bad, so sad: the traditional (but unnecessary) space
+// in a self-closing tagColor requires a second test of printedProps.
+const printElement = (
+ type: string,
+ printedProps: string,
+ printedChildren: string,
+ config: Config,
+ indentation: string,
+): string => {
+ const tagColor = config.colors.tag;
+ return (
+ tagColor.open
+ + '<'
+ + type
+ + (printedProps
+ && tagColor.close
+ + printedProps
+ + config.spacingOuter
+ + indentation
+ + tagColor.open)
+ + (printedChildren
+ ? '>'
+ + tagColor.close
+ + printedChildren
+ + config.spacingOuter
+ + indentation
+ + tagColor.open
+ + ''
+ + type
+ : (printedProps && !config.min ? '' : ' ') + '/')
+ + '>'
+ + tagColor.close
+ );
+};
+
+const printElementAsLeaf = (type: string, config: Config): string => {
+ const tagColor = config.colors.tag;
+ return (
+ tagColor.open
+ + '<'
+ + type
+ + tagColor.close
+ + ' …'
+ + tagColor.open
+ + ' />'
+ + tagColor.close
+ );
+};
+
+const ELEMENT_NODE = 1;
+const TEXT_NODE = 3;
+const COMMENT_NODE = 8;
+const FRAGMENT_NODE = 11;
+
+const ELEMENT_REGEXP = /^((HTML|SVG)\w*)?Element$/;
+
+const isCustomElement = (val: any) => {
+ const { tagName } = val;
+ return Boolean(
+ (typeof tagName === 'string' && tagName.includes('-'))
+ || (typeof val.hasAttribute === 'function' && val.hasAttribute('is')),
+ );
+};
+
+const testNode = (val: any) => {
+ const constructorName = val.constructor.name;
+
+ const { nodeType } = val;
+
+ return (
+ (nodeType === ELEMENT_NODE
+ && (ELEMENT_REGEXP.test(constructorName) || isCustomElement(val)))
+ || (nodeType === TEXT_NODE && constructorName === 'Text')
+ || (nodeType === COMMENT_NODE && constructorName === 'Comment')
+ || (nodeType === FRAGMENT_NODE && constructorName === 'DocumentFragment')
+ );
+};
+
+export const test: NewPlugin['test'] = (val: any) =>
+ (val?.constructor?.name || isCustomElement(val)) && testNode(val);
+
+type HandledType = Element | Text | Comment | DocumentFragment;
+
+function nodeIsText(node: HandledType): node is Text {
+ return node.nodeType === TEXT_NODE;
+}
+
+function nodeIsComment(node: HandledType): node is Comment {
+ return node.nodeType === COMMENT_NODE;
+}
+
+function nodeIsFragment(node: HandledType): node is DocumentFragment {
+ return node.nodeType === FRAGMENT_NODE;
+}
+
+export default function createDOMElementFilter(
+ filterNode: (node: Node) => boolean,
+): NewPlugin {
+ return {
+ test: (val: any) =>
+ (val?.constructor?.name || isCustomElement(val)) && testNode(val),
+ serialize: (
+ node: HandledType,
+ config: Config,
+ indentation: string,
+ depth: number,
+ refs: Refs,
+ printer: Printer,
+ ) => {
+ if (nodeIsText(node)) {
+ return printText(node.data, config);
+ }
+
+ if (nodeIsComment(node)) {
+ return printComment(node.data, config);
+ }
+
+ const type = nodeIsFragment(node)
+ ? `DocumentFragment`
+ : node.tagName.toLowerCase();
+
+ if (++depth > config.maxDepth) {
+ return printElementAsLeaf(type, config);
+ }
+
+ return printElement(
+ type,
+ printProps(
+ nodeIsFragment(node)
+ ? []
+ : Array.from(node.attributes)
+ .map(attr => attr.name)
+ .sort(),
+ nodeIsFragment(node)
+ ? {}
+ : Array.from(node.attributes).reduce>(
+ (props, attribute) => {
+ props[attribute.name] = attribute.value;
+ return props;
+ },
+ {},
+ ),
+ config,
+ indentation + config.indent,
+ depth,
+ refs,
+ printer,
+ ),
+ printChildren(
+ Array.prototype.slice
+ .call(node.childNodes || node.children)
+ .filter(filterNode),
+ config,
+ indentation + config.indent,
+ depth,
+ refs,
+ printer,
+ ),
+ config,
+ indentation,
+ );
+ },
+ };
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/index.js b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/index.js
new file mode 100644
index 0000000000..fc8f1f0eb7
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/index.js
@@ -0,0 +1,173 @@
+import { JSDOM } from 'jsdom';
+import * as dtl from '..';
+
+test('works without a global dom', async () => {
+ const container = new JSDOM(`
+
+
+
+
+
+ `).window.document.body;
+ container.querySelector('#login-form').addEventListener('submit', e => {
+ e.preventDefault();
+ const { username, password } = e.target.elements;
+ setTimeout(() => {
+ const dataContainer = container.querySelector('#data-container');
+ const pre = container.ownerDocument.createElement('pre');
+ pre.dataset.testid = 'submitted-data';
+ pre.textContent = JSON.stringify({
+ username: username.value,
+ password: password.value,
+ });
+ dataContainer.appendChild(pre);
+ }, 20);
+ });
+
+ const fakeUser = { username: 'chucknorris', password: 'i need no password' };
+ const usernameField = dtl.getByLabelText(container, /username/i);
+ const passwordField = dtl.getByLabelText(container, /password/i);
+ usernameField.value = fakeUser.username;
+ passwordField.value = fakeUser.password;
+ const submitButton = dtl.getByText(container, /submit/i);
+ dtl.fireEvent.click(submitButton);
+ const submittedDataPre = await dtl.findByTestId(
+ container,
+ 'submitted-data',
+ {},
+ { container },
+ );
+ const data = JSON.parse(submittedDataPre.textContent);
+ expect(data).toEqual(fakeUser);
+});
+
+test('works without a browser context on a dom node (JSDOM Fragment)', () => {
+ const container = JSDOM.fragment(`
+
+
+
+
+
+ `);
+
+ expect(dtl.getByLabelText(container, /username/i)).toMatchInlineSnapshot(`
+
+ `);
+ expect(dtl.getByLabelText(container, /password/i)).toMatchInlineSnapshot(`
+
+ `);
+});
+
+test('byRole works without a global DOM', () => {
+ const {
+ window: {
+ document: { body: container },
+ },
+ } = new JSDOM(`
+
+
+
+
+
+ `);
+
+ expect(dtl.getByRole(container, 'button')).toMatchInlineSnapshot(`
+
+ `);
+});
+
+test('findBy works without a global DOM', async () => {
+ const { window } = new JSDOM(`
');
+
+ // process.env.COLORS is a string, so make sure we test it as such
+ process.env.COLORS = 'false';
+ expect(prettyDOM(container)).toMatchInlineSnapshot(`
+
[39m
+ `);
+ process.versions.node = originalNodeVersion;
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/screen.js b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/screen.js
new file mode 100644
index 0000000000..36894bb66b
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/screen.js
@@ -0,0 +1,8 @@
+import { screen } from '..';
+
+test('the screen export throws a helpful error message when no global document is accessible', () => {
+ expect(() => screen.getByText(/hello world/i))
+ .toThrowErrorMatchingInlineSnapshot(
+ `For queries bound to document.body a global document has to be available... Learn more: https://testing-library.com/s/screen-global-error`,
+ );
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/.eslintrc b/packages/testing-library/lynx-dom-testing-library/src/__tests__/.eslintrc
new file mode 100644
index 0000000000..e36a045650
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/.eslintrc
@@ -0,0 +1,7 @@
+{
+ "rules": {
+ // this rule doesn't properly detect that our custom `render` does not
+ // insert elements into the document.
+ "jest-dom/prefer-in-document": "off"
+ }
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/get-by-errors.js.snap b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/get-by-errors.js.snap
new file mode 100644
index 0000000000..42bc84aee0
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/get-by-errors.js.snap
@@ -0,0 +1,5 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`getByLabelText query will throw the custom error returned by config.getElementError 1`] = `My custom error: Unable to find a label with the text of: TEST QUERY`;
+
+exports[`getByText query will throw the custom error returned by config.getElementError 1`] = `My custom error: Unable to find an element with the text: TEST QUERY. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.`;
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/role-helpers.js.snap b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/role-helpers.js.snap
new file mode 100644
index 0000000000..75bbed7fa7
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/role-helpers.js.snap
@@ -0,0 +1,220 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`logRoles calls console.log with output from prettyRoles 1`] = `
+region:
+
+Name "a region":
+
+
+--------------------------------------------------
+link:
+
+Name "link":
+
+
+--------------------------------------------------
+navigation:
+
+Name "":
+
+
+--------------------------------------------------
+heading:
+
+Name "Main Heading":
+
+
+Name "Sub Heading":
+
+
+Name "Tertiary Heading":
+
+
+--------------------------------------------------
+article:
+
+Name "":
+
+
+--------------------------------------------------
+list:
+
+Name "":
+
',
+ );
+ const noStyle = getAllByText(/hello/i);
+ expect(noStyle).toHaveLength(2);
+ expect(noStyle[0].tagName).toBe('SCRIPT');
+ expect(noStyle[1].tagName).toBe('DIV');
+});
+
+test('the default value for `ignore` is used in errors', () => {
+ configure({ defaultIgnore: 'div' });
+
+ const { getByText } = render('
Hello
');
+
+ expect(() => getByText(/hello/i)).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an element with the text: /hello/i. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
+
+ Ignored nodes: comments, div
+
+ `);
+});
+
+test('get/query input element by current value', () => {
+ const { getByDisplayValue, queryByDisplayValue, getByTestId } =
+ renderIntoDocument(`
+
+
+
+ `);
+ expect(getByDisplayValue('Mercury').placeholder).toEqual('name');
+ expect(queryByDisplayValue('Mercury').placeholder).toEqual('name');
+
+ getByTestId('name').value = 'Norris';
+ expect(getByDisplayValue('Norris').placeholder).toEqual('name');
+ expect(queryByDisplayValue('Norris').placeholder).toEqual('name');
+
+ expect(queryByDisplayValue('Nor', { exact: false }).placeholder).toEqual(
+ 'name',
+ );
+});
+
+test('get/query select element by current value', () => {
+ const { getByDisplayValue, queryByDisplayValue, getByTestId } =
+ renderIntoDocument(`
+
+ `);
+
+ expect(getByDisplayValue('Alaska').id).toEqual('state-select');
+ expect(queryByDisplayValue('Alaska').id).toEqual('state-select');
+
+ getByTestId('state').value = 'AL';
+ expect(getByDisplayValue('Alabama').id).toEqual('state-select');
+ expect(queryByDisplayValue('Alabama').id).toEqual('state-select');
+});
+
+test('get/query textarea element by current value', () => {
+ const { getByDisplayValue, queryByDisplayValue, getByTestId } =
+ renderIntoDocument(`
+
+ `);
+
+ expect(getByDisplayValue('Hello').id).toEqual('content-textarea');
+ expect(queryByDisplayValue('Hello').id).toEqual('content-textarea');
+
+ getByTestId('content').value = 'World';
+ expect(getByDisplayValue('World').id).toEqual('content-textarea');
+ expect(queryByDisplayValue('World').id).toEqual('content-textarea');
+});
+
+test('can get a textarea with children', () => {
+ const { getByLabelText } = renderIntoDocument(`
+
+ `);
+ getByLabelText('Label');
+});
+
+test('can get a select with options', () => {
+ const { getByLabelText } = renderIntoDocument(`
+
+ `);
+ getByLabelText('Label');
+});
+
+test('can get an element with aria-labelledby when label has a child', () => {
+ const { getByLabelText } = render(`
+
+
+
+
+
+
+ `);
+ expect(getByLabelText('First Label', { selector: 'input' }).id).toBe(
+ '1st-input',
+ );
+ expect(getByLabelText('Second Label', { selector: 'input' }).id).toBe(
+ '2nd-input',
+ );
+});
+test('gets an element when there is an aria-labelledby a not found id', () => {
+ const { getByLabelText } = render(`
+
+
+
+
+
+ `);
+ expect(getByLabelText('Test').id).toBe('input-id');
+});
+
+test('return a proper error message when no label is found and there is an aria-labelledby a not found id', () => {
+ const { getByLabelText } = render(
+ '',
+ );
+
+ expect(() => getByLabelText('LucyRicardo'))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find a label with the text of: LucyRicardo
+
+ Ignored nodes: comments, script, style
+
+
+
+ `);
+});
+
+// https://github.com/testing-library/dom-testing-library/issues/723
+it('gets form controls by label text on IE and other legacy browsers', () => {
+ // Simulate lack of support for HTMLInputElement.prototype.labels
+ jest
+ .spyOn(HTMLInputElement.prototype, 'labels', 'get')
+ .mockReturnValue(undefined);
+
+ const { getByLabelText } = renderIntoDocument(`
+
+ `);
+ expect(getByLabelText('Label text').id).toBe('input-id');
+});
+
+// https://github.com/testing-library/dom-testing-library/issues/787
+it(`get the output element by it's label`, () => {
+ const { getByLabelText, rerender } = renderIntoDocument(`
+
+ `);
+ expect(getByLabelText('foo')).toBeInTheDocument();
+
+ rerender(`
+
+ `);
+
+ expect(getByLabelText('foo')).toBeInTheDocument();
+});
+
+// https://github.com/testing-library/dom-testing-library/issues/343#issuecomment-555385756
+it(`should get element by it's label when there are elements with same text`, () => {
+ const { getByLabelText } = renderIntoDocument(`
+
+ `);
+ expect(getByLabelText('test 1')).toBeInTheDocument();
+});
+
+// TODO: undesired behavior. It should ignore the same element: https://github.com/testing-library/dom-testing-library/pull/907#pullrequestreview-678736288
+test('ByText error message ignores not the same elements as configured in `ignore`', () => {
+ const { getByText } = renderIntoDocument(`
+
+
+ `);
+
+ expect(() =>
+ getByText('.css-selector', { selector: 'style', ignore: 'script' })
+ ).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an element with the text: .css-selector, which matches selector 'style'. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+
+
+
+
+ `);
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/event-wrapper.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/event-wrapper.js
new file mode 100644
index 0000000000..860cf92662
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/event-wrapper.js
@@ -0,0 +1,31 @@
+import { configure, fireEvent } from '..';
+
+let originalConfig;
+
+beforeEach(() => {
+ configure(oldConfig => {
+ originalConfig = oldConfig;
+ return null;
+ });
+});
+
+afterEach(() => {
+ jest.clearAllMocks();
+ configure(originalConfig);
+});
+
+test('fireEvent calls the eventWrapper', () => {
+ const mockEventWrapper = jest.fn();
+ configure(() => {
+ return { eventWrapper: mockEventWrapper };
+ });
+ const el = document.createElement('div');
+ fireEvent.click(el);
+ expect(mockEventWrapper).toHaveBeenCalledWith(expect.any(Function));
+ expect(mockEventWrapper).toHaveBeenCalledTimes(1);
+});
+
+test('fireEvent has a default eventWrapper', () => {
+ const el = document.createElement('div');
+ expect(() => fireEvent.click(el)).not.toThrow();
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/events.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/events.js
new file mode 100644
index 0000000000..74584f4185
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/events.js
@@ -0,0 +1,448 @@
+import { eventMap, eventAliasMap } from '../event-map';
+import { fireEvent, createEvent } from '..';
+
+const eventTypes = [
+ {
+ type: 'Clipboard',
+ events: ['copy', 'cut', 'paste'],
+ elementType: 'input',
+ },
+ {
+ type: 'Composition',
+ events: ['compositionEnd', 'compositionStart', 'compositionUpdate'],
+ elementType: 'input',
+ },
+ {
+ type: 'Keyboard',
+ events: ['keyDown', 'keyPress', 'keyUp'],
+ elementType: 'input',
+ },
+ {
+ type: 'Focus',
+ events: ['focus', 'blur', 'focusIn', 'focusOut'],
+ elementType: 'input',
+ },
+ {
+ type: 'Input',
+ events: ['change', 'input', 'invalid'],
+ elementType: 'input',
+ },
+ {
+ type: 'Form',
+ events: ['submit', 'reset'],
+ elementType: 'form',
+ },
+ {
+ type: 'Mouse',
+ events: [
+ 'click',
+ 'contextMenu',
+ 'dblClick',
+ 'drag',
+ 'dragEnd',
+ 'dragEnter',
+ 'dragExit',
+ 'dragLeave',
+ 'dragOver',
+ 'dragStart',
+ 'drop',
+ 'mouseDown',
+ 'mouseEnter',
+ 'mouseLeave',
+ 'mouseMove',
+ 'mouseOut',
+ 'mouseOver',
+ 'mouseUp',
+ ],
+ elementType: 'button',
+ },
+ {
+ type: 'Selection',
+ events: ['select'],
+ elementType: 'input',
+ },
+ {
+ type: 'Touch',
+ events: ['touchCancel', 'touchEnd', 'touchMove', 'touchStart'],
+ elementType: 'button',
+ },
+ {
+ type: 'UI',
+ events: ['scroll'],
+ elementType: 'div',
+ },
+ {
+ type: '',
+ events: ['load', 'error'],
+ elementType: 'img',
+ },
+ {
+ type: 'Window',
+ events: ['offline', 'online', 'pageHide', 'pageShow'],
+ elementType: 'window',
+ },
+ {
+ type: '',
+ events: ['load', 'error'],
+ elementType: 'script',
+ },
+ {
+ type: 'Wheel',
+ events: ['wheel'],
+ elementType: 'div',
+ },
+ {
+ type: 'Media',
+ events: [
+ 'abort',
+ 'canPlay',
+ 'canPlayThrough',
+ 'durationChange',
+ 'emptied',
+ 'encrypted',
+ 'ended',
+ 'error',
+ 'loadedData',
+ 'loadedMetadata',
+ 'loadStart',
+ 'pause',
+ 'play',
+ 'playing',
+ 'progress',
+ 'rateChange',
+ 'seeked',
+ 'seeking',
+ 'stalled',
+ 'suspend',
+ 'timeUpdate',
+ 'volumeChange',
+ 'waiting',
+ ],
+ elementType: 'video',
+ },
+ {
+ type: 'Animation',
+ events: ['animationStart', 'animationEnd', 'animationIteration'],
+ elementType: 'div',
+ },
+ {
+ type: 'Transition',
+ events: [
+ 'transitionCancel',
+ 'transitionEnd',
+ 'transitionRun',
+ 'transitionStart',
+ ],
+ elementType: 'div',
+ },
+ {
+ type: 'Pointer',
+ events: [
+ 'pointerOver',
+ 'pointerEnter',
+ 'pointerDown',
+ 'pointerMove',
+ 'pointerUp',
+ 'pointerCancel',
+ 'pointerOut',
+ 'pointerLeave',
+ 'gotPointerCapture',
+ 'lostPointerCapture',
+ ],
+ elementType: 'div',
+ },
+];
+
+const allEvents = Object.keys(eventMap);
+
+const bubblingEvents = allEvents.filter(
+ eventName => eventMap[eventName].defaultInit.bubbles,
+);
+
+const composedEvents = allEvents.filter(
+ eventName => eventMap[eventName].defaultInit.composed,
+);
+
+const nonBubblingEvents = allEvents.filter(
+ eventName => !bubblingEvents.includes(eventName),
+);
+
+const nonComposedEvents = allEvents.filter(
+ eventName => !composedEvents.includes(eventName),
+);
+
+eventTypes.forEach(({ type, events, elementType }) => {
+ describe(`${type} Events`, () => {
+ events.forEach(eventName => {
+ it(`fires ${eventName}`, () => {
+ const node = document.createElement(elementType);
+ const spy = jest.fn();
+ node.addEventListener(eventName.toLowerCase(), spy);
+ fireEvent[eventName](node);
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
+
+it('fires resize', () => {
+ const node = document.defaultView;
+ const spy = jest.fn();
+ node.addEventListener('resize', spy, { once: true });
+ fireEvent.resize(node);
+ expect(spy).toHaveBeenCalledTimes(1);
+});
+
+describe(`Bubbling Events`, () => {
+ bubblingEvents.forEach(event =>
+ it(`bubbles ${event}`, () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener(event.toLowerCase(), spy);
+
+ const innerNode = document.createElement('div');
+ node.appendChild(innerNode);
+
+ fireEvent[event](innerNode);
+ expect(spy).toHaveBeenCalledTimes(1);
+ })
+ );
+
+ nonBubblingEvents.forEach(event =>
+ it(`doesn't bubble ${event}`, () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener(event.toLowerCase(), spy);
+
+ const innerNode = document.createElement('div');
+ node.appendChild(innerNode);
+
+ fireEvent[event](innerNode);
+ expect(spy).not.toHaveBeenCalled();
+ })
+ );
+});
+
+describe(`Composed Events`, () => {
+ composedEvents.forEach(event =>
+ it(`${event} crosses shadow DOM boundary`, () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener(event.toLowerCase(), spy);
+
+ const shadowRoot = node.attachShadow({ mode: 'closed' });
+ const innerNode = document.createElement('div');
+ shadowRoot.appendChild(innerNode);
+
+ fireEvent[event](innerNode);
+ expect(spy).toHaveBeenCalledTimes(1);
+ })
+ );
+
+ nonComposedEvents.forEach(event =>
+ it(`${event} does not cross shadow DOM boundary`, () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener(event.toLowerCase(), spy);
+
+ const shadowRoot = node.attachShadow({ mode: 'closed' });
+ const innerNode = document.createElement('div');
+ shadowRoot.appendChild(innerNode);
+
+ fireEvent[event](innerNode);
+ expect(spy).not.toHaveBeenCalled();
+ })
+ );
+});
+
+describe(`Aliased Events`, () => {
+ Object.keys(eventAliasMap).forEach(eventAlias => {
+ it(`fires ${eventAlias}`, () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener(eventAliasMap[eventAlias].toLowerCase(), spy);
+
+ fireEvent[eventAlias](node);
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
+
+test('assigns target properties', () => {
+ const node = document.createElement('input');
+ const spy = jest.fn();
+ const value = 'a';
+ node.addEventListener('change', spy);
+ fireEvent.change(node, { target: { value } });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(node).toHaveValue(value);
+});
+
+test('assigns selection-related target properties', () => {
+ const node = document.createElement('input');
+ const spy = jest.fn();
+ const value = 'ab';
+ const selectionStart = 1;
+ const selectionEnd = 2;
+ node.addEventListener('change', spy);
+ fireEvent.change(node, { target: { value, selectionStart, selectionEnd } });
+ expect(node).toHaveValue(value);
+ expect(node.selectionStart).toBe(selectionStart);
+ expect(node.selectionEnd).toBe(selectionEnd);
+});
+
+test('assigning a value to a target that cannot have a value throws an error', () => {
+ const node = document.createElement('div');
+ expect(() => fireEvent.change(node, { target: { value: 'a' } }))
+ .toThrowErrorMatchingInlineSnapshot(
+ `The given element does not have a value setter`,
+ );
+});
+
+test('assigning the files property on an input', () => {
+ const node = document.createElement('input');
+ const file = new document.defaultView.File(['(⌐□_□)'], 'chucknorris.png', {
+ type: 'image/png',
+ });
+ fireEvent.change(node, { target: { files: [file] } });
+ expect(node.files).toEqual([file]);
+});
+
+test('assigns dataTransfer properties', () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener('dragover', spy);
+ fireEvent.dragOver(node, { dataTransfer: { dropEffect: 'move' } });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy.mock.calls[0][0]).toHaveProperty(
+ 'dataTransfer.dropEffect',
+ 'move',
+ );
+});
+
+test('assigns dataTransfer non-enumerable properties', () => {
+ window.DataTransfer = function DataTransfer() {};
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ const item = {};
+ const dataTransfer = new window.DataTransfer();
+
+ Object.defineProperty(dataTransfer, 'items', {
+ value: [item],
+ enumerable: false,
+ });
+ node.addEventListener('drop', spy);
+ fireEvent.drop(node, { dataTransfer });
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy.mock.calls[0][0].dataTransfer.items).toHaveLength(1);
+
+ delete window.DataTransfer;
+});
+
+test('assigning the files property on dataTransfer', () => {
+ const node = document.createElement('div');
+ const file = new document.defaultView.File(['(⌐□_□)'], 'chucknorris.png', {
+ type: 'image/png',
+ });
+ const spy = jest.fn();
+ node.addEventListener('drop', spy);
+ fireEvent.drop(node, { dataTransfer: { files: [file] } });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy.mock.calls[0][0]).toHaveProperty('dataTransfer.files', [file]);
+});
+
+test('assigns clipboardData properties', () => {
+ const node = document.createElement('div');
+ const spy = jest.fn();
+ node.addEventListener('paste', spy);
+ const clipboardData = {
+ dropEffect: 'none',
+ effectAllowed: 'uninitialized',
+ files: [],
+ items: [
+ {
+ kind: 'string',
+ type: 'text/plain',
+ file: {
+ getAsFile() {
+ return null;
+ },
+ },
+ },
+ ],
+ types: ['text/plain'],
+ getData() {
+ return 'example';
+ },
+ };
+ fireEvent.paste(node, { clipboardData });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy.mock.calls[0][0].clipboardData).toBe(clipboardData);
+ expect(clipboardData.items[0].file.getAsFile()).toBeNull();
+ expect(clipboardData.getData('text')).toBe('example');
+});
+
+test('fires events on Window', () => {
+ const messageSpy = jest.fn();
+ window.addEventListener('message', messageSpy);
+ fireEvent(window, new window.MessageEvent('message', { data: 'hello' }));
+ expect(messageSpy).toHaveBeenCalledTimes(1);
+ window.removeEventListener('message', messageSpy);
+});
+
+test('fires history popstate event on Window', () => {
+ const popStateSpy = jest.fn();
+ window.addEventListener('popstate', popStateSpy);
+ fireEvent.popState(window, {
+ location: 'http://www.example.com/?page=1',
+ state: { page: 1 },
+ });
+ expect(popStateSpy).toHaveBeenCalledTimes(1);
+ window.removeEventListener('popstate', popStateSpy);
+});
+
+test('fires shortcut events on Window', () => {
+ const clickSpy = jest.fn();
+ window.addEventListener('click', clickSpy);
+ fireEvent.click(window);
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ window.removeEventListener('click', clickSpy);
+});
+
+test('throws a useful error message when firing events on non-existent nodes', () => {
+ expect(() => fireEvent(undefined, new MouseEvent('click'))).toThrow(
+ 'Unable to fire a "click" event - please provide a DOM element.',
+ );
+});
+
+test('throws a useful error message when firing events on non-existent nodes (shortcut)', () => {
+ expect(() => fireEvent.click(undefined)).toThrow(
+ 'Unable to fire a "click" event - please provide a DOM element.',
+ );
+});
+
+test('throws a useful error message when firing non-events', () => {
+ expect(() => fireEvent(document.createElement('div'), undefined)).toThrow(
+ 'Unable to fire an event - please provide an event object.',
+ );
+});
+
+test('fires events on Document', () => {
+ const keyDownSpy = jest.fn();
+ document.addEventListener('keydown', keyDownSpy);
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(keyDownSpy).toHaveBeenCalledTimes(1);
+ document.removeEventListener('keydown', keyDownSpy);
+});
+
+test('can create generic events', () => {
+ const el = document.createElement('div');
+ const eventName = 'my-custom-event';
+ const handler = jest.fn();
+ el.addEventListener(eventName, handler);
+ const event = createEvent(eventName, el);
+ fireEvent(el, event);
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(event);
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/fake-timers.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/fake-timers.js
new file mode 100644
index 0000000000..4e5a4a89d8
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/fake-timers.js
@@ -0,0 +1,108 @@
+import { waitFor, waitForElementToBeRemoved } from '..';
+import { render } from './helpers/test-utils';
+
+async function runWaitFor({ time = 300 } = {}, options) {
+ const response = 'data';
+ const doAsyncThing = () =>
+ new Promise(r => setTimeout(() => r(response), time));
+ let result;
+ doAsyncThing().then(r => (result = r));
+
+ await waitFor(() => expect(result).toBe(response), options);
+}
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+test('real timers', async () => {
+ // the only difference when not using fake timers is this test will
+ // have to wait the full length of the timeout
+ await runWaitFor();
+});
+
+test('legacy', async () => {
+ jest.useFakeTimers('legacy');
+ await runWaitFor();
+});
+
+test('modern', async () => {
+ jest.useFakeTimers();
+ await runWaitFor();
+});
+
+test('fake timer timeout', async () => {
+ jest.useFakeTimers();
+ await expect(
+ waitFor(
+ () => {
+ throw new Error('always throws');
+ },
+ { timeout: 10, onTimeout: e => e },
+ ),
+ ).rejects.toMatchInlineSnapshot(`[Error: always throws]`);
+});
+
+test('times out after 1000ms by default', async () => {
+ const startReal = performance.now();
+ jest.useFakeTimers();
+ const { container } = render(``);
+ const startFake = performance.now();
+ // there's a bug with this rule here...
+ // eslint-disable-next-line jest/valid-expect
+ await expect(
+ waitForElementToBeRemoved(() => container, { onTimeout: e => e }),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `Timed out in waitForElementToBeRemoved.`,
+ );
+ // NOTE: this assertion ensures that the timeout runs in the declared (fake) clock.
+ expect(performance.now() - startFake).toBeGreaterThanOrEqual(1000);
+ jest.useRealTimers();
+ // NOTE: this assertion ensures that the timeout runs in the declared (fake) clock
+ // while in real time the time was only a fraction since the real clock is only bound by the CPU.
+ // So 20ms is really just an approximation on how long the CPU needs to execute our code.
+ // If people want to timeout in real time they should rely on their test runners.
+ expect(performance.now() - startReal).toBeLessThanOrEqual(20);
+});
+
+test('recursive timers do not cause issues', async () => {
+ jest.useFakeTimers();
+ let recurse = true;
+ function startTimer() {
+ setTimeout(() => {
+ // eslint-disable-next-line jest/no-conditional-in-test -- false-positive
+ if (recurse) startTimer();
+ }, 1);
+ }
+
+ startTimer();
+ await runWaitFor({ time: 800 }, { timeout: 900 });
+
+ recurse = false;
+});
+
+test('legacy fake timers do waitFor requestAnimationFrame', async () => {
+ jest.useFakeTimers('legacy');
+
+ let exited = false;
+ requestAnimationFrame(() => {
+ exited = true;
+ });
+
+ await waitFor(() => {
+ expect(exited).toBe(true);
+ });
+});
+
+test('modern fake timers do waitFor requestAnimationFrame', async () => {
+ jest.useFakeTimers('modern');
+
+ let exited = false;
+ requestAnimationFrame(() => {
+ exited = true;
+ });
+
+ await waitFor(() => {
+ expect(exited).toBe(true);
+ });
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/get-by-errors.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/get-by-errors.js
new file mode 100644
index 0000000000..d8c6230db4
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/get-by-errors.js
@@ -0,0 +1,119 @@
+import cases from 'jest-in-case';
+import { screen } from '..';
+import { configure, getConfig } from '../config';
+import { render } from './helpers/test-utils';
+
+const originalConfig = getConfig();
+beforeEach(() => {
+ configure(originalConfig);
+});
+
+cases(
+ 'getBy* queries throw an error when there are multiple elements returned',
+ ({ name, query, html }) => {
+ const utils = render(html);
+ expect(() => utils[name](query)).toThrow(/multiple elements/i);
+ },
+ {
+ getByLabelText: {
+ query: /his/,
+ html: ``,
+ },
+ getByPlaceholderText: {
+ query: /his/,
+ html: ``,
+ },
+ getByText: {
+ query: /his/,
+ html: `
[39m
+
+ "/home/john/projects/sample-error/error-example.js:7:14
+ 5 | document.createTextNode('Hello World!')
+ 6 | )
+ > 7 | screen.debug()
+ | ^
+ "
+
+ `);
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/matches.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/matches.js
new file mode 100644
index 0000000000..0ae43af035
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/matches.js
@@ -0,0 +1,55 @@
+import { fuzzyMatches, matches } from '../matches';
+
+// unit tests for text match utils
+
+const node = null;
+const normalizer = str => str;
+
+test('matchers accept strings', () => {
+ expect(matches('ABC', node, 'ABC', normalizer)).toBe(true);
+ expect(fuzzyMatches('ABC', node, 'ABC', normalizer)).toBe(true);
+});
+
+test('matchers accept regex', () => {
+ expect(matches('ABC', node, /ABC/, normalizer)).toBe(true);
+ expect(fuzzyMatches('ABC', node, /ABC/, normalizer)).toBe(true);
+});
+
+// https://stackoverflow.com/questions/1520800/why-does-a-regexp-with-global-flag-give-wrong-results
+test('a regex with the global flag consistently (re-)finds a match', () => {
+ const regex = /ABC/g;
+ const spy = jest.spyOn(console, 'warn').mockImplementation();
+
+ expect(matches('ABC', node, regex, normalizer)).toBe(true);
+ expect(fuzzyMatches('ABC', node, regex, normalizer)).toBe(true);
+
+ expect(spy).toBeCalledTimes(2);
+ expect(spy).toHaveBeenCalledWith(
+ `To match all elements we had to reset the lastIndex of the RegExp because the global flag is enabled. We encourage to remove the global flag from the RegExp.`,
+ );
+
+ console.warn.mockClear();
+});
+
+test('matchers accept functions', () => {
+ expect(matches('ABC', node, text => text === 'ABC', normalizer)).toBe(true);
+ expect(fuzzyMatches('ABC', node, text => text === 'ABC', normalizer)).toBe(
+ true,
+ );
+});
+
+test('matchers return false if text to match is not a string', () => {
+ expect(matches(null, node, 'ABC', normalizer)).toBe(false);
+ expect(fuzzyMatches(null, node, 'ABC', normalizer)).toBe(false);
+});
+
+test('matchers throw on invalid matcher inputs', () => {
+ expect(() => matches('ABC', node, null, normalizer))
+ .toThrowErrorMatchingInlineSnapshot(
+ `It looks like null was passed instead of a matcher. Did you do something like getByText(null)?`,
+ );
+ expect(() => fuzzyMatches('ABC', node, undefined, normalizer))
+ .toThrowErrorMatchingInlineSnapshot(
+ `It looks like undefined was passed instead of a matcher. Did you do something like getByText(undefined)?`,
+ );
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/misc.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/misc.js
new file mode 100644
index 0000000000..ace93adf99
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/misc.js
@@ -0,0 +1,15 @@
+import { queryByAttribute } from '..';
+import { render } from './helpers/test-utils';
+
+// we used to use queryByAttribute internally, but we don't anymore. Some people
+// use it as an undocumented part of the API, so we'll keep it around.
+test('queryByAttribute', () => {
+ const { container } = render(
+ '',
+ );
+ expect(queryByAttribute('data-foo', container, 'bar')).not.toBeNull();
+ expect(queryByAttribute('blah', container, 'sup')).toBeNull();
+ expect(() => queryByAttribute('data-foo', container, /bar/)).toThrow(
+ /multiple/,
+ );
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/pretty-dom.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/pretty-dom.js
new file mode 100644
index 0000000000..531d065834
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/pretty-dom.js
@@ -0,0 +1,149 @@
+/* global globalThis */
+import { prettyDOM as prettyDOMImpl } from '../pretty-dom';
+import { render, renderIntoDocument } from './helpers/test-utils';
+
+function prettyDOM(...args) {
+ let originalProcess;
+ // this shouldn't be defined in this environment in the first place
+ if (typeof process === 'undefined') {
+ throw new Error('process is no longer defined. Remove this setup code.');
+ } else {
+ originalProcess = process;
+ delete globalThis.process;
+ }
+
+ try {
+ return prettyDOMImpl(...args);
+ } finally {
+ globalThis.process = originalProcess;
+ }
+}
+
+test('prettyDOM prints out the given DOM element tree', () => {
+ const { container } = render('
`);
+ expect(() => getByRole('article')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "article"
+
+ Here are the accessible roles:
+
+ heading:
+
+ Name "Hi":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+ Hi
+
+
+ `);
+});
+
+test('when hidden: true logs available roles when it fails', () => {
+ const { getByRole } = render(`
Hi
`);
+ expect(() => getByRole('article', { hidden: true }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an element with the role "article"
+
+ Here are the available roles:
+
+ heading:
+
+ Name "Hi":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+
+ Hi
+
+
+
+ `);
+});
+
+test('logs error when there are no accessible roles', () => {
+ const { getByRole } = render('');
+ expect(() => getByRole('article')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "article"
+
+ There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the \`hidden\` option to \`true\`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole
+
+ Ignored nodes: comments, script, style
+
+
+
+ `);
+});
+
+test('logs a different error if inaccessible roles should be included', () => {
+ const { getByRole } = render('');
+ expect(() => getByRole('article', { hidden: true }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an element with the role "article"
+
+ There are no available roles.
+
+ Ignored nodes: comments, script, style
+
+
+
+ `);
+});
+
+test('by default excludes elements that have the html hidden attribute or any of their parents', () => {
+ const { getByRole } = render('
');
+
+ expect(() => getByRole('list')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "list"
+
+ There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the \`hidden\` option to \`true\`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+ `);
+});
+
+test('by default excludes elements which have display: none or any of their parents', () => {
+ const { getByRole } = render(
+ '
',
+ );
+
+ expect(() => getByRole('list')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "list"
+
+ There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the \`hidden\` option to \`true\`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+ `);
+});
+
+test('by default excludes elements which have visibility hidden', () => {
+ // works in jsdom < 15.2 only when the actual element in question has this
+ // css property. only jsdom@^15.2 implements inheritance for `visibility`
+ const { getByRole } = render('
');
+
+ expect(() => getByRole('list')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "list"
+
+ There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the \`hidden\` option to \`true\`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+ `);
+});
+
+test('by default excludes elements which have aria-hidden="true" or any of their parents', () => {
+ // > if it, or any of its ancestors [...] have their aria-hidden attribute value set to true.
+ // -- https://www.w3.org/TR/wai-aria/#aria-hidden
+ // > In other words, aria-hidden="true" on a parent overrides aria-hidden="false" on descendants.
+ // -- https://www.w3.org/TR/core-aam-1.1/#exclude_elements2
+ const { getByRole } = render(
+ '
',
+ );
+
+ expect(() => getByRole('list')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "list"
+
+ There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the \`hidden\` option to \`true\`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+ `);
+});
+
+test('considers the computed visibility style not the parent', () => {
+ // this behavior deviates from the spec which includes "any descendant"
+ // if visibility is hidden. However, chrome a11y tree and nvda will include
+ // the following markup. This behavior might change depending on how
+ // https://github.com/w3c/aria/issues/1055 is resolved.
+ const { getByRole } = render(
+ '
',
+ );
+
+ expect(getByRole('list')).not.toBeNull();
+});
+
+test('can include inaccessible roles', () => {
+ // this behavior deviates from the spec which includes "any descendant"
+ // if visibility is hidden. However, chrome a11y tree and nvda will include
+ // the following markup. This behavior might change depending on how
+ // https://github.com/w3c/aria/issues/1055 is resolved.
+ const { getByRole } = render('
`);
+
+ expect(() => getByRole('heading', { name: 'something that does not match' }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "heading" and name "something that does not match"
+
+ Here are the accessible roles:
+
+ heading:
+
+ Name "Sign up":
+
+
+ --------------------------------------------------
+ emphasis:
+
+ Name "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
`);
+
+ expect(() => getByRole('heading', { name: /something that does not match/ }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "heading" and name \`/something that does not match/\`
+
+ Here are the accessible roles:
+
+ heading:
+
+ Name "Sign up":
+
+
+ --------------------------------------------------
+ emphasis:
+
+ Name "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+ Sign
+
+ up
+
+
+
+ `);
+
+ expect(() => getByRole('heading', { name: () => false }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "heading" and name \`() => false\`
+
+ Here are the accessible roles:
+
+ heading:
+
+ Name "Sign up":
+
+
+ --------------------------------------------------
+ emphasis:
+
+ Name "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+ Sign
+
+ up
+
+
+
+ `);
+});
+
+test('does not include the container in the queryable roles', () => {
+ const { getByRole } = render(``, {
+ container: document.createElement('ul'),
+ });
+ expect(() => getByRole('list')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "list"
+
+ Here are the accessible roles:
+
+ listitem:
+
+ Name "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+
+ `);
+});
+
+test('has no useful error message in findBy', async () => {
+ const { findByRole } = render(``);
+
+ await expect(findByRole('option', { timeout: 1 })).rejects.toThrow(
+ 'Unable to find role="option"',
+ );
+});
+
+test('findBy error message for missing elements contains a name hint', async () => {
+ const { findByRole } = render(``);
+
+ await expect(findByRole('button', { name: 'Submit' })).rejects.toThrow(
+ 'Unable to find role="button" and name "Submit"',
+ );
+});
+
+test('explicit role is most specific', () => {
+ const { getByRole } = renderIntoDocument(
+ ``,
+ );
+
+ expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "button"
+
+ Here are the accessible roles:
+
+ tab:
+
+ Name "my-tab":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+
+ `);
+});
+
+test('accessible regex name in error message for multiple found', () => {
+ const { getByRole } = render(
+ ``,
+ );
+
+ expect(() => getByRole('button', { name: /value/i }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Found multiple elements with the role "button" and name \`/value/i\`
+
+ Here are the matching elements:
+
+ Ignored nodes: comments, script, style
+
+
+ Ignored nodes: comments, script, style
+
+
+ Ignored nodes: comments, script, style
+
+
+ (If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+ `);
+});
+
+test('accessible string name in error message for multiple found', () => {
+ const { getByRole } = render(
+ ``,
+ );
+
+ expect(() => getByRole('button', { name: 'Submit' }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Found multiple elements with the role "button" and name "Submit"
+
+ Here are the matching elements:
+
+ Ignored nodes: comments, script, style
+
+
+ Ignored nodes: comments, script, style
+
+
+ Ignored nodes: comments, script, style
+
+
+ (If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+ `);
+});
+
+test('matching elements in error for multiple found', () => {
+ const { getByRole } = render(
+ `
Wrong role
`,
+ );
+
+ expect(() => getByRole('button', { name: /value/i }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Found multiple elements with the role "button" and name \`/value/i\`
+
+ Here are the matching elements:
+
+ Ignored nodes: comments, script, style
+
+
+ Ignored nodes: comments, script, style
+
+
+ (If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).
+
+ Ignored nodes: comments, script, style
+
');
+ expect(getByRole('list')).not.toBeNull();
+ });
+
+ test('can be configured to consider ::before and ::after for accessible names which logs errors in jsdom', () => {
+ try {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ configure({ computedStyleSupportsPseudoElements: true });
+ const { queryByRole } = render('');
+
+ queryByRole('button', { name: 'Hello, Dave!' });
+
+ expect(console.error).toHaveBeenCalledTimes(2);
+ expect(console.error.mock.calls[0][0].message).toMatch(
+ 'Not implemented: window.computedStyle(elt, pseudoElt)',
+ );
+ expect(console.error.mock.calls[1][0].message).toMatch(
+ 'Not implemented: window.computedStyle(elt, pseudoElt)',
+ );
+ } finally {
+ jest.restoreAllMocks();
+ }
+ });
+});
+
+test('should find the input using type property instead of attribute', () => {
+ const { getByRole } = render('');
+ expect(getByRole('textbox')).not.toBeNull();
+});
+
+test('can be filtered by accessible description', () => {
+ const targetedNotificationMessage = 'Your session is about to expire!';
+ const { getByRole } = renderIntoDocument(
+ `
+
+
+
+
You have unread emails
+
+
+
+
${targetedNotificationMessage}
+
+
`,
+ );
+
+ const notification = getByRole('alertdialog', {
+ description: targetedNotificationMessage,
+ });
+
+ expect(notification).not.toBeNull();
+ expect(notification).toHaveTextContent(targetedNotificationMessage);
+
+ expect(
+ getQueriesForElement(notification).getByRole('button', { name: 'Close' }),
+ ).not.toBeNull();
+});
+
+test('error should include description when filtering and no results are found', () => {
+ const targetedNotificationMessage = 'Your session is about to expire!';
+ const { getByRole } = renderIntoDocument(
+ `
${targetedNotificationMessage}
`,
+ );
+
+ expect(() =>
+ getByRole('alertdialog', { description: targetedNotificationMessage })
+ ).toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "alertdialog" and description "Your session is about to expire!"
+
+ Here are the accessible roles:
+
+ dialog:
+
+ Name "":
+ Description "Your session is about to expire!":
+
+
+ --------------------------------------------------
+ button:
+
+ Name "Close":
+ Description "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
`,
+ );
+
+ expect(() => getByRole('alertdialog', { description: /unknown description/ }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "alertdialog" and description \`/unknown description/\`
+
+ Here are the accessible roles:
+
+ alertdialog:
+
+ Name "":
+ Description "Your session is about to expire!":
+
+
+ --------------------------------------------------
+ button:
+
+ Name "Close":
+ Description "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+
+ Your session is about to expire!
+
+
+
+ `);
+
+ expect(() => getByRole('alertdialog', { description: () => false }))
+ .toThrowErrorMatchingInlineSnapshot(`
+ Unable to find an accessible element with the role "alertdialog" and description \`() => false\`
+
+ Here are the accessible roles:
+
+ alertdialog:
+
+ Name "":
+ Description "Your session is about to expire!":
+
+
+ --------------------------------------------------
+ button:
+
+ Name "Close":
+ Description "":
+
+
+ --------------------------------------------------
+
+ Ignored nodes: comments, script, style
+
+
+
+
+
+
+ Your session is about to expire!
+
+
+
+ `);
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/screen.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/screen.js
new file mode 100644
index 0000000000..ed748f9d1f
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/screen.js
@@ -0,0 +1,123 @@
+import jestSnapshotSerializerAnsi from 'jest-snapshot-serializer-ansi';
+import { screen } from '..';
+import { render, renderIntoDocument } from './helpers/test-utils';
+
+expect.addSnapshotSerializer(jestSnapshotSerializerAnsi);
+
+// Since screen.debug internally calls getUserCodeFrame, we mock it so it doesn't affect these tests
+jest.mock('../get-user-code-frame', () => ({
+ getUserCodeFrame: () => '',
+}));
+
+beforeEach(() => {
+ jest.spyOn(console, 'log').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ console.log.mockRestore();
+});
+
+test('exposes queries that are attached to document.body', async () => {
+ renderIntoDocument(`
hello world
`);
+ screen.getByText(/hello world/i);
+ await screen.findByText(/hello world/i);
+ expect(screen.queryByText(/hello world/i)).not.toBeNull();
+});
+
+test('logs Playground URL that are attached to document.body', () => {
+ renderIntoDocument(`
hello world
`);
+ screen.logTestingPlaygroundURL();
+ expect(console.log).toHaveBeenCalledTimes(1);
+ expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Open this URL in your browser
+
+ https://testing-playground.com/#markup=DwEwlgbgfAFgpgGwQewAQHdkCcEmAenGiA
+ `);
+});
+
+test('logs messsage when element is empty', () => {
+ screen.logTestingPlaygroundURL(document.createElement('div'));
+ expect(console.log).toHaveBeenCalledTimes(1);
+ expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(
+ `The provided element doesn't have any children.`,
+ );
+});
+
+test('logs messsage when element is not a valid HTML', () => {
+ screen.logTestingPlaygroundURL(null);
+ expect(console.log).toHaveBeenCalledTimes(1);
+ expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(
+ `The element you're providing isn't a valid DOM element.`,
+ );
+ console.log.mockClear();
+ screen.logTestingPlaygroundURL({});
+ expect(console.log).toHaveBeenCalledTimes(1);
+ expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(
+ `The element you're providing isn't a valid DOM element.`,
+ );
+});
+
+test('logs Playground URL that are passed as element', () => {
+ screen.logTestingPlaygroundURL(render(`
Sign up
`).container);
+ expect(console.log).toHaveBeenCalledTimes(1);
+ expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Open this URL in your browser
+
+ https://testing-playground.com/#markup=DwCwjAfAyglg5gOwATAKYFsIFcAOwD0GEB4EQA
+ `);
+});
+
+test('returns Playground URL that are passed as element', () => {
+ const playGroundUrl = screen.logTestingPlaygroundURL(
+ render(`
+
+ `);
+
+ expect(
+ getSuggestedQuery(container.querySelector('input'), 'get', 'labelText'),
+ ).toMatchObject({
+ queryName: 'LabelText',
+ queryMethod: 'getByLabelText',
+ queryArgs: [/one/i],
+ variant: 'get',
+ });
+});
+
+test('should not suggest or warn about hidden element when suggested query is already used.', () => {
+ console.warn.mockImplementation(() => {});
+
+ renderIntoDocument(`
+
+ `);
+
+ expect(() => screen.getByRole('textbox', { hidden: true })).not
+ .toThrowError();
+ expect(console.warn).not.toHaveBeenCalled();
+});
+test('should suggest and warn about if element is not in the accessibility tree', () => {
+ console.warn.mockImplementation(() => {});
+
+ renderIntoDocument(`
+
+ `);
+
+ expect(() => screen.getByTestId('foo', { hidden: true })).toThrowError(
+ /getByRole\('textbox', \{ hidden: true \}\)/,
+ );
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining(`Element is inaccessible.`),
+ );
+});
+
+test('should suggest hidden option if element is not in the accessibility tree', () => {
+ console.warn.mockImplementation(() => {});
+
+ const { container } = renderIntoDocument(`
+
+ `);
+
+ const suggestion = getSuggestedQuery(
+ container.querySelector('input'),
+ 'get',
+ 'role',
+ );
+ expect(suggestion).toMatchObject({
+ queryName: 'Role',
+ queryMethod: 'getByRole',
+ queryArgs: ['textbox', { hidden: true }],
+ variant: 'get',
+ warning:
+ `Element is inaccessible. This means that the element and all its children are invisible to screen readers.
+ If you are using the aria-hidden prop, make sure this is the right choice for your case.
+ `,
+ });
+ suggestion.toString();
+
+ expect(console.warn.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ Element is inaccessible. This means that the element and all its children are invisible to screen readers.
+ If you are using the aria-hidden prop, make sure this is the right choice for your case.
+ ,
+ ],
+ ]
+ `);
+});
+
+test('should find label text using the aria-labelledby', () => {
+ const { container } = renderIntoDocument(`
+
');
+ const normalizer = str => str;
+
+ expect(() => queryAllByText('abc', { trim: false, normalizer })).toThrow();
+ expect(() => queryAllByText('abc', { trim: true, normalizer })).toThrow();
+ expect(() => queryAllByText('abc', { collapseWhitespace: false, normalizer }))
+ .toThrow();
+ expect(() => queryAllByText('abc', { collapseWhitespace: true, normalizer }))
+ .toThrow();
+});
+
+test('getDefaultNormalizer returns a normalizer that supports trim and collapseWhitespace', () => {
+ // Default is trim: true and collapseWhitespace: true
+ expect(getDefaultNormalizer()(' abc def ')).toEqual('abc def');
+
+ // Turning off trimming should not turn off whitespace collapsing
+ expect(getDefaultNormalizer({ trim: false })(' abc def ')).toEqual(
+ ' abc def ',
+ );
+
+ // Turning off whitespace collapsing should not turn off trimming
+ expect(
+ getDefaultNormalizer({ collapseWhitespace: false })(' abc def '),
+ ).toEqual('abc def');
+
+ // Whilst it's rather pointless, we should be able to turn both off
+ expect(
+ getDefaultNormalizer({ trim: false, collapseWhitespace: false })(
+ ' abc def ',
+ ),
+ ).toEqual(' abc def ');
+});
+
+test('we support an older API with trim and collapseWhitespace instead of a normalizer', () => {
+ const { queryAllByText } = render('
x y
');
+ expect(queryAllByText('x y')).toHaveLength(1);
+ expect(queryAllByText('x y', { trim: false })).toHaveLength(0);
+ expect(queryAllByText(' x y ', { trim: false })).toHaveLength(1);
+ expect(queryAllByText('x y', { collapseWhitespace: false })).toHaveLength(0);
+ expect(queryAllByText('x y', { collapseWhitespace: false })).toHaveLength(1);
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/wait-for-element-to-be-removed.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/wait-for-element-to-be-removed.js
new file mode 100644
index 0000000000..a4e39e6491
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/wait-for-element-to-be-removed.js
@@ -0,0 +1,154 @@
+import { waitForElementToBeRemoved } from '..';
+import { renderIntoDocument } from './helpers/test-utils';
+
+test('resolves on mutation only when the element is removed', async () => {
+ const { queryAllByTestId } = renderIntoDocument(`
+
+
+ `);
+ const divs = queryAllByTestId('div');
+ // first mutation
+ setTimeout(() => {
+ divs.forEach(d => d.setAttribute('id', 'mutated'));
+ });
+ // removal
+ setTimeout(() => {
+ divs.forEach(div => div.parentElement.removeChild(div));
+ }, 100);
+ // the timeout is here for two reasons:
+ // 1. It helps test the timeout config
+ // 2. The element should be removed immediately
+ // so if it doesn't in the first 100ms then we know something's wrong
+ // so we'll fail early and not wait the full timeout
+ await waitForElementToBeRemoved(() => queryAllByTestId('div'), {
+ timeout: 200,
+ });
+});
+
+test('resolves on mutation if callback throws an error', async () => {
+ const { getByTestId } = renderIntoDocument(`
+
+`);
+ const div = getByTestId('div');
+ setTimeout(() => {
+ div.parentElement.removeChild(div);
+ });
+ await waitForElementToBeRemoved(() => getByTestId('div'), { timeout: 100 });
+});
+
+test('requires an element to exist first', () => {
+ return expect(
+ waitForElementToBeRemoved(null),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.`,
+ );
+});
+
+test('requires element\'s parent to exist first', () => {
+ const { getByTestId } = renderIntoDocument(`
+
asd
+`);
+ const div = getByTestId('div');
+ div.parentElement.removeChild(div);
+
+ return expect(
+ waitForElementToBeRemoved(div),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.`,
+ );
+});
+
+test('requires an unempty array of elements to exist first', () => {
+ return expect(
+ waitForElementToBeRemoved([]),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.`,
+ );
+});
+
+test('requires an element to exist first (function form)', () => {
+ return expect(
+ waitForElementToBeRemoved(() => null),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.`,
+ );
+});
+
+test('requires an unempty array of elements to exist first (function form)', () => {
+ return expect(
+ waitForElementToBeRemoved(() => []),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.`,
+ );
+});
+
+test('after successful removal, fulfills promise with empty value (undefined)', () => {
+ const { getByTestId } = renderIntoDocument(`
+
+`);
+ const div = getByTestId('div');
+ const waitResult = waitForElementToBeRemoved(() => getByTestId('div'), {
+ timeout: 100,
+ });
+ div.parentElement.removeChild(div);
+ return expect(waitResult).resolves.toBeUndefined();
+});
+
+test('rethrows non-testing-lib errors', () => {
+ let throwIt = false;
+ const div = document.createElement('div');
+ const error = new Error('my own error');
+ return expect(
+ waitForElementToBeRemoved(() => {
+ // eslint-disable-next-line jest/no-conditional-in-test -- false-positive
+ if (throwIt) {
+ throw error;
+ }
+ throwIt = true;
+ return div;
+ }),
+ ).rejects.toBe(error);
+});
+
+test('logs timeout error when it times out', async () => {
+ const div = document.createElement('div');
+ await expect(
+ waitForElementToBeRemoved(() => div, { timeout: 1, onTimeout: e => e }),
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `Timed out in waitForElementToBeRemoved.`,
+ );
+});
+
+test('accepts an element as an argument and waits for it to be removed from its top-most parent', async () => {
+ const { queryByTestId } = renderIntoDocument(`
+
+ `);
+ const div = queryByTestId('div');
+ setTimeout(() => {
+ div.parentElement.removeChild(div);
+ }, 20);
+
+ await waitForElementToBeRemoved(div, { timeout: 200 });
+});
+
+test('accepts an array of elements as an argument and waits for those elements to be removed from their top-most parent', async () => {
+ const { queryAllByTestId } = renderIntoDocument(`
+
+
+
+
+
+
+
+
+ `);
+ const [div1, div2] = queryAllByTestId('div');
+ setTimeout(() => {
+ div1.parentElement.removeChild(div1);
+ }, 20);
+
+ setTimeout(() => {
+ div2.parentElement.removeChild(div2);
+ }, 50);
+ await waitForElementToBeRemoved([div1, div2], { timeout: 200 });
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/wait-for.js b/packages/testing-library/lynx-dom-testing-library/src/__tests__/wait-for.js
new file mode 100644
index 0000000000..d29763649f
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/wait-for.js
@@ -0,0 +1,451 @@
+import jestSnapshotSerializerAnsi from 'jest-snapshot-serializer-ansi';
+import { waitFor } from '..';
+import { configure, getConfig } from '../config';
+import { renderIntoDocument } from './helpers/test-utils';
+
+expect.addSnapshotSerializer(jestSnapshotSerializerAnsi);
+
+function deferred() {
+ let resolve, reject;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+let originalConfig;
+beforeEach(() => {
+ originalConfig = getConfig();
+});
+
+afterEach(() => {
+ configure(originalConfig);
+ // restore timers
+ jest.useRealTimers();
+});
+
+test('waits callback to not throw an error', async () => {
+ const spy = jest.fn();
+ // we are using random timeout here to simulate a real-time example
+ // of an async operation calling a callback at a non-deterministic time
+ const randomTimeout = Math.floor(Math.random() * 60);
+ setTimeout(spy, randomTimeout);
+
+ await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+ expect(spy).toHaveBeenCalledWith();
+});
+
+// we used to have a limitation where we had to set an interval of 0 to 1
+// otherwise there would be problems. I don't think this limitation exists
+// anymore, but we'll keep this test around to make sure a problem doesn't
+// crop up.
+test('can accept an interval of 0', () => waitFor(() => {}, { interval: 0 }));
+
+test('can timeout after the given timeout time', async () => {
+ const error = new Error('throws every time');
+ const result = await waitFor(
+ () => {
+ throw error;
+ },
+ { timeout: 8, interval: 5 },
+ ).catch(e => e);
+ expect(result).toBe(error);
+});
+
+test('if no error is thrown then throws a timeout error', async () => {
+ const result = await waitFor(
+ () => {
+ // eslint-disable-next-line no-throw-literal
+ throw undefined;
+ },
+ { timeout: 8, interval: 5, onTimeout: e => e },
+ ).catch(e => e);
+ expect(result).toMatchInlineSnapshot(`[Error: Timed out in waitFor.]`);
+});
+
+test('if showOriginalStackTrace on a timeout error then the stack trace does not include this file', async () => {
+ const result = await waitFor(
+ () => {
+ // eslint-disable-next-line no-throw-literal
+ throw undefined;
+ },
+ { timeout: 8, interval: 5, showOriginalStackTrace: true },
+ ).catch(e => e);
+ expect(result.stack).not.toMatch(__dirname);
+});
+
+test('uses full stack error trace when showOriginalStackTrace present', async () => {
+ const error = new Error('Throws the full stack trace');
+ // even if the error is a TestingLibraryElementError
+ error.name = 'TestingLibraryElementError';
+ const originalStackTrace = error.stack;
+ const result = await waitFor(
+ () => {
+ throw error;
+ },
+ { timeout: 8, interval: 5, showOriginalStackTrace: true },
+ ).catch(e => e);
+ expect(result.stack).toBe(originalStackTrace);
+});
+
+test('does not change the stack trace if the thrown error is not a TestingLibraryElementError', async () => {
+ const error = new Error('Throws the full stack trace');
+ const originalStackTrace = error.stack;
+ const result = await waitFor(
+ () => {
+ throw error;
+ },
+ { timeout: 8, interval: 5 },
+ ).catch(e => e);
+ expect(result.stack).toBe(originalStackTrace);
+});
+
+test('provides an improved stack trace if the thrown error is a TestingLibraryElementError', async () => {
+ const error = new Error('Throws the full stack trace');
+ error.name = 'TestingLibraryElementError';
+ const originalStackTrace = error.stack;
+ const result = await waitFor(
+ () => {
+ throw error;
+ },
+ { timeout: 8, interval: 5 },
+ ).catch(e => e);
+ // too hard to test that the stack trace is what we want it to be
+ // so we'll just make sure that it's not the same as the original
+ expect(result.stack).not.toBe(originalStackTrace);
+});
+
+test('throws nice error if provided callback is not a function', () => {
+ const { queryByTestId } = renderIntoDocument(`
+
+ `);
+ const someElement = queryByTestId('div');
+ expect(() => waitFor(someElement)).toThrow(
+ 'Received `callback` arg must be a function',
+ );
+});
+
+test('timeout logs a pretty DOM', async () => {
+ renderIntoDocument(`
`);
+ const error = await waitFor(
+ () => {
+ throw new Error('always throws');
+ },
+ { timeout: 1 },
+ ).catch(e => e);
+
+ expect(getElementError).toBeCalledTimes(1);
+ expect(error.message).toMatchInlineSnapshot(`Custom element error`);
+});
+
+test('when a promise is returned, it does not call the callback again until that promise rejects', async () => {
+ const sleep = t => new Promise(r => setTimeout(r, t));
+ const p1 = deferred();
+ const waitForCb = jest.fn(() => p1.promise);
+ const waitForPromise = waitFor(waitForCb, { interval: 1 });
+ expect(waitForCb).toHaveBeenCalledTimes(1);
+ waitForCb.mockClear();
+ await sleep(50);
+ expect(waitForCb).toHaveBeenCalledTimes(0);
+
+ const p2 = deferred();
+ waitForCb.mockImplementation(() => p2.promise);
+
+ p1.reject('p1 rejection (should not fail this test)');
+ await sleep(50);
+
+ expect(waitForCb).toHaveBeenCalledTimes(1);
+ p2.resolve();
+
+ await waitForPromise;
+});
+
+test('when a promise is returned, if that is not resolved within the timeout, then waitFor is rejected', async () => {
+ const sleep = t => new Promise(r => setTimeout(r, t));
+ const { promise } = deferred();
+ const waitForError = waitFor(() => promise, { timeout: 1 }).catch(e => e);
+ await sleep(5);
+
+ expect((await waitForError).message).toMatchInlineSnapshot(`
+ Timed out in waitFor.
+
+ Ignored nodes: comments, script, style
+
+
+
+
+ `);
+});
+
+test('if you switch from fake timers to real timers during the wait period you get an error', async () => {
+ jest.useFakeTimers();
+ const waitForError = waitFor(() => {
+ throw new Error('this error message does not matter...');
+ }).catch(e => e);
+
+ // this is the problem...
+ jest.useRealTimers();
+
+ const error = await waitForError;
+
+ expect(error.message).toMatchInlineSnapshot(
+ `Changed from using fake timers to real timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to real timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`,
+ );
+ // stack trace has this file in it
+ expect(error.stack).toMatch(__dirname);
+});
+
+test('if you switch from real timers to fake timers during the wait period you get an error', async () => {
+ const waitForError = waitFor(() => {
+ throw new Error('this error message does not matter...');
+ }).catch(e => e);
+
+ // this is the problem...
+ jest.useFakeTimers();
+ const error = await waitForError;
+
+ expect(error.message).toMatchInlineSnapshot(
+ `Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830`,
+ );
+ // stack trace has this file in it
+ expect(error.stack).toMatch(__dirname);
+});
+
+test('the fake timers => real timers error shows the original stack trace when configured to do so', async () => {
+ jest.useFakeTimers();
+ const waitForError = waitFor(
+ () => {
+ throw new Error('this error message does not matter...');
+ },
+ { showOriginalStackTrace: true },
+ ).catch(e => e);
+
+ jest.useRealTimers();
+
+ expect((await waitForError).stack).not.toMatch(__dirname);
+});
+
+test('the real timers => fake timers error shows the original stack trace when configured to do so', async () => {
+ const waitForError = waitFor(
+ () => {
+ throw new Error('this error message does not matter...');
+ },
+ { showOriginalStackTrace: true },
+ ).catch(e => e);
+
+ jest.useFakeTimers();
+
+ expect((await waitForError).stack).not.toMatch(__dirname);
+});
+
+test('does not work after it resolves', async () => {
+ jest.useFakeTimers('modern');
+ const contextStack = [];
+ configure({
+ // @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
+ unstable_advanceTimersWrapper: callback => {
+ contextStack.push('act:start');
+ try {
+ const result = callback();
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
+ if (typeof result?.then === 'function') {
+ const thenable = result;
+ return {
+ then: (resolve, reject) => {
+ thenable.then(
+ returnValue => {
+ contextStack.push('act:end');
+ resolve(returnValue);
+ },
+ error => {
+ contextStack.push('act:end');
+ reject(error);
+ },
+ );
+ },
+ };
+ } else {
+ contextStack.push('act:end');
+ return undefined;
+ }
+ } catch {
+ contextStack.push('act:end');
+ return undefined;
+ }
+ },
+ asyncWrapper: async callback => {
+ contextStack.push('no-act:start');
+ try {
+ await callback();
+ } finally {
+ contextStack.push('no-act:end');
+ }
+ },
+ });
+
+ let timeoutResolved = false;
+ setTimeout(() => {
+ contextStack.push('timeout');
+ timeoutResolved = true;
+ }, 100);
+
+ contextStack.push('waitFor:start');
+ await waitFor(
+ () => {
+ contextStack.push('callback');
+ // eslint-disable-next-line jest/no-conditional-in-test -- false-positive
+ if (!timeoutResolved) {
+ throw new Error('not found');
+ }
+ },
+ { interval: 50 },
+ );
+ contextStack.push('waitFor:end');
+
+ expect(contextStack).toMatchInlineSnapshot(`
+ [
+ waitFor:start,
+ no-act:start,
+ callback,
+ act:start,
+ act:end,
+ callback,
+ act:start,
+ timeout,
+ act:end,
+ callback,
+ no-act:end,
+ waitFor:end,
+ ]
+ `);
+
+ await Promise.resolve();
+
+ // The context call stack should not change
+ expect(contextStack).toMatchInlineSnapshot(`
+ [
+ waitFor:start,
+ no-act:start,
+ callback,
+ act:start,
+ act:end,
+ callback,
+ act:start,
+ timeout,
+ act:end,
+ callback,
+ no-act:end,
+ waitFor:end,
+ ]
+ `);
+});
+
+test(`when fake timer is installed, on waitFor timeout, it doesn't call the callback afterward`, async () => {
+ jest.useFakeTimers('modern');
+
+ configure({
+ // @testing-library/react usage to ensure `IS_REACT_ACT_ENVIRONMENT` is set when acting.
+ unstable_advanceTimersWrapper: callback => {
+ try {
+ const result = callback();
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
+ if (typeof result?.then === 'function') {
+ const thenable = result;
+ return {
+ then: (resolve, reject) => {
+ thenable.then(
+ returnValue => {
+ resolve(returnValue);
+ },
+ error => {
+ reject(error);
+ },
+ );
+ },
+ };
+ } else {
+ return undefined;
+ }
+ } catch {
+ return undefined;
+ }
+ },
+ asyncWrapper: async callback => {
+ try {
+ await callback();
+ } finally {
+ /* eslint no-empty: "off" */
+ }
+ },
+ });
+
+ const callback = jest.fn(() => {
+ throw new Error('We want to timeout');
+ });
+ const interval = 50;
+
+ await expect(() => waitFor(callback, { interval, timeout: 100 })).rejects
+ .toThrow();
+ expect(callback).toHaveBeenCalledWith();
+
+ callback.mockClear();
+
+ await jest.advanceTimersByTimeAsync(interval);
+
+ expect(callback).not.toHaveBeenCalledWith();
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/src/config.ts b/packages/testing-library/lynx-dom-testing-library/src/config.ts
new file mode 100644
index 0000000000..fec60da4c4
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/config.ts
@@ -0,0 +1,81 @@
+import { Config, ConfigFn } from '../types/config';
+// import {prettyDOM} from './pretty-dom'
+
+type Callback = () => T;
+interface InternalConfig extends Config {
+ _disableExpensiveErrorDiagnostics: boolean;
+}
+
+// It would be cleaner for this to live inside './queries', but
+// other parts of the code assume that all exports from
+// './queries' are query functions.
+let config: InternalConfig = {
+ testIdAttribute: 'data-testid',
+ asyncUtilTimeout: 1000,
+ // asyncWrapper and advanceTimersWrapper is to support React's async `act` function.
+ // forcing react-testing-library to wrap all async functions would've been
+ // a total nightmare (consider wrapping every findBy* query and then also
+ // updating `within` so those would be wrapped too. Total nightmare).
+ // so we have this config option that's really only intended for
+ // react-testing-library to use. For that reason, this feature will remain
+ // undocumented.
+ asyncWrapper: cb => cb(),
+ unstable_advanceTimersWrapper: cb => cb(),
+ eventWrapper: cb => cb(),
+ // default value for the `hidden` option in `ByRole` queries
+ defaultHidden: false,
+ // default value for the `ignore` option in `ByText` queries
+ defaultIgnore: 'script, style',
+ // showOriginalStackTrace flag to show the full error stack traces for async errors
+ showOriginalStackTrace: false,
+
+ // throw errors w/ suggestions for better queries. Opt in so off by default.
+ throwSuggestions: false,
+
+ // called when getBy* queries fail. (message, container) => Error
+ getElementError(message, container) {
+ // @ts-ignore
+ const prettifiedDOM = container.toJSON();
+ const error = new Error(
+ [
+ message,
+ `Ignored nodes: comments, ${config.defaultIgnore}\n${prettifiedDOM}`,
+ ]
+ .filter(Boolean)
+ .join('\n\n'),
+ );
+ error.name = 'TestingLibraryElementError';
+ return error;
+ },
+ _disableExpensiveErrorDiagnostics: false,
+ computedStyleSupportsPseudoElements: false,
+};
+
+export function runWithExpensiveErrorDiagnosticsDisabled(
+ callback: Callback,
+) {
+ try {
+ config._disableExpensiveErrorDiagnostics = true;
+ return callback();
+ } finally {
+ config._disableExpensiveErrorDiagnostics = false;
+ }
+}
+
+export function configure(newConfig: ConfigFn | Partial) {
+ if (typeof newConfig === 'function') {
+ // Pass the existing config out to the provided function
+ // and accept a delta in return
+ newConfig = newConfig(config);
+ }
+
+ // Merge the incoming config delta
+ config = {
+ ...config,
+ ...newConfig,
+ };
+}
+
+export function getConfig() {
+ return config;
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/src/event-map.js b/packages/testing-library/lynx-dom-testing-library/src/event-map.js
new file mode 100644
index 0000000000..13774bfc38
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/event-map.js
@@ -0,0 +1,92 @@
+export const eventMap = {
+ // LynxBindCatchEvent Events
+ tap: {
+ defaultInit: {},
+ },
+ longtap: {
+ defaultInit: {},
+ },
+ // LynxEvent Events
+ bgload: {
+ defaultInit: {},
+ },
+ bgerror: {
+ defaultInit: {},
+ },
+ touchstart: {
+ defaultInit: {},
+ },
+ touchmove: {
+ defaultInit: {},
+ },
+ touchcancel: {
+ defaultInit: {},
+ },
+ touchend: {
+ defaultInit: {},
+ },
+ longpress: {
+ defaultInit: {},
+ },
+ transitionstart: {
+ defaultInit: {},
+ },
+ transitioncancel: {
+ defaultInit: {},
+ },
+ transitionend: {
+ defaultInit: {},
+ },
+ animationstart: {
+ defaultInit: {},
+ },
+ animationiteration: {
+ defaultInit: {},
+ },
+ animationcancel: {
+ defaultInit: {},
+ },
+ animationend: {
+ defaultInit: {},
+ },
+ mousedown: {
+ defaultInit: {},
+ },
+ mouseup: {
+ defaultInit: {},
+ },
+ mousemove: {
+ defaultInit: {},
+ },
+ mouseclick: {
+ defaultInit: {},
+ },
+ mousedblclick: {
+ defaultInit: {},
+ },
+ mouselongpress: {
+ defaultInit: {},
+ },
+ wheel: {
+ defaultInit: {},
+ },
+ keydown: {
+ defaultInit: {},
+ },
+ keyup: {
+ defaultInit: {},
+ },
+ focus: {
+ defaultInit: {},
+ },
+ blur: {
+ defaultInit: {},
+ },
+ layoutchange: {
+ defaultInit: {},
+ },
+};
+
+export const eventAliasMap = {
+ doubleClick: 'dblClick',
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/events.js b/packages/testing-library/lynx-dom-testing-library/src/events.js
new file mode 100644
index 0000000000..443638a56e
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/events.js
@@ -0,0 +1,94 @@
+import { getConfig } from './config';
+import { getWindowFromNode } from './helpers';
+import { eventMap, eventAliasMap } from './event-map';
+
+function fireEvent(element, event) {
+ return getConfig().eventWrapper(() => {
+ if (!event) {
+ throw new Error(
+ `Unable to fire an event - please provide an event object.`,
+ );
+ }
+ if (!element) {
+ throw new Error(
+ `Unable to fire a "${event.type}" event - please provide a DOM element.`,
+ );
+ }
+ return element.dispatchEvent(event);
+ });
+}
+
+function createEvent(
+ eventName,
+ node,
+ init,
+ { defaultInit = {} } = {},
+) {
+ if (!node) {
+ throw new Error(
+ `Unable to fire a "${eventName}" event - please provide a DOM element.`,
+ );
+ }
+ const eventInit = { ...defaultInit, ...init };
+ const { target: { value, files, ...targetProperties } = {} } = eventInit;
+ if (value !== undefined) {
+ setNativeValue(node, value);
+ }
+ if (files !== undefined) {
+ // input.files is a read-only property so this is not allowed:
+ // input.files = [file]
+ // so we have to use this workaround to set the property
+ Object.defineProperty(node, 'files', {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: files,
+ });
+ }
+ Object.assign(node, targetProperties);
+ const event = {
+ eventName,
+ ...(eventInit || {}),
+ };
+ return event;
+}
+
+Object.keys(eventMap).forEach(key => {
+ const { defaultInit } = eventMap[key];
+ const eventName = key.toLowerCase();
+
+ createEvent[key] = (node, init) =>
+ createEvent(eventName, node, init, { defaultInit });
+ fireEvent[key] = (node, init) =>
+ fireEvent(node, createEvent[key](node, init));
+});
+
+// function written after some investigation here:
+// https://github.com/facebook/react/issues/10135#issuecomment-401496776
+function setNativeValue(element, value) {
+ const { set: valueSetter } = Object.getOwnPropertyDescriptor(element, 'value')
+ || {};
+ const prototype = Object.getPrototypeOf(element);
+ const { set: prototypeValueSetter } =
+ Object.getOwnPropertyDescriptor(prototype, 'value') || {};
+ if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
+ prototypeValueSetter.call(element, value);
+ } else {
+ /* istanbul ignore if */
+ // eslint-disable-next-line no-lonely-if -- Can't be ignored by istanbul otherwise
+ if (valueSetter) {
+ valueSetter.call(element, value);
+ } else {
+ throw new Error('The given element does not have a value setter');
+ }
+ }
+}
+
+Object.keys(eventAliasMap).forEach(aliasKey => {
+ const key = eventAliasMap[aliasKey];
+ fireEvent[aliasKey] = (...args) => fireEvent[key](...args);
+});
+
+export { fireEvent, createEvent };
+
+/* eslint complexity:["error", 9] */
diff --git a/packages/testing-library/lynx-dom-testing-library/src/get-node-text.ts b/packages/testing-library/lynx-dom-testing-library/src/get-node-text.ts
new file mode 100644
index 0000000000..eab9f944b6
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/get-node-text.ts
@@ -0,0 +1,16 @@
+import { TEXT_NODE } from './helpers';
+
+function getNodeText(node: HTMLElement): string {
+ if (
+ node.matches('input[type=submit], input[type=button], input[type=reset]')
+ ) {
+ return (node as HTMLInputElement).value;
+ }
+
+ return Array.from(node.childNodes)
+ .filter(child => child.nodeType === TEXT_NODE && Boolean(child.textContent))
+ .map(c => c.textContent)
+ .join('');
+}
+
+export { getNodeText };
diff --git a/packages/testing-library/lynx-dom-testing-library/src/get-queries-for-element.js b/packages/testing-library/lynx-dom-testing-library/src/get-queries-for-element.js
new file mode 100644
index 0000000000..42641733f2
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/get-queries-for-element.js
@@ -0,0 +1,25 @@
+import * as defaultQueries from './queries';
+
+/**
+ * @typedef {{[key: string]: Function}} FuncMap
+ */
+
+/**
+ * @param {HTMLElement} element container
+ * @param {FuncMap} queries object of functions
+ * @param {Object} initialValue for reducer
+ * @returns {FuncMap} returns object of functions bound to container
+ */
+function getQueriesForElement(
+ element,
+ queries = defaultQueries,
+ initialValue = {},
+) {
+ return Object.keys(queries).reduce((helpers, key) => {
+ const fn = queries[key];
+ helpers[key] = fn.bind(null, element);
+ return helpers;
+ }, initialValue);
+}
+
+export { getQueriesForElement };
diff --git a/packages/testing-library/lynx-dom-testing-library/src/get-user-code-frame.js b/packages/testing-library/lynx-dom-testing-library/src/get-user-code-frame.js
new file mode 100644
index 0000000000..539d7647f9
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/get-user-code-frame.js
@@ -0,0 +1,67 @@
+// We try to load node dependencies
+let chalk = null;
+let readFileSync = null;
+let codeFrameColumns = null;
+
+try {
+ const nodeRequire = module && module.require;
+
+ readFileSync = nodeRequire.call(module, 'fs').readFileSync;
+ codeFrameColumns = nodeRequire.call(
+ module,
+ '@babel/code-frame',
+ ).codeFrameColumns;
+ chalk = nodeRequire.call(module, 'chalk');
+} catch {
+ // We're in a browser environment
+}
+
+// frame has the form "at myMethod (location/to/my/file.js:10:2)"
+function getCodeFrame(frame) {
+ const locationStart = frame.indexOf('(') + 1;
+ const locationEnd = frame.indexOf(')');
+ const frameLocation = frame.slice(locationStart, locationEnd);
+
+ const frameLocationElements = frameLocation.split(':');
+ const [filename, line, column] = [
+ frameLocationElements[0],
+ parseInt(frameLocationElements[1], 10),
+ parseInt(frameLocationElements[2], 10),
+ ];
+
+ let rawFileContents = '';
+ try {
+ rawFileContents = readFileSync(filename, 'utf-8');
+ } catch {
+ return '';
+ }
+
+ const codeFrame = codeFrameColumns(
+ rawFileContents,
+ {
+ start: { line, column },
+ },
+ {
+ highlightCode: true,
+ linesBelow: 0,
+ },
+ );
+ return `${chalk.dim(frameLocation)}\n${codeFrame}\n`;
+}
+
+function getUserCodeFrame() {
+ // If we couldn't load dependencies, we can't generate the user trace
+ /* istanbul ignore next */
+ if (!readFileSync || !codeFrameColumns) {
+ return '';
+ }
+ const err = new Error();
+ const firstClientCodeFrame = err.stack
+ .split('\n')
+ .slice(1) // Remove first line which has the form "Error: TypeError"
+ .find(frame => !frame.includes('node_modules/')); // Ignore frames from 3rd party libraries
+
+ return getCodeFrame(firstClientCodeFrame);
+}
+
+export { getUserCodeFrame };
diff --git a/packages/testing-library/lynx-dom-testing-library/src/helpers.ts b/packages/testing-library/lynx-dom-testing-library/src/helpers.ts
new file mode 100644
index 0000000000..4573a28a24
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/helpers.ts
@@ -0,0 +1,90 @@
+// Constant node.nodeType for text nodes, see:
+// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#Node_type_constants
+const TEXT_NODE = 3;
+
+function jestFakeTimersAreEnabled() {
+ /* istanbul ignore else */
+ // eslint-disable-next-line
+ if (typeof jest !== 'undefined' && jest !== null) {
+ return (
+ // legacy timers
+ (setTimeout as any)._isMockFunction === true
+ // modern timers
+ // eslint-disable-next-line prefer-object-has-own -- not supported by our support matrix
+ || Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
+ );
+ }
+ // istanbul ignore next
+ return false;
+}
+
+function getDocument() {
+ return lynxDOM.mainThread.elementTree.root;
+}
+function getWindowFromNode(node: any) {
+ if (node.defaultView) {
+ // node is document
+ return node.defaultView;
+ } else if (node.ownerDocument && node.ownerDocument.defaultView) {
+ // node is a DOM node
+ return node.ownerDocument.defaultView;
+ } else if (node.window) {
+ // node is window
+ return node.window;
+ } else if (node.ownerDocument && node.ownerDocument.defaultView === null) {
+ throw new Error(
+ `It looks like the window object is not available for the provided node.`,
+ );
+ } else if (node.then instanceof Function) {
+ throw new Error(
+ `It looks like you passed a Promise object instead of a DOM node. Did you do something like \`fireEvent.click(screen.findBy...\` when you meant to use a \`getBy\` query \`fireEvent.click(screen.getBy...\`, or await the findBy query \`fireEvent.click(await screen.findBy...\`?`,
+ );
+ } else if (Array.isArray(node)) {
+ throw new Error(
+ `It looks like you passed an Array instead of a DOM node. Did you do something like \`fireEvent.click(screen.getAllBy...\` when you meant to use a \`getBy\` query \`fireEvent.click(screen.getBy...\`?`,
+ );
+ } else if (
+ typeof node.debug === 'function'
+ && typeof node.logTestingPlaygroundURL === 'function'
+ ) {
+ throw new Error(
+ `It looks like you passed a \`screen\` object. Did you do something like \`fireEvent.click(screen, ...\` when you meant to use a query, e.g. \`fireEvent.click(screen.getBy..., \`?`,
+ );
+ } else {
+ // The user passed something unusual to a calling function
+ throw new Error(
+ `The given node is not an Element, the node type is: ${typeof node}.`,
+ );
+ }
+}
+
+function checkContainerType(container: unknown) {
+ if (
+ !container
+ || !(typeof (container as any).querySelector === 'function')
+ || !(typeof (container as any).querySelectorAll === 'function')
+ ) {
+ throw new TypeError(
+ `Expected container to be an Element, a Document or a DocumentFragment but got ${
+ getTypeName(
+ container,
+ )
+ }.`,
+ );
+ }
+
+ function getTypeName(object: unknown) {
+ if (typeof object === 'object') {
+ return object === null ? 'null' : object.constructor.name;
+ }
+ return typeof object;
+ }
+}
+
+export {
+ getWindowFromNode,
+ getDocument,
+ checkContainerType,
+ jestFakeTimersAreEnabled,
+ TEXT_NODE,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/index.js b/packages/testing-library/lynx-dom-testing-library/src/index.js
new file mode 100644
index 0000000000..9d536d52b3
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/index.js
@@ -0,0 +1,27 @@
+import { getQueriesForElement } from './get-queries-for-element';
+import * as queries from './queries';
+import * as queryHelpers from './query-helpers';
+
+export * from './queries';
+export * from './wait-for';
+export * from './wait-for-element-to-be-removed';
+export { getDefaultNormalizer } from './matches';
+export * from './get-node-text';
+export * from './events';
+export * from './get-queries-for-element';
+export * from './screen';
+export * from './query-helpers';
+export { getRoles, logRoles, isInaccessible } from './role-helpers';
+export * from './pretty-dom';
+export { configure, getConfig } from './config';
+export * from './suggestions';
+
+export {
+ // "within" reads better in user-code
+ // "getQueriesForElement" reads better in library code
+ // so we have both
+ getQueriesForElement as within,
+ // export query utils under a namespace for convenience:
+ queries,
+ queryHelpers,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/label-helpers.ts b/packages/testing-library/lynx-dom-testing-library/src/label-helpers.ts
new file mode 100644
index 0000000000..0ce26de8a5
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/label-helpers.ts
@@ -0,0 +1,85 @@
+import { TEXT_NODE } from './helpers';
+
+const labelledNodeNames = [
+ 'button',
+ 'meter',
+ 'output',
+ 'progress',
+ 'select',
+ 'textarea',
+ 'input',
+];
+
+function getTextContent(
+ node: Element | HTMLInputElement | Node,
+): string | null {
+ if (labelledNodeNames.includes(node.nodeName.toLowerCase())) {
+ return '';
+ }
+
+ if (node.nodeType === TEXT_NODE) return node.textContent;
+
+ return Array.from(node.childNodes)
+ .map(childNode => getTextContent(childNode))
+ .join('');
+}
+
+function getLabelContent(element: Element): string | null {
+ let textContent: string | null;
+ if (element.tagName.toLowerCase() === 'label') {
+ textContent = getTextContent(element);
+ } else {
+ textContent = (element as HTMLInputElement).value || element.textContent;
+ }
+ return textContent;
+}
+
+// Based on https://github.com/eps1lon/dom-accessibility-api/pull/352
+function getRealLabels(element: Element) {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- types are not aware of older browsers that don't implement `labels`
+ if ((element as HTMLInputElement).labels !== undefined) {
+ return (element as HTMLInputElement).labels ?? [];
+ }
+
+ if (!isLabelable(element)) return [];
+
+ const labels = element.ownerDocument.querySelectorAll('label');
+ return Array.from(labels).filter(label => label.control === element);
+}
+
+function isLabelable(element: Element) {
+ return (
+ /BUTTON|METER|OUTPUT|PROGRESS|SELECT|TEXTAREA/.test(element.tagName)
+ || (element.tagName === 'INPUT'
+ && element.getAttribute('type') !== 'hidden')
+ );
+}
+
+function getLabels(
+ container: Element,
+ element: Element,
+ { selector = '*' } = {},
+): { content: string | null; formControl: HTMLElement | null }[] {
+ const ariaLabelledBy = element.getAttribute('aria-labelledby');
+ const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : [];
+ return labelsId.length
+ ? labelsId.map(labelId => {
+ const labellingElement = container.querySelector(
+ `[id="${labelId}"]`,
+ );
+ return labellingElement
+ ? { content: getLabelContent(labellingElement), formControl: null }
+ : { content: '', formControl: null };
+ })
+ : Array.from(getRealLabels(element)).map(label => {
+ const textToMatch = getLabelContent(label);
+ const formControlSelector =
+ 'button, input, meter, output, progress, select, textarea';
+ const labelledFormControl = Array.from(
+ label.querySelectorAll(formControlSelector),
+ ).filter(formControlElement => formControlElement.matches(selector))[0];
+ return { content: textToMatch, formControl: labelledFormControl };
+ });
+}
+
+export { getLabels, getRealLabels, getLabelContent };
diff --git a/packages/testing-library/lynx-dom-testing-library/src/matches.ts b/packages/testing-library/lynx-dom-testing-library/src/matches.ts
new file mode 100644
index 0000000000..939f558d8a
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/matches.ts
@@ -0,0 +1,125 @@
+import {
+ Matcher,
+ NormalizerFn,
+ NormalizerOptions,
+ DefaultNormalizerOptions,
+} from '../types';
+
+function assertNotNullOrUndefined(
+ matcher: T,
+): asserts matcher is NonNullable {
+ if (matcher === null || matcher === undefined) {
+ throw new Error(
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- implicitly converting `T` to `string`
+ `It looks like ${matcher} was passed instead of a matcher. Did you do something like getByText(${matcher})?`,
+ );
+ }
+}
+
+function fuzzyMatches(
+ textToMatch: string | null,
+ node: Element | null,
+ matcher: Matcher | null,
+ normalizer: NormalizerFn,
+) {
+ if (typeof textToMatch !== 'string') {
+ return false;
+ }
+ assertNotNullOrUndefined(matcher);
+
+ const normalizedText = normalizer(textToMatch);
+
+ if (typeof matcher === 'string' || typeof matcher === 'number') {
+ return normalizedText
+ .toLowerCase()
+ .includes(matcher.toString().toLowerCase());
+ } else if (typeof matcher === 'function') {
+ return matcher(normalizedText, node);
+ } else {
+ return matchRegExp(matcher, normalizedText);
+ }
+}
+
+function matches(
+ textToMatch: string | null,
+ node: Element | null,
+ matcher: Matcher | null,
+ normalizer: NormalizerFn,
+) {
+ if (typeof textToMatch !== 'string') {
+ return false;
+ }
+
+ assertNotNullOrUndefined(matcher);
+
+ const normalizedText = normalizer(textToMatch);
+ if (matcher instanceof Function) {
+ return matcher(normalizedText, node);
+ } else if (matcher instanceof RegExp) {
+ return matchRegExp(matcher, normalizedText);
+ } else {
+ return normalizedText === String(matcher);
+ }
+}
+
+function getDefaultNormalizer({
+ trim = true,
+ collapseWhitespace = true,
+}: DefaultNormalizerOptions = {}): NormalizerFn {
+ return text => {
+ let normalizedText = text;
+ normalizedText = trim ? normalizedText.trim() : normalizedText;
+ normalizedText = collapseWhitespace
+ ? normalizedText.replace(/\s+/g, ' ')
+ : normalizedText;
+ return normalizedText;
+ };
+}
+
+/**
+ * Constructs a normalizer to pass to functions in matches.js
+ * @param {boolean|undefined} trim The user-specified value for `trim`, without
+ * any defaulting having been applied
+ * @param {boolean|undefined} collapseWhitespace The user-specified value for
+ * `collapseWhitespace`, without any defaulting having been applied
+ * @param {Function|undefined} normalizer The user-specified normalizer
+ * @returns {Function} A normalizer
+ */
+
+function makeNormalizer({
+ trim,
+ collapseWhitespace,
+ normalizer,
+}: NormalizerOptions) {
+ if (!normalizer) {
+ // No custom normalizer specified. Just use default.
+ return getDefaultNormalizer({ trim, collapseWhitespace });
+ }
+
+ if (
+ typeof trim !== 'undefined'
+ || typeof collapseWhitespace !== 'undefined'
+ ) {
+ // They've also specified a value for trim or collapseWhitespace
+ throw new Error(
+ 'trim and collapseWhitespace are not supported with a normalizer. '
+ + 'If you want to use the default trim and collapseWhitespace logic in your normalizer, '
+ + 'use "getDefaultNormalizer({trim, collapseWhitespace})" and compose that into your normalizer',
+ );
+ }
+
+ return normalizer;
+}
+
+function matchRegExp(matcher: RegExp, text: string) {
+ const match = matcher.test(text);
+ if (matcher.global && matcher.lastIndex !== 0) {
+ console.warn(
+ `To match all elements we had to reset the lastIndex of the RegExp because the global flag is enabled. We encourage to remove the global flag from the RegExp.`,
+ );
+ matcher.lastIndex = 0;
+ }
+ return match;
+}
+
+export { fuzzyMatches, matches, getDefaultNormalizer, makeNormalizer };
diff --git a/packages/testing-library/lynx-dom-testing-library/src/pretty-dom.js b/packages/testing-library/lynx-dom-testing-library/src/pretty-dom.js
new file mode 100644
index 0000000000..e3e6780f45
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/pretty-dom.js
@@ -0,0 +1,105 @@
+import * as prettyFormat from 'pretty-format';
+import createDOMElementFilter from './DOMElementFilter';
+import { getUserCodeFrame } from './get-user-code-frame';
+import { getDocument } from './helpers';
+import { getConfig } from './config';
+
+const shouldHighlight = () => {
+ if (typeof process === 'undefined') {
+ // Don't colorize in non-node environments (e.g. Browsers)
+ return false;
+ }
+ let colors;
+ // Try to safely parse env COLORS: We will default behavior if any step fails.
+ try {
+ const colorsJSON = process.env?.COLORS;
+ if (colorsJSON) {
+ colors = JSON.parse(colorsJSON);
+ }
+ } catch {
+ // If this throws, process.env?.COLORS wasn't parsable. Since we only
+ // care about `true` or `false`, we can safely ignore the error.
+ }
+
+ if (typeof colors === 'boolean') {
+ // If `colors` is set explicitly (both `true` and `false`), use that value.
+ return colors;
+ } else {
+ // If `colors` is not set, colorize if we're in node.
+ return process.versions !== undefined
+ && process.versions.node !== undefined;
+ }
+};
+
+const { DOMCollection } = prettyFormat.plugins;
+
+// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants
+const ELEMENT_NODE = 1;
+const COMMENT_NODE = 8;
+
+// https://github.com/facebook/jest/blob/615084195ae1ae61ddd56162c62bbdda17587569/packages/pretty-format/src/plugins/DOMElement.ts#L50
+function filterCommentsAndDefaultIgnoreTagsTags(value) {
+ return (
+ value.nodeType !== COMMENT_NODE
+ && (value.nodeType !== ELEMENT_NODE
+ || !value.matches(getConfig().defaultIgnore))
+ );
+}
+
+function prettyDOM(dom, maxLength, options = {}) {
+ if (!dom) {
+ dom = getDocument().body;
+ }
+ if (typeof maxLength !== 'number') {
+ maxLength = (typeof process !== 'undefined'
+ && typeof process.env !== 'undefined'
+ && process.env.DEBUG_PRINT_LIMIT)
+ || 7000;
+ }
+
+ if (maxLength === 0) {
+ return '';
+ }
+ if (dom.documentElement) {
+ dom = dom.documentElement;
+ }
+
+ let domTypeName = typeof dom;
+ if (domTypeName === 'object') {
+ domTypeName = dom.constructor.name;
+ } else {
+ // To don't fall with `in` operator
+ dom = {};
+ }
+ if (!('outerHTML' in dom)) {
+ throw new TypeError(
+ `Expected an element or document but got ${domTypeName}`,
+ );
+ }
+
+ const {
+ filterNode = filterCommentsAndDefaultIgnoreTagsTags,
+ ...prettyFormatOptions
+ } = options;
+
+ const debugContent = prettyFormat.format(dom, {
+ plugins: [createDOMElementFilter(filterNode), DOMCollection],
+ printFunctionName: false,
+ highlight: shouldHighlight(),
+ ...prettyFormatOptions,
+ });
+ return maxLength !== undefined && dom.outerHTML.length > maxLength
+ ? `${debugContent.slice(0, maxLength)}...`
+ : debugContent;
+}
+
+const logDOM = (...args) => {
+ const userCodeFrame = getUserCodeFrame();
+ if (userCodeFrame) {
+ console.log(`${prettyDOM(...args)}\n\n${userCodeFrame}`);
+ } else {
+ console.log(prettyDOM(...args));
+ }
+};
+
+export { prettyDOM, logDOM, prettyFormat };
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/all-utils.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/all-utils.ts
new file mode 100644
index 0000000000..eb0db6e343
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/all-utils.ts
@@ -0,0 +1,4 @@
+export * from '../matches';
+export * from '../get-node-text';
+export * from '../query-helpers';
+export * from '../config';
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/alt-text.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/alt-text.ts
new file mode 100644
index 0000000000..76f1436acc
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/alt-text.ts
@@ -0,0 +1,51 @@
+import {
+ queryAllByAttribute,
+ wrapAllByQueryWithSuggestion,
+} from '../query-helpers';
+import { checkContainerType } from '../helpers';
+import {
+ AllByBoundAttribute,
+ GetErrorFunction,
+ MatcherOptions,
+} from '../../types';
+import { buildQueries } from './all-utils';
+
+// Valid tags are img, input, area and custom elements
+const VALID_TAG_REGEXP = /^(img|input|area|.+-.+)$/i;
+
+const queryAllByAltText: AllByBoundAttribute = (
+ container,
+ alt,
+ options: MatcherOptions = {},
+) => {
+ checkContainerType(container);
+ return queryAllByAttribute('alt', container, alt, options).filter(node =>
+ VALID_TAG_REGEXP.test(node.tagName)
+ );
+};
+
+const getMultipleError: GetErrorFunction<[unknown]> = (c, alt) =>
+ `Found multiple elements with the alt text: ${alt}`;
+const getMissingError: GetErrorFunction<[unknown]> = (c, alt) =>
+ `Unable to find an element with the alt text: ${alt}`;
+
+const queryAllByAltTextWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [altText: Matcher, options?: SelectorMatcherOptions]
+>(queryAllByAltText, queryAllByAltText.name, 'queryAll');
+const [
+ queryByAltText,
+ getAllByAltText,
+ getByAltText,
+ findAllByAltText,
+ findByAltText,
+] = buildQueries(queryAllByAltText, getMultipleError, getMissingError);
+
+export {
+ queryByAltText,
+ queryAllByAltTextWithSuggestions as queryAllByAltText,
+ getByAltText,
+ getAllByAltText,
+ findAllByAltText,
+ findByAltText,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/display-value.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/display-value.ts
new file mode 100644
index 0000000000..6ec3b7f15b
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/display-value.ts
@@ -0,0 +1,75 @@
+import { wrapAllByQueryWithSuggestion } from '../query-helpers';
+import { checkContainerType } from '../helpers';
+import {
+ AllByBoundAttribute,
+ GetErrorFunction,
+ Matcher,
+ MatcherOptions,
+} from '../../types';
+import {
+ getNodeText,
+ matches,
+ fuzzyMatches,
+ makeNormalizer,
+ buildQueries,
+} from './all-utils';
+
+const queryAllByDisplayValue: AllByBoundAttribute = (
+ container,
+ value,
+ { exact = true, collapseWhitespace, trim, normalizer } = {},
+) => {
+ checkContainerType(container);
+ const matcher = exact ? matches : fuzzyMatches;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+ return Array.from(
+ container.querySelectorAll(`input,textarea,select`),
+ ).filter(node => {
+ if (node.tagName === 'SELECT') {
+ const selectedOptions = Array.from(
+ (node as HTMLSelectElement).options,
+ ).filter(option => option.selected);
+ return selectedOptions.some(optionNode =>
+ matcher(getNodeText(optionNode), optionNode, value, matchNormalizer)
+ );
+ } else {
+ return matcher(
+ (node as HTMLInputElement).value,
+ node,
+ value,
+ matchNormalizer,
+ );
+ }
+ });
+};
+
+const getMultipleError: GetErrorFunction<[unknown]> = (c, value) =>
+ `Found multiple elements with the display value: ${value}.`;
+const getMissingError: GetErrorFunction<[unknown]> = (c, value) =>
+ `Unable to find an element with the display value: ${value}.`;
+
+const queryAllByDisplayValueWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [value: Matcher, options?: MatcherOptions]
+>(queryAllByDisplayValue, queryAllByDisplayValue.name, 'queryAll');
+
+const [
+ queryByDisplayValue,
+ getAllByDisplayValue,
+ getByDisplayValue,
+ findAllByDisplayValue,
+ findByDisplayValue,
+] = buildQueries(queryAllByDisplayValue, getMultipleError, getMissingError);
+
+export {
+ queryByDisplayValue,
+ queryAllByDisplayValueWithSuggestions as queryAllByDisplayValue,
+ getByDisplayValue,
+ getAllByDisplayValue,
+ findAllByDisplayValue,
+ findByDisplayValue,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/index.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/index.ts
new file mode 100644
index 0000000000..c2d3722406
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/index.ts
@@ -0,0 +1,8 @@
+export * from './label-text';
+export * from './placeholder-text';
+export * from './text';
+export * from './display-value';
+export * from './alt-text';
+export * from './title';
+export * from './role';
+export * from './test-id';
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/label-text.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/label-text.ts
new file mode 100644
index 0000000000..eadd71087a
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/label-text.ts
@@ -0,0 +1,235 @@
+import { getConfig } from '../config';
+import { checkContainerType } from '../helpers';
+import { getLabels, getRealLabels, getLabelContent } from '../label-helpers';
+import {
+ AllByText,
+ GetErrorFunction,
+ Matcher,
+ MatcherOptions,
+ SelectorMatcherOptions,
+} from '../../types';
+import {
+ fuzzyMatches,
+ matches,
+ makeNormalizer,
+ queryAllByAttribute,
+ makeFindQuery,
+ makeSingleQuery,
+ wrapAllByQueryWithSuggestion,
+ wrapSingleQueryWithSuggestion,
+} from './all-utils';
+
+function queryAllLabels(
+ container: HTMLElement,
+): { textToMatch: string | null; node: HTMLElement }[] {
+ return Array.from(container.querySelectorAll('label,input'))
+ .map(node => {
+ return { node, textToMatch: getLabelContent(node) };
+ })
+ .filter(({ textToMatch }) => textToMatch !== null);
+}
+
+const queryAllLabelsByText: AllByText = (
+ container,
+ text,
+ { exact = true, trim, collapseWhitespace, normalizer } = {},
+) => {
+ const matcher = exact ? matches : fuzzyMatches;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+
+ const textToMatchByLabels = queryAllLabels(container);
+
+ return textToMatchByLabels
+ .filter(({ node, textToMatch }) =>
+ matcher(textToMatch, node, text, matchNormalizer)
+ )
+ .map(({ node }) => node);
+};
+
+const queryAllByLabelText: AllByText = (
+ container,
+ text,
+ { selector = '*', exact = true, collapseWhitespace, trim, normalizer } = {},
+) => {
+ checkContainerType(container);
+
+ const matcher = exact ? matches : fuzzyMatches;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+ const matchingLabelledElements = Array.from(
+ container.querySelectorAll('*'),
+ )
+ .filter(element => {
+ return (
+ getRealLabels(element).length || element.hasAttribute('aria-labelledby')
+ );
+ })
+ .reduce((labelledElements, labelledElement) => {
+ const labelList = getLabels(container, labelledElement, { selector });
+ labelList
+ .filter(label => Boolean(label.formControl))
+ .forEach(label => {
+ if (
+ matcher(label.content, label.formControl, text, matchNormalizer)
+ && label.formControl
+ ) {
+ labelledElements.push(label.formControl);
+ }
+ });
+ const labelsValue = labelList
+ .filter(label => Boolean(label.content))
+ .map(label => label.content);
+ if (
+ matcher(labelsValue.join(' '), labelledElement, text, matchNormalizer)
+ ) {
+ labelledElements.push(labelledElement);
+ }
+ if (labelsValue.length > 1) {
+ labelsValue.forEach((labelValue, index) => {
+ if (matcher(labelValue, labelledElement, text, matchNormalizer)) {
+ labelledElements.push(labelledElement);
+ }
+
+ const labelsFiltered = [...labelsValue];
+ labelsFiltered.splice(index, 1);
+
+ if (labelsFiltered.length > 1) {
+ if (
+ matcher(
+ labelsFiltered.join(' '),
+ labelledElement,
+ text,
+ matchNormalizer,
+ )
+ ) {
+ labelledElements.push(labelledElement);
+ }
+ }
+ });
+ }
+
+ return labelledElements;
+ }, [])
+ .concat(
+ queryAllByAttribute('aria-label', container, text, {
+ exact,
+ normalizer: matchNormalizer,
+ }),
+ );
+
+ return Array.from(new Set(matchingLabelledElements)).filter(element =>
+ element.matches(selector)
+ );
+};
+
+// the getAll* query would normally look like this:
+// const getAllByLabelText = makeGetAllQuery(
+// queryAllByLabelText,
+// (c, text) => `Unable to find a label with the text of: ${text}`,
+// )
+// however, we can give a more helpful error message than the generic one,
+// so we're writing this one out by hand.
+const getAllByLabelText: AllByText = (container, text, ...rest) => {
+ const els = queryAllByLabelText(container, text, ...rest);
+ if (!els.length) {
+ const labels = queryAllLabelsByText(container, text, ...rest);
+ if (labels.length) {
+ const tagNames = labels
+ .map(label =>
+ getTagNameOfElementAssociatedWithLabelViaFor(container, label)
+ )
+ .filter(tagName => !!tagName);
+ if (tagNames.length) {
+ throw getConfig().getElementError(
+ tagNames
+ .map(
+ tagName =>
+ `Found a label with the text of: ${text}, however the element associated with this label (<${tagName} />) is non-labellable [https://html.spec.whatwg.org/multipage/forms.html#category-label]. If you really need to label a <${tagName} />, you can use aria-label or aria-labelledby instead.`,
+ )
+ .join('\n\n'),
+ container,
+ );
+ } else {
+ throw getConfig().getElementError(
+ `Found a label with the text of: ${text}, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.`,
+ container,
+ );
+ }
+ } else {
+ throw getConfig().getElementError(
+ `Unable to find a label with the text of: ${text}`,
+ container,
+ );
+ }
+ }
+ return els;
+};
+
+function getTagNameOfElementAssociatedWithLabelViaFor(
+ container: Element,
+ label: Element,
+): string | null {
+ const htmlFor = label.getAttribute('for');
+ if (!htmlFor) {
+ return null;
+ }
+
+ const element = container.querySelector(`[id="${htmlFor}"]`);
+ return element ? element.tagName.toLowerCase() : null;
+}
+
+// the reason mentioned above is the same reason we're not using buildQueries
+const getMultipleError: GetErrorFunction<[unknown]> = (c, text) =>
+ `Found multiple elements with the text of: ${text}`;
+const queryByLabelText = wrapSingleQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: SelectorMatcherOptions]
+>(
+ makeSingleQuery(queryAllByLabelText, getMultipleError),
+ queryAllByLabelText.name,
+ 'query',
+);
+const getByLabelText = makeSingleQuery(getAllByLabelText, getMultipleError);
+
+const findAllByLabelText = makeFindQuery(
+ wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: SelectorMatcherOptions]
+ >(getAllByLabelText, getAllByLabelText.name, 'findAll'),
+);
+const findByLabelText = makeFindQuery(
+ wrapSingleQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: SelectorMatcherOptions]
+ >(getByLabelText, getAllByLabelText.name, 'find'),
+);
+
+const getAllByLabelTextWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: MatcherOptions]
+>(getAllByLabelText, getAllByLabelText.name, 'getAll');
+const getByLabelTextWithSuggestions = wrapSingleQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: SelectorMatcherOptions]
+>(getByLabelText, getAllByLabelText.name, 'get');
+
+const queryAllByLabelTextWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: SelectorMatcherOptions]
+>(queryAllByLabelText, queryAllByLabelText.name, 'queryAll');
+
+export {
+ queryAllByLabelTextWithSuggestions as queryAllByLabelText,
+ queryByLabelText,
+ getAllByLabelTextWithSuggestions as getAllByLabelText,
+ getByLabelTextWithSuggestions as getByLabelText,
+ findAllByLabelText,
+ findByLabelText,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/placeholder-text.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/placeholder-text.ts
new file mode 100644
index 0000000000..a6bafbb379
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/placeholder-text.ts
@@ -0,0 +1,35 @@
+import { wrapAllByQueryWithSuggestion } from '../query-helpers';
+import { checkContainerType } from '../helpers';
+import { AllByBoundAttribute, GetErrorFunction } from '../../types';
+import { queryAllByAttribute, buildQueries } from './all-utils';
+
+const queryAllByPlaceholderText: AllByBoundAttribute = (...args) => {
+ checkContainerType(args[0]);
+ return queryAllByAttribute('placeholder', ...args);
+};
+const getMultipleError: GetErrorFunction<[unknown]> = (c, text) =>
+ `Found multiple elements with the placeholder text of: ${text}`;
+const getMissingError: GetErrorFunction<[unknown]> = (c, text) =>
+ `Unable to find an element with the placeholder text of: ${text}`;
+
+const queryAllByPlaceholderTextWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [placeholderText: Matcher, options?: MatcherOptions]
+>(queryAllByPlaceholderText, queryAllByPlaceholderText.name, 'queryAll');
+
+const [
+ queryByPlaceholderText,
+ getAllByPlaceholderText,
+ getByPlaceholderText,
+ findAllByPlaceholderText,
+ findByPlaceholderText,
+] = buildQueries(queryAllByPlaceholderText, getMultipleError, getMissingError);
+
+export {
+ queryByPlaceholderText,
+ queryAllByPlaceholderTextWithSuggestions as queryAllByPlaceholderText,
+ getByPlaceholderText,
+ getAllByPlaceholderText,
+ findAllByPlaceholderText,
+ findByPlaceholderText,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/role.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/role.ts
new file mode 100644
index 0000000000..4e82cabb49
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/role.ts
@@ -0,0 +1,419 @@
+/* eslint-disable complexity */
+import {
+ computeAccessibleDescription,
+ computeAccessibleName,
+} from 'dom-accessibility-api';
+import {
+ roles as allRoles,
+ roleElements,
+ ARIARoleDefinitionKey,
+} from 'aria-query';
+import {
+ computeAriaSelected,
+ computeAriaBusy,
+ computeAriaChecked,
+ computeAriaPressed,
+ computeAriaCurrent,
+ computeAriaExpanded,
+ computeAriaValueNow,
+ computeAriaValueMax,
+ computeAriaValueMin,
+ computeAriaValueText,
+ computeHeadingLevel,
+ getImplicitAriaRoles,
+ prettyRoles,
+ isInaccessible,
+ isSubtreeInaccessible,
+} from '../role-helpers';
+import { wrapAllByQueryWithSuggestion } from '../query-helpers';
+import { checkContainerType } from '../helpers';
+import {
+ AllByRole,
+ ByRoleMatcher,
+ ByRoleOptions,
+ GetErrorFunction,
+ Matcher,
+ MatcherFunction,
+ MatcherOptions,
+} from '../../types';
+
+import { buildQueries, getConfig, matches } from './all-utils';
+
+const queryAllByRole: AllByRole = (
+ container,
+ role,
+ {
+ hidden = getConfig().defaultHidden,
+ name,
+ description,
+ queryFallbacks = false,
+ selected,
+ busy,
+ checked,
+ pressed,
+ current,
+ level,
+ expanded,
+ value: {
+ now: valueNow,
+ min: valueMin,
+ max: valueMax,
+ text: valueText,
+ } = {} as NonNullable,
+ } = {},
+) => {
+ checkContainerType(container);
+
+ if (selected !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-selected']
+ === undefined
+ ) {
+ throw new Error(`"aria-selected" is not supported on role "${role}".`);
+ }
+ }
+
+ if (busy !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-busy']
+ === undefined
+ ) {
+ throw new Error(`"aria-busy" is not supported on role "${role}".`);
+ }
+ }
+
+ if (checked !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-checked']
+ === undefined
+ ) {
+ throw new Error(`"aria-checked" is not supported on role "${role}".`);
+ }
+ }
+
+ if (pressed !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-pressed']
+ === undefined
+ ) {
+ throw new Error(`"aria-pressed" is not supported on role "${role}".`);
+ }
+ }
+
+ if (current !== undefined) {
+ /* istanbul ignore next */
+ // guard against unknown roles
+ // All currently released ARIA versions support `aria-current` on all roles.
+ // Leaving this for symmetry and forward compatibility
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-current']
+ === undefined
+ ) {
+ throw new Error(`"aria-current" is not supported on role "${role}".`);
+ }
+ }
+
+ if (level !== undefined) {
+ // guard against using `level` option with any role other than `heading`
+ if (role !== 'heading') {
+ throw new Error(`Role "${role}" cannot have "level" property.`);
+ }
+ }
+
+ if (valueNow !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-valuenow']
+ === undefined
+ ) {
+ throw new Error(`"aria-valuenow" is not supported on role "${role}".`);
+ }
+ }
+
+ if (valueMax !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-valuemax']
+ === undefined
+ ) {
+ throw new Error(`"aria-valuemax" is not supported on role "${role}".`);
+ }
+ }
+
+ if (valueMin !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-valuemin']
+ === undefined
+ ) {
+ throw new Error(`"aria-valuemin" is not supported on role "${role}".`);
+ }
+ }
+
+ if (valueText !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-valuetext']
+ === undefined
+ ) {
+ throw new Error(`"aria-valuetext" is not supported on role "${role}".`);
+ }
+ }
+
+ if (expanded !== undefined) {
+ // guard against unknown roles
+ if (
+ allRoles.get(role as ARIARoleDefinitionKey)?.props['aria-expanded']
+ === undefined
+ ) {
+ throw new Error(`"aria-expanded" is not supported on role "${role}".`);
+ }
+ }
+
+ const subtreeIsInaccessibleCache = new WeakMap();
+ function cachedIsSubtreeInaccessible(element: Element) {
+ if (!subtreeIsInaccessibleCache.has(element)) {
+ subtreeIsInaccessibleCache.set(element, isSubtreeInaccessible(element));
+ }
+
+ return subtreeIsInaccessibleCache.get(element) as boolean;
+ }
+
+ return Array.from(
+ container.querySelectorAll(
+ // Only query elements that can be matched by the following filters
+ makeRoleSelector(role),
+ ),
+ )
+ .filter(node => {
+ const isRoleSpecifiedExplicitly = node.hasAttribute('role');
+
+ if (isRoleSpecifiedExplicitly) {
+ const roleValue = node.getAttribute('role') as string;
+ if (queryFallbacks) {
+ return roleValue
+ .split(' ')
+ .filter(Boolean)
+ .some(roleAttributeToken => roleAttributeToken === role);
+ }
+ // other wise only send the first token to match
+ const [firstRoleAttributeToken] = roleValue.split(' ');
+ return firstRoleAttributeToken === role;
+ }
+
+ const implicitRoles = getImplicitAriaRoles(node) as string[];
+
+ return implicitRoles.some(implicitRole => {
+ return implicitRole === role;
+ });
+ })
+ .filter(element => {
+ if (selected !== undefined) {
+ return selected === computeAriaSelected(element);
+ }
+ if (busy !== undefined) {
+ return busy === computeAriaBusy(element);
+ }
+ if (checked !== undefined) {
+ return checked === computeAriaChecked(element);
+ }
+ if (pressed !== undefined) {
+ return pressed === computeAriaPressed(element);
+ }
+ if (current !== undefined) {
+ return current === computeAriaCurrent(element);
+ }
+ if (expanded !== undefined) {
+ return expanded === computeAriaExpanded(element);
+ }
+ if (level !== undefined) {
+ return level === computeHeadingLevel(element);
+ }
+ if (
+ valueNow !== undefined
+ || valueMax !== undefined
+ || valueMin !== undefined
+ || valueText !== undefined
+ ) {
+ let valueMatches = true;
+ if (valueNow !== undefined) {
+ valueMatches &&= valueNow === computeAriaValueNow(element);
+ }
+ if (valueMax !== undefined) {
+ valueMatches &&= valueMax === computeAriaValueMax(element);
+ }
+ if (valueMin !== undefined) {
+ valueMatches &&= valueMin === computeAriaValueMin(element);
+ }
+ if (valueText !== undefined) {
+ valueMatches &&= matches(
+ computeAriaValueText(element) ?? null,
+ element,
+ valueText,
+ text => text,
+ );
+ }
+
+ return valueMatches;
+ }
+ // don't care if aria attributes are unspecified
+ return true;
+ })
+ .filter(element => {
+ if (name === undefined) {
+ // Don't care
+ return true;
+ }
+
+ return matches(
+ computeAccessibleName(element, {
+ computedStyleSupportsPseudoElements:
+ getConfig().computedStyleSupportsPseudoElements,
+ }),
+ element,
+ name as MatcherFunction,
+ text => text,
+ );
+ })
+ .filter(element => {
+ if (description === undefined) {
+ // Don't care
+ return true;
+ }
+
+ return matches(
+ computeAccessibleDescription(element, {
+ computedStyleSupportsPseudoElements:
+ getConfig().computedStyleSupportsPseudoElements,
+ }),
+ element,
+ description as Matcher,
+ text => text,
+ );
+ })
+ .filter(element => {
+ return hidden === false
+ ? isInaccessible(element, {
+ isSubtreeInaccessible: cachedIsSubtreeInaccessible,
+ }) === false
+ : true;
+ });
+};
+
+function makeRoleSelector(role: ByRoleMatcher) {
+ const explicitRoleSelector = `*[role~="${role}"]`;
+
+ const roleRelations = roleElements.get(role as ARIARoleDefinitionKey)
+ ?? new Set();
+ const implicitRoleSelectors = new Set(
+ Array.from(roleRelations).map(({ name }) => name),
+ );
+
+ // Current transpilation config sometimes assumes `...` is always applied to arrays.
+ // `...` is equivalent to `Array.prototype.concat` for arrays.
+ // If you replace this code with `[explicitRoleSelector, ...implicitRoleSelectors]`, make sure every transpilation target retains the `...` in favor of `Array.prototype.concat`.
+ return [explicitRoleSelector]
+ .concat(Array.from(implicitRoleSelectors))
+ .join(',');
+}
+
+const getNameHint = (name: ByRoleOptions['name']): string => {
+ let nameHint = '';
+ if (name === undefined) {
+ nameHint = '';
+ } else if (typeof name === 'string') {
+ nameHint = ` and name "${name}"`;
+ } else {
+ nameHint = ` and name \`${name}\``;
+ }
+
+ return nameHint;
+};
+
+const getMultipleError: GetErrorFunction<
+ [matcher: ByRoleMatcher, options: ByRoleOptions]
+> = (c, role, { name } = {}) => {
+ return `Found multiple elements with the role "${role}"${getNameHint(name)}`;
+};
+
+const getMissingError: GetErrorFunction<
+ [matcher: ByRoleMatcher, options: ByRoleOptions]
+> = (
+ container,
+ role,
+ { hidden = getConfig().defaultHidden, name, description } = {},
+) => {
+ if (getConfig()._disableExpensiveErrorDiagnostics) {
+ return `Unable to find role="${role}"${getNameHint(name)}`;
+ }
+
+ let roles = '';
+ Array.from((container as Element).children).forEach(childElement => {
+ roles += prettyRoles(childElement, {
+ hidden,
+ includeDescription: description !== undefined,
+ });
+ });
+ let roleMessage;
+
+ if (roles.length === 0) {
+ if (hidden === false) {
+ roleMessage =
+ 'There are no accessible roles. But there might be some inaccessible roles. '
+ + 'If you wish to access them, then set the `hidden` option to `true`. '
+ + 'Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole';
+ } else {
+ roleMessage = 'There are no available roles.';
+ }
+ } else {
+ roleMessage = `
+Here are the ${hidden === false ? 'accessible' : 'available'} roles:
+
+ ${roles.replace(/\n/g, '\n ').replace(/\n\s\s\n/g, '\n\n')}
+`.trim();
+ }
+
+ let nameHint = '';
+ if (name === undefined) {
+ nameHint = '';
+ } else if (typeof name === 'string') {
+ nameHint = ` and name "${name}"`;
+ } else {
+ nameHint = ` and name \`${name}\``;
+ }
+
+ let descriptionHint = '';
+ if (description === undefined) {
+ descriptionHint = '';
+ } else if (typeof description === 'string') {
+ descriptionHint = ` and description "${description}"`;
+ } else {
+ descriptionHint = ` and description \`${description}\``;
+ }
+
+ return `
+Unable to find an ${
+ hidden === false ? 'accessible ' : ''
+ }element with the role "${role}"${nameHint}${descriptionHint}
+
+${roleMessage}`.trim();
+};
+const queryAllByRoleWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [labelText: Matcher, options?: MatcherOptions]
+>(queryAllByRole, queryAllByRole.name, 'queryAll');
+const [queryByRole, getAllByRole, getByRole, findAllByRole, findByRole] =
+ buildQueries(queryAllByRole, getMultipleError, getMissingError);
+
+export {
+ queryByRole,
+ queryAllByRoleWithSuggestions as queryAllByRole,
+ getAllByRole,
+ getByRole,
+ findAllByRole,
+ findByRole,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/test-id.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/test-id.ts
new file mode 100644
index 0000000000..fb006229f6
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/test-id.ts
@@ -0,0 +1,38 @@
+import { checkContainerType } from '../helpers';
+import { wrapAllByQueryWithSuggestion } from '../query-helpers';
+import { AllByBoundAttribute, GetErrorFunction } from '../../types';
+import { queryAllByAttribute, getConfig, buildQueries } from './all-utils';
+
+const getTestIdAttribute = () => getConfig().testIdAttribute;
+
+const queryAllByTestId: AllByBoundAttribute = (...args) => {
+ checkContainerType(args[0]);
+ return queryAllByAttribute(getTestIdAttribute(), ...args);
+};
+
+const getMultipleError: GetErrorFunction<[unknown]> = (c, id) =>
+ `Found multiple elements by: [${getTestIdAttribute()}="${id}"]`;
+const getMissingError: GetErrorFunction<[unknown]> = (c, id) =>
+ `Unable to find an element by: [${getTestIdAttribute()}="${id}"]`;
+
+const queryAllByTestIdWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [testId: Matcher, options?: MatcherOptions]
+>(queryAllByTestId, queryAllByTestId.name, 'queryAll');
+
+const [
+ queryByTestId,
+ getAllByTestId,
+ getByTestId,
+ findAllByTestId,
+ findByTestId,
+] = buildQueries(queryAllByTestId, getMultipleError, getMissingError);
+
+export {
+ queryByTestId,
+ queryAllByTestIdWithSuggestions as queryAllByTestId,
+ getByTestId,
+ getAllByTestId,
+ findAllByTestId,
+ findByTestId,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/text.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/text.ts
new file mode 100644
index 0000000000..1c791c82b8
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/text.ts
@@ -0,0 +1,92 @@
+import { wrapAllByQueryWithSuggestion } from '../query-helpers';
+import { checkContainerType } from '../helpers';
+import {
+ AllByText,
+ GetErrorFunction,
+ SelectorMatcherOptions,
+ Matcher,
+} from '../../types';
+import {
+ fuzzyMatches,
+ matches,
+ makeNormalizer,
+ getNodeText,
+ buildQueries,
+ getConfig,
+} from './all-utils';
+
+const queryAllByText: AllByText = (
+ container,
+ text,
+ {
+ selector = '*',
+ exact = true,
+ collapseWhitespace,
+ trim,
+ ignore = getConfig().defaultIgnore,
+ normalizer,
+ } = {},
+) => {
+ checkContainerType(container);
+ const matcher = exact ? matches : fuzzyMatches;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+ let baseArray: HTMLElement[] = [];
+ if (typeof container.matches === 'function' && container.matches(selector)) {
+ baseArray = [container];
+ }
+ return (
+ [
+ ...baseArray,
+ ...Array.from(container.querySelectorAll(selector)),
+ ]
+ // TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :)
+ .filter(node => !ignore || !node.matches(ignore as string))
+ .filter(node => matcher(getNodeText(node), node, text, matchNormalizer))
+ );
+};
+
+const getMultipleError: GetErrorFunction<[unknown]> = (c, text) =>
+ `Found multiple elements with the text: ${text}`;
+const getMissingError: GetErrorFunction<[Matcher, SelectorMatcherOptions]> = (
+ c,
+ text,
+ options = {},
+) => {
+ const { collapseWhitespace, trim, normalizer, selector } = options;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+ const normalizedText = matchNormalizer(text.toString());
+ const isNormalizedDifferent = normalizedText !== text.toString();
+ const isCustomSelector = (selector ?? '*') !== '*';
+ return `Unable to find an element with the text: ${
+ isNormalizedDifferent
+ ? `${normalizedText} (normalized from '${text}')`
+ : text
+ }${
+ isCustomSelector ? `, which matches selector '${selector}'` : ''
+ }. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.`;
+};
+
+const queryAllByTextWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [text: Matcher, options?: MatcherOptions]
+>(queryAllByText, queryAllByText.name, 'queryAll');
+
+const [queryByText, getAllByText, getByText, findAllByText, findByText] =
+ buildQueries(queryAllByText, getMultipleError, getMissingError);
+
+export {
+ queryByText,
+ queryAllByTextWithSuggestions as queryAllByText,
+ getByText,
+ getAllByText,
+ findAllByText,
+ findByText,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/queries/title.ts b/packages/testing-library/lynx-dom-testing-library/src/queries/title.ts
new file mode 100644
index 0000000000..6b6f0d120c
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/queries/title.ts
@@ -0,0 +1,63 @@
+import { wrapAllByQueryWithSuggestion } from '../query-helpers';
+import { checkContainerType } from '../helpers';
+import {
+ AllByBoundAttribute,
+ GetErrorFunction,
+ Matcher,
+ MatcherOptions,
+} from '../../types';
+import {
+ fuzzyMatches,
+ matches,
+ makeNormalizer,
+ getNodeText,
+ buildQueries,
+} from './all-utils';
+
+const isSvgTitle = (node: HTMLElement) =>
+ node.tagName.toLowerCase() === 'title'
+ && node.parentElement?.tagName.toLowerCase() === 'svg';
+
+const queryAllByTitle: AllByBoundAttribute = (
+ container,
+ text,
+ { exact = true, collapseWhitespace, trim, normalizer } = {},
+) => {
+ checkContainerType(container);
+ const matcher = exact ? matches : fuzzyMatches;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+ return Array.from(
+ container.querySelectorAll('[title], svg > title'),
+ ).filter(
+ node =>
+ matcher(node.getAttribute('title'), node, text, matchNormalizer)
+ || (isSvgTitle(node)
+ && matcher(getNodeText(node), node, text, matchNormalizer)),
+ );
+};
+
+const getMultipleError: GetErrorFunction<[unknown]> = (c, title) =>
+ `Found multiple elements with the title: ${title}.`;
+const getMissingError: GetErrorFunction<[unknown]> = (c, title) =>
+ `Unable to find an element with the title: ${title}.`;
+
+const queryAllByTitleWithSuggestions = wrapAllByQueryWithSuggestion<
+ // @ts-expect-error -- See `wrapAllByQueryWithSuggestion` Argument constraint comment
+ [title: Matcher, options?: MatcherOptions]
+>(queryAllByTitle, queryAllByTitle.name, 'queryAll');
+
+const [queryByTitle, getAllByTitle, getByTitle, findAllByTitle, findByTitle] =
+ buildQueries(queryAllByTitle, getMultipleError, getMissingError);
+
+export {
+ queryByTitle,
+ queryAllByTitleWithSuggestions as queryAllByTitle,
+ getByTitle,
+ getAllByTitle,
+ findAllByTitle,
+ findByTitle,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/query-helpers.ts b/packages/testing-library/lynx-dom-testing-library/src/query-helpers.ts
new file mode 100644
index 0000000000..d715dd2f37
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/query-helpers.ts
@@ -0,0 +1,274 @@
+import {
+ type GetErrorFunction,
+ type Matcher,
+ type MatcherOptions,
+ type QueryMethod,
+ type Variant,
+ type waitForOptions as WaitForOptions,
+ type WithSuggest,
+} from '../types';
+import { getSuggestedQuery } from './suggestions';
+import { fuzzyMatches, matches, makeNormalizer } from './matches';
+import { waitFor } from './wait-for';
+import { getConfig } from './config';
+
+function getElementError(message: string | null, container: HTMLElement) {
+ return getConfig().getElementError(message, container);
+}
+
+function getMultipleElementsFoundError(
+ message: string,
+ container: HTMLElement,
+) {
+ return getElementError(
+ `${message}\n\n(If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).`,
+ container,
+ );
+}
+
+function queryAllByAttribute(
+ attribute: string,
+ container: HTMLElement,
+ text: Matcher,
+ { exact = true, collapseWhitespace, trim, normalizer }: MatcherOptions = {},
+): HTMLElement[] {
+ const matcher = exact ? matches : fuzzyMatches;
+ const matchNormalizer = makeNormalizer({
+ collapseWhitespace,
+ trim,
+ normalizer,
+ });
+ return Array.from(
+ container.querySelectorAll(`[${attribute}]`),
+ ).filter(node =>
+ matcher(node.getAttribute(attribute), node, text, matchNormalizer)
+ );
+}
+
+function queryByAttribute(
+ attribute: string,
+ container: HTMLElement,
+ text: Matcher,
+ options?: MatcherOptions,
+) {
+ const els = queryAllByAttribute(attribute, container, text, options);
+ if (els.length > 1) {
+ throw getMultipleElementsFoundError(
+ `Found multiple elements by [${attribute}=${text}]`,
+ container,
+ );
+ }
+ return els[0] || null;
+}
+
+// this accepts a query function and returns a function which throws an error
+// if more than one elements is returned, otherwise it returns the first
+// element or null
+function makeSingleQuery(
+ allQuery: QueryMethod,
+ getMultipleError: GetErrorFunction,
+) {
+ return (container: HTMLElement, ...args: Arguments) => {
+ const els = allQuery(container, ...args);
+ if (els.length > 1) {
+ const elementStrings = els
+ .map(element => getElementError(null, element).message)
+ .join('\n\n');
+
+ throw getMultipleElementsFoundError(
+ `${getMultipleError(container, ...args)}
+
+Here are the matching elements:
+
+${elementStrings}`,
+ container,
+ );
+ }
+ return els[0] || null;
+ };
+}
+
+function getSuggestionError(
+ suggestion: { toString(): string },
+ container: HTMLElement,
+) {
+ return getConfig().getElementError(
+ `A better query is available, try this:
+${suggestion.toString()}
+`,
+ container,
+ );
+}
+
+// this accepts a query function and returns a function which throws an error
+// if an empty list of elements is returned
+function makeGetAllQuery(
+ allQuery: (container: HTMLElement, ...args: Arguments) => HTMLElement[],
+ getMissingError: GetErrorFunction,
+) {
+ return (container: HTMLElement, ...args: Arguments) => {
+ const els = allQuery(container, ...args);
+ if (!els.length) {
+ throw getConfig().getElementError(
+ getMissingError(container, ...args),
+ container,
+ );
+ }
+
+ return els;
+ };
+}
+
+// this accepts a getter query function and returns a function which calls
+// waitFor and passing a function which invokes the getter.
+function makeFindQuery(
+ getter: (
+ container: HTMLElement,
+ text: QueryMatcher,
+ options: MatcherOptions,
+ ) => QueryFor,
+) {
+ return (
+ container: HTMLElement,
+ text: QueryMatcher,
+ options: MatcherOptions,
+ waitForOptions: WaitForOptions,
+ ) => {
+ return waitFor(
+ () => {
+ return getter(container, text, options);
+ },
+ { container, ...waitForOptions },
+ );
+ };
+}
+
+const wrapSingleQueryWithSuggestion =
+ (
+ query: (container: HTMLElement, ...args: Arguments) => HTMLElement | null,
+ queryAllByName: string,
+ variant: Variant,
+ ) =>
+ (container: HTMLElement, ...args: Arguments) => {
+ const element = query(container, ...args);
+ const [{ suggest = getConfig().throwSuggestions } = {}] = args.slice(
+ -1,
+ ) as [
+ WithSuggest,
+ ];
+ if (element && suggest) {
+ const suggestion = getSuggestedQuery(element, variant);
+ if (
+ suggestion
+ && !queryAllByName.endsWith(suggestion.queryName as string)
+ ) {
+ throw getSuggestionError(suggestion.toString(), container);
+ }
+ }
+
+ return element;
+ };
+
+const wrapAllByQueryWithSuggestion = <
+ // We actually want `Arguments extends [args: ...unknown[], options?: Options]`
+ // But that's not supported by TS so we have to `@ts-expect-error` every callsite
+ Arguments extends [...unknown[], WithSuggest],
+>(
+ query: (container: HTMLElement, ...args: Arguments) => HTMLElement[],
+ queryAllByName: string,
+ variant: Variant,
+) =>
+(container: HTMLElement, ...args: Arguments) => {
+ const els = query(container, ...args);
+
+ const [{ suggest = getConfig().throwSuggestions } = {}] = args.slice(-1) as [
+ WithSuggest,
+ ];
+ if (els.length && suggest) {
+ // get a unique list of all suggestion messages. We are only going to make a suggestion if
+ // all the suggestions are the same
+ const uniqueSuggestionMessages = [
+ ...new Set(
+ els.map(
+ element => getSuggestedQuery(element, variant)?.toString() as string,
+ ),
+ ),
+ ];
+
+ if (
+ // only want to suggest if all the els have the same suggestion.
+ uniqueSuggestionMessages.length === 1
+ && !queryAllByName.endsWith(
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TODO: Can this be null at runtime?
+ getSuggestedQuery(els[0], variant)!.queryName as string,
+ )
+ ) {
+ throw getSuggestionError(uniqueSuggestionMessages[0], container);
+ }
+ }
+
+ return els;
+};
+
+// TODO: This deviates from the published declarations
+// However, the implementation always required a dyadic (after `container`) not variadic `queryAllBy` considering the implementation of `makeFindQuery`
+// This is at least statically true and can be verified by accepting `QueryMethod`
+function buildQueries(
+ queryAllBy: QueryMethod<
+ [matcher: QueryMatcher, options: MatcherOptions],
+ HTMLElement[]
+ >,
+ getMultipleError: GetErrorFunction<
+ [matcher: QueryMatcher, options: MatcherOptions]
+ >,
+ getMissingError: GetErrorFunction<
+ [matcher: QueryMatcher, options: MatcherOptions]
+ >,
+) {
+ const queryBy = wrapSingleQueryWithSuggestion(
+ makeSingleQuery(queryAllBy, getMultipleError),
+ queryAllBy.name,
+ 'query',
+ );
+ const getAllBy = makeGetAllQuery(queryAllBy, getMissingError);
+
+ const getBy = makeSingleQuery(getAllBy, getMultipleError);
+ const getByWithSuggestions = wrapSingleQueryWithSuggestion(
+ getBy,
+ queryAllBy.name,
+ 'get',
+ );
+ const getAllWithSuggestions = wrapAllByQueryWithSuggestion(
+ getAllBy,
+ queryAllBy.name.replace('query', 'get'),
+ 'getAll',
+ );
+
+ const findAllBy = makeFindQuery(
+ wrapAllByQueryWithSuggestion(getAllBy, queryAllBy.name, 'findAll'),
+ );
+ const findBy = makeFindQuery(
+ wrapSingleQueryWithSuggestion(getBy, queryAllBy.name, 'find'),
+ );
+
+ return [
+ queryBy,
+ getAllWithSuggestions,
+ getByWithSuggestions,
+ findAllBy,
+ findBy,
+ ];
+}
+
+export {
+ getElementError,
+ wrapAllByQueryWithSuggestion,
+ wrapSingleQueryWithSuggestion,
+ getMultipleElementsFoundError,
+ queryAllByAttribute,
+ queryByAttribute,
+ makeSingleQuery,
+ makeGetAllQuery,
+ makeFindQuery,
+ buildQueries,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/role-helpers.js b/packages/testing-library/lynx-dom-testing-library/src/role-helpers.js
new file mode 100644
index 0000000000..821a0b4848
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/role-helpers.js
@@ -0,0 +1,401 @@
+import { elementRoles } from 'aria-query';
+import {
+ computeAccessibleDescription,
+ computeAccessibleName,
+} from 'dom-accessibility-api';
+import { prettyDOM } from './pretty-dom';
+import { getConfig } from './config';
+
+const elementRoleList = buildElementRoleList(elementRoles);
+
+/**
+ * @param {Element} element -
+ * @returns {boolean} - `true` if `element` and its subtree are inaccessible
+ */
+function isSubtreeInaccessible(element) {
+ if (element.hidden === true) {
+ return true;
+ }
+
+ if (element.getAttribute('aria-hidden') === 'true') {
+ return true;
+ }
+
+ const window = element.ownerDocument.defaultView;
+ if (window.getComputedStyle(element).display === 'none') {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Partial implementation https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
+ * which should only be used for elements with a non-presentational role i.e.
+ * `role="none"` and `role="presentation"` will not be excluded.
+ *
+ * Implements aria-hidden semantics (i.e. parent overrides child)
+ * Ignores "Child Presentational: True" characteristics
+ *
+ * @param {Element} element -
+ * @param {object} [options] -
+ * @param {function (element: Element): boolean} options.isSubtreeInaccessible -
+ * can be used to return cached results from previous isSubtreeInaccessible calls
+ * @returns {boolean} true if excluded, otherwise false
+ */
+function isInaccessible(element, options = {}) {
+ const {
+ isSubtreeInaccessible: isSubtreeInaccessibleImpl = isSubtreeInaccessible,
+ } = options;
+ const window = element.ownerDocument.defaultView;
+ // since visibility is inherited we can exit early
+ if (window.getComputedStyle(element).visibility === 'hidden') {
+ return true;
+ }
+
+ let currentElement = element;
+ while (currentElement) {
+ if (isSubtreeInaccessibleImpl(currentElement)) {
+ return true;
+ }
+
+ currentElement = currentElement.parentElement;
+ }
+
+ return false;
+}
+
+function getImplicitAriaRoles(currentNode) {
+ // eslint bug here:
+ // eslint-disable-next-line no-unused-vars
+ for (const { match, roles } of elementRoleList) {
+ if (match(currentNode)) {
+ return [...roles];
+ }
+ }
+
+ return [];
+}
+
+function buildElementRoleList(elementRolesMap) {
+ function makeElementSelector({ name, attributes }) {
+ return `${name}${
+ attributes
+ .map(({ name: attributeName, value, constraints = [] }) => {
+ const shouldNotExist = constraints.indexOf('undefined') !== -1;
+ const shouldBeNonEmpty = constraints.indexOf('set') !== -1;
+ const hasExplicitValue = typeof value !== 'undefined';
+
+ if (hasExplicitValue) {
+ return `[${attributeName}="${value}"]`;
+ } else if (shouldNotExist) {
+ return `:not([${attributeName}])`;
+ } else if (shouldBeNonEmpty) {
+ return `[${attributeName}]:not([${attributeName}=""])`;
+ }
+
+ return `[${attributeName}]`;
+ })
+ .join('')
+ }`;
+ }
+
+ function getSelectorSpecificity({ attributes = [] }) {
+ return attributes.length;
+ }
+
+ function bySelectorSpecificity(
+ { specificity: leftSpecificity },
+ { specificity: rightSpecificity },
+ ) {
+ return rightSpecificity - leftSpecificity;
+ }
+
+ function match(element) {
+ let { attributes = [] } = element;
+
+ // https://github.com/testing-library/dom-testing-library/issues/814
+ const typeTextIndex = attributes.findIndex(
+ attribute =>
+ attribute.value
+ && attribute.name === 'type'
+ && attribute.value === 'text',
+ );
+
+ if (typeTextIndex >= 0) {
+ // not using splice to not mutate the attributes array
+ attributes = [
+ ...attributes.slice(0, typeTextIndex),
+ ...attributes.slice(typeTextIndex + 1),
+ ];
+ }
+
+ const selector = makeElementSelector({ ...element, attributes });
+
+ return node => {
+ if (typeTextIndex >= 0 && node.type !== 'text') {
+ return false;
+ }
+
+ return node.matches(selector);
+ };
+ }
+
+ let result = [];
+
+ // eslint bug here:
+ // eslint-disable-next-line no-unused-vars
+ for (const [element, roles] of elementRolesMap.entries()) {
+ result = [
+ ...result,
+ {
+ match: match(element),
+ roles: Array.from(roles),
+ specificity: getSelectorSpecificity(element),
+ },
+ ];
+ }
+
+ return result.sort(bySelectorSpecificity);
+}
+
+function getRoles(container, { hidden = false } = {}) {
+ function flattenDOM(node) {
+ return [
+ node,
+ ...Array.from(node.children).reduce(
+ (acc, child) => [...acc, ...flattenDOM(child)],
+ [],
+ ),
+ ];
+ }
+
+ return flattenDOM(container)
+ .filter(element => {
+ return hidden === false ? isInaccessible(element) === false : true;
+ })
+ .reduce((acc, node) => {
+ let roles = [];
+ // TODO: This violates html-aria which does not allow any role on every element
+ if (node.hasAttribute('role')) {
+ roles = node.getAttribute('role').split(' ').slice(0, 1);
+ } else {
+ roles = getImplicitAriaRoles(node);
+ }
+
+ return roles.reduce(
+ (rolesAcc, role) =>
+ Array.isArray(rolesAcc[role])
+ ? { ...rolesAcc, [role]: [...rolesAcc[role], node] }
+ : { ...rolesAcc, [role]: [node] },
+ acc,
+ );
+ }, {});
+}
+
+function prettyRoles(dom, { hidden, includeDescription }) {
+ const roles = getRoles(dom, { hidden });
+ // We prefer to skip generic role, we don't recommend it
+ return Object.entries(roles)
+ .filter(([role]) => role !== 'generic')
+ .map(([role, elements]) => {
+ const delimiterBar = '-'.repeat(50);
+ const elementsString = elements
+ .map(el => {
+ const nameString = `Name "${
+ computeAccessibleName(el, {
+ computedStyleSupportsPseudoElements:
+ getConfig().computedStyleSupportsPseudoElements,
+ })
+ }":\n`;
+
+ const domString = prettyDOM(el.cloneNode(false));
+
+ if (includeDescription) {
+ const descriptionString = `Description "${
+ computeAccessibleDescription(
+ el,
+ {
+ computedStyleSupportsPseudoElements:
+ getConfig().computedStyleSupportsPseudoElements,
+ },
+ )
+ }":\n`;
+ return `${nameString}${descriptionString}${domString}`;
+ }
+
+ return `${nameString}${domString}`;
+ })
+ .join('\n\n');
+
+ return `${role}:\n\n${elementsString}\n\n${delimiterBar}`;
+ })
+ .join('\n');
+}
+
+const logRoles = (dom, { hidden = false } = {}) =>
+ console.log(prettyRoles(dom, { hidden }));
+
+/**
+ * @param {Element} element -
+ * @returns {boolean | undefined} - false/true if (not)selected, undefined if not selectable
+ */
+function computeAriaSelected(element) {
+ // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
+ // https://www.w3.org/TR/html-aam-1.0/#details-id-97
+ if (element.tagName === 'OPTION') {
+ return element.selected;
+ }
+
+ // explicit value
+ return checkBooleanAttribute(element, 'aria-selected');
+}
+
+/**
+ * @param {Element} element -
+ * @returns {boolean} -
+ */
+function computeAriaBusy(element) {
+ // https://www.w3.org/TR/wai-aria-1.1/#aria-busy
+ return element.getAttribute('aria-busy') === 'true';
+}
+
+/**
+ * @param {Element} element -
+ * @returns {boolean | undefined} - false/true if (not)checked, undefined if not checked-able
+ */
+function computeAriaChecked(element) {
+ // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
+ // https://www.w3.org/TR/html-aam-1.0/#details-id-56
+ // https://www.w3.org/TR/html-aam-1.0/#details-id-67
+ if ('indeterminate' in element && element.indeterminate) {
+ return undefined;
+ }
+ if ('checked' in element) {
+ return element.checked;
+ }
+
+ // explicit value
+ return checkBooleanAttribute(element, 'aria-checked');
+}
+
+/**
+ * @param {Element} element -
+ * @returns {boolean | undefined} - false/true if (not)pressed, undefined if not press-able
+ */
+function computeAriaPressed(element) {
+ // https://www.w3.org/TR/wai-aria-1.1/#aria-pressed
+ return checkBooleanAttribute(element, 'aria-pressed');
+}
+
+/**
+ * @param {Element} element -
+ * @returns {boolean | string | null} -
+ */
+function computeAriaCurrent(element) {
+ // https://www.w3.org/TR/wai-aria-1.1/#aria-current
+ return (
+ checkBooleanAttribute(element, 'aria-current')
+ ?? element.getAttribute('aria-current')
+ ?? false
+ );
+}
+
+/**
+ * @param {Element} element -
+ * @returns {boolean | undefined} - false/true if (not)expanded, undefined if not expand-able
+ */
+function computeAriaExpanded(element) {
+ // https://www.w3.org/TR/wai-aria-1.1/#aria-expanded
+ return checkBooleanAttribute(element, 'aria-expanded');
+}
+
+function checkBooleanAttribute(element, attribute) {
+ const attributeValue = element.getAttribute(attribute);
+ if (attributeValue === 'true') {
+ return true;
+ }
+ if (attributeValue === 'false') {
+ return false;
+ }
+ return undefined;
+}
+
+/**
+ * @param {Element} element -
+ * @returns {number | undefined} - number if implicit heading or aria-level present, otherwise undefined
+ */
+function computeHeadingLevel(element) {
+ // https://w3c.github.io/html-aam/#el-h1-h6
+ // https://w3c.github.io/html-aam/#el-h1-h6
+ const implicitHeadingLevels = {
+ H1: 1,
+ H2: 2,
+ H3: 3,
+ H4: 4,
+ H5: 5,
+ H6: 6,
+ };
+ // explicit aria-level value
+ // https://www.w3.org/TR/wai-aria-1.2/#aria-level
+ const ariaLevelAttribute = element.getAttribute('aria-level')
+ && Number(element.getAttribute('aria-level'));
+
+ return ariaLevelAttribute || implicitHeadingLevels[element.tagName];
+}
+
+/**
+ * @param {Element} element -
+ * @returns {number | undefined} -
+ */
+function computeAriaValueNow(element) {
+ const valueNow = element.getAttribute('aria-valuenow');
+ return valueNow === null ? undefined : +valueNow;
+}
+
+/**
+ * @param {Element} element -
+ * @returns {number | undefined} -
+ */
+function computeAriaValueMax(element) {
+ const valueMax = element.getAttribute('aria-valuemax');
+ return valueMax === null ? undefined : +valueMax;
+}
+
+/**
+ * @param {Element} element -
+ * @returns {number | undefined} -
+ */
+function computeAriaValueMin(element) {
+ const valueMin = element.getAttribute('aria-valuemin');
+ return valueMin === null ? undefined : +valueMin;
+}
+
+/**
+ * @param {Element} element -
+ * @returns {string | undefined} -
+ */
+function computeAriaValueText(element) {
+ const valueText = element.getAttribute('aria-valuetext');
+ return valueText === null ? undefined : valueText;
+}
+
+export {
+ getRoles,
+ logRoles,
+ getImplicitAriaRoles,
+ isSubtreeInaccessible,
+ prettyRoles,
+ isInaccessible,
+ computeAriaSelected,
+ computeAriaBusy,
+ computeAriaChecked,
+ computeAriaPressed,
+ computeAriaCurrent,
+ computeAriaExpanded,
+ computeAriaValueNow,
+ computeAriaValueMax,
+ computeAriaValueMin,
+ computeAriaValueText,
+ computeHeadingLevel,
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/screen.ts b/packages/testing-library/lynx-dom-testing-library/src/screen.ts
new file mode 100644
index 0000000000..e9591e4e65
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/screen.ts
@@ -0,0 +1,58 @@
+// WARNING: `lz-string` only has a default export but statically we assume named exports are allowed
+// TODO: Statically verify we don't rely on NodeJS implicit named imports.
+import lzString from 'lz-string';
+import { type OptionsReceived } from 'pretty-format';
+import { getQueriesForElement } from './get-queries-for-element';
+import { getDocument } from './helpers';
+import { logDOM } from './pretty-dom';
+import * as queries from './queries';
+
+function unindent(string: string) {
+ // remove white spaces first, to save a few bytes.
+ // testing-playground will reformat on load any ways.
+ return string.replace(/[ \t]*[\n][ \t]*/g, '\n');
+}
+
+function encode(value: string) {
+ return lzString.compressToEncodedURIComponent(unindent(value));
+}
+
+function getPlaygroundUrl(markup: string) {
+ return `https://testing-playground.com/#markup=${encode(markup)}`;
+}
+
+const debug = (
+ element?: Array | Element | HTMLDocument,
+ maxLength?: number,
+ options?: OptionsReceived,
+): void =>
+ Array.isArray(element)
+ ? element.forEach(el => logDOM(el, maxLength, options))
+ : logDOM(element, maxLength, options);
+
+const logTestingPlaygroundURL = (element = getDocument().body) => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!element || !('innerHTML' in element)) {
+ console.log(`The element you're providing isn't a valid DOM element.`);
+ return;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!element.innerHTML) {
+ console.log(`The provided element doesn't have any children.`);
+ return;
+ }
+ const playgroundUrl = getPlaygroundUrl(element.innerHTML);
+ console.log(`Open this URL in your browser\n\n${playgroundUrl}`);
+ return playgroundUrl;
+};
+
+const initialValue = { debug, logTestingPlaygroundURL };
+
+// export const screen = getQueriesForElement(document.body, queries, initialValue)
+export const getScreen = () => {
+ return getQueriesForElement(
+ lynxDOM.mainThread.elementTree.root,
+ queries,
+ initialValue,
+ );
+};
diff --git a/packages/testing-library/lynx-dom-testing-library/src/suggestions.js b/packages/testing-library/lynx-dom-testing-library/src/suggestions.js
new file mode 100644
index 0000000000..53a36ba23f
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/suggestions.js
@@ -0,0 +1,139 @@
+import { computeAccessibleName } from 'dom-accessibility-api';
+import { getDefaultNormalizer } from './matches';
+import { getNodeText } from './get-node-text';
+import { getConfig } from './config';
+import { getImplicitAriaRoles, isInaccessible } from './role-helpers';
+import { getLabels } from './label-helpers';
+
+const normalize = getDefaultNormalizer();
+
+function escapeRegExp(string) {
+ return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+}
+
+function getRegExpMatcher(string) {
+ return new RegExp(escapeRegExp(string.toLowerCase()), 'i');
+}
+
+function makeSuggestion(queryName, element, content, { variant, name }) {
+ let warning = '';
+ const queryOptions = {};
+ const queryArgs = [
+ ['Role', 'TestId'].includes(queryName)
+ ? content
+ : getRegExpMatcher(content),
+ ];
+
+ if (name) {
+ queryOptions.name = getRegExpMatcher(name);
+ }
+
+ if (queryName === 'Role' && isInaccessible(element)) {
+ queryOptions.hidden = true;
+ warning =
+ `Element is inaccessible. This means that the element and all its children are invisible to screen readers.
+ If you are using the aria-hidden prop, make sure this is the right choice for your case.
+ `;
+ }
+ if (Object.keys(queryOptions).length > 0) {
+ queryArgs.push(queryOptions);
+ }
+
+ const queryMethod = `${variant}By${queryName}`;
+
+ return {
+ queryName,
+ queryMethod,
+ queryArgs,
+ variant,
+ warning,
+ toString() {
+ if (warning) {
+ console.warn(warning);
+ }
+ let [text, options] = queryArgs;
+
+ text = typeof text === 'string' ? `'${text}'` : text;
+
+ options = options
+ ? `, { ${
+ Object.entries(options)
+ .map(([k, v]) => `${k}: ${v}`)
+ .join(', ')
+ } }`
+ : '';
+
+ return `${queryMethod}(${text}${options})`;
+ },
+ };
+}
+
+function canSuggest(currentMethod, requestedMethod, data) {
+ return (
+ data
+ && (!requestedMethod
+ || requestedMethod.toLowerCase() === currentMethod.toLowerCase())
+ );
+}
+
+export function getSuggestedQuery(element, variant = 'get', method) {
+ // don't create suggestions for script and style elements
+ if (element.matches(getConfig().defaultIgnore)) {
+ return undefined;
+ }
+
+ // We prefer to suggest something else if the role is generic
+ const role = element.getAttribute('role')
+ ?? getImplicitAriaRoles(element)?.[0];
+ if (role !== 'generic' && canSuggest('Role', method, role)) {
+ return makeSuggestion('Role', element, role, {
+ variant,
+ name: computeAccessibleName(element, {
+ computedStyleSupportsPseudoElements:
+ getConfig().computedStyleSupportsPseudoElements,
+ }),
+ });
+ }
+
+ const labelText = getLabels(document, element)
+ .map(label => label.content)
+ .join(' ');
+ if (canSuggest('LabelText', method, labelText)) {
+ return makeSuggestion('LabelText', element, labelText, { variant });
+ }
+
+ const placeholderText = element.getAttribute('placeholder');
+ if (canSuggest('PlaceholderText', method, placeholderText)) {
+ return makeSuggestion('PlaceholderText', element, placeholderText, {
+ variant,
+ });
+ }
+
+ const textContent = normalize(getNodeText(element));
+ if (canSuggest('Text', method, textContent)) {
+ return makeSuggestion('Text', element, textContent, { variant });
+ }
+
+ if (canSuggest('DisplayValue', method, element.value)) {
+ return makeSuggestion('DisplayValue', element, normalize(element.value), {
+ variant,
+ });
+ }
+
+ const alt = element.getAttribute('alt');
+ if (canSuggest('AltText', method, alt)) {
+ return makeSuggestion('AltText', element, alt, { variant });
+ }
+
+ const title = element.getAttribute('title');
+ if (canSuggest('Title', method, title)) {
+ return makeSuggestion('Title', element, title, { variant });
+ }
+
+ const testId = element.getAttribute(getConfig().testIdAttribute);
+ if (canSuggest('TestId', method, testId)) {
+ return makeSuggestion('TestId', element, testId, { variant });
+ }
+
+ return undefined;
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/src/wait-for-element-to-be-removed.js b/packages/testing-library/lynx-dom-testing-library/src/wait-for-element-to-be-removed.js
new file mode 100644
index 0000000000..cb18c61070
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/wait-for-element-to-be-removed.js
@@ -0,0 +1,55 @@
+import { waitFor } from './wait-for';
+
+const isRemoved = result =>
+ !result || (Array.isArray(result) && !result.length);
+
+// Check if the element is not present.
+// As the name implies, waitForElementToBeRemoved should check `present` --> `removed`
+function initialCheck(elements) {
+ if (isRemoved(elements)) {
+ throw new Error(
+ 'The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.',
+ );
+ }
+}
+
+async function waitForElementToBeRemoved(callback, options) {
+ // created here so we get a nice stacktrace
+ const timeoutError = new Error('Timed out in waitForElementToBeRemoved.');
+ if (typeof callback !== 'function') {
+ initialCheck(callback);
+ const elements = Array.isArray(callback) ? callback : [callback];
+ const getRemainingElements = elements.map(element => {
+ let parent = element.parentElement;
+ if (parent === null) return () => null;
+ while (parent.parentElement) parent = parent.parentElement;
+ return () => (parent.contains(element) ? element : null);
+ });
+ callback = () => getRemainingElements.map(c => c()).filter(Boolean);
+ }
+
+ initialCheck(callback());
+
+ return waitFor(() => {
+ let result;
+ try {
+ result = callback();
+ } catch (error) {
+ if (error.name === 'TestingLibraryElementError') {
+ return undefined;
+ }
+ throw error;
+ }
+ if (!isRemoved(result)) {
+ throw timeoutError;
+ }
+ return undefined;
+ }, options);
+}
+
+export { waitForElementToBeRemoved };
+
+/*
+eslint
+ require-await: "off"
+*/
diff --git a/packages/testing-library/lynx-dom-testing-library/src/wait-for.js b/packages/testing-library/lynx-dom-testing-library/src/wait-for.js
new file mode 100644
index 0000000000..5fc2d0f46a
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/src/wait-for.js
@@ -0,0 +1,133 @@
+import {
+ getWindowFromNode,
+ getDocument,
+ jestFakeTimersAreEnabled,
+ // We import these from the helpers rather than using the global version
+ // because these will be *real* timers, regardless of whether we're in
+ // an environment that's faked the timers out.
+ checkContainerType,
+} from './helpers';
+import { getConfig, runWithExpensiveErrorDiagnosticsDisabled } from './config';
+
+// This is so the stack trace the developer sees is one that's
+// closer to their code (because async stack traces are hard to follow).
+function copyStackTrace(target, source) {
+ target.stack = source.stack.replace(source.message, target.message);
+}
+
+function waitFor(
+ callback,
+ {
+ container = getDocument(),
+ timeout = getConfig().asyncUtilTimeout,
+ showOriginalStackTrace = getConfig().showOriginalStackTrace,
+ stackTraceError,
+ interval = 50,
+ onTimeout = error => {
+ Object.defineProperty(error, 'message', {
+ value: getConfig().getElementError(error.message, container).message,
+ });
+ return error;
+ },
+ },
+) {
+ if (typeof callback !== 'function') {
+ throw new TypeError('Received `callback` arg must be a function');
+ }
+
+ return new Promise(async (resolve, reject) => {
+ let lastError, intervalId;
+ let promiseStatus = 'idle';
+
+ const overallTimeoutTimer = setTimeout(handleTimeout, timeout);
+
+ try {
+ checkContainerType(container);
+ } catch (e) {
+ reject(e);
+ return;
+ }
+ // eslint-disable-next-line prefer-const
+ intervalId = setInterval(checkRealTimersCallback, interval);
+ // const {MutationObserver} = getWindowFromNode(container)
+ // observer = new MutationObserver(checkRealTimersCallback)
+ // observer.observe(container, mutationObserverOptions)
+ checkCallback();
+
+ function onDone(error, result) {
+ clearTimeout(overallTimeoutTimer);
+ clearInterval(intervalId);
+
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ }
+
+ function checkRealTimersCallback() {
+ return checkCallback();
+ }
+
+ function checkCallback() {
+ if (promiseStatus === 'pending') return;
+ try {
+ const result = runWithExpensiveErrorDiagnosticsDisabled(callback);
+ if (typeof result?.then === 'function') {
+ promiseStatus = 'pending';
+ result.then(
+ resolvedValue => {
+ promiseStatus = 'resolved';
+ onDone(null, resolvedValue);
+ },
+ rejectedValue => {
+ promiseStatus = 'rejected';
+ lastError = rejectedValue;
+ },
+ );
+ } else {
+ onDone(null, result);
+ }
+ // If `callback` throws, wait for the next mutation, interval, or timeout.
+ } catch (error) {
+ // Save the most recent callback error to reject the promise with it in the event of a timeout
+ lastError = error;
+ }
+ }
+
+ function handleTimeout() {
+ let error;
+ if (lastError) {
+ error = lastError;
+ if (
+ !showOriginalStackTrace
+ && error.name === 'TestingLibraryElementError'
+ ) {
+ copyStackTrace(error, stackTraceError);
+ }
+ } else {
+ error = new Error('Timed out in waitFor.');
+ if (!showOriginalStackTrace) {
+ copyStackTrace(error, stackTraceError);
+ }
+ }
+ onDone(onTimeout(error), null);
+ }
+ });
+}
+
+function waitForWrapper(callback, options) {
+ // create the error here so its stack trace is as close to the
+ // calling code as possible
+ const stackTraceError = new Error('STACK_TRACE_MESSAGE');
+ return getConfig().asyncWrapper(() =>
+ waitFor(callback, { stackTraceError, ...options })
+ );
+}
+
+export { waitForWrapper as waitFor };
+
+/*
+eslint
+ max-lines-per-function: ["error", {"max": 200}],
+*/
diff --git a/packages/testing-library/lynx-dom-testing-library/tests/jest.config.dom.js b/packages/testing-library/lynx-dom-testing-library/tests/jest.config.dom.js
new file mode 100644
index 0000000000..f555d5a648
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/tests/jest.config.dom.js
@@ -0,0 +1,21 @@
+const path = require('path');
+const {
+ // global config options that would trigger warnings in project configs
+ collectCoverageFrom,
+ watchPlugins,
+ ...baseConfig
+} = require('kcd-scripts/jest');
+
+const projectConfig = {
+ ...baseConfig,
+ rootDir: path.join(__dirname, '..'),
+ displayName: 'dom',
+ coveragePathIgnorePatterns: [
+ ...baseConfig.coveragePathIgnorePatterns,
+ '/__tests__/',
+ '/__node_tests__/',
+ ],
+ testEnvironment: 'jest-environment-jsdom',
+};
+
+module.exports = projectConfig;
diff --git a/packages/testing-library/lynx-dom-testing-library/tests/jest.config.node.js b/packages/testing-library/lynx-dom-testing-library/tests/jest.config.node.js
new file mode 100644
index 0000000000..feebbdaa71
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/tests/jest.config.node.js
@@ -0,0 +1,22 @@
+const path = require('path');
+const {
+ // global config options that would trigger warnings in project configs
+ collectCoverageFrom,
+ watchPlugins,
+ ...baseConfig
+} = require('kcd-scripts/jest');
+
+const projectConfig = {
+ ...baseConfig,
+ rootDir: path.join(__dirname, '..'),
+ displayName: 'node',
+ testEnvironment: 'jest-environment-node',
+ coveragePathIgnorePatterns: [
+ ...baseConfig.coveragePathIgnorePatterns,
+ '/__tests__/',
+ '/__node_tests__/',
+ ],
+ testMatch: ['**/__node_tests__/**.js'],
+};
+
+module.exports = projectConfig;
diff --git a/packages/testing-library/lynx-dom-testing-library/tests/setup-env.js b/packages/testing-library/lynx-dom-testing-library/tests/setup-env.js
new file mode 100644
index 0000000000..715ac64f64
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/tests/setup-env.js
@@ -0,0 +1,45 @@
+import '@testing-library/jest-dom/extend-expect';
+
+// add serializer for MutationRecord
+expect.addSnapshotSerializer({
+ print: (record, serialize) => {
+ return serialize({
+ addedNodes: record.addedNodes,
+ attributeName: record.attributeName,
+ attributeNamespace: record.attributeNamespace,
+ nextSibling: record.nextSibling,
+ oldValue: record.oldValue,
+ previousSibling: record.previousSibling,
+ removedNodes: record.removedNodes,
+ target: record.target,
+ type: record.type,
+ });
+ },
+ test: value => {
+ // list of records will stringify to the same value
+ return (
+ Array.isArray(value) === false
+ && String(value) === '[object MutationRecord]'
+ );
+ },
+});
+
+beforeAll(() => {
+ const originalWarn = console.warn;
+ jest.spyOn(console, 'warn').mockImplementation((...args) => {
+ if (args[0] && args[0].includes && args[0].includes('deprecated')) {
+ return;
+ }
+ originalWarn(...args);
+ });
+});
+
+afterEach(() => {
+ if (jest.isMockFunction(global.setTimeout)) {
+ jest.useRealTimers();
+ }
+});
+
+afterAll(() => {
+ jest.restoreAllMocks();
+});
diff --git a/packages/testing-library/lynx-dom-testing-library/tsconfig.json b/packages/testing-library/lynx-dom-testing-library/tsconfig.json
new file mode 100644
index 0000000000..ba9a2a78ba
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "lib": ["ES2021"],
+ "module": "ESNext",
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "moduleResolution": "bundler",
+ "useDefineForClassFields": true,
+ "allowImportingTsExtensions": true,
+ "noImplicitAny": false,
+ },
+ "include": ["src"],
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/types/.eslintrc b/packages/testing-library/lynx-dom-testing-library/types/.eslintrc
new file mode 100644
index 0000000000..29aa4797db
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "one-var": "off",
+ "@typescript-eslint/no-explicit-any": "off" // let's do better in the future
+ }
+}
diff --git a/packages/testing-library/lynx-dom-testing-library/types/__tests__/type-tests.ts b/packages/testing-library/lynx-dom-testing-library/types/__tests__/type-tests.ts
new file mode 100644
index 0000000000..ff93f3cb27
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/__tests__/type-tests.ts
@@ -0,0 +1,268 @@
+import {
+ createEvent,
+ fireEvent,
+ isInaccessible,
+ queries,
+ buildQueries,
+ queryAllByAttribute,
+ screen,
+ waitFor,
+ waitForElementToBeRemoved,
+ MatcherOptions,
+ BoundFunctions,
+ within,
+} from '@testing-library/dom';
+
+const {
+ getByText,
+ queryByText,
+ findByText,
+ getAllByText,
+ queryAllByText,
+ findAllByText,
+ queryAllByRole,
+ queryByRole,
+ findByRole,
+} = queries;
+
+export async function testQueries() {
+ // element queries
+ const element = document.createElement('div');
+ getByText(element, 'foo');
+ getByText(element, 1);
+ queryByText(element, 'foo');
+ await findByText(element, 'foo');
+ await findByText(element, 'foo', undefined, { timeout: 10 });
+ getAllByText(element, 'bar');
+ queryAllByText(element, 'bar');
+ await findAllByText(element, 'bar');
+ await findAllByText(element, 'bar', undefined, { timeout: 10 });
+
+ // screen queries
+ screen.getByText('foo');
+ screen.getByText('foo');
+ screen.queryByText('foo');
+ await screen.findByText('foo');
+ await screen.findByText('foo', undefined, { timeout: 10 });
+ screen.debug(screen.getAllByText('bar'));
+ screen.queryAllByText('bar');
+ await screen.findAllByText('bar');
+ await screen.findAllByRole('button', { name: 'submit' });
+ await screen.findAllByText('bar', undefined, { timeout: 10 });
+}
+
+export async function testQueryHelpers() {
+ const element = document.createElement('div');
+ const includesAutomationId = (content: string, automationId: string) =>
+ content.split(/\s+/).some(id => id === automationId);
+ const queryAllByAutomationId = (
+ container: HTMLElement,
+ automationId: string[] | string,
+ options?: MatcherOptions,
+ ) =>
+ queryAllByAttribute(
+ 'testId',
+ container,
+ content =>
+ Array.isArray(automationId)
+ ? automationId.every(id => includesAutomationId(content, id))
+ : includesAutomationId(content, automationId),
+ options,
+ );
+
+ const createIdRelatedErrorHandler =
+ (errorMessage: string, defaultErrorMessage: string) =>
+ (container: Element | null, ...args: T[]) => {
+ const [key, value] = args;
+ if (!container) {
+ return 'Container element not specified';
+ }
+ if (key && value) {
+ return errorMessage
+ .replace('[key]', String(key))
+ .replace('[value]', String(value));
+ }
+ return defaultErrorMessage;
+ };
+
+ const [
+ queryByAutomationId,
+ getAllByAutomationId,
+ getByAutomationId,
+ findAllByAutomationId,
+ findByAutomationId,
+ ] = buildQueries(
+ queryAllByAutomationId,
+ createIdRelatedErrorHandler(
+ `Found multiple with key [key] and value [value]`,
+ 'Multiple error',
+ ),
+ createIdRelatedErrorHandler(
+ `Unable to find an element with the [key] attribute of: [value]`,
+ 'Missing error',
+ ),
+ );
+ queryByAutomationId(element, 'id');
+ getAllByAutomationId(element, 'id');
+ getByAutomationId(element, ['id', 'automationId']);
+ await findAllByAutomationId(element, 'id', {}, { timeout: 1000 });
+ await findByAutomationId(element, 'id', {}, { timeout: 1000 });
+ // test optional params too
+ await findAllByAutomationId(element, 'id', {});
+ await findByAutomationId(element, 'id', {});
+ await findAllByAutomationId(element, 'id');
+ await findByAutomationId(element, 'id');
+
+ await findAllByAutomationId(element, ['id', 'id'], {});
+ await findByAutomationId(element, ['id', 'id'], {});
+ await findAllByAutomationId(element, ['id', 'id']);
+ await findByAutomationId(element, ['id', 'id']);
+
+ const screenWithCustomQueries = within(document.body, {
+ ...queries,
+ queryByAutomationId,
+ getAllByAutomationId,
+ getByAutomationId,
+ findAllByAutomationId,
+ findByAutomationId,
+ });
+
+ screenWithCustomQueries.queryByAutomationId('id');
+ screenWithCustomQueries.getAllByAutomationId('id');
+ screenWithCustomQueries.getByAutomationId(['id', 'automationId']);
+ await screenWithCustomQueries.findAllByAutomationId('id', {}, {
+ timeout: 1000,
+ });
+ await screenWithCustomQueries.findByAutomationId('id', {}, { timeout: 1000 });
+}
+
+export function testBoundFunctions() {
+ const boundfunctions = {} as BoundFunctions<{
+ customQueryOne: (container: HTMLElement, text: string) => HTMLElement;
+ customQueryTwo: (
+ container: HTMLElement,
+ text: string,
+ text2: string,
+ ) => HTMLElement;
+ customQueryThree: (container: HTMLElement, number: number) => HTMLElement;
+ }>;
+
+ boundfunctions.customQueryOne('one');
+ boundfunctions.customQueryTwo('one', 'two');
+ boundfunctions.customQueryThree(3);
+}
+
+export async function testByRole() {
+ const element = document.createElement('button');
+ element.setAttribute('aria-hidden', 'true');
+
+ console.assert(queryByRole(element, 'button') === null);
+ console.assert(queryByRole(element, 'button', { hidden: true }) !== null);
+
+ console.assert(screen.queryByRole('button') === null);
+ console.assert(screen.queryByRole('button', { hidden: true }) !== null);
+
+ console.assert(
+ (await findByRole(element, 'button', undefined, { timeout: 10 })) === null,
+ );
+ console.assert(
+ (await findByRole(element, 'button', { hidden: true }, { timeout: 10 }))
+ !== null,
+ );
+
+ console.assert(
+ queryAllByRole(document.body, 'progressbar', { queryFallbacks: true })
+ .length === 1,
+ );
+
+ // `name` option
+ console.assert(queryByRole(element, 'button', { name: 'Logout' }) === null);
+ console.assert(queryByRole(element, 'button', { name: /^Log/ }) === null);
+ console.assert(
+ queryByRole(element, 'button', {
+ name: (name, el) => name === 'Login' && el.hasAttribute('disabled'),
+ }) === null,
+ );
+
+ console.assert(queryByRole(element, 'foo') === null);
+ console.assert(screen.queryByRole('foo') === null);
+}
+
+export function testA11yHelper() {
+ const element = document.createElement('svg');
+ console.assert(!isInaccessible(element));
+}
+
+export function eventTest() {
+ fireEvent.popState(window, {
+ location: 'http://www.example.com/?page=1',
+ state: { page: 1 },
+ });
+
+ // HTMLElement
+ const element = document.createElement('div');
+ fireEvent.click(getByText(element, 'foo'));
+
+ // ChildNode
+ const child = document.createElement('div');
+ element.appendChild(child);
+ if (!element.firstChild) {
+ // Narrow Type
+ throw new Error(`Can't find firstChild`);
+ }
+ fireEvent.click(element.firstChild);
+
+ // Custom event
+ const customEvent = createEvent('customEvent', element);
+ fireEvent(element, customEvent);
+}
+
+export async function testWaitFors() {
+ const element = document.createElement('div');
+
+ await waitFor(() => getByText(element, 'apple'));
+ await waitFor(() => getAllByText(element, 'apple'));
+ const result: HTMLSpanElement = await waitFor(() =>
+ getByText(element, 'apple')
+ );
+ if (!result) {
+ // Use value
+ throw new Error(`Can't find result`);
+ }
+
+ element.innerHTML = 'apple';
+
+ await waitForElementToBeRemoved(() => getByText(element, 'apple'), {
+ interval: 3000,
+ container: element,
+ timeout: 5000,
+ });
+ await waitForElementToBeRemoved(getByText(element, 'apple'));
+ await waitForElementToBeRemoved(getAllByText(element, 'apple'));
+
+ await waitFor(async () => {});
+}
+
+export async function testWithin() {
+ const container = within(document.body);
+ container.queryAllByLabelText('Some label');
+
+ container.getByText('Click me');
+ container.getByText('Click me');
+ container.getAllByText('Click me');
+
+ await container.findByRole('button', { name: /click me/i });
+ container.getByRole('button', { name: /click me/i });
+
+ let withinQueries = within(document.body);
+ withinQueries = within(document.body);
+ withinQueries.getByRole('button', { name: /click me/i });
+ withinQueries = within(document.body);
+ withinQueries.getByRole('button', { name: /click me/i });
+}
+
+/*
+eslint
+ @typescript-eslint/no-unnecessary-condition: "off",
+ import/no-extraneous-dependencies: "off"
+*/
diff --git a/packages/testing-library/lynx-dom-testing-library/types/config.d.ts b/packages/testing-library/lynx-dom-testing-library/types/config.d.ts
new file mode 100644
index 0000000000..015510b2bc
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/config.d.ts
@@ -0,0 +1,27 @@
+export interface Config {
+ testIdAttribute: string;
+ /**
+ * WARNING: `unstable` prefix means this API may change in patch and minor releases.
+ * @param cb
+ */
+ unstable_advanceTimersWrapper(cb: (...args: unknown[]) => unknown): unknown;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ asyncWrapper(cb: (...args: any[]) => any): Promise;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ eventWrapper(cb: (...args: any[]) => any): void;
+ asyncUtilTimeout: number;
+ computedStyleSupportsPseudoElements: boolean;
+ defaultHidden: boolean;
+ /** default value for the `ignore` option in `ByText` queries */
+ defaultIgnore: string;
+ showOriginalStackTrace: boolean;
+ throwSuggestions: boolean;
+ getElementError: (message: string | null, container: Element) => Error;
+}
+
+export interface ConfigFn {
+ (existingConfig: Config): Partial;
+}
+
+export function configure(configDelta: ConfigFn | Partial): void;
+export function getConfig(): Config;
diff --git a/packages/testing-library/lynx-dom-testing-library/types/events.d.ts b/packages/testing-library/lynx-dom-testing-library/types/events.d.ts
new file mode 100644
index 0000000000..184761aeef
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/events.d.ts
@@ -0,0 +1,118 @@
+export type EventType =
+ | 'copy'
+ | 'cut'
+ | 'paste'
+ | 'compositionEnd'
+ | 'compositionStart'
+ | 'compositionUpdate'
+ | 'keyDown'
+ | 'keyPress'
+ | 'keyUp'
+ | 'focus'
+ | 'blur'
+ | 'focusIn'
+ | 'focusOut'
+ | 'change'
+ | 'input'
+ | 'invalid'
+ | 'submit'
+ | 'reset'
+ | 'click'
+ | 'contextMenu'
+ | 'dblClick'
+ | 'drag'
+ | 'dragEnd'
+ | 'dragEnter'
+ | 'dragExit'
+ | 'dragLeave'
+ | 'dragOver'
+ | 'dragStart'
+ | 'drop'
+ | 'mouseDown'
+ | 'mouseEnter'
+ | 'mouseLeave'
+ | 'mouseMove'
+ | 'mouseOut'
+ | 'mouseOver'
+ | 'mouseUp'
+ | 'popState'
+ | 'select'
+ | 'touchCancel'
+ | 'touchEnd'
+ | 'touchMove'
+ | 'touchStart'
+ | 'resize'
+ | 'scroll'
+ | 'wheel'
+ | 'abort'
+ | 'canPlay'
+ | 'canPlayThrough'
+ | 'durationChange'
+ | 'emptied'
+ | 'encrypted'
+ | 'ended'
+ | 'loadedData'
+ | 'loadedMetadata'
+ | 'loadStart'
+ | 'pause'
+ | 'play'
+ | 'playing'
+ | 'progress'
+ | 'rateChange'
+ | 'seeked'
+ | 'seeking'
+ | 'stalled'
+ | 'suspend'
+ | 'timeUpdate'
+ | 'volumeChange'
+ | 'waiting'
+ | 'load'
+ | 'error'
+ | 'animationStart'
+ | 'animationEnd'
+ | 'animationIteration'
+ | 'transitionCancel'
+ | 'transitionEnd'
+ | 'transitionRun'
+ | 'transitionStart'
+ | 'doubleClick'
+ | 'pointerOver'
+ | 'pointerEnter'
+ | 'pointerDown'
+ | 'pointerMove'
+ | 'pointerUp'
+ | 'pointerCancel'
+ | 'pointerOut'
+ | 'pointerLeave'
+ | 'gotPointerCapture'
+ | 'lostPointerCapture'
+ | 'offline'
+ | 'online'
+ | 'pageHide'
+ | 'pageShow';
+
+export type FireFunction = (
+ element: Document | Element | Window | Node,
+ event: Event,
+) => boolean;
+export type FireObject = {
+ [K in EventType]: (
+ element: Document | Element | Window | Node,
+ options?: {},
+ ) => boolean;
+};
+export type CreateFunction = (
+ eventName: string,
+ node: Document | Element | Window | Node,
+ init?: {},
+ options?: { EventType?: string; defaultInit?: {} },
+) => Event;
+export type CreateObject = {
+ [K in EventType]: (
+ element: Document | Element | Window | Node,
+ options?: {},
+ ) => Event;
+};
+
+export const createEvent: CreateObject & CreateFunction;
+export const fireEvent: FireFunction & FireObject;
diff --git a/packages/testing-library/lynx-dom-testing-library/types/get-node-text.d.ts b/packages/testing-library/lynx-dom-testing-library/types/get-node-text.d.ts
new file mode 100644
index 0000000000..5c5654b5c7
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/get-node-text.d.ts
@@ -0,0 +1 @@
+export function getNodeText(node: HTMLElement): string;
diff --git a/packages/testing-library/lynx-dom-testing-library/types/get-queries-for-element.d.ts b/packages/testing-library/lynx-dom-testing-library/types/get-queries-for-element.d.ts
new file mode 100644
index 0000000000..9f5f3d4ce8
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/get-queries-for-element.d.ts
@@ -0,0 +1,183 @@
+import * as queries from './queries';
+
+export type BoundFunction = T extends (
+ container: HTMLElement,
+ ...args: infer P
+) => infer R ? (...args: P) => R
+ : never;
+
+export type BoundFunctions = Q extends typeof queries ?
+ & {
+ getByLabelText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByLabelText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByLabelText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByLabelText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByLabelText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByLabelText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByPlaceholderText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByPlaceholderText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByPlaceholderText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByPlaceholderText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByPlaceholderText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByPlaceholderText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByAltText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByAltText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByAltText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByAltText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByAltText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByAltText(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByTitle(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByTitle(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByTitle(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByTitle(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByTitle(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByTitle(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByDisplayValue(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByDisplayValue(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByDisplayValue(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByDisplayValue(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByDisplayValue(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByDisplayValue(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByRole(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByRole(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByRole(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByRole(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByRole(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByRole(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getByTestId(
+ ...args: Parameters>>
+ ): ReturnType>;
+ getAllByTestId(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryByTestId(
+ ...args: Parameters>>
+ ): ReturnType>;
+ queryAllByTestId(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findByTestId(
+ ...args: Parameters>>
+ ): ReturnType>;
+ findAllByTestId(
+ ...args: Parameters>>
+ ): ReturnType>;
+ }
+ & {
+ [P in keyof Q]: BoundFunction;
+ }
+ : {
+ [P in keyof Q]: BoundFunction;
+ };
+
+export type Query = (
+ container: HTMLElement,
+ ...args: any[]
+) =>
+ | Error
+ | HTMLElement
+ | HTMLElement[]
+ | Promise
+ | Promise
+ | null;
+
+export interface Queries {
+ [T: string]: Query;
+}
+
+export function getQueriesForElement<
+ QueriesToBind extends Queries = typeof queries,
+ // Extra type parameter required for reassignment.
+ T extends QueriesToBind = QueriesToBind,
+ Element,
+>(element: Element, queriesToBind?: T): BoundFunctions;
diff --git a/packages/testing-library/lynx-dom-testing-library/types/index.d.ts b/packages/testing-library/lynx-dom-testing-library/types/index.d.ts
new file mode 100644
index 0000000000..a4478de93e
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/index.d.ts
@@ -0,0 +1,22 @@
+// TypeScript Version: 3.8
+
+import { getQueriesForElement } from './get-queries-for-element';
+import * as queries from './queries';
+import * as queryHelpers from './query-helpers';
+
+declare const within: typeof getQueriesForElement;
+export { queries, queryHelpers, within };
+
+export * from './queries';
+export * from './query-helpers';
+export * from './screen';
+export * from './wait-for';
+export * from './wait-for-element-to-be-removed';
+export * from './matches';
+export * from './get-node-text';
+export * from './events';
+export * from './get-queries-for-element';
+export * from './pretty-dom';
+export * from './role-helpers';
+export * from './config';
+export * from './suggestions';
diff --git a/packages/testing-library/lynx-dom-testing-library/types/matches.d.ts b/packages/testing-library/lynx-dom-testing-library/types/matches.d.ts
new file mode 100644
index 0000000000..33e62f8a1d
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/matches.d.ts
@@ -0,0 +1,46 @@
+import { ARIARole } from 'aria-query';
+
+export type MatcherFunction = (
+ content: string,
+ element: Element | null,
+) => boolean;
+export type Matcher = MatcherFunction | RegExp | number | string;
+
+// Get autocomplete for ARIARole union types, while still supporting another string
+// Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-567871939
+export type ByRoleMatcher = ARIARole | (string & {});
+
+export type NormalizerFn = (text: string) => string;
+
+export interface NormalizerOptions extends DefaultNormalizerOptions {
+ normalizer?: NormalizerFn;
+}
+
+export interface MatcherOptions {
+ exact?: boolean;
+ /** Use normalizer with getDefaultNormalizer instead */
+ trim?: boolean;
+ /** Use normalizer with getDefaultNormalizer instead */
+ collapseWhitespace?: boolean;
+ normalizer?: NormalizerFn;
+ /** suppress suggestions for a specific query */
+ suggest?: boolean;
+}
+
+export type Match = (
+ textToMatch: string,
+ node: HTMLElement | null,
+ matcher: Matcher,
+ options?: MatcherOptions,
+) => boolean;
+
+export interface DefaultNormalizerOptions {
+ trim?: boolean;
+ collapseWhitespace?: boolean;
+}
+
+export function getDefaultNormalizer(
+ options?: DefaultNormalizerOptions,
+): NormalizerFn;
+
+// N.B. Don't expose fuzzyMatches + matches here: they're not public API
diff --git a/packages/testing-library/lynx-dom-testing-library/types/pretty-dom.d.ts b/packages/testing-library/lynx-dom-testing-library/types/pretty-dom.d.ts
new file mode 100644
index 0000000000..b72c3d42f2
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/pretty-dom.d.ts
@@ -0,0 +1,21 @@
+import * as prettyFormat from 'pretty-format';
+
+export interface PrettyDOMOptions extends prettyFormat.OptionsReceived {
+ /**
+ * Given a `Node` return `false` if you wish to ignore that node in the output.
+ * By default, ignores ``, `` and comment nodes.
+ */
+ filterNode?: (node: Node) => boolean;
+}
+
+export function prettyDOM(
+ dom?: Element | HTMLDocument,
+ maxLength?: number,
+ options?: PrettyDOMOptions,
+): string | false;
+export function logDOM(
+ dom?: Element | HTMLDocument,
+ maxLength?: number,
+ options?: PrettyDOMOptions,
+): void;
+export { prettyFormat };
diff --git a/packages/testing-library/lynx-dom-testing-library/types/queries.d.ts b/packages/testing-library/lynx-dom-testing-library/types/queries.d.ts
new file mode 100644
index 0000000000..8cf94f31d6
--- /dev/null
+++ b/packages/testing-library/lynx-dom-testing-library/types/queries.d.ts
@@ -0,0 +1,315 @@
+import { ByRoleMatcher, Matcher, MatcherOptions } from './matches';
+import { SelectorMatcherOptions } from './query-helpers';
+import { waitForOptions } from './wait-for';
+
+export type QueryByBoundAttribute = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: MatcherOptions,
+) => T | null;
+
+export type AllByBoundAttribute = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: MatcherOptions,
+) => T[];
+
+export type FindAllByBoundAttribute = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: MatcherOptions,
+ waitForElementOptions?: waitForOptions,
+) => Promise;
+
+export type GetByBoundAttribute = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: MatcherOptions,
+) => T;
+
+export type FindByBoundAttribute = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: MatcherOptions,
+ waitForElementOptions?: waitForOptions,
+) => Promise;
+
+export type QueryByText = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: SelectorMatcherOptions,
+) => T | null;
+
+export type AllByText = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: SelectorMatcherOptions,
+) => T[];
+
+export type FindAllByText = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: SelectorMatcherOptions,
+ waitForElementOptions?: waitForOptions,
+) => Promise;
+
+export type GetByText = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: SelectorMatcherOptions,
+) => T;
+
+export type FindByText = (
+ container: HTMLElement,
+ id: Matcher,
+ options?: SelectorMatcherOptions,
+ waitForElementOptions?: waitForOptions,
+) => Promise;
+
+export interface ByRoleOptions {
+ /** suppress suggestions for a specific query */
+ suggest?: boolean;
+ /**
+ * If true includes elements in the query set that are usually excluded from
+ * the accessibility tree. `role="none"` or `role="presentation"` are included
+ * in either case.
+ */
+ hidden?: boolean;
+ /**
+ * If true only includes elements in the query set that are marked as
+ * selected in the accessibility tree, i.e., `aria-selected="true"`
+ */
+ selected?: boolean;
+ /**
+ * If true only includes elements in the query set that are marked as
+ * busy in the accessibility tree, i.e., `aria-busy="true"`
+ */
+ busy?: boolean;
+ /**
+ * If true only includes elements in the query set that are marked as
+ * checked in the accessibility tree, i.e., `aria-checked="true"`
+ */
+ checked?: boolean;
+ /**
+ * If true only includes elements in the query set that are marked as
+ * pressed in the accessibility tree, i.e., `aria-pressed="true"`
+ */
+ pressed?: boolean;
+ /**
+ * Filters elements by their `aria-current` state. `true` and `false` match `aria-current="true"` and `aria-current="false"` (as well as a missing `aria-current` attribute) respectively.
+ */
+ current?: boolean | string;
+ /**
+ * If true only includes elements in the query set that are marked as
+ * expanded in the accessibility tree, i.e., `aria-expanded="true"`
+ */
+ expanded?: boolean;
+ /**
+ * Includes elements with the `"heading"` role matching the indicated level,
+ * either by the semantic HTML heading elements `
-
` or matching
+ * the `aria-level` attribute.
+ */
+ level?: number;
+ value?: {
+ now?: number;
+ min?: number;
+ max?: number;
+ text?: Matcher;
+ };
+ /**
+ * Includes every role used in the `role` attribute
+ * For example *ByRole('progressbar', {queryFallbacks: true})` will find