diff --git a/package.json b/package.json index 73095fc3..409328bd 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.0-semantically-released", "description": "Simple and complete DOM testing utilities that encourage good testing practices.", "main": "dist/index.js", + "types": "types/index.d.ts", "module": "dist/@testing-library/dom.esm.js", "umd:main": "dist/@testing-library/dom.umd.js", "source": "src/index.js", @@ -29,19 +30,21 @@ "test": "kcd-scripts test", "test:debug": "node --inspect-brk ./node_modules/.bin/jest --watch --runInBand", "test:update": "npm test -- --updateSnapshot --coverage", - "validate": "kcd-scripts validate" + "validate": "kcd-scripts validate", + "typecheck": "dtslint ./types/" }, "files": [ - "dist" + "dist", + "types" ], "dependencies": { "@babel/runtime": "^7.9.6", - "@types/testing-library__dom": "^7.0.2", "aria-query": "^4.0.2", "dom-accessibility-api": "^0.4.3", "pretty-format": "^26.0.1" }, "devDependencies": { + "dtslint": "^3.4.2", "@testing-library/jest-dom": "^5.5.0", "jest-in-case": "^1.0.2", "jest-serializer-ansi": "^1.0.3", diff --git a/types/__tests__/type-tests.ts b/types/__tests__/type-tests.ts new file mode 100644 index 00000000..5203434e --- /dev/null +++ b/types/__tests__/type-tests.ts @@ -0,0 +1,122 @@ +import { + fireEvent, + isInaccessible, + queries, + screen, + waitFor, + waitForElementToBeRemoved, +} from '../index' + +const { + getByText, + queryByText, + findByText, + getAllByText, + queryAllByText, + findAllByText, + queryAllByRole, + queryByRole, + findByRole, +} = queries + +async function testQueries() { + // element queries + const element = document.createElement('div') + getByText(element, 'foo') + queryByText(element, 'foo') + await findByText(element, 'foo') + await findByText(element, 'foo', undefined, {timeout: 10}) + getAllByText(element, 'bar') + queryAllByText(element, 'bar') + await findAllByText(element, 'bar') + await findAllByText(element, 'bar', undefined, {timeout: 10}) + + // screen queries + screen.getByText('foo') + screen.queryByText('foo') + await screen.findByText('foo') + await screen.findByText('foo', undefined, {timeout: 10}) + screen.debug(screen.getAllByText('bar')) + screen.queryAllByText('bar') + await screen.findAllByText('bar') + await screen.findAllByText('bar', undefined, {timeout: 10}) +} + +async function testByRole() { + const element = document.createElement('button') + element.setAttribute('aria-hidden', 'true') + + console.assert(queryByRole(element, 'button') === null) + console.assert(queryByRole(element, 'button', {hidden: true}) !== null) + + console.assert(screen.queryByRole('button') === null) + console.assert(screen.queryByRole('button', {hidden: true}) !== null) + + console.assert( + (await findByRole(element, 'button', undefined, {timeout: 10})) === null, + ) + console.assert( + (await findByRole(element, 'button', {hidden: true}, {timeout: 10})) !== + null, + ) + + console.assert( + queryAllByRole(document.body, 'progressbar', {queryFallbacks: true}) + .length === 1, + ) + + // `name` option + console.assert(queryByRole(element, 'button', {name: 'Logout'}) === null) + console.assert(queryByRole(element, 'button', {name: /^Log/}) === null) + console.assert( + queryByRole(element, 'button', { + name: (name, element) => + name === 'Login' && element.hasAttribute('disabled'), + }) === null, + ) +} + +function testA11yHelper() { + const element = document.createElement('svg') + console.assert(!isInaccessible(element)) +} + +function eventTest() { + fireEvent.popState(window, { + location: 'http://www.example.com/?page=1', + state: {page: 1}, + }) + + // HTMLElement + const element = document.createElement('div') + fireEvent.click(getByText(element, 'foo')) + + // ChildNode + const child = document.createElement('div') + element.appendChild(child) + if (!element.firstChild) { + // Narrow Type + throw new Error(`Can't find firstChild`) + } + fireEvent.click(element.firstChild) +} + +async function testWaitFors() { + const element = document.createElement('div') + + await waitFor(() => getByText(element, 'apple')) + await waitFor(() => getAllByText(element, 'apple')) + const result: HTMLSpanElement = await waitFor(() => + getByText(element, 'apple'), + ) + if (!result) { + // Use value + throw new Error(`Can't find result`) + } + + element.innerHTML = '<span>apple</span>' + + await waitForElementToBeRemoved(() => getByText(element, 'apple'), {interval: 3000, container: element, timeout: 5000}) + await waitForElementToBeRemoved(getByText(element, 'apple')) + await waitForElementToBeRemoved(getAllByText(element, 'apple')) +} diff --git a/types/config.d.ts b/types/config.d.ts new file mode 100644 index 00000000..a2063d99 --- /dev/null +++ b/types/config.d.ts @@ -0,0 +1,12 @@ +export interface Config { + testIdAttribute: string; + asyncWrapper(cb: (...args: any[]) => any): Promise<any>; + asyncUtilTimeout: number; + defaultHidden: boolean; +} + +export interface ConfigFn { + (existingConfig: Config): Partial<Config>; +} + +export function configure(configDelta: Partial<Config> | ConfigFn): void; diff --git a/types/events.d.ts b/types/events.d.ts new file mode 100644 index 00000000..d9c50cb7 --- /dev/null +++ b/types/events.d.ts @@ -0,0 +1,95 @@ +export type EventType = + | 'copy' + | 'cut' + | 'paste' + | 'compositionEnd' + | 'compositionStart' + | 'compositionUpdate' + | 'keyDown' + | 'keyPress' + | 'keyUp' + | 'focus' + | 'blur' + | 'focusIn' + | 'focusOut' + | 'change' + | 'input' + | 'invalid' + | 'submit' + | 'reset' + | 'click' + | 'contextMenu' + | 'dblClick' + | 'drag' + | 'dragEnd' + | 'dragEnter' + | 'dragExit' + | 'dragLeave' + | 'dragOver' + | 'dragStart' + | 'drop' + | 'mouseDown' + | 'mouseEnter' + | 'mouseLeave' + | 'mouseMove' + | 'mouseOut' + | 'mouseOver' + | 'mouseUp' + | 'popState' + | 'select' + | 'touchCancel' + | 'touchEnd' + | 'touchMove' + | 'touchStart' + | 'scroll' + | 'wheel' + | 'abort' + | 'canPlay' + | 'canPlayThrough' + | 'durationChange' + | 'emptied' + | 'encrypted' + | 'ended' + | 'loadedData' + | 'loadedMetadata' + | 'loadStart' + | 'pause' + | 'play' + | 'playing' + | 'progress' + | 'rateChange' + | 'seeked' + | 'seeking' + | 'stalled' + | 'suspend' + | 'timeUpdate' + | 'volumeChange' + | 'waiting' + | 'load' + | 'error' + | 'animationStart' + | 'animationEnd' + | 'animationIteration' + | 'transitionEnd' + | 'doubleClick' + | 'pointerOver' + | 'pointerEnter' + | 'pointerDown' + | 'pointerMove' + | 'pointerUp' + | 'pointerCancel' + | 'pointerOut' + | 'pointerLeave' + | 'gotPointerCapture' + | 'lostPointerCapture'; + +export type FireFunction = (element: Document | Element | Window | Node, event: Event) => boolean; +export type FireObject = { + [K in EventType]: (element: Document | Element | Window | Node, options?: {}) => boolean; +}; +export type CreateObject = { + [K in EventType]: (element: Document | Element | Window | Node, options?: {}) => Event; +}; + +export const createEvent: CreateObject; +export const fireEvent: FireFunction & FireObject; diff --git a/types/get-node-text.d.ts b/types/get-node-text.d.ts new file mode 100644 index 00000000..5c5654b5 --- /dev/null +++ b/types/get-node-text.d.ts @@ -0,0 +1 @@ +export function getNodeText(node: HTMLElement): string; diff --git a/types/get-queries-for-element.d.ts b/types/get-queries-for-element.d.ts new file mode 100644 index 00000000..90e5626d --- /dev/null +++ b/types/get-queries-for-element.d.ts @@ -0,0 +1,30 @@ +import { Matcher } from './matches'; +import * as queries from './queries'; + +export type BoundFunction<T> = T extends ( + attribute: string, + element: HTMLElement, + text: infer P, + options: infer Q, +) => infer R + ? (text: P, options?: Q) => R + : T extends (a1: any, text: infer P, options: infer Q, waitForElementOptions: infer W) => infer R + ? (text: P, options?: Q, waitForElementOptions?: W) => R + : T extends (a1: any, text: infer P, options: infer Q) => infer R + ? (text: P, options?: Q) => R + : never; +export type BoundFunctions<T> = { [P in keyof T]: BoundFunction<T[P]> }; + +export type Query = ( + container: HTMLElement, + ...args: any[] +) => Error | Promise<HTMLElement[]> | Promise<HTMLElement> | HTMLElement[] | HTMLElement | null; + +export interface Queries { + [T: string]: Query; +} + +export function getQueriesForElement<T extends Queries = typeof queries>( + element: HTMLElement, + queriesToBind?: T, +): BoundFunctions<T>; diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 00000000..406db91c --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,24 @@ +// TypeScript Version: 3.8 + +import { getQueriesForElement } from './get-queries-for-element'; +import * as queries from './queries'; +import * as queryHelpers from './query-helpers'; + +declare const within: typeof getQueriesForElement; +export { queries, queryHelpers, within }; + +export * from './queries'; +export * from './query-helpers'; +export * from './screen'; +export * from './wait'; +export * from './wait-for'; +export * from './wait-for-dom-change'; +export * from './wait-for-element'; +export * from './wait-for-element-to-be-removed'; +export * from './matches'; +export * from './get-node-text'; +export * from './events'; +export * from './get-queries-for-element'; +export * from './pretty-dom'; +export * from './role-helpers'; +export * from './config'; diff --git a/types/matches.d.ts b/types/matches.d.ts new file mode 100644 index 00000000..0b8dad4d --- /dev/null +++ b/types/matches.d.ts @@ -0,0 +1,29 @@ +export type MatcherFunction = (content: string, element: HTMLElement) => boolean; +export type Matcher = string | RegExp | MatcherFunction; + +export type NormalizerFn = (text: string) => string; + +export interface MatcherOptions { + exact?: boolean; + /** Use normalizer with getDefaultNormalizer instead */ + trim?: boolean; + /** Use normalizer with getDefaultNormalizer instead */ + collapseWhitespace?: boolean; + normalizer?: NormalizerFn; +} + +export type Match = ( + textToMatch: string, + node: HTMLElement | null, + matcher: Matcher, + options?: MatcherOptions, +) => boolean; + +export interface DefaultNormalizerOptions { + trim?: boolean; + collapseWhitespace?: boolean; +} + +export function getDefaultNormalizer(options?: DefaultNormalizerOptions): NormalizerFn; + +// N.B. Don't expose fuzzyMatches + matches here: they're not public API diff --git a/types/pretty-dom.d.ts b/types/pretty-dom.d.ts new file mode 100644 index 00000000..bca6afb4 --- /dev/null +++ b/types/pretty-dom.d.ts @@ -0,0 +1,4 @@ +import { OptionsReceived } from 'pretty-format'; + +export function prettyDOM(dom?: Element | HTMLDocument, maxLength?: number, options?: OptionsReceived): string | false; +export function logDOM(dom?: Element | HTMLDocument, maxLength?: number, options?: OptionsReceived): void; diff --git a/types/queries.d.ts b/types/queries.d.ts new file mode 100644 index 00000000..6f8633ce --- /dev/null +++ b/types/queries.d.ts @@ -0,0 +1,134 @@ +import { Matcher, MatcherOptions } from './matches'; +import { SelectorMatcherOptions } from './query-helpers'; +import { waitForOptions } from 'wait-for'; + +export type QueryByBoundAttribute = ( + container: HTMLElement, + id: Matcher, + options?: MatcherOptions, +) => HTMLElement | null; + +export type AllByBoundAttribute = (container: HTMLElement, id: Matcher, options?: MatcherOptions) => HTMLElement[]; + +export type FindAllByBoundAttribute = ( + container: HTMLElement, + id: Matcher, + options?: MatcherOptions, + waitForElementOptions?: waitForOptions, +) => Promise<HTMLElement[]>; + +export type GetByBoundAttribute = (container: HTMLElement, id: Matcher, options?: MatcherOptions) => HTMLElement; + +export type FindByBoundAttribute = ( + container: HTMLElement, + id: Matcher, + options?: MatcherOptions, + waitForElementOptions?: waitForOptions, +) => Promise<HTMLElement>; + +export type QueryByText = (container: HTMLElement, id: Matcher, options?: SelectorMatcherOptions) => HTMLElement | null; + +export type AllByText = (container: HTMLElement, id: Matcher, options?: SelectorMatcherOptions) => HTMLElement[]; + +export type FindAllByText = ( + container: HTMLElement, + id: Matcher, + options?: SelectorMatcherOptions, + waitForElementOptions?: waitForOptions, +) => Promise<HTMLElement[]>; + +export type GetByText = (container: HTMLElement, id: Matcher, options?: SelectorMatcherOptions) => HTMLElement; + +export type FindByText = ( + container: HTMLElement, + id: Matcher, + options?: SelectorMatcherOptions, + waitForElementOptions?: waitForOptions, +) => Promise<HTMLElement>; + +export interface ByRoleOptions extends MatcherOptions { + /** + * If true includes elements in the query set that are usually excluded from + * the accessibility tree. `role="none"` or `role="presentation"` are included + * in either case. + */ + hidden?: boolean; + /** + * Includes every role used in the `role` attribute + * For example *ByRole('progressbar', {queryFallbacks: true})` will find <div role="meter progresbar">`. + */ + queryFallbacks?: boolean; + /** + * Only considers elements with the specified accessible name. + */ + name?: string | RegExp | ((accessibleName: string, element: Element) => boolean); +} + +export type AllByRole = (container: HTMLElement, role: Matcher, options?: ByRoleOptions) => HTMLElement[]; + +export type GetByRole = (container: HTMLElement, role: Matcher, options?: ByRoleOptions) => HTMLElement; + +export type QueryByRole = (container: HTMLElement, role: Matcher, options?: ByRoleOptions) => HTMLElement | null; + +export type FindByRole = ( + container: HTMLElement, + role: Matcher, + options?: ByRoleOptions, + waitForElementOptions?: waitForOptions, +) => Promise<HTMLElement>; + +export type FindAllByRole = ( + container: HTMLElement, + role: Matcher, + options?: ByRoleOptions, + waitForElementOptions?: waitForOptions, +) => Promise<HTMLElement[]>; + +export const getByLabelText: GetByText; +export const getAllByLabelText: AllByText; +export const queryByLabelText: QueryByText; +export const queryAllByLabelText: AllByText; +export const findByLabelText: FindByText; +export const findAllByLabelText: FindAllByText; +export const getByPlaceholderText: GetByBoundAttribute; +export const getAllByPlaceholderText: AllByBoundAttribute; +export const queryByPlaceholderText: QueryByBoundAttribute; +export const queryAllByPlaceholderText: AllByBoundAttribute; +export const findByPlaceholderText: FindByBoundAttribute; +export const findAllByPlaceholderText: FindAllByBoundAttribute; +export const getByText: GetByText; +export const getAllByText: AllByText; +export const queryByText: QueryByText; +export const queryAllByText: AllByText; +export const findByText: FindByText; +export const findAllByText: FindAllByText; +export const getByAltText: GetByBoundAttribute; +export const getAllByAltText: AllByBoundAttribute; +export const queryByAltText: QueryByBoundAttribute; +export const queryAllByAltText: AllByBoundAttribute; +export const findByAltText: FindByBoundAttribute; +export const findAllByAltText: FindAllByBoundAttribute; +export const getByTitle: GetByBoundAttribute; +export const getAllByTitle: AllByBoundAttribute; +export const queryByTitle: QueryByBoundAttribute; +export const queryAllByTitle: AllByBoundAttribute; +export const findByTitle: FindByBoundAttribute; +export const findAllByTitle: FindAllByBoundAttribute; +export const getByDisplayValue: GetByBoundAttribute; +export const getAllByDisplayValue: AllByBoundAttribute; +export const queryByDisplayValue: QueryByBoundAttribute; +export const queryAllByDisplayValue: AllByBoundAttribute; +export const findByDisplayValue: FindByBoundAttribute; +export const findAllByDisplayValue: FindAllByBoundAttribute; +export const getByRole: GetByRole; +export const getAllByRole: AllByRole; +export const queryByRole: QueryByRole; +export const queryAllByRole: AllByRole; +export const findByRole: FindByRole; +export const findAllByRole: FindAllByRole; +export const getByTestId: GetByBoundAttribute; +export const getAllByTestId: AllByBoundAttribute; +export const queryByTestId: QueryByBoundAttribute; +export const queryAllByTestId: AllByBoundAttribute; +export const findByTestId: FindByBoundAttribute; +export const findAllByTestId: FindAllByBoundAttribute; diff --git a/types/query-helpers.d.ts b/types/query-helpers.d.ts new file mode 100644 index 00000000..63a1f69b --- /dev/null +++ b/types/query-helpers.d.ts @@ -0,0 +1,46 @@ +import { Matcher, MatcherOptions } from './matches'; + +export interface SelectorMatcherOptions extends MatcherOptions { + selector?: string; +} + +export type QueryByAttribute = ( + attribute: string, + container: HTMLElement, + id: Matcher, + options?: MatcherOptions, +) => HTMLElement | null; + +export type AllByAttribute = ( + attribute: string, + container: HTMLElement, + id: Matcher, + options?: MatcherOptions, +) => HTMLElement[]; + +export const queryByAttribute: QueryByAttribute; +export const queryAllByAttribute: AllByAttribute; +export function getElementError(message: string, container: HTMLElement): Error; + +/** + * query methods have a common call signature. Only the return type differs. + */ +export type QueryMethod<Arguments extends any[], Return> = (container: HTMLElement, ...args: Arguments) => Return; +export type QueryBy<Arguments extends any[]> = QueryMethod<Arguments, HTMLElement | null>; +export type GetAllBy<Arguments extends any[]> = QueryMethod<Arguments, HTMLElement[]>; +export type FindAllBy<Arguments extends any[]> = QueryMethod<Arguments, Promise<HTMLElement[]>>; +export type GetBy<Arguments extends any[]> = QueryMethod<Arguments, HTMLElement>; +export type FindBy<Arguments extends any[]> = QueryMethod<Arguments, Promise<HTMLElement>>; + +export type BuiltQueryMethods<Arguments extends any[]> = [ + QueryBy<Arguments>, + GetAllBy<Arguments>, + GetBy<Arguments>, + FindAllBy<Arguments>, + FindBy<Arguments> +]; +export function buildQueries<Arguments extends any[]>( + queryByAll: GetAllBy<Arguments>, + getMultipleError: (container: HTMLElement, ...args: Arguments) => string, + getMissingError: (container: HTMLElement, ...args: Arguments) => string, +): BuiltQueryMethods<Arguments>; diff --git a/types/role-helpers.d.ts b/types/role-helpers.d.ts new file mode 100644 index 00000000..3dd35b78 --- /dev/null +++ b/types/role-helpers.d.ts @@ -0,0 +1,6 @@ +export function logRoles(container: HTMLElement): string; +export function getRoles(container: HTMLElement): { [index: string]: HTMLElement[] }; +/** + * https://testing-library.com/docs/dom-testing-library/api-helpers#isinaccessible + */ +export function isInaccessible(element: Element): boolean; diff --git a/types/screen.d.ts b/types/screen.d.ts new file mode 100644 index 00000000..906b59ef --- /dev/null +++ b/types/screen.d.ts @@ -0,0 +1,17 @@ +import { BoundFunctions, Queries } from './get-queries-for-element'; +import * as queries from './queries'; +import { OptionsReceived } from 'pretty-format'; + +export type Screen<Q extends Queries = typeof queries> = BoundFunctions<Q> & { + /** + * Convenience function for `pretty-dom` which also allows an array + * of elements + */ + debug: ( + element: Element | HTMLDocument | Array<Element | HTMLDocument>, + maxLength?: number, + options?: OptionsReceived, + ) => void; +}; + +export const screen: Screen; diff --git a/types/tsconfig.json b/types/tsconfig.json new file mode 100644 index 00000000..c4da27db --- /dev/null +++ b/types/tsconfig.json @@ -0,0 +1,17 @@ +// this additional tsconfig is required by dtslint +// see: https://github.com/Microsoft/dtslint#typestsconfigjson +{ + "compilerOptions": { + "module": "commonjs", + "lib": ["es6", "dom"], + "noImplicitAny": true, + "noImplicitThis": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noEmit": true, + + // If the library is an external module (uses `export`), this allows your test file to import "mylib" instead of "./index". + // If the library is global (cannot be imported via `import` or `require`), leave this out. + "baseUrl": "." + } +} diff --git a/types/tslint.json b/types/tslint.json new file mode 100644 index 00000000..5d45232f --- /dev/null +++ b/types/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": ["dtslint/dtslint.json"], + "rules": { + "no-useless-files": false, + "no-relative-import-in-test": false, + "semicolon": false + } +} diff --git a/types/wait-for-dom-change.d.ts b/types/wait-for-dom-change.d.ts new file mode 100644 index 00000000..2fe72c10 --- /dev/null +++ b/types/wait-for-dom-change.d.ts @@ -0,0 +1,3 @@ +import { waitForOptions } from "index"; + +export function waitForDomChange(options?: waitForOptions): Promise<any>; diff --git a/types/wait-for-element-to-be-removed.d.ts b/types/wait-for-element-to-be-removed.d.ts new file mode 100644 index 00000000..42a891ac --- /dev/null +++ b/types/wait-for-element-to-be-removed.d.ts @@ -0,0 +1,6 @@ +import { waitForOptions } from "wait-for"; + +export function waitForElementToBeRemoved<T>( + callback: (() => T) | T, + options?: waitForOptions, +): Promise<T>; diff --git a/types/wait-for-element.d.ts b/types/wait-for-element.d.ts new file mode 100644 index 00000000..f21fb112 --- /dev/null +++ b/types/wait-for-element.d.ts @@ -0,0 +1,3 @@ +import { waitForOptions } from "wait-for"; + +export function waitForElement<T>(callback: () => T, options?: waitForOptions): Promise<T>; diff --git a/types/wait-for.d.ts b/types/wait-for.d.ts new file mode 100644 index 00000000..3c39073b --- /dev/null +++ b/types/wait-for.d.ts @@ -0,0 +1,11 @@ +export interface waitForOptions { + container?: HTMLElement; + timeout?: number; + interval?: number; + mutationObserverOptions?: MutationObserverInit; +} + +export function waitFor<T>( + callback: () => T, + options?: waitForOptions, +): Promise<T>; diff --git a/types/wait.d.ts b/types/wait.d.ts new file mode 100644 index 00000000..3763e7bd --- /dev/null +++ b/types/wait.d.ts @@ -0,0 +1,7 @@ +export function wait( + callback?: () => void, + options?: { + timeout?: number; + interval?: number; + }, +): Promise<void>;