diff --git a/package.json b/package.json index 27747f6e..17f6b8ce 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "import/prefer-default-export": "off", "import/no-unassigned-import": "off", "import/no-useless-path-segments": "off", - "no-console": "off" + "no-console": "off", + "@typescript-eslint/no-non-null-assertion": "off" } }, "eslintIgnore": [ diff --git a/src/__tests__/config.js b/src/__tests__/config.ts similarity index 95% rename from src/__tests__/config.js rename to src/__tests__/config.ts index 40123a53..a26a570c 100644 --- a/src/__tests__/config.js +++ b/src/__tests__/config.ts @@ -1,7 +1,8 @@ import {configure, getConfig} from '../config' +import {Config} from '../../types/config' describe('configuration API', () => { - let originalConfig + let originalConfig: Config beforeEach(() => { // Grab the existing configuration so we can restore // it at the end of the test @@ -15,10 +16,6 @@ describe('configuration API', () => { configure(originalConfig) }) - beforeEach(() => { - configure({other: 123}) - }) - describe('getConfig', () => { test('returns existing configuration', () => { const conf = getConfig() diff --git a/src/__tests__/helpers.js b/src/__tests__/helpers.ts similarity index 83% rename from src/__tests__/helpers.js rename to src/__tests__/helpers.ts index 1bc85837..582ff297 100644 --- a/src/__tests__/helpers.js +++ b/src/__tests__/helpers.ts @@ -16,12 +16,14 @@ test('returns global document if exists', () => { describe('window retrieval throws when given something other than a node', () => { test('Promise as node', () => { expect(() => + // @ts-expect-error using a promise will trhow a specific error getWindowFromNode(new Promise(jest.fn())), ).toThrowErrorMatchingInlineSnapshot( `"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...\`?"`, ) }) test('unknown as node', () => { + // @ts-expect-error using an object will throw a specific error expect(() => getWindowFromNode({})).toThrowErrorMatchingInlineSnapshot( `"Unable to find the \\"window\\" object for the given node. Please file an issue with the code that's causing you to see this error: https://github.com/testing-library/dom-testing-library/issues/new"`, ) @@ -37,16 +39,19 @@ describe('query container validation throws when validation fails', () => { ) }) test('null as container', () => { + // @ts-expect-error passing a wrong container will throw an error expect(() => checkContainerType(null)).toThrowErrorMatchingInlineSnapshot( `"Expected container to be an Element, a Document or a DocumentFragment but got null."`, ) }) test('array as container', () => { + // @ts-expect-error passing a wrong container will throw an error expect(() => checkContainerType([])).toThrowErrorMatchingInlineSnapshot( `"Expected container to be an Element, a Document or a DocumentFragment but got Array."`, ) }) test('object as container', () => { + // @ts-expect-error passing a wrong container will throw an error expect(() => checkContainerType({})).toThrowErrorMatchingInlineSnapshot( `"Expected container to be an Element, a Document or a DocumentFragment but got Object."`, ) @@ -61,7 +66,9 @@ test('should always use realTimers before using callback when timers are faked w runWithRealTimers(() => { expect(originalSetTimeout).toEqual(globalObj.setTimeout) }) + // @ts-expect-error if we are using logacy timers expect(globalObj.setTimeout._isMockFunction).toBe(true) + // @ts-expect-error if we are using logacy timers expect(globalObj.setTimeout.clock).toBeUndefined() jest.useRealTimers() @@ -71,7 +78,9 @@ test('should always use realTimers before using callback when timers are faked w runWithRealTimers(() => { expect(originalSetTimeout).toEqual(globalObj.setTimeout) }) + // @ts-expect-error if we are using modern timers expect(globalObj.setTimeout._isMockFunction).toBeUndefined() + // @ts-expect-error if we are using modern timers expect(globalObj.setTimeout.clock).toBeDefined() }) @@ -79,11 +88,12 @@ test('should not use realTimers when timers are not faked with useFakeTimers', ( const originalSetTimeout = globalObj.setTimeout // useFakeTimers is not used, timers are faked in some other way - const fakedSetTimeout = callback => { + const fakedSetTimeout = (callback: () => void) => { callback() } fakedSetTimeout.clock = jest.fn() + //@ts-expect-error override the default setTimeout with a fake timer globalObj.setTimeout = fakedSetTimeout runWithRealTimers(() => { diff --git a/src/__tests__/label-helpers.js b/src/__tests__/label-helpers.ts similarity index 100% rename from src/__tests__/label-helpers.js rename to src/__tests__/label-helpers.ts diff --git a/src/__tests__/matches.js b/src/__tests__/matches.ts similarity index 85% rename from src/__tests__/matches.js rename to src/__tests__/matches.ts index 3f5e6b3e..e5ce9566 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.ts @@ -3,7 +3,7 @@ import {fuzzyMatches, matches} from '../matches' // unit tests for text match utils const node = null -const normalizer = str => str +const normalizer = (text: string) => text test('matchers accept strings', () => { expect(matches('ABC', node, 'ABC', normalizer)).toBe(true) @@ -39,3 +39,8 @@ test('matchers throw on invalid matcher inputs', () => { `"It looks like undefined was passed instead of a matcher. Did you do something like getByText(undefined)?"`, ) }) + +test('should use matchers with numbers', () => { + expect(matches('1234', node, 1234, normalizer)).toBe(true) + expect(fuzzyMatches('test1234', node, 1234, normalizer)).toBe(true) +}) diff --git a/src/__tests__/pretty-dom.js b/src/__tests__/pretty-dom.ts similarity index 85% rename from src/__tests__/pretty-dom.js rename to src/__tests__/pretty-dom.ts index 1d1ce79b..27791c9a 100644 --- a/src/__tests__/pretty-dom.js +++ b/src/__tests__/pretty-dom.ts @@ -9,7 +9,7 @@ beforeEach(() => { }) afterEach(() => { - console.log.mockRestore() + ;(console.log as jest.Mock).mockRestore() }) test('prettyDOM prints out the given DOM element tree', () => { @@ -38,6 +38,7 @@ test('prettyDOM defaults to document.body', () => { ` renderIntoDocument('
Hello World!
') expect(prettyDOM()).toMatchInlineSnapshot(defaultInlineSnapshot) + //@ts-expect-error js check, should print the document.body expect(prettyDOM(null)).toMatchInlineSnapshot(defaultInlineSnapshot) }) @@ -54,7 +55,8 @@ test('logDOM logs prettyDOM to the console', () => { const {container} = render('
Hello World!
') logDOM(container) expect(console.log).toHaveBeenCalledTimes(1) - expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(((console.log as jest.Mock).mock.calls[0] as string[])[0]) + .toMatchInlineSnapshot(` "
Hello World! @@ -64,7 +66,7 @@ test('logDOM logs prettyDOM to the console', () => { }) test('logDOM logs prettyDOM with code frame to the console', () => { - getUserCodeFrame.mockImplementationOnce( + ;(getUserCodeFrame as jest.Mock).mockImplementationOnce( () => `"/home/john/projects/sample-error/error-example.js:7:14 5 | document.createTextNode('Hello World!') 6 | ) @@ -76,7 +78,8 @@ test('logDOM logs prettyDOM with code frame to the console', () => { const {container} = render('
Hello World!
') logDOM(container) expect(console.log).toHaveBeenCalledTimes(1) - expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(((console.log as jest.Mock).mock.calls[0] as string[])[0]) + .toMatchInlineSnapshot(` "
Hello World! @@ -95,16 +98,19 @@ test('logDOM logs prettyDOM with code frame to the console', () => { describe('prettyDOM fails with first parameter without outerHTML field', () => { test('with array', () => { + // @ts-expect-error use an array as arg expect(() => prettyDOM(['outerHTML'])).toThrowErrorMatchingInlineSnapshot( `"Expected an element or document but got Array"`, ) }) test('with number', () => { + // @ts-expect-error use a number as arg expect(() => prettyDOM(1)).toThrowErrorMatchingInlineSnapshot( `"Expected an element or document but got number"`, ) }) test('with object', () => { + // @ts-expect-error use an object as arg expect(() => prettyDOM({})).toThrowErrorMatchingInlineSnapshot( `"Expected an element or document but got Object"`, ) diff --git a/src/__tests__/suggestions.js b/src/__tests__/suggestions.ts similarity index 88% rename from src/__tests__/suggestions.js rename to src/__tests__/suggestions.ts index 3bb5364d..65d1760a 100644 --- a/src/__tests__/suggestions.js +++ b/src/__tests__/suggestions.ts @@ -8,7 +8,7 @@ beforeAll(() => { afterEach(() => { configure({testIdAttribute: 'data-testid'}) - console.warn.mockClear() + ;(console.warn as jest.Mock).mockClear() }) afterAll(() => { @@ -188,38 +188,38 @@ test('escapes regular expressions in suggestion', () => { expect( getSuggestedQuery( - container.querySelector('img'), + container.querySelector('img')!, 'get', - 'altText', - ).toString(), + 'AltText', + )?.toString(), ).toEqual(`getByAltText(/the problem \\(picture of a question mark\\)/i)`) - expect(getSuggestedQuery(container.querySelector('p')).toString()).toEqual( + expect(getSuggestedQuery(container.querySelector('p')!)?.toString()).toEqual( `getByText(/loading \\.\\.\\. \\(1\\)/i)`, ) expect( getSuggestedQuery( - container.querySelector('input'), + container.querySelector('input')!, 'get', - 'placeholderText', - ).toString(), + 'PlaceholderText', + )?.toString(), ).toEqual(`getByPlaceholderText(/should escape \\+\\-'\\(\\//i)`) expect( getSuggestedQuery( - container.querySelector('input'), + container.querySelector('input')!, 'get', - 'displayValue', - ).toString(), + 'DisplayValue', + )?.toString(), ).toEqual(`getByDisplayValue(/my super string \\+\\-\\('\\{\\}\\^\\$\\)/i)`) expect( getSuggestedQuery( - container.querySelector('input'), + container.querySelector('input')!, 'get', - 'labelText', - ).toString(), + 'LabelText', + )?.toString(), ).toEqual(`getByLabelText(/inp\\-t lab\\^l w\\{th c\\+ars to esc\\\\pe/i)`) }) @@ -238,7 +238,7 @@ it('should not suggest by label when using by label', async () => { ) // if a suggestion is made, this call will throw, thus failing the test. - const password = await screen.findByLabelText(/bar/i) + const password = (await screen.findByLabelText(/bar/i)) as Element expect(password).toHaveAttribute('type', 'password') }) @@ -316,8 +316,7 @@ test(`should suggest getByDisplayValue`, () => { renderIntoDocument( ``, ) - - document.getElementById('password').value = 'Prine' // RIP John Prine + ;(document.getElementById('password') as HTMLInputElement).value = 'Prine' // RIP John Prine expect(() => screen.getByTestId('password')).toThrowError( /getByDisplayValue\(\/prine\/i\)/, @@ -375,23 +374,27 @@ test(`should suggest getByTitle`, () => { }) test('getSuggestedQuery handles `variant` and defaults to `get`', () => { - const button = render(``).container.firstChild + const button = render(``).container + .firstChild as HTMLButtonElement - expect(getSuggestedQuery(button).toString()).toMatch(/getByRole/) - expect(getSuggestedQuery(button, 'get').toString()).toMatch(/getByRole/) - expect(getSuggestedQuery(button, 'getAll').toString()).toMatch(/getAllByRole/) - expect(getSuggestedQuery(button, 'query').toString()).toMatch(/queryByRole/) - expect(getSuggestedQuery(button, 'queryAll').toString()).toMatch( + expect(getSuggestedQuery(button)?.toString()).toMatch(/getByRole/) + expect(getSuggestedQuery(button, 'get')?.toString()).toMatch(/getByRole/) + expect(getSuggestedQuery(button, 'getAll')?.toString()).toMatch( + /getAllByRole/, + ) + expect(getSuggestedQuery(button, 'query')?.toString()).toMatch(/queryByRole/) + expect(getSuggestedQuery(button, 'queryAll')?.toString()).toMatch( /queryAllByRole/, ) - expect(getSuggestedQuery(button, 'find').toString()).toMatch(/findByRole/) - expect(getSuggestedQuery(button, 'findAll').toString()).toMatch( + expect(getSuggestedQuery(button, 'find')?.toString()).toMatch(/findByRole/) + expect(getSuggestedQuery(button, 'findAll')?.toString()).toMatch( /findAllByRole/, ) }) test('getSuggestedQuery returns rich data for tooling', () => { - const button = render(``).container.firstChild + const button = render(``).container + .firstChild as HTMLButtonElement expect(getSuggestedQuery(button)).toMatchObject({ queryName: 'Role', @@ -400,11 +403,11 @@ test('getSuggestedQuery returns rich data for tooling', () => { variant: 'get', }) - expect(getSuggestedQuery(button).toString()).toEqual( + expect(getSuggestedQuery(button)?.toString()).toEqual( `getByRole('button', { name: /submit/i })`, ) - const div = render(`cancel`).container.firstChild + const div = render(`cancel`).container.firstChild as HTMLDivElement expect(getSuggestedQuery(div)).toMatchObject({ queryName: 'Text', @@ -413,7 +416,7 @@ test('getSuggestedQuery returns rich data for tooling', () => { variant: 'get', }) - expect(getSuggestedQuery(div).toString()).toEqual(`getByText(/cancel/i)`) + expect(getSuggestedQuery(div)?.toString()).toEqual(`getByText(/cancel/i)`) }) test('getSuggestedQuery can return specified methods in addition to the best', () => { @@ -432,9 +435,8 @@ test('getSuggestedQuery can return specified methods in addition to the best', ( `) - const input = container.querySelector('input') - const button = container.querySelector('button') - + const input = container.querySelector('input')! + const button = container.querySelector('button')! // this function should be insensitive for the method param. // Role and role should work the same expect(getSuggestedQuery(input, 'get', 'role')).toMatchObject({ @@ -515,7 +517,7 @@ test('getSuggestedQuery works with custom testIdAttribute', () => { `) - const input = container.querySelector('input') + const input = container.querySelector('input')! expect(getSuggestedQuery(input, 'get', 'TestId')).toMatchObject({ queryName: 'TestId', @@ -531,8 +533,8 @@ test('getSuggestedQuery does not create suggestions for script and style element `) - const script = container.querySelector('script') - const style = container.querySelector('style') + const script = container.querySelector('script')! + const style = container.querySelector('style')! expect(getSuggestedQuery(script, 'get', 'TestId')).toBeUndefined() expect(getSuggestedQuery(style, 'get', 'TestId')).toBeUndefined() @@ -552,7 +554,7 @@ test('should get the first label with aria-labelledby contains multiple ids', () `) expect( - getSuggestedQuery(container.querySelector('input'), 'get', 'labelText'), + getSuggestedQuery(container.querySelector('input')!, 'get', 'LabelText'), ).toMatchObject({ queryName: 'LabelText', queryMethod: 'getByLabelText', @@ -562,7 +564,7 @@ test('should get the first label with aria-labelledby contains multiple ids', () }) test('should not suggest or warn about hidden element when suggested query is already used.', () => { - console.warn.mockImplementation(() => {}) + ;(console.warn as jest.Mock).mockImplementation(() => {}) renderIntoDocument(` @@ -572,7 +574,7 @@ test('should not suggest or warn about hidden element when suggested query is al expect(console.warn).not.toHaveBeenCalled() }) test('should suggest and warn about if element is not in the accessibility tree', () => { - console.warn.mockImplementation(() => {}) + ;(console.warn as jest.Mock).mockImplementation(() => {}) renderIntoDocument(` @@ -587,14 +589,14 @@ test('should suggest and warn about if element is not in the accessibility tree' }) test('should suggest hidden option if element is not in the accessibility tree', () => { - console.warn.mockImplementation(() => {}) + ;(console.warn as jest.Mock).mockImplementation(() => {}) const {container} = renderIntoDocument(` `) const suggestion = getSuggestedQuery( - container.querySelector('input'), + container.querySelector('input')!, 'get', 'role', ) @@ -607,9 +609,9 @@ test('should suggest hidden option if element is not in the accessibility tree', If you are using the aria-hidden prop, make sure this is the right choice for your case. `, }) - suggestion.toString() + suggestion?.toString() - expect(console.warn.mock.calls).toMatchInlineSnapshot(` + expect((console.warn as jest.Mock).mock.calls).toMatchInlineSnapshot(` Array [ Array [ "Element is inaccessible. This means that the element and all its children are invisible to screen readers. @@ -634,9 +636,9 @@ test('should find label text using the aria-labelledby', () => { expect( getSuggestedQuery( - container.querySelector('[id="sixth-id"]'), + container.querySelector('[id="sixth-id"]')!, 'get', - 'labelText', + 'LabelText', ), ).toMatchInlineSnapshot( { diff --git a/src/helpers.js b/src/helpers.ts similarity index 79% rename from src/helpers.js rename to src/helpers.ts index f686d46b..72a7476a 100644 --- a/src/helpers.js +++ b/src/helpers.ts @@ -4,7 +4,7 @@ const globalObj = typeof window === 'undefined' ? global : window const TEXT_NODE = 3 // Currently this fn only supports jest timers, but it could support other test runners in the future. -function runWithRealTimers(callback) { +function runWithRealTimers(callback: () => T): T { const fakeTimersType = getJestFakeTimersType() if (fakeTimersType) { jest.useRealTimers() @@ -29,13 +29,16 @@ function getJestFakeTimersType() { } if ( + // @ts-expect-error check if we are using jest.fakeTimers('legacy') typeof globalObj.setTimeout._isMockFunction !== 'undefined' && + // @ts-expect-error check if we are using jest.fakeTimers('legacy') globalObj.setTimeout._isMockFunction ) { return 'legacy' } if ( + // @ts-expect-error check if we are using jest.fakeTimers('modern') typeof globalObj.setTimeout.clock !== 'undefined' && typeof jest.getRealSystemTime !== 'undefined' ) { @@ -54,7 +57,7 @@ const jestFakeTimersAreEnabled = () => Boolean(getJestFakeTimersType()) // we only run our tests in node, and setImmediate is supported in node. // istanbul ignore next -function setImmediatePolyfill(fn) { +function setImmediatePolyfill(fn: () => void) { return globalObj.setTimeout(fn, 0) } @@ -62,6 +65,7 @@ function getTimeFunctions() { // istanbul ignore next return { clearTimeoutFn: globalObj.clearTimeout, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- for browser compatibility setImmediateFn: globalObj.setImmediate || setImmediatePolyfill, setTimeoutFn: globalObj.setTimeout, } @@ -78,16 +82,17 @@ function getDocument() { } return window.document } -function getWindowFromNode(node) { - if (node.defaultView) { +function getWindowFromNode(node: Document | Node | Window) { + if ('defaultView' in node) { // node is document return node.defaultView - } else if (node.ownerDocument && node.ownerDocument.defaultView) { + } else if ('ownerDocument' in node && node.ownerDocument?.defaultView) { // node is a DOM node return node.ownerDocument.defaultView - } else if (node.window) { + } else if ('window' in node) { // node is window return node.window + // @ts-expect-error in case the use pass a Promise, we want to provide a specific message for this case } 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...\`?`, @@ -100,7 +105,7 @@ function getWindowFromNode(node) { } } -function checkContainerType(container) { +function checkContainerType(container?: Element) { if ( !container || !(typeof container.querySelector === 'function') || @@ -113,9 +118,9 @@ function checkContainerType(container) { ) } - function getTypeName(object) { + function getTypeName(object: unknown) { if (typeof object === 'object') { - return object === null ? 'null' : object.constructor.name + return object === null ? 'null' : (object as Object).constructor.name } return typeof object } diff --git a/src/label-helpers.ts b/src/label-helpers.ts index e8010a1b..09da31de 100644 --- a/src/label-helpers.ts +++ b/src/label-helpers.ts @@ -1,5 +1,10 @@ import {TEXT_NODE} from './helpers' +export interface Label { + content: string | null + formControl: Element | null +} + const labelledNodeNames = [ 'button', 'meter', @@ -55,10 +60,10 @@ function isLabelable(element: Element) { } function getLabels( - container: Element, + container: Element | Document, element: Element, {selector = '*'} = {}, -) { +): Label[] { const ariaLabelledBy = element.getAttribute('aria-labelledby') const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : [] return labelsId.length diff --git a/src/pretty-dom.js b/src/pretty-dom.js deleted file mode 100644 index edc1734c..00000000 --- a/src/pretty-dom.js +++ /dev/null @@ -1,74 +0,0 @@ -import prettyFormat from 'pretty-format' -import {getUserCodeFrame} from './get-user-code-frame' -import {getDocument} from './helpers' - -function inCypress(dom) { - const window = - (dom.ownerDocument && dom.ownerDocument.defaultView) || undefined - return ( - (typeof global !== 'undefined' && global.Cypress) || - (typeof window !== 'undefined' && window.Cypress) - ) -} - -const inNode = () => - typeof process !== 'undefined' && - process.versions !== undefined && - process.versions.node !== undefined - -const getMaxLength = dom => - inCypress(dom) - ? 0 - : (typeof process !== 'undefined' && process.env.DEBUG_PRINT_LIMIT) || 7000 - -const {DOMElement, DOMCollection} = prettyFormat.plugins - -function prettyDOM(dom, maxLength, options) { - if (!dom) { - dom = getDocument().body - } - if (typeof maxLength !== 'number') { - maxLength = getMaxLength(dom) - } - - 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 debugContent = prettyFormat(dom, { - plugins: [DOMElement, DOMCollection], - printFunctionName: false, - highlight: inNode(), - ...options, - }) - 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/src/pretty-dom.ts b/src/pretty-dom.ts new file mode 100644 index 00000000..724a09ca --- /dev/null +++ b/src/pretty-dom.ts @@ -0,0 +1,92 @@ +import prettyFormat from 'pretty-format' +import {getUserCodeFrame} from './get-user-code-frame' +import {getDocument} from './helpers' + +function inCypress(dom: Element | HTMLDocument) { + const window = dom.ownerDocument?.defaultView ?? undefined + return ( + (typeof global !== 'undefined' && + 'Cypress' in global && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as typeof global & {Cypress: any}).Cypress) || + (typeof window !== 'undefined' && + 'Cypress' in window && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as typeof window & {Cypress: any}).Cypress) + ) +} + +const inNode = () => + typeof process !== 'undefined' && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- for browser compatibility + process.versions !== undefined && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- for browser compatibility + process.versions.node !== undefined + +const getMaxLength = (dom: Element | HTMLDocument): number => + inCypress(dom) + ? 0 + : (typeof process !== 'undefined' && + process.env.DEBUG_PRINT_LIMIT && + parseInt(process.env.DEBUG_PRINT_LIMIT, 10)) || + 7000 + +const {DOMElement, DOMCollection} = prettyFormat.plugins + +function prettyDOM( + dom?: Element | HTMLDocument, + maxLength?: number, + options?: prettyFormat.OptionsReceived, +) { + if (!dom) { + dom = getDocument().body + } + if (typeof maxLength !== 'number') { + maxLength = getMaxLength(dom) + } + + if (maxLength === 0) { + return '' + } + if (typeof dom === 'object' && 'documentElement' in dom) { + dom = dom.documentElement + } + + let domTypeName: string = typeof dom + if (domTypeName === 'object') { + domTypeName = dom.constructor.name + } else { + dom = undefined + } + + if (!dom || !('outerHTML' in dom)) { + throw new TypeError( + `Expected an element or document but got ${domTypeName}`, + ) + } + + const debugContent = prettyFormat(dom, { + plugins: [DOMElement, DOMCollection], + printFunctionName: false, + highlight: inNode(), + ...options, + }) + return dom.outerHTML.length > maxLength + ? `${debugContent.slice(0, maxLength)}...` + : debugContent +} + +const logDOM = ( + dom?: Element | HTMLDocument, + maxLength?: number, + options?: prettyFormat.OptionsReceived, +) => { + const userCodeFrame = getUserCodeFrame() + if (userCodeFrame) { + console.log(`${prettyDOM(dom, maxLength, options)}\n\n${userCodeFrame}`) + } else { + console.log(prettyDOM(dom, maxLength, options)) + } +} + +export {prettyDOM, logDOM, prettyFormat} diff --git a/src/role-helpers.js b/src/role-helpers.ts similarity index 68% rename from src/role-helpers.js rename to src/role-helpers.ts index 25fa88e6..795146a8 100644 --- a/src/role-helpers.js +++ b/src/role-helpers.ts @@ -1,16 +1,38 @@ -import {elementRoles} from 'aria-query' +import { + elementRoles, + ARIARoleRelationConcept, + ARIARoleDefintionKey, + ARIARoleRelationConceptAttribute, +} from 'aria-query' import {computeAccessibleName} from 'dom-accessibility-api' +import {InaccessibleOptions} from '../types/role-helpers' import {prettyDOM} from './pretty-dom' import {getConfig} from './config' -const elementRoleList = buildElementRoleList(elementRoles) +type HeadingLevels = { + [key: string]: number +} + +type GetRolesOptions = { + hidden: boolean +} + +type ElementRole = { + match: (node: Element) => boolean + roles: ARIARoleDefintionKey[] + specificity: number +} + +type Roles = {[index: string]: Element[]} + +const elementRoleList: ElementRole[] = buildElementRoleList(elementRoles) /** * @param {Element} element - * @returns {boolean} - `true` if `element` and its subtree are inaccessible */ -function isSubtreeInaccessible(element) { - if (element.hidden === true) { +function isSubtreeInaccessible(element: Element) { + if ((element as HTMLElement).hidden) { return true } @@ -19,7 +41,7 @@ function isSubtreeInaccessible(element) { } const window = element.ownerDocument.defaultView - if (window.getComputedStyle(element).display === 'none') { + if (window?.getComputedStyle(element).display === 'none') { return true } @@ -40,17 +62,17 @@ function isSubtreeInaccessible(element) { * can be used to return cached results from previous isSubtreeInaccessible calls * @returns {boolean} true if excluded, otherwise false */ -function isInaccessible(element, options = {}) { +function isInaccessible(element: Element, options: InaccessibleOptions = {}) { const { isSubtreeInaccessible: isSubtreeInaccessibleImpl = isSubtreeInaccessible, } = options const window = element.ownerDocument.defaultView // since visibility is inherited we can exit early - if (window.getComputedStyle(element).visibility === 'hidden') { + if (window?.getComputedStyle(element).visibility === 'hidden') { return true } - let currentElement = element + let currentElement: Element | null = element while (currentElement) { if (isSubtreeInaccessibleImpl(currentElement)) { return true @@ -62,7 +84,7 @@ function isInaccessible(element, options = {}) { return false } -function getImplicitAriaRoles(currentNode) { +function getImplicitAriaRoles(currentNode: Element) { // eslint bug here: // eslint-disable-next-line no-unused-vars for (const {match, roles} of elementRoleList) { @@ -74,11 +96,17 @@ function getImplicitAriaRoles(currentNode) { return [] } -function buildElementRoleList(elementRolesMap) { - function makeElementSelector({name, attributes}) { +function buildElementRoleList( + elementRolesMap: Map>, +) { + function makeElementSelector( + name: string, + attributes: ARIARoleRelationConceptAttribute[], + ) { return `${name}${attributes .map(({name: attributeName, value, constraints = []}) => { - const shouldNotExist = constraints.indexOf('undefined') !== -1 + //@ts-expect-error I think there is an issue in aria-label + const shouldNotExist = constraints.includes('undefined') if (shouldNotExist) { return `:not([${attributeName}])` } else if (value) { @@ -90,19 +118,19 @@ function buildElementRoleList(elementRolesMap) { .join('')}` } - function getSelectorSpecificity({attributes = []}) { + function getSelectorSpecificity({attributes = []}: ARIARoleRelationConcept) { return attributes.length } function bySelectorSpecificity( - {specificity: leftSpecificity}, - {specificity: rightSpecificity}, + {specificity: leftSpecificity}: ElementRole, + {specificity: rightSpecificity}: ElementRole, ) { return rightSpecificity - leftSpecificity } - function match(element) { - return node => { + function match(element: ARIARoleRelationConcept) { + return (node: Element) => { let {attributes = []} = element // https://github.com/testing-library/dom-testing-library/issues/814 const typeTextIndex = attributes.findIndex( @@ -117,20 +145,18 @@ function buildElementRoleList(elementRolesMap) { ...attributes.slice(0, typeTextIndex), ...attributes.slice(typeTextIndex + 1), ] - if (node.type !== 'text') { + if ((node as HTMLInputElement).type !== 'text') { return false } } - return node.matches(makeElementSelector({...element, attributes})) + return node.matches(makeElementSelector(element.name, attributes)) } } - let result = [] + let result: ElementRole[] = [] - // eslint bug here: - // eslint-disable-next-line no-unused-vars - for (const [element, roles] of elementRolesMap.entries()) { + for (const [element, roles] of Array.from(elementRolesMap.entries())) { result = [ ...result, { @@ -144,11 +170,14 @@ function buildElementRoleList(elementRolesMap) { return result.sort(bySelectorSpecificity) } -function getRoles(container, {hidden = false} = {}) { - function flattenDOM(node) { +function getRoles( + container: Element, + {hidden}: GetRolesOptions = {hidden: false}, +): Roles { + function flattenDOM(node: Element): Element[] { return [ node, - ...Array.from(node.children).reduce( + ...Array.from(node.children).reduce( (acc, child) => [...acc, ...flattenDOM(child)], [], ), @@ -156,14 +185,15 @@ function getRoles(container, {hidden = false} = {}) { } return flattenDOM(container) - .filter(element => { - return hidden === false ? isInaccessible(element) === false : true + .filter((element: Element) => { + return hidden || !isInaccessible(element) }) - .reduce((acc, node) => { + .reduce((acc, node: Element) => { 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) + const nodeRole = node.getAttribute('role') + if (nodeRole) { + roles = nodeRole.split(' ').slice(0, 1) } else { roles = getImplicitAriaRoles(node) } @@ -178,7 +208,7 @@ function getRoles(container, {hidden = false} = {}) { }, {}) } -function prettyRoles(dom, {hidden}) { +function prettyRoles(dom: Element, {hidden}: GetRolesOptions) { const roles = getRoles(dom, {hidden}) // We prefer to skip generic role, we don't recommend it return Object.entries(roles) @@ -191,7 +221,7 @@ function prettyRoles(dom, {hidden}) { computedStyleSupportsPseudoElements: getConfig() .computedStyleSupportsPseudoElements, })}":\n` - const domString = prettyDOM(el.cloneNode(false)) + const domString = prettyDOM(el.cloneNode(false) as Element) return `${nameString}${domString}` }) .join('\n\n') @@ -201,18 +231,18 @@ function prettyRoles(dom, {hidden}) { .join('\n') } -const logRoles = (dom, {hidden = false} = {}) => +const logRoles = (dom: Element, {hidden}: GetRolesOptions = {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) { +function computeAriaSelected(element: 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 + return (element as HTMLOptionElement).selected } // explicit value @@ -223,15 +253,18 @@ function computeAriaSelected(element) { * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)checked, undefined if not checked-able */ -function computeAriaChecked(element) { +function computeAriaChecked(element: 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) { + if ( + 'indeterminate' in element && + (element as HTMLInputElement).indeterminate + ) { return undefined } if ('checked' in element) { - return element.checked + return (element as HTMLInputElement).checked } // explicit value @@ -242,7 +275,7 @@ function computeAriaChecked(element) { * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)pressed, undefined if not press-able */ -function computeAriaPressed(element) { +function computeAriaPressed(element: Element) { // https://www.w3.org/TR/wai-aria-1.1/#aria-pressed return checkBooleanAttribute(element, 'aria-pressed') } @@ -251,12 +284,12 @@ function computeAriaPressed(element) { * @param {Element} element - * @returns {boolean | undefined} - false/true if (not)expanded, undefined if not expand-able */ -function computeAriaExpanded(element) { +function computeAriaExpanded(element: Element) { // https://www.w3.org/TR/wai-aria-1.1/#aria-expanded return checkBooleanAttribute(element, 'aria-expanded') } -function checkBooleanAttribute(element, attribute) { +function checkBooleanAttribute(element: Element, attribute: string) { const attributeValue = element.getAttribute(attribute) if (attributeValue === 'true') { return true @@ -271,10 +304,10 @@ function checkBooleanAttribute(element, attribute) { * @param {Element} element - * @returns {number | undefined} - number if implicit heading or aria-level present, otherwise undefined */ -function computeHeadingLevel(element) { +function computeHeadingLevel(element: Element) { // https://w3c.github.io/html-aam/#el-h1-h6 // https://w3c.github.io/html-aam/#el-h1-h6 - const implicitHeadingLevels = { + const implicitHeadingLevels: HeadingLevels = { H1: 1, H2: 2, H3: 3, @@ -288,7 +321,7 @@ function computeHeadingLevel(element) { element.getAttribute('aria-level') && Number(element.getAttribute('aria-level')) - return ariaLevelAttribute || implicitHeadingLevels[element.tagName] + return ariaLevelAttribute ?? implicitHeadingLevels[element.tagName] } export { diff --git a/src/suggestions.js b/src/suggestions.ts similarity index 65% rename from src/suggestions.js rename to src/suggestions.ts index ba5a1f29..8b5581b8 100644 --- a/src/suggestions.js +++ b/src/suggestions.ts @@ -1,24 +1,51 @@ import {computeAccessibleName} from 'dom-accessibility-api' +import { + Method, + Variant, + Suggestion, + QueryArgs, + QueryOptions, +} from '../types/suggestions' import {getDefaultNormalizer} from './matches' import {getNodeText} from './get-node-text' import {DEFAULT_IGNORE_TAGS, getConfig} from './config' import {getImplicitAriaRoles, isInaccessible} from './role-helpers' import {getLabels} from './label-helpers' +type SuggestionOptions = { + variant: Variant + name?: string +} + +function isInput(element: Element): element is HTMLInputElement { + return (element as Element & {value: unknown}).value !== undefined +} + const normalize = getDefaultNormalizer() -function escapeRegExp(string) { - return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +function escapeRegExp(text: string) { + return text.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +function isDefinedOption( + value: [string, boolean | RegExp | undefined], +): value is [string, boolean | RegExp] { + return value[1] !== undefined } -function getRegExpMatcher(string) { - return new RegExp(escapeRegExp(string.toLowerCase()), 'i') +function getRegExpMatcher(text: string) { + return new RegExp(escapeRegExp(text.toLowerCase()), 'i') } -function makeSuggestion(queryName, element, content, {variant, name}) { +function makeSuggestion( + queryName: string, + element: Element, + content: string, + {variant, name}: SuggestionOptions, +) { let warning = '' - const queryOptions = {} - const queryArgs = [ + const queryOptions: QueryOptions = {} + const queryArgs: QueryArgs = [ ['Role', 'TestId'].includes(queryName) ? content : getRegExpMatcher(content), @@ -50,38 +77,48 @@ function makeSuggestion(queryName, element, content, {variant, name}) { if (warning) { console.warn(warning) } - let [text, options] = queryArgs + const [text, options] = queryArgs - text = typeof text === 'string' ? `'${text}'` : text + const normalizedText = typeof text === 'string' ? `'${text}'` : text - options = options + const stringifiedOptions = options ? `, { ${Object.entries(options) - .map(([k, v]) => `${k}: ${v}`) + .filter<[string, boolean | RegExp]>(isDefinedOption) + .map(([k, v]) => `${k}: ${v.toString()}`) .join(', ')} }` : '' - return `${queryMethod}(${text}${options})` + return `${queryMethod}(${normalizedText.toString()}${stringifiedOptions})` }, } } -function canSuggest(currentMethod, requestedMethod, data) { +function canSuggest( + currentMethod: string, + requestedMethod: string | undefined, + data: string | null, +): data is string { return ( - data && + typeof data === 'string' && + data.length > 0 && (!requestedMethod || requestedMethod.toLowerCase() === currentMethod.toLowerCase()) ) } -export function getSuggestedQuery(element, variant = 'get', method) { +export function getSuggestedQuery( + element: Element, + variant: Variant = 'get', + method?: Method, +): Suggestion | undefined { // don't create suggestions for script and style elements if (element.matches(DEFAULT_IGNORE_TAGS)) { return undefined } //We prefer to suggest something else if the role is generic - const role = - element.getAttribute('role') ?? getImplicitAriaRoles(element)?.[0] + const role: string = + element.getAttribute('role') ?? getImplicitAriaRoles(element)[0] if (role !== 'generic' && canSuggest('Role', method, role)) { return makeSuggestion('Role', element, role, { variant, @@ -111,7 +148,7 @@ export function getSuggestedQuery(element, variant = 'get', method) { return makeSuggestion('Text', element, textContent, {variant}) } - if (canSuggest('DisplayValue', method, element.value)) { + if (isInput(element) && canSuggest('DisplayValue', method, element.value)) { return makeSuggestion('DisplayValue', element, normalize(element.value), { variant, }) diff --git a/types/role-helpers.d.ts b/types/role-helpers.d.ts index 4aaf54b0..366329e1 100644 --- a/types/role-helpers.d.ts +++ b/types/role-helpers.d.ts @@ -1,9 +1,19 @@ -export function logRoles(container: HTMLElement): string +export type InaccessibleOptions = { + isSubtreeInaccessible?: (element: Element) => boolean +} +export function logRoles( + container: HTMLElement, + options: GetRolesOptions, +): string export function getRoles( container: HTMLElement, + options: GetRolesOptions, ): {[index: string]: HTMLElement[]} /** * https://testing-library.com/docs/dom-testing-library/api-helpers#isinaccessible */ export function isInaccessible(element: Element): boolean -export function computeHeadingLevel(element: Element): number | undefined +export function computeHeadingLevel( + element: Element, + options: InaccessibleOptions, +): number | undefined diff --git a/types/suggestions.d.ts b/types/suggestions.d.ts index c2743641..fe5e09bb 100644 --- a/types/suggestions.d.ts +++ b/types/suggestions.d.ts @@ -2,7 +2,7 @@ export interface QueryOptions { [key: string]: RegExp | boolean } -export type QueryArgs = [string, QueryOptions?] +export type QueryArgs = [string | RegExp, QueryOptions?] export interface Suggestion { queryName: string