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