From b6bc17f4493c00a5f462a98985262ed8adccca64 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 11:48:28 +0100 Subject: [PATCH 01/13] feat: migrate helpers.ts and pretty-dom.ts --- src/{helpers.js => helpers.ts} | 23 +++++---- src/pretty-dom.js | 74 --------------------------- src/pretty-dom.ts | 92 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 83 deletions(-) rename src/{helpers.js => helpers.ts} (79%) delete mode 100644 src/pretty-dom.js create mode 100644 src/pretty-dom.ts 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..048b431d 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) { 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: T) { 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/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} From 81c109e3449b989fb5891fce4824c1fd1055db41 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 13:50:43 +0100 Subject: [PATCH 02/13] feat: support Document and add return type to getLabels --- src/label-helpers.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 From fb126671c3907ea517c9313df7453cd6d569cc04 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 13:51:39 +0100 Subject: [PATCH 03/13] feat: migrate suggestions.ts --- src/{suggestions.js => suggestions.ts} | 84 +++++++++++++++++++------- 1 file changed, 61 insertions(+), 23 deletions(-) rename src/{suggestions.js => suggestions.ts} (59%) diff --git a/src/suggestions.js b/src/suggestions.ts similarity index 59% rename from src/suggestions.js rename to src/suggestions.ts index ba5a1f29..58777b63 100644 --- a/src/suggestions.js +++ b/src/suggestions.ts @@ -1,24 +1,46 @@ import {computeAccessibleName} from 'dom-accessibility-api' +import {Method, Variant} 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 +} + +type QueryOptions = { + name?: RegExp + hidden?: boolean +} + +function isInput(element: Element): element is HTMLInputElement { + return (element as Element & {value: unknown}).value !== undefined +} + +type QueryArgs = [string | RegExp, QueryOptions?] + 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 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,22 +72,28 @@ 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}`) + .map(([k, v]) => { + return v === undefined ? '' : `${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, +) { return ( data && (!requestedMethod || @@ -73,15 +101,20 @@ function canSuggest(currentMethod, requestedMethod, data) { ) } -export function getSuggestedQuery(element, variant = 'get', method) { +export function getSuggestedQuery( + element: Element, + variant: Variant = 'get', + method?: Method, +) { // 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) as string[])[0] if (role !== 'generic' && canSuggest('Role', method, role)) { return makeSuggestion('Role', element, role, { variant, @@ -101,9 +134,14 @@ export function getSuggestedQuery(element, variant = 'get', method) { const placeholderText = element.getAttribute('placeholder') if (canSuggest('PlaceholderText', method, placeholderText)) { - return makeSuggestion('PlaceholderText', element, placeholderText, { - variant, - }) + return makeSuggestion( + 'PlaceholderText', + element, + placeholderText as string, + { + variant, + }, + ) } const textContent = normalize(getNodeText(element)) @@ -111,7 +149,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, }) @@ -119,17 +157,17 @@ export function getSuggestedQuery(element, variant = 'get', method) { const alt = element.getAttribute('alt') if (canSuggest('AltText', method, alt)) { - return makeSuggestion('AltText', element, alt, {variant}) + return makeSuggestion('AltText', element, alt as string, {variant}) } const title = element.getAttribute('title') if (canSuggest('Title', method, title)) { - return makeSuggestion('Title', element, title, {variant}) + return makeSuggestion('Title', element, title as string, {variant}) } const testId = element.getAttribute(getConfig().testIdAttribute) if (canSuggest('TestId', method, testId)) { - return makeSuggestion('TestId', element, testId, {variant}) + return makeSuggestion('TestId', element, testId as string, {variant}) } return undefined From 14fff7ba60faa2693d7278502b3779733df567cc Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 14:35:50 +0100 Subject: [PATCH 04/13] fix: can pass numbers using *byText --- src/__tests__/matches.js | 5 +++++ src/matches.ts | 6 ++++-- types/matches.d.ts | 2 +- types/tsconfig.json | 6 +++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js index 3f5e6b3e..084f449c 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.js @@ -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/matches.ts b/src/matches.ts index 5aecb416..5e27ad3e 100644 --- a/src/matches.ts +++ b/src/matches.ts @@ -31,8 +31,10 @@ function fuzzyMatches( const normalizedText = normalizer(textToMatch) - if (typeof matcher === 'string') { - return normalizedText.toLowerCase().includes(matcher.toLowerCase()) + if (typeof matcher === 'string' || typeof matcher === 'number') { + return normalizedText + .toLowerCase() + .includes(matcher.toString().toLowerCase()) } else if (typeof matcher === 'function') { return matcher(normalizedText, node) } else { diff --git a/types/matches.d.ts b/types/matches.d.ts index 03f9a4b8..2d05d4ab 100644 --- a/types/matches.d.ts +++ b/types/matches.d.ts @@ -6,7 +6,7 @@ export type MatcherFunction = ( content: string, element: Nullish, ) => boolean -export type Matcher = MatcherFunction | RegExp | string +export type Matcher = MatcherFunction | RegExp | string | number // Get autocomplete for ARIARole union types, while still supporting another string // Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-505826972 diff --git a/types/tsconfig.json b/types/tsconfig.json index 3c43903c..315b2549 100644 --- a/types/tsconfig.json +++ b/types/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "../tsconfig.json" + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": {"@testing-library/dom": ["."]} + } } From 0de8b6b463b68429abdf81325f37c6f15042d999 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 15:05:13 +0100 Subject: [PATCH 05/13] fix: remove undefined values --- src/suggestions.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/suggestions.ts b/src/suggestions.ts index 58777b63..33e0b7a6 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -28,6 +28,12 @@ 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(text: string) { return new RegExp(escapeRegExp(text.toLowerCase()), 'i') } @@ -78,9 +84,8 @@ function makeSuggestion( const stringifiedOptions = options ? `, { ${Object.entries(options) - .map(([k, v]) => { - return v === undefined ? '' : `${k}: ${v.toString()}` - }) + .filter<[string, boolean | RegExp]>(isDefinedOption) + .map(([k, v]) => `${k}: ${v.toString()}`) .join(', ')} }` : '' From 31a60bd9be30f7c6a95ae991829b4edf4a84d933 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 15:55:45 +0100 Subject: [PATCH 06/13] test: migrate tests to ts --- src/__tests__/{config.js => config.ts} | 7 +- src/__tests__/{helpers.js => helpers.ts} | 12 +- .../{label-helpers.js => label-helpers.ts} | 0 src/__tests__/{matches.js => matches.ts} | 2 +- .../{pretty-dom.js => pretty-dom.ts} | 14 +- .../{suggestions.js => suggestions.ts} | 122 ++++++++++-------- src/suggestions.ts | 17 ++- types/suggestions.d.ts | 2 +- 8 files changed, 104 insertions(+), 72 deletions(-) rename src/__tests__/{config.js => config.ts} (95%) rename src/__tests__/{helpers.js => helpers.ts} (83%) rename src/__tests__/{label-helpers.js => label-helpers.ts} (100%) rename src/__tests__/{matches.js => matches.ts} (97%) rename src/__tests__/{pretty-dom.js => pretty-dom.ts} (85%) rename src/__tests__/{suggestions.js => suggestions.ts} (85%) 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 97% rename from src/__tests__/matches.js rename to src/__tests__/matches.ts index 084f449c..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) 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 85% rename from src/__tests__/suggestions.js rename to src/__tests__/suggestions.ts index 3bb5364d..911ca2ef 100644 --- a/src/__tests__/suggestions.js +++ b/src/__tests__/suggestions.ts @@ -1,5 +1,6 @@ import {configure} from '../config' import {screen, getSuggestedQuery} from '..' +import {Suggestion} from '../../types/suggestions' import {renderIntoDocument, render} from './helpers/test-utils' beforeAll(() => { @@ -8,7 +9,7 @@ beforeAll(() => { afterEach(() => { configure({testIdAttribute: 'data-testid'}) - console.warn.mockClear() + ;(console.warn as jest.Mock).mockClear() }) afterAll(() => { @@ -187,39 +188,41 @@ test('escapes regular expressions in suggestion', () => { ) expect( - getSuggestedQuery( - container.querySelector('img'), + (getSuggestedQuery( + container.querySelector('img') as HTMLImageElement, 'get', - 'altText', - ).toString(), + 'AltText', + ) as Suggestion).toString(), ).toEqual(`getByAltText(/the problem \\(picture of a question mark\\)/i)`) - expect(getSuggestedQuery(container.querySelector('p')).toString()).toEqual( - `getByText(/loading \\.\\.\\. \\(1\\)/i)`, - ) + expect( + (getSuggestedQuery( + container.querySelector('p') as HTMLParagraphElement, + ) as Suggestion).toString(), + ).toEqual(`getByText(/loading \\.\\.\\. \\(1\\)/i)`) expect( - getSuggestedQuery( - container.querySelector('input'), + (getSuggestedQuery( + container.querySelector('input') as HTMLInputElement, 'get', - 'placeholderText', - ).toString(), + 'PlaceholderText', + ) as Suggestion).toString(), ).toEqual(`getByPlaceholderText(/should escape \\+\\-'\\(\\//i)`) expect( - getSuggestedQuery( - container.querySelector('input'), + (getSuggestedQuery( + container.querySelector('input') as HTMLInputElement, 'get', - 'displayValue', - ).toString(), + 'DisplayValue', + ) as Suggestion).toString(), ).toEqual(`getByDisplayValue(/my super string \\+\\-\\('\\{\\}\\^\\$\\)/i)`) expect( - getSuggestedQuery( - container.querySelector('input'), + (getSuggestedQuery( + container.querySelector('input') as HTMLInputElement, 'get', - 'labelText', - ).toString(), + 'LabelText', + ) as Suggestion).toString(), ).toEqual(`getByLabelText(/inp\\-t lab\\^l w\\{th c\\+ars to esc\\\\pe/i)`) }) @@ -238,7 +241,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 +319,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 +377,35 @@ 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( - /queryAllByRole/, + expect((getSuggestedQuery(button) as Suggestion).toString()).toMatch( + /getByRole/, ) - expect(getSuggestedQuery(button, 'find').toString()).toMatch(/findByRole/) - expect(getSuggestedQuery(button, 'findAll').toString()).toMatch( - /findAllByRole/, + expect((getSuggestedQuery(button, 'get') as Suggestion).toString()).toMatch( + /getByRole/, + ) + expect( + (getSuggestedQuery(button, 'getAll') as Suggestion).toString(), + ).toMatch(/getAllByRole/) + expect((getSuggestedQuery(button, 'query') as Suggestion).toString()).toMatch( + /queryByRole/, ) + expect( + (getSuggestedQuery(button, 'queryAll') as Suggestion).toString(), + ).toMatch(/queryAllByRole/) + expect((getSuggestedQuery(button, 'find') as Suggestion).toString()).toMatch( + /findByRole/, + ) + expect( + (getSuggestedQuery(button, 'findAll') as Suggestion).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 +414,11 @@ test('getSuggestedQuery returns rich data for tooling', () => { variant: 'get', }) - expect(getSuggestedQuery(button).toString()).toEqual( + expect((getSuggestedQuery(button) as Suggestion).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 +427,9 @@ test('getSuggestedQuery returns rich data for tooling', () => { variant: 'get', }) - expect(getSuggestedQuery(div).toString()).toEqual(`getByText(/cancel/i)`) + expect((getSuggestedQuery(div) as Suggestion).toString()).toEqual( + `getByText(/cancel/i)`, + ) }) test('getSuggestedQuery can return specified methods in addition to the best', () => { @@ -432,8 +448,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') as HTMLInputElement + const button = container.querySelector('button') as HTMLButtonElement // this function should be insensitive for the method param. // Role and role should work the same @@ -515,7 +531,7 @@ test('getSuggestedQuery works with custom testIdAttribute', () => { `) - const input = container.querySelector('input') + const input = container.querySelector('input') as HTMLInputElement expect(getSuggestedQuery(input, 'get', 'TestId')).toMatchObject({ queryName: 'TestId', @@ -531,8 +547,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') as HTMLScriptElement + const style = container.querySelector('style') as HTMLStyleElement expect(getSuggestedQuery(script, 'get', 'TestId')).toBeUndefined() expect(getSuggestedQuery(style, 'get', 'TestId')).toBeUndefined() @@ -552,7 +568,11 @@ test('should get the first label with aria-labelledby contains multiple ids', () `) expect( - getSuggestedQuery(container.querySelector('input'), 'get', 'labelText'), + getSuggestedQuery( + container.querySelector('input') as HTMLInputElement, + 'get', + 'LabelText', + ), ).toMatchObject({ queryName: 'LabelText', queryMethod: 'getByLabelText', @@ -562,7 +582,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 +592,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,17 +607,17 @@ 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') as HTMLInputElement, 'get', 'role', - ) + ) as Suggestion expect(suggestion).toMatchObject({ queryName: 'Role', queryMethod: 'getByRole', @@ -609,7 +629,7 @@ test('should suggest hidden option if element is not in the accessibility tree', }) 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 +654,9 @@ test('should find label text using the aria-labelledby', () => { expect( getSuggestedQuery( - container.querySelector('[id="sixth-id"]'), + container.querySelector('[id="sixth-id"]') as HTMLInputElement, 'get', - 'labelText', + 'LabelText', ), ).toMatchInlineSnapshot( { diff --git a/src/suggestions.ts b/src/suggestions.ts index 33e0b7a6..d808d2d1 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -1,5 +1,11 @@ import {computeAccessibleName} from 'dom-accessibility-api' -import {Method, Variant} from '../types/suggestions' +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' @@ -11,17 +17,10 @@ type SuggestionOptions = { name?: string } -type QueryOptions = { - name?: RegExp - hidden?: boolean -} - function isInput(element: Element): element is HTMLInputElement { return (element as Element & {value: unknown}).value !== undefined } -type QueryArgs = [string | RegExp, QueryOptions?] - const normalize = getDefaultNormalizer() function escapeRegExp(text: string) { @@ -110,7 +109,7 @@ 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 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 From 6394866ccec65f411d56a8ddc4604f927a19b2b7 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 16:40:01 +0100 Subject: [PATCH 07/13] refactor: remove cast --- src/__tests__/suggestions.ts | 59 +++++++++++++++--------------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/src/__tests__/suggestions.ts b/src/__tests__/suggestions.ts index 911ca2ef..8789ec54 100644 --- a/src/__tests__/suggestions.ts +++ b/src/__tests__/suggestions.ts @@ -1,6 +1,5 @@ import {configure} from '../config' import {screen, getSuggestedQuery} from '..' -import {Suggestion} from '../../types/suggestions' import {renderIntoDocument, render} from './helpers/test-utils' beforeAll(() => { @@ -188,41 +187,41 @@ test('escapes regular expressions in suggestion', () => { ) expect( - (getSuggestedQuery( + getSuggestedQuery( container.querySelector('img') as HTMLImageElement, 'get', 'AltText', - ) as Suggestion).toString(), + )?.toString(), ).toEqual(`getByAltText(/the problem \\(picture of a question mark\\)/i)`) expect( - (getSuggestedQuery( + getSuggestedQuery( container.querySelector('p') as HTMLParagraphElement, - ) as Suggestion).toString(), + )?.toString(), ).toEqual(`getByText(/loading \\.\\.\\. \\(1\\)/i)`) expect( - (getSuggestedQuery( + getSuggestedQuery( container.querySelector('input') as HTMLInputElement, 'get', 'PlaceholderText', - ) as Suggestion).toString(), + )?.toString(), ).toEqual(`getByPlaceholderText(/should escape \\+\\-'\\(\\//i)`) expect( - (getSuggestedQuery( + getSuggestedQuery( container.querySelector('input') as HTMLInputElement, 'get', 'DisplayValue', - ) as Suggestion).toString(), + )?.toString(), ).toEqual(`getByDisplayValue(/my super string \\+\\-\\('\\{\\}\\^\\$\\)/i)`) expect( - (getSuggestedQuery( + getSuggestedQuery( container.querySelector('input') as HTMLInputElement, 'get', 'LabelText', - ) as Suggestion).toString(), + )?.toString(), ).toEqual(`getByLabelText(/inp\\-t lab\\^l w\\{th c\\+ars to esc\\\\pe/i)`) }) @@ -380,27 +379,19 @@ test('getSuggestedQuery handles `variant` and defaults to `get`', () => { const button = render(``).container .firstChild as HTMLButtonElement - expect((getSuggestedQuery(button) as Suggestion).toString()).toMatch( - /getByRole/, + expect(getSuggestedQuery(button)?.toString()).toMatch(/getByRole/) + expect(getSuggestedQuery(button, 'get')?.toString()).toMatch(/getByRole/) + expect(getSuggestedQuery(button, 'getAll')?.toString()).toMatch( + /getAllByRole/, ) - expect((getSuggestedQuery(button, 'get') as Suggestion).toString()).toMatch( - /getByRole/, + expect(getSuggestedQuery(button, 'query')?.toString()).toMatch(/queryByRole/) + expect(getSuggestedQuery(button, 'queryAll')?.toString()).toMatch( + /queryAllByRole/, ) - expect( - (getSuggestedQuery(button, 'getAll') as Suggestion).toString(), - ).toMatch(/getAllByRole/) - expect((getSuggestedQuery(button, 'query') as Suggestion).toString()).toMatch( - /queryByRole/, + expect(getSuggestedQuery(button, 'find')?.toString()).toMatch(/findByRole/) + expect(getSuggestedQuery(button, 'findAll')?.toString()).toMatch( + /findAllByRole/, ) - expect( - (getSuggestedQuery(button, 'queryAll') as Suggestion).toString(), - ).toMatch(/queryAllByRole/) - expect((getSuggestedQuery(button, 'find') as Suggestion).toString()).toMatch( - /findByRole/, - ) - expect( - (getSuggestedQuery(button, 'findAll') as Suggestion).toString(), - ).toMatch(/findAllByRole/) }) test('getSuggestedQuery returns rich data for tooling', () => { @@ -414,7 +405,7 @@ test('getSuggestedQuery returns rich data for tooling', () => { variant: 'get', }) - expect((getSuggestedQuery(button) as Suggestion).toString()).toEqual( + expect(getSuggestedQuery(button)?.toString()).toEqual( `getByRole('button', { name: /submit/i })`, ) @@ -427,9 +418,7 @@ test('getSuggestedQuery returns rich data for tooling', () => { variant: 'get', }) - expect((getSuggestedQuery(div) as Suggestion).toString()).toEqual( - `getByText(/cancel/i)`, - ) + expect(getSuggestedQuery(div)?.toString()).toEqual(`getByText(/cancel/i)`) }) test('getSuggestedQuery can return specified methods in addition to the best', () => { @@ -617,7 +606,7 @@ test('should suggest hidden option if element is not in the accessibility tree', container.querySelector('input') as HTMLInputElement, 'get', 'role', - ) as Suggestion + ) expect(suggestion).toMatchObject({ queryName: 'Role', queryMethod: 'getByRole', @@ -627,7 +616,7 @@ 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 as jest.Mock).mock.calls).toMatchInlineSnapshot(` Array [ From c4fc301bd2e48b1c1f20c2f4e7a618c0835f06c1 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 16:48:29 +0100 Subject: [PATCH 08/13] refactor: use uknown as type for param --- src/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.ts b/src/helpers.ts index 048b431d..11ef426c 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -118,7 +118,7 @@ function checkContainerType(container?: Element) { ) } - function getTypeName(object: T) { + function getTypeName(object: unknown) { if (typeof object === 'object') { return object === null ? 'null' : (object as Object).constructor.name } From e748cda465b9c1d1c1f2d77780b612bc96de8c09 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Thu, 17 Dec 2020 18:42:15 +0100 Subject: [PATCH 09/13] feat: migrate role-helpers.ts --- src/{role-helpers.js => role-helpers.ts} | 123 ++++++++++++++--------- src/suggestions.ts | 3 +- types/role-helpers.d.ts | 14 ++- 3 files changed, 91 insertions(+), 49 deletions(-) rename src/{role-helpers.js => role-helpers.ts} (68%) 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..aeb18187 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 && 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 && 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.ts b/src/suggestions.ts index d808d2d1..7682a164 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -117,8 +117,7 @@ export function getSuggestedQuery( //We prefer to suggest something else if the role is generic const role: string = - element.getAttribute('role') ?? - (getImplicitAriaRoles(element) as string[])[0] + element.getAttribute('role') ?? getImplicitAriaRoles(element)[0] if (role !== 'generic' && canSuggest('Role', method, role)) { return makeSuggestion('Role', element, role, { 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 From 71a1e81e7f89f5d490ea015c270ec38062e32a59 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Fri, 18 Dec 2020 08:55:56 +0100 Subject: [PATCH 10/13] fix: return generic --- src/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.ts b/src/helpers.ts index 11ef426c..72a7476a 100644 --- a/src/helpers.ts +++ 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: () => T) { +function runWithRealTimers(callback: () => T): T { const fakeTimersType = getJestFakeTimersType() if (fakeTimersType) { jest.useRealTimers() From 31c56b583564bcb6a0603cd555f1b2ab2d22ef6f Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Fri, 18 Dec 2020 09:02:43 +0100 Subject: [PATCH 11/13] refactor: use optional chaining --- src/role-helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/role-helpers.ts b/src/role-helpers.ts index aeb18187..795146a8 100644 --- a/src/role-helpers.ts +++ b/src/role-helpers.ts @@ -41,7 +41,7 @@ function isSubtreeInaccessible(element: Element) { } const window = element.ownerDocument.defaultView - if (window && window.getComputedStyle(element).display === 'none') { + if (window?.getComputedStyle(element).display === 'none') { return true } @@ -68,7 +68,7 @@ function isInaccessible(element: Element, options: InaccessibleOptions = {}) { } = options const window = element.ownerDocument.defaultView // since visibility is inherited we can exit early - if (window && window.getComputedStyle(element).visibility === 'hidden') { + if (window?.getComputedStyle(element).visibility === 'hidden') { return true } From ce2f04d96e42c79ec2d5583b9ac7051679b1a10b Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Fri, 18 Dec 2020 11:55:51 +0100 Subject: [PATCH 12/13] fix: use type guard --- src/suggestions.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/suggestions.ts b/src/suggestions.ts index 7682a164..8b5581b8 100644 --- a/src/suggestions.ts +++ b/src/suggestions.ts @@ -97,9 +97,10 @@ 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()) ) @@ -137,14 +138,9 @@ export function getSuggestedQuery( const placeholderText = element.getAttribute('placeholder') if (canSuggest('PlaceholderText', method, placeholderText)) { - return makeSuggestion( - 'PlaceholderText', - element, - placeholderText as string, - { - variant, - }, - ) + return makeSuggestion('PlaceholderText', element, placeholderText, { + variant, + }) } const textContent = normalize(getNodeText(element)) @@ -160,17 +156,17 @@ export function getSuggestedQuery( const alt = element.getAttribute('alt') if (canSuggest('AltText', method, alt)) { - return makeSuggestion('AltText', element, alt as string, {variant}) + return makeSuggestion('AltText', element, alt, {variant}) } const title = element.getAttribute('title') if (canSuggest('Title', method, title)) { - return makeSuggestion('Title', element, title as string, {variant}) + return makeSuggestion('Title', element, title, {variant}) } const testId = element.getAttribute(getConfig().testIdAttribute) if (canSuggest('TestId', method, testId)) { - return makeSuggestion('TestId', element, testId as string, {variant}) + return makeSuggestion('TestId', element, testId, {variant}) } return undefined From f9b43d5c371653fa3e3fd78e774b9427d23a4b52 Mon Sep 17 00:00:00 2001 From: marcosvega91 Date: Tue, 22 Dec 2020 11:28:23 +0100 Subject: [PATCH 13/13] fix: disable @typescript-eslint/no-non-null-assertion --- package.json | 3 ++- src/__tests__/suggestions.ts | 37 +++++++++++++++--------------------- 2 files changed, 17 insertions(+), 23 deletions(-) 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__/suggestions.ts b/src/__tests__/suggestions.ts index 8789ec54..65d1760a 100644 --- a/src/__tests__/suggestions.ts +++ b/src/__tests__/suggestions.ts @@ -188,21 +188,19 @@ test('escapes regular expressions in suggestion', () => { expect( getSuggestedQuery( - container.querySelector('img') as HTMLImageElement, + container.querySelector('img')!, 'get', 'AltText', )?.toString(), ).toEqual(`getByAltText(/the problem \\(picture of a question mark\\)/i)`) - expect( - getSuggestedQuery( - container.querySelector('p') as HTMLParagraphElement, - )?.toString(), - ).toEqual(`getByText(/loading \\.\\.\\. \\(1\\)/i)`) + expect(getSuggestedQuery(container.querySelector('p')!)?.toString()).toEqual( + `getByText(/loading \\.\\.\\. \\(1\\)/i)`, + ) expect( getSuggestedQuery( - container.querySelector('input') as HTMLInputElement, + container.querySelector('input')!, 'get', 'PlaceholderText', )?.toString(), @@ -210,7 +208,7 @@ test('escapes regular expressions in suggestion', () => { expect( getSuggestedQuery( - container.querySelector('input') as HTMLInputElement, + container.querySelector('input')!, 'get', 'DisplayValue', )?.toString(), @@ -218,7 +216,7 @@ test('escapes regular expressions in suggestion', () => { expect( getSuggestedQuery( - container.querySelector('input') as HTMLInputElement, + container.querySelector('input')!, 'get', 'LabelText', )?.toString(), @@ -437,9 +435,8 @@ test('getSuggestedQuery can return specified methods in addition to the best', ( `) - const input = container.querySelector('input') as HTMLInputElement - const button = container.querySelector('button') as HTMLButtonElement - + 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({ @@ -520,7 +517,7 @@ test('getSuggestedQuery works with custom testIdAttribute', () => { `) - const input = container.querySelector('input') as HTMLInputElement + const input = container.querySelector('input')! expect(getSuggestedQuery(input, 'get', 'TestId')).toMatchObject({ queryName: 'TestId', @@ -536,8 +533,8 @@ test('getSuggestedQuery does not create suggestions for script and style element `) - const script = container.querySelector('script') as HTMLScriptElement - const style = container.querySelector('style') as HTMLStyleElement + const script = container.querySelector('script')! + const style = container.querySelector('style')! expect(getSuggestedQuery(script, 'get', 'TestId')).toBeUndefined() expect(getSuggestedQuery(style, 'get', 'TestId')).toBeUndefined() @@ -557,11 +554,7 @@ test('should get the first label with aria-labelledby contains multiple ids', () `) expect( - getSuggestedQuery( - container.querySelector('input') as HTMLInputElement, - 'get', - 'LabelText', - ), + getSuggestedQuery(container.querySelector('input')!, 'get', 'LabelText'), ).toMatchObject({ queryName: 'LabelText', queryMethod: 'getByLabelText', @@ -603,7 +596,7 @@ test('should suggest hidden option if element is not in the accessibility tree', `) const suggestion = getSuggestedQuery( - container.querySelector('input') as HTMLInputElement, + container.querySelector('input')!, 'get', 'role', ) @@ -643,7 +636,7 @@ test('should find label text using the aria-labelledby', () => { expect( getSuggestedQuery( - container.querySelector('[id="sixth-id"]') as HTMLInputElement, + container.querySelector('[id="sixth-id"]')!, 'get', 'LabelText', ),