diff --git a/src/helpers.js b/src/helpers.ts similarity index 68% rename from src/helpers.js rename to src/helpers.ts index 8cdecc78..adaf32ef 100644 --- a/src/helpers.js +++ b/src/helpers.ts @@ -1,26 +1,38 @@ +import {Screen} from '../types' + const globalObj = typeof window === 'undefined' ? global : window // Constant node.nodeType for text nodes, see: // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#Node_type_constants const TEXT_NODE = 3 // 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 { return hasJestTimers() ? runWithJestRealTimers(callback).callbackReturnValue : // istanbul ignore next callback() } -function hasJestTimers() { +function hasJestTimers(): boolean { return ( typeof jest !== 'undefined' && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition jest !== null && typeof jest.useRealTimers === 'function' ) } -function runWithJestRealTimers(callback) { - const timerAPI = { +export interface TimerApi { + clearInterval: typeof clearInterval + clearTimeout: typeof clearTimeout + setInterval: typeof setInterval + setTimeout: typeof setTimeout & {clock?: unknown} + setImmediate?: typeof setImmediate + clearImmediate?: typeof clearImmediate +} + +function runWithJestRealTimers(callback: () => T) { + const timerAPI: TimerApi = { clearInterval, clearTimeout, setInterval, @@ -41,10 +53,11 @@ function runWithJestRealTimers(callback) { const callbackReturnValue = callback() const usedFakeTimers = Object.entries(timerAPI).some( - ([name, func]) => func !== globalObj[name], + ([name, func]) => func !== globalObj[name as keyof TimerApi], ) if (usedFakeTimers) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition jest.useFakeTimers(timerAPI.setTimeout?.clock ? 'modern' : 'legacy') } @@ -54,7 +67,7 @@ function runWithJestRealTimers(callback) { } } -function jestFakeTimersAreEnabled() { +function jestFakeTimersAreEnabled(): boolean { return hasJestTimers() ? runWithJestRealTimers(() => {}).usedFakeTimers : // istanbul ignore next @@ -63,7 +76,7 @@ function jestFakeTimersAreEnabled() { // we only run our tests in node, and setImmediate is supported in node. // istanbul ignore next -function setImmediatePolyfill(fn) { +function setImmediatePolyfill(fn: TimerHandler): number { return globalObj.setTimeout(fn, 0) } @@ -71,6 +84,7 @@ function getTimeFunctions() { // istanbul ignore next return { clearTimeoutFn: globalObj.clearTimeout, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition setImmediateFn: globalObj.setImmediate || setImmediatePolyfill, setTimeoutFn: globalObj.setTimeout, } @@ -80,24 +94,30 @@ const {clearTimeoutFn, setImmediateFn, setTimeoutFn} = runWithRealTimers( getTimeFunctions, ) -function getDocument() { +function getDocument(): Document { /* istanbul ignore if */ if (typeof window === 'undefined') { throw new Error('Could not find default container') } return window.document } -function getWindowFromNode(node) { +function getWindowFromNode( + node: EventTarget & { + defaultView?: (Window & typeof globalThis) | null + ownerDocument?: Document | null + window?: Window & typeof globalThis + }, +): Window & typeof globalThis { if (node.defaultView) { // node is document return node.defaultView - } else if (node.ownerDocument && node.ownerDocument.defaultView) { + } else if (node.ownerDocument?.defaultView) { // node is a DOM node return node.ownerDocument.defaultView } else if (node.window) { // node is window return node.window - } else if (node.then instanceof Function) { + } else if (((node as unknown) as Promise).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...\`?`, ) @@ -106,8 +126,8 @@ function getWindowFromNode(node) { `It looks like you passed an Array instead of a DOM node. Did you do something like \`fireEvent.click(screen.getAllBy...\` when you meant to use a \`getBy\` query \`fireEvent.click(screen.getBy...\`?`, ) } else if ( - typeof node.debug === 'function' && - typeof node.logTestingPlaygroundURL === 'function' + typeof ((node as unknown) as Screen).debug === 'function' && + typeof ((node as unknown) as Screen).logTestingPlaygroundURL === 'function' ) { throw new Error( `It looks like you passed a \`screen\` object. Did you do something like \`fireEvent.click(screen, ...\` when you meant to use a query, e.g. \`fireEvent.click(screen.getBy..., \`?`, @@ -120,11 +140,16 @@ function getWindowFromNode(node) { } } -function checkContainerType(container) { +export type Container = Document | DocumentFragment | Element + +function checkContainerType( + container: Node | null, +): asserts container is Container { if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition !container || - !(typeof container.querySelector === 'function') || - !(typeof container.querySelectorAll === 'function') + !(typeof (container as Container).querySelector === 'function') || + !(typeof (container as Container).querySelectorAll === 'function') ) { throw new TypeError( `Expected container to be an Element, a Document or a DocumentFragment but got ${getTypeName( @@ -133,7 +158,7 @@ function checkContainerType(container) { ) } - function getTypeName(object) { + function getTypeName(object: unknown) { if (typeof object === 'object') { return object === null ? 'null' : object.constructor.name } diff --git a/src/wait-for-dom-change.js b/src/wait-for-dom-change.ts similarity index 71% rename from src/wait-for-dom-change.js rename to src/wait-for-dom-change.ts index 1344db9d..a1631663 100644 --- a/src/wait-for-dom-change.js +++ b/src/wait-for-dom-change.ts @@ -1,3 +1,4 @@ +import {waitForOptions} from '../types' import { getWindowFromNode, getDocument, @@ -22,7 +23,7 @@ function waitForDomChange({ attributes: true, characterData: true, }, -} = {}) { +}: waitForOptions = {}) { if (!hasWarned) { hasWarned = true console.warn( @@ -37,17 +38,19 @@ function waitForDomChange({ observer.observe(container, mutationObserverOptions), ) - function onDone(error, result) { - clearTimeout(timer) + function onDone(error: Error | null, result: MutationRecord[] | null) { + ;(clearTimeout as (t: NodeJS.Timeout | number) => void)(timer) setImmediate(() => observer.disconnect()) if (error) { reject(error) } else { - resolve(result) + // either error or result is null, so if error is null then result is not + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolve(result!) } } - function onMutation(mutationsList) { + function onMutation(mutationsList: MutationRecord[]) { onDone(null, mutationsList) } @@ -57,8 +60,8 @@ function waitForDomChange({ }) } -function waitForDomChangeWrapper(...args) { - return getConfig().asyncWrapper(() => waitForDomChange(...args)) +function waitForDomChangeWrapper(options?: waitForOptions) { + return getConfig().asyncWrapper(() => waitForDomChange(options)) } export {waitForDomChangeWrapper as waitForDomChange} diff --git a/src/wait-for-element-to-be-removed.js b/src/wait-for-element-to-be-removed.ts similarity index 52% rename from src/wait-for-element-to-be-removed.js rename to src/wait-for-element-to-be-removed.ts index 2b5bcb9a..21e1943c 100644 --- a/src/wait-for-element-to-be-removed.js +++ b/src/wait-for-element-to-be-removed.ts @@ -1,10 +1,14 @@ +import {waitForOptions} from '../types' import {waitFor} from './wait-for' -const isRemoved = result => !result || (Array.isArray(result) && !result.length) +type Result = (Node | null)[] | Node | null + +const isRemoved = (result: Result): boolean => + !result || (Array.isArray(result) && !result.length) // Check if the element is not present. // As the name implies, waitForElementToBeRemoved should check `present` --> `removed` -function initialCheck(elements) { +function initialCheck(elements: Result): void { if (isRemoved(elements)) { throw new Error( 'The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.', @@ -12,20 +16,25 @@ function initialCheck(elements) { } } -async function waitForElementToBeRemoved(callback, options) { +async function waitForElementToBeRemoved( + arg: Result | (() => Result), + options?: waitForOptions, +): Promise { // created here so we get a nice stacktrace const timeoutError = new Error('Timed out in waitForElementToBeRemoved.') - if (typeof callback !== 'function') { - initialCheck(callback) - const elements = Array.isArray(callback) ? callback : [callback] + + function handleArg(argument: Result): () => Result { + initialCheck(argument) + const elements = Array.isArray(argument) ? argument : [argument] const getRemainingElements = elements.map(element => { - let parent = element.parentElement - if (parent === null) return () => null + let parent = element?.parentElement + if (!parent) return () => null while (parent.parentElement) parent = parent.parentElement - return () => (parent.contains(element) ? element : null) + return () => (parent?.contains(element) ? element : null) }) - callback = () => getRemainingElements.map(c => c()).filter(Boolean) + return () => getRemainingElements.map(c => c()).filter(Boolean) } + const callback = typeof arg === 'function' ? arg : handleArg(arg) initialCheck(callback()) @@ -33,8 +42,8 @@ async function waitForElementToBeRemoved(callback, options) { let result try { result = callback() - } catch (error) { - if (error.name === 'TestingLibraryElementError') { + } catch (error: unknown) { + if ((error as Error).name === 'TestingLibraryElementError') { return undefined } throw error diff --git a/src/wait-for-element.js b/src/wait-for-element.ts similarity index 81% rename from src/wait-for-element.js rename to src/wait-for-element.ts index 060f17be..67ed4d9e 100644 --- a/src/wait-for-element.js +++ b/src/wait-for-element.ts @@ -1,3 +1,4 @@ +import {waitForOptions} from '../types' import {waitFor} from './wait-for' let hasWarned = false @@ -5,13 +6,17 @@ let hasWarned = false // deprecated... TODO: remove this method. People should use a find* query or // wait instead the reasoning is that this doesn't really do anything useful // that you can't get from using find* or wait. -async function waitForElement(callback, options) { +async function waitForElement( + callback: () => T, + options?: waitForOptions, +): Promise { if (!hasWarned) { hasWarned = true console.warn( `\`waitForElement\` has been deprecated. Use a \`find*\` query (preferred: https://testing-library.com/docs/dom-testing-library/api-queries#findby) or use \`waitFor\` instead: https://testing-library.com/docs/dom-testing-library/api-async#waitfor`, ) } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!callback) { throw new Error('waitForElement requires a callback as the first parameter') } diff --git a/src/wait-for.js b/src/wait-for.ts similarity index 82% rename from src/wait-for.js rename to src/wait-for.ts index 860b7a15..47d08bc9 100644 --- a/src/wait-for.js +++ b/src/wait-for.ts @@ -1,3 +1,4 @@ +import {waitForOptions} from '../types' import { getWindowFromNode, getDocument, @@ -14,12 +15,16 @@ import {getConfig, runWithExpensiveErrorDiagnosticsDisabled} from './config' // This is so the stack trace the developer sees is one that's // closer to their code (because async stack traces are hard to follow). -function copyStackTrace(target, source) { - target.stack = source.stack.replace(source.message, target.message) +function copyStackTrace(target: Error, source: Error): void { + target.stack = source.stack?.replace(source.message, target.message) } -function waitFor( - callback, +function isPromise(value: Promise | T): value is Promise { + return typeof (value as Promise | undefined)?.then === 'function' +} + +function waitFor( + callback: () => Promise | T, { container = getDocument(), timeout = getConfig().asyncUtilTimeout, @@ -39,14 +44,16 @@ function waitFor( attributes: true, characterData: true, }, - }, -) { + }: waitForOptions & {stackTraceError: Error}, +): Promise { if (typeof callback !== 'function') { throw new TypeError('Received `callback` arg must be a function') } return new Promise(async (resolve, reject) => { - let lastError, intervalId, observer + let lastError: unknown, + intervalId: NodeJS.Timeout, + observer: MutationObserver let finished = false let promiseStatus = 'idle' @@ -59,7 +66,7 @@ function waitFor( // infinite loop. However, eslint isn't smart enough to know that we're // setting finished inside `onDone` which will be called when we're done // waiting or when we've timed out. - // eslint-disable-next-line no-unmodified-loop-condition + // eslint-disable-next-line no-unmodified-loop-condition, @typescript-eslint/no-unnecessary-condition while (!finished) { if (!jestFakeTimersAreEnabled()) { const error = new Error( @@ -92,7 +99,7 @@ function waitFor( } else { try { checkContainerType(container) - } catch (e) { + } catch (e: unknown) { reject(e) return } @@ -103,9 +110,11 @@ function waitFor( checkCallback() } - function onDone(error, result) { + function onDone(error: Error | null, result: T | null) { finished = true - clearTimeout(overallTimeoutTimer) + ;(clearTimeout as (t: NodeJS.Timeout | number) => void)( + overallTimeoutTimer, + ) if (!usingJestFakeTimers) { clearInterval(intervalId) @@ -115,7 +124,9 @@ function waitFor( if (error) { reject(error) } else { - resolve(result) + // either error or result is null, so if error is null then result is not + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolve(result!) } } @@ -135,7 +146,7 @@ function waitFor( if (promiseStatus === 'pending') return try { const result = runWithExpensiveErrorDiagnosticsDisabled(callback) - if (typeof result?.then === 'function') { + if (isPromise(result)) { promiseStatus = 'pending' result.then( resolvedValue => { @@ -151,7 +162,7 @@ function waitFor( onDone(null, result) } // If `callback` throws, wait for the next mutation, interval, or timeout. - } catch (error) { + } catch (error: unknown) { // Save the most recent callback error to reject the promise with it in the event of a timeout lastError = error } @@ -159,7 +170,7 @@ function waitFor( function handleTimeout() { let error - if (lastError) { + if (lastError && lastError instanceof Error) { error = lastError if ( !showOriginalStackTrace && @@ -178,7 +189,10 @@ function waitFor( }) } -function waitForWrapper(callback, options) { +function waitForWrapper( + callback: () => Promise | T, + options?: waitForOptions, +) { // create the error here so its stack trace is as close to the // calling code as possible const stackTraceError = new Error('STACK_TRACE_MESSAGE') @@ -191,16 +205,18 @@ let hasWarned = false // deprecated... TODO: remove this method. We renamed this to `waitFor` so the // code people write reads more clearly. -function wait(...args) { - // istanbul ignore next - const [first = () => {}, ...rest] = args +function wait( + first?: () => Promise | T, + options?: waitForOptions, +) { + const callback = () => (undefined as unknown) as T if (!hasWarned) { hasWarned = true console.warn( `\`wait\` has been deprecated and replaced by \`waitFor\` instead. In most cases you should be able to find/replace \`wait\` with \`waitFor\`. Learn more: https://testing-library.com/docs/dom-testing-library/api-async#waitfor.`, ) } - return waitForWrapper(first, ...rest) + return waitForWrapper(callback, options) } export {waitForWrapper as waitFor, wait} diff --git a/types/config.d.ts b/types/config.d.ts index c9c33633..145a7c7f 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -1,15 +1,13 @@ export interface Config { testIdAttribute: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - asyncWrapper(cb: (...args: any[]) => any): Promise - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventWrapper(cb: (...args: any[]) => any): void + asyncWrapper(cb: () => Promise): Promise + eventWrapper(cb: () => void): void asyncUtilTimeout: number computedStyleSupportsPseudoElements: boolean defaultHidden: boolean showOriginalStackTrace: boolean throwSuggestions: boolean - getElementError: (message: string | null, container: Element) => Error + getElementError: (message: string | null, container: Node) => Error } export interface ConfigFn { diff --git a/types/events.d.ts b/types/events.d.ts index 925da69f..4b8a890c 100644 --- a/types/events.d.ts +++ b/types/events.d.ts @@ -1,25 +1,21 @@ export type EventType = - | 'copy' - | 'cut' - | 'paste' - | 'compositionEnd' - | 'compositionStart' - | 'compositionUpdate' - | 'keyDown' - | 'keyPress' - | 'keyUp' - | 'focus' + | 'abort' + | 'animationEnd' + | 'animationIteration' + | 'animationStart' | 'blur' - | 'focusIn' - | 'focusOut' + | 'canPlay' + | 'canPlayThrough' | 'change' - | 'input' - | 'invalid' - | 'submit' - | 'reset' | 'click' + | 'compositionEnd' + | 'compositionStart' + | 'compositionUpdate' | 'contextMenu' + | 'copy' + | 'cut' | 'dblClick' + | 'doubleClick' | 'drag' | 'dragEnd' | 'dragEnter' @@ -28,83 +24,87 @@ export type EventType = | 'dragOver' | 'dragStart' | 'drop' - | 'mouseDown' - | 'mouseEnter' - | 'mouseLeave' - | 'mouseMove' - | 'mouseOut' - | 'mouseOver' - | 'mouseUp' - | 'popState' - | 'select' - | 'touchCancel' - | 'touchEnd' - | 'touchMove' - | 'touchStart' - | 'scroll' - | 'wheel' - | 'abort' - | 'canPlay' - | 'canPlayThrough' | 'durationChange' | 'emptied' | 'encrypted' | 'ended' + | 'error' + | 'focus' + | 'focusIn' + | 'focusOut' + | 'gotPointerCapture' + | 'input' + | 'invalid' + | 'keyDown' + | 'keyPress' + | 'keyUp' + | 'load' | 'loadedData' | 'loadedMetadata' | 'loadStart' + | 'lostPointerCapture' + | 'mouseDown' + | 'mouseEnter' + | 'mouseLeave' + | 'mouseMove' + | 'mouseOut' + | 'mouseOver' + | 'mouseUp' + | 'paste' | 'pause' | 'play' | 'playing' + | 'pointerCancel' + | 'pointerDown' + | 'pointerEnter' + | 'pointerLeave' + | 'pointerMove' + | 'pointerOut' + | 'pointerOver' + | 'pointerUp' + | 'popState' | 'progress' | 'rateChange' + | 'reset' + | 'scroll' | 'seeked' | 'seeking' + | 'select' | 'stalled' + | 'submit' | 'suspend' | 'timeUpdate' + | 'touchCancel' + | 'touchEnd' + | 'touchMove' + | 'touchStart' + | 'transitionEnd' | 'volumeChange' | 'waiting' - | 'load' - | 'error' - | 'animationStart' - | 'animationEnd' - | 'animationIteration' - | 'transitionEnd' - | 'doubleClick' - | 'pointerOver' - | 'pointerEnter' - | 'pointerDown' - | 'pointerMove' - | 'pointerUp' - | 'pointerCancel' - | 'pointerOut' - | 'pointerLeave' - | 'gotPointerCapture' - | 'lostPointerCapture' + | 'wheel' export type FireFunction = ( - element: Document | Element | Window | Node, + element: Document | Element | Node | Window, event: Event, ) => boolean export type FireObject = { [K in EventType]: ( - element: Document | Element | Window | Node, + element: Document | Element | Node | Window, options?: {}, ) => boolean } export type CreateFunction = ( eventName: string, - node: Document | Element | Window | Node, + node: Document | Element | Node | Window, init?: {}, options?: {EventType?: string; defaultInit?: {}}, ) => Event export type CreateObject = { [K in EventType]: ( - element: Document | Element | Window | Node, + element: Document | Element | Node | Window, options?: {}, ) => Event } -export const createEvent: CreateObject & CreateFunction +export const createEvent: CreateFunction & CreateObject export const fireEvent: FireFunction & FireObject diff --git a/types/wait-for.d.ts b/types/wait-for.d.ts index ab194169..a0d06afa 100644 --- a/types/wait-for.d.ts +++ b/types/wait-for.d.ts @@ -1,9 +1,10 @@ export interface waitForOptions { - container?: HTMLElement + container?: Node timeout?: number interval?: number onTimeout?: (error: Error) => Error mutationObserverOptions?: MutationObserverInit + showOriginalStackTrace?: boolean } export function waitFor(