From 251ea7048ed385fd2b20b4ac2eec28e5dacf1bce Mon Sep 17 00:00:00 2001 From: Juhana Jauhiainen Date: Tue, 15 Dec 2020 05:30:34 +0200 Subject: [PATCH] feat: Convert to TypeScript (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added tsconfig.json. Modified eslint config * Convert src/cleanup.js to TypeScript * Change src/cleanup.ts to use inline types * revert eslintrc * disable eslint for cleanup import this can be reverted when `pure.js` is converted to typescript * convert index to typescript just a rename 😅 * BREAKING CHANGE: Worked on typing asyncUtils. Removed deprecated wait. Disabled import/no-unresolved for now * Fix incorrect cleanup callback type * asyncUtils has been updated. Two lines are disabled for eslint. * remove unnecessary eslint disable * Prettier was stripping try catch types - see: https://prettier.io/blog/2020/08/24/2.1.0.html\#type-annotations-on-catch-clauses-8805httpsgithubcomprettierprettierpull8805-by-fiskerhttpsgithubcomfisker - Updated prettier in local devDeps to resolve directly to newest supported version * Type for callback handle generic input & undefined handled by nullish operator TS expecting explicit return of undefined from arrow function with type Expected to return a value at the end of arrow function.eslintconsistent-return resolved by passing return in catch with undefined, same behavior more explicit * comments for review * VoidFunction in place for void until decided behavior for waitFor() * Utilizing OR operator with generic Types allows for desired behavior and previous tests pass * mistakenly left out linter ignores in last commit * Minimum types started for Pure file - File needs better, improved typing and Refactoring for Linter Disables to be removed IF POSSIBLE * removed comments -- answered in PR - Types and OR check should handle expected behavior * Generic HTML types for initialProps * Generic HTML types for initialProps * Generic type for callback and initialProps * Generics added and CB toplevel same generic pattern added * force rebuild with unbound method eslint ignore * parser issue -- attempt to fix by removing specific TS eslint rule * Newer versions of ESLint might resolve the parser issue * Added configs to get started on test conversion * Generic type for resultContainer this should allow typescript to infer the type of result.current further improvments needed to add type guards * convert tests that require no changes renamed tests that didn't require code changes to the tests * add types to cleanup test * Test TS Overhaul - Amr, Tiger and myself worked on these commits - Other Raid members in chat assisted - All tests are strongly typed with minimal types to allow for working and made sure tests types were easily usable with types in Pure and Utils file, allow for good UX & DX * Amr updated types useEffect * Jens suggestion for more generic number key type * Remove wait reference from docs * Add nobrayner to contributors * Add JacobMGEvans to contributors * Update src/pure.tsx * Add tigerabrodi to contributors * Add Amr, Juhana, and Jens to contributors * update suspenseHook, cache type and the type of the error in catch. * cleanup.ts, update the way addCleanup adds another callback to cleanupCallbacks. * Made generics more descriptive, made TestHook generic * Remove some eslint disables that didn't do anything * Remove DefinitelyTyped reference in CONTRIBUTING.md * chore: disable declaration for tests * Removes createTimeoutError. Adds constructor to TimeoutError. Adds typeing to waitForValueToChange options * Remove conditional in TestHook for hookProps * Replace old types defs with referenced types in dependencies * Remove destructuring of `testRenderer` * Disabled floating promise lint rule globally * Refactor TestHook catch to not disable lint rules * Disabled eslint error for while(true) * Cleaned up some line warnings from tests * Added "typecheck" kcd-script to improve "validate" script * Clean up ThrowError type in errorHook tests * Replace VoidFunction with () => void * Replace CallableFunction with a more explicit function type Co-authored-by: Braydon Hall <40751395+nobrayner@users.noreply.github.com> Co-authored-by: Amr Gad Co-authored-by: tigerabrodi Co-authored-by: Jacob Evans Co-authored-by: Jacob M-G Evans <27247160+JacobMGEvans@users.noreply.github.com> Co-authored-by: marcosvega91 Co-authored-by: Michael Peyper --- .all-contributorsrc | 58 ++++++++ .eslintrc | 10 +- CONTRIBUTING.md | 5 - README.md | 8 ++ docs/api-reference.md | 25 ---- jest.config.js | 3 +- package.json | 14 +- src/asyncUtils.js | 123 ----------------- src/asyncUtils.ts | 112 ++++++++++++++++ src/{cleanup.js => cleanup.ts} | 8 +- src/{index.js => index.ts} | 0 src/pure.js | 105 --------------- src/pure.tsx | 124 ++++++++++++++++++ test/{asyncHook.js => asyncHook.ts} | 90 +------------ ...up.disabled.js => autoCleanup.disabled.ts} | 3 +- ...fterEach.js => autoCleanup.noAfterEach.ts} | 4 +- test/{autoCleanup.js => autoCleanup.ts} | 0 test/{cleanup.js => cleanup.ts} | 12 +- test/{customHook.js => customHook.ts} | 0 test/{errorHook.js => errorHook.ts} | 8 +- test/{resultHistory.js => resultHistory.ts} | 0 test/{suspenseHook.js => suspenseHook.ts} | 12 +- test/tsconfig.json | 8 ++ test/{useContext.js => useContext.tsx} | 7 +- test/{useEffect.js => useEffect.ts} | 4 +- test/{useMemo.js => useMemo.ts} | 0 test/{useReducer.js => useReducer.ts} | 3 +- test/{useRef.js => useRef.ts} | 2 +- test/{useState.js => useState.ts} | 0 tsconfig.json | 8 ++ 30 files changed, 373 insertions(+), 383 deletions(-) delete mode 100644 src/asyncUtils.js create mode 100644 src/asyncUtils.ts rename src/{cleanup.js => cleanup.ts} (55%) rename src/{index.js => index.ts} (100%) delete mode 100644 src/pure.js create mode 100644 src/pure.tsx rename test/{asyncHook.js => asyncHook.ts} (74%) rename test/{autoCleanup.disabled.js => autoCleanup.disabled.ts} (89%) rename test/{autoCleanup.noAfterEach.js => autoCleanup.noAfterEach.ts} (80%) rename test/{autoCleanup.js => autoCleanup.ts} (100%) rename test/{cleanup.js => cleanup.ts} (92%) rename test/{customHook.js => customHook.ts} (100%) rename test/{errorHook.js => errorHook.ts} (95%) rename test/{resultHistory.js => resultHistory.ts} (100%) rename test/{suspenseHook.js => suspenseHook.ts} (76%) create mode 100644 test/tsconfig.json rename test/{useContext.js => useContext.tsx} (89%) rename test/{useEffect.js => useEffect.ts} (89%) rename test/{useMemo.js => useMemo.ts} (100%) rename test/{useReducer.js => useReducer.ts} (79%) rename test/{useRef.js => useRef.ts} (92%) rename test/{useState.js => useState.ts} (100%) create mode 100644 tsconfig.json diff --git a/.all-contributorsrc b/.all-contributorsrc index fd2fd14a..d7d137c5 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -212,6 +212,64 @@ "contributions": [ "code" ] + }, + { + "login": "nobrayner", + "name": "Braydon Hall", + "avatar_url": "https://avatars2.githubusercontent.com/u/40751395?v=4", + "profile": "https://github.com/nobrayner", + "contributions": [ + "code" + ] + }, + { + "login": "JacobMGEvans", + "name": "Jacob M-G Evans", + "avatar_url": "https://avatars1.githubusercontent.com/u/27247160?v=4", + "profile": "https://dev.to/jacobmgevans", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "tigerabrodi", + "name": "Tiger Abrodi", + "avatar_url": "https://avatars1.githubusercontent.com/u/49603590?v=4", + "profile": "https://tigerabrodi.dev/", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "merodiro", + "name": "Amr A.Mohammed", + "avatar_url": "https://avatars1.githubusercontent.com/u/17033502?v=4", + "profile": "https://github.com/merodiro", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "juhanakristian", + "name": "Juhana Jauhiainen", + "avatar_url": "https://avatars1.githubusercontent.com/u/544386?v=4", + "profile": "https://github.com/juhanakristian", + "contributions": [ + "code" + ] + }, + { + "login": "jensmeindertsma", + "name": "Jens Meindertsma", + "avatar_url": "https://avatars3.githubusercontent.com/u/64677517?v=4", + "profile": "https://github.com/jensmeindertsma", + "contributions": [ + "code", + "test" + ] } ], "commitConvention": "none" diff --git a/.eslintrc b/.eslintrc index d6c4554f..052c9caa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,10 +1,16 @@ { - "extends": "./node_modules/kcd-scripts/eslint.js", + "extends": ["./node_modules/kcd-scripts/eslint.js"], "rules": { "max-lines-per-function": "off", "no-constant-condition": "off", "no-await-in-loop": "off", + "no-console": "off", + "import/no-unresolved": "off", "react-hooks/rules-of-hooks": "off", - "no-console": "off" + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-unnecessary-condition": "off" + }, + "parserOptions": { + "project": ["./tsconfig.json", "./test/tsconfig.json"] } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8924985f..24384b04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,11 +27,6 @@ select the added contribution type. Please make sure to run the tests before you commit your changes. You can do so by running `npm test`. -### Update Typings - -The TypeScript type definitions can be found in the -[DefinitelyTyped repo](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/testing-library__react-hooks). - ## Help needed Please check out the diff --git a/README.md b/README.md index 72ef55a9..b62c279d 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,14 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Josh

📖
Na'aman Hirschfeld

💻 + +
Braydon Hall

💻 +
Jacob M-G Evans

💻 ⚠️ +
Tiger Abrodi

💻 ⚠️ +
Amr A.Mohammed

💻 ⚠️ +
Juhana Jauhiainen

💻 +
Jens Meindertsma

💻 ⚠️ + diff --git a/docs/api-reference.md b/docs/api-reference.md index a3cc05e5..fa7daf4b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -259,28 +259,3 @@ The maximum amount of time in milliseconds (ms) to wait. By default, no timeout If this option is set to `true`, any errors that occur while waiting are treated as a failed check. If this option is set to `false`, any errors that occur while waiting cause the promise to be rejected. By default, errors are not suppressed for this utility. - -### `wait` - -_(DEPRECATED, use [`waitFor`](/reference/api#waitfor) instead)_ - -```js -function wait(callback: function(): boolean|void, options?: { - timeout?: number, - suppressErrors?: boolean -}): Promise -``` - -Returns a `Promise` that resolves if the provided callback executes without exception and returns a -truthy or `undefined` value. It is safe to use the [`result` of `renderHook`](/reference/api#result) -in the callback to perform assertion or to test values. - -#### `timeout` - -The maximum amount of time in milliseconds (ms) to wait. By default, no timeout is applied. - -#### `suppressErrors` - -If this option is set to `true`, any errors that occur while waiting are treated as a failed check. -If this option is set to `false`, any errors that occur while waiting cause the promise to be -rejected. By default, errors are suppressed for this utility. diff --git a/jest.config.js b/jest.config.js index c2e6a069..bb6a1d85 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,7 @@ +// eslint-disable-next-line const { jest: jestConfig } = require('kcd-scripts/config') module.exports = Object.assign(jestConfig, { roots: ['/src', '/test'], - testMatch: ['/test/*.js'] + testMatch: ['/test/*.(ts|tsx|js)'] }) diff --git a/package.json b/package.json index cb1f5f57..5e90940a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.0-semantically-released", "description": "Simple and complete React hooks testing utilities that encourage good testing practices.", "main": "lib/index.js", + "types": "lib/index.d.ts", "keywords": [ "testing", "react", @@ -27,29 +28,34 @@ "validate": "kcd-scripts validate", "prepare": "npm run build", "build": "kcd-scripts build --out-dir lib", + "test": "kcd-scripts test", + "typecheck": "kcd-scripts typecheck", "lint": "kcd-scripts lint", "format": "kcd-scripts format", "coverage": "codecov", - "test": "kcd-scripts test", "docs:dev": "docz dev", "docs:build": "docz build", "contributors:add": "all-contributors add" }, "dependencies": { "@babel/runtime": "^7.12.5", - "@types/testing-library__react-hooks": "^3.4.0" + "@types/react": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^4.9.1", + "@typescript-eslint/parser": "^4.9.1", "all-contributors-cli": "6.19.0", "codecov": "3.8.1", "docz": "2.3.1", "docz-theme-default": "1.2.0", "docz-utils": "2.3.0", + "eslint": "7.15.0", "kcd-scripts": "7.5.2", + "prettier": "^2.2.1", "react": "17.0.1", "react-test-renderer": "17.0.1", - "typescript": "4.1.2", - "eslint": "7.15.0" + "typescript": "4.1.2" }, "peerDependencies": { "react": ">=16.9.0", diff --git a/src/asyncUtils.js b/src/asyncUtils.js deleted file mode 100644 index afb5ac7e..00000000 --- a/src/asyncUtils.js +++ /dev/null @@ -1,123 +0,0 @@ -import { act } from 'react-test-renderer' - -function createTimeoutError(utilName, { timeout }) { - const timeoutError = new Error(`Timed out in ${utilName} after ${timeout}ms.`) - timeoutError.timeout = true - return timeoutError -} - -function resolveAfter(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - -let hasWarnedDeprecatedWait = false - -function asyncUtils(addResolver) { - let nextUpdatePromise = null - - const waitForNextUpdate = async (options = {}) => { - if (!nextUpdatePromise) { - nextUpdatePromise = new Promise((resolve, reject) => { - let timeoutId - if (options.timeout > 0) { - timeoutId = setTimeout( - () => reject(createTimeoutError('waitForNextUpdate', options)), - options.timeout - ) - } - addResolver(() => { - clearTimeout(timeoutId) - nextUpdatePromise = null - resolve() - }) - }) - await act(() => nextUpdatePromise) - } - await nextUpdatePromise - } - - const waitFor = async (callback, { interval, timeout, suppressErrors = true } = {}) => { - // eslint-disable-next-line consistent-return - const checkResult = () => { - try { - const callbackResult = callback() - return callbackResult || callbackResult === undefined - } catch (e) { - if (!suppressErrors) { - throw e - } - } - } - - const waitForResult = async () => { - const initialTimeout = timeout - while (true) { - const startTime = Date.now() - try { - const nextCheck = interval - ? Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) - : waitForNextUpdate({ timeout }) - - await nextCheck - - if (checkResult()) { - return - } - } catch (e) { - if (e.timeout) { - throw createTimeoutError('waitFor', { timeout: initialTimeout }) - } - throw e - } - timeout -= Date.now() - startTime - } - } - - if (!checkResult()) { - await waitForResult() - } - } - - const waitForValueToChange = async (selector, options = {}) => { - const initialValue = selector() - try { - await waitFor(() => selector() !== initialValue, { - suppressErrors: false, - ...options - }) - } catch (e) { - if (e.timeout) { - throw createTimeoutError('waitForValueToChange', options) - } - throw e - } - } - - const wait = async (callback, { timeout, suppressErrors } = {}) => { - if (!hasWarnedDeprecatedWait) { - hasWarnedDeprecatedWait = true - console.warn( - '`wait` has been deprecated. Use `waitFor` instead: https://react-hooks-testing-library.com/reference/api#waitfor.' - ) - } - try { - await waitFor(callback, { timeout, suppressErrors }) - } catch (e) { - if (e.timeout) { - throw createTimeoutError('wait', { timeout }) - } - throw e - } - } - - return { - wait, - waitFor, - waitForNextUpdate, - waitForValueToChange - } -} - -export default asyncUtils diff --git a/src/asyncUtils.ts b/src/asyncUtils.ts new file mode 100644 index 00000000..22921721 --- /dev/null +++ b/src/asyncUtils.ts @@ -0,0 +1,112 @@ +import { act } from 'react-test-renderer' + +export interface WaitOptions { + interval?: number + timeout?: number + suppressErrors?: boolean +} + +class TimeoutError extends Error { + constructor(utilName: string, { timeout }: Pick) { + super(`Timed out in ${utilName} after ${timeout as number}ms.`) + } +} + +function resolveAfter(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function asyncUtils(addResolver: (callback: () => void) => void) { + let nextUpdatePromise: Promise | null = null + + const waitForNextUpdate = async (options: Pick = {}) => { + if (!nextUpdatePromise) { + nextUpdatePromise = new Promise((resolve, reject) => { + let timeoutId: ReturnType + if (options.timeout && options.timeout > 0) { + timeoutId = setTimeout( + () => reject(new TimeoutError('waitForNextUpdate', options)), + options.timeout + ) + } + addResolver(() => { + clearTimeout(timeoutId) + nextUpdatePromise = null + resolve() + }) + }) + await act(() => nextUpdatePromise as Promise) + } + await nextUpdatePromise + } + + const waitFor = async ( + callback: () => T | Promise, + { interval, timeout, suppressErrors = true }: WaitOptions = {} + ) => { + const checkResult = () => { + try { + const callbackResult = callback() + return callbackResult || callbackResult === undefined + } catch (error: unknown) { + if (!suppressErrors) { + throw error as Error + } + return undefined + } + } + + const waitForResult = async () => { + const initialTimeout = timeout + while (true) { + const startTime = Date.now() + try { + const nextCheck = interval + ? Promise.race([waitForNextUpdate({ timeout }), resolveAfter(interval)]) + : waitForNextUpdate({ timeout }) + + await nextCheck + + if (checkResult()) { + return + } + } catch (error: unknown) { + if (error instanceof TimeoutError) { + throw new TimeoutError('waitFor', { timeout: initialTimeout }) + } + throw error as Error + } + if (timeout) timeout -= Date.now() - startTime + } + } + + if (!checkResult()) { + await waitForResult() + } + } + + const waitForValueToChange = async (selector: () => unknown, options: WaitOptions = {}) => { + const initialValue = selector() + try { + await waitFor(() => selector() !== initialValue, { + suppressErrors: false, + ...options + }) + } catch (error: unknown) { + if (error instanceof TimeoutError) { + throw new TimeoutError('waitForValueToChange', options) + } + throw error as Error + } + } + + return { + waitFor, + waitForNextUpdate, + waitForValueToChange + } +} + +export default asyncUtils diff --git a/src/cleanup.js b/src/cleanup.ts similarity index 55% rename from src/cleanup.js rename to src/cleanup.ts index d3172c46..8309bd04 100644 --- a/src/cleanup.js +++ b/src/cleanup.ts @@ -1,4 +1,4 @@ -let cleanupCallbacks = [] +let cleanupCallbacks: (() => Promise | void)[] = [] async function cleanup() { for (const callback of cleanupCallbacks) { @@ -7,12 +7,12 @@ async function cleanup() { cleanupCallbacks = [] } -function addCleanup(callback) { - cleanupCallbacks.unshift(callback) +function addCleanup(callback: () => Promise | void) { + cleanupCallbacks = [callback, ...cleanupCallbacks] return () => removeCleanup(callback) } -function removeCleanup(callback) { +function removeCleanup(callback: () => Promise | void) { cleanupCallbacks = cleanupCallbacks.filter((cb) => cb !== callback) } diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/src/pure.js b/src/pure.js deleted file mode 100644 index 435d0736..00000000 --- a/src/pure.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, { Suspense } from 'react' -import { act, create } from 'react-test-renderer' -import asyncUtils from './asyncUtils' -import { cleanup, addCleanup, removeCleanup } from './cleanup' - -function TestHook({ callback, hookProps, onError, children }) { - try { - children(callback(hookProps)) - } catch (err) { - if (err.then) { - throw err - } else { - onError(err) - } - } - return null -} - -function Fallback() { - return null -} - -function resultContainer() { - const results = [] - const resolvers = [] - - const result = { - get all() { - return results.map(({ value, error }) => error || value) - }, - get current() { - const { value, error } = results[results.length - 1] - if (error) { - throw error - } - return value - }, - get error() { - const { error } = results[results.length - 1] - return error - } - } - - const updateResult = (value, error) => { - results.push({ value, error }) - resolvers.splice(0, resolvers.length).forEach((resolve) => resolve()) - } - - return { - result, - addResolver: (resolver) => { - resolvers.push(resolver) - }, - setValue: (value) => updateResult(value), - setError: (error) => updateResult(undefined, error) - } -} - -function renderHook(callback, { initialProps, wrapper } = {}) { - const { result, setValue, setError, addResolver } = resultContainer() - const hookProps = { current: initialProps } - - const wrapUiIfNeeded = (innerElement) => - wrapper ? React.createElement(wrapper, hookProps.current, innerElement) : innerElement - - const toRender = () => - wrapUiIfNeeded( - }> - - {setValue} - - - ) - - let testRenderer - act(() => { - testRenderer = create(toRender()) - }) - const { unmount, update } = testRenderer - - function rerenderHook(newProps = hookProps.current) { - hookProps.current = newProps - act(() => { - update(toRender()) - }) - } - - function unmountHook() { - act(() => { - removeCleanup(unmountHook) - unmount() - }) - } - - addCleanup(unmountHook) - - return { - result, - rerender: rerenderHook, - unmount: unmountHook, - ...asyncUtils(addResolver) - } -} - -export { renderHook, cleanup, addCleanup, removeCleanup, act } diff --git a/src/pure.tsx b/src/pure.tsx new file mode 100644 index 00000000..a1c14897 --- /dev/null +++ b/src/pure.tsx @@ -0,0 +1,124 @@ +import React, { ReactElement, ReactNode, Suspense } from 'react' +import { act, create, ReactTestRenderer } from 'react-test-renderer' +import asyncUtils from './asyncUtils' +import { cleanup, addCleanup, removeCleanup } from './cleanup' + +function isPromise(value: unknown): boolean { + return typeof (value as PromiseLike).then === 'function' +} + +type TestHookProps = { + callback: (props: TProps) => TResult + hookProps: TProps | undefined + onError: (error: Error) => void + children: (value: TResult) => void +} + +function TestHook({ + callback, + hookProps, + onError, + children +}: TestHookProps) { + try { + // coerce undefined into TProps, so it maintains the previous behaviour + children(callback(hookProps as TProps)) + } catch (err: unknown) { + if (isPromise(err)) { + throw err + } else { + onError(err as Error) + } + } + return null +} + +function Fallback() { + return null +} + +function resultContainer() { + const results: Array<{ value?: TValue; error?: Error }> = [] + const resolvers: Array<() => void> = [] + + const result = { + get all() { + return results.map(({ value, error }) => error ?? value) + }, + get current() { + const { value, error } = results[results.length - 1] + if (error) { + throw error + } + return value as TValue + }, + get error() { + const { error } = results[results.length - 1] + return error + } + } + + const updateResult = (value?: TValue, error?: Error) => { + results.push({ value, error }) + resolvers.splice(0, resolvers.length).forEach((resolve) => resolve()) + } + + return { + result, + addResolver: (resolver: () => void) => { + resolvers.push(resolver) + }, + setValue: (value: TValue) => updateResult(value), + setError: (error: Error) => updateResult(undefined, error) + } +} + +function renderHook( + callback: (props: TProps) => TResult, + { initialProps, wrapper }: { initialProps?: TProps; wrapper?: React.ComponentType } = {} +) { + const { result, setValue, setError, addResolver } = resultContainer() + const hookProps = { current: initialProps } + + const wrapUiIfNeeded = (innerElement: ReactNode) => + wrapper ? React.createElement(wrapper, hookProps.current, innerElement) : innerElement + + const toRender = () => + wrapUiIfNeeded( + }> + + {setValue} + + + ) as ReactElement + + let testRenderer: ReactTestRenderer + act(() => { + testRenderer = create(toRender()) + }) + + function rerenderHook(newProps: typeof initialProps = hookProps.current) { + hookProps.current = newProps + act(() => { + testRenderer.update(toRender()) + }) + } + + function unmountHook() { + act(() => { + removeCleanup(unmountHook) + testRenderer.unmount() + }) + } + + addCleanup(unmountHook) + + return { + result, + rerender: rerenderHook, + unmount: unmountHook, + ...asyncUtils(addResolver) + } +} + +export { renderHook, cleanup, addCleanup, removeCleanup, act } diff --git a/test/asyncHook.js b/test/asyncHook.ts similarity index 74% rename from test/asyncHook.js rename to test/asyncHook.ts index 74d321a6..5479db82 100644 --- a/test/asyncHook.js +++ b/test/asyncHook.ts @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { renderHook } from '../src' describe('async hook tests', () => { - const useSequence = (...values) => { + const useSequence = (...values: string[]) => { const [first, ...otherValues] = values const [value, setValue] = useState(first) const index = useRef(0) @@ -266,92 +266,4 @@ describe('async hook tests', () => { expect(result.current).toBe('third') }) - - test('should wait for expectation to pass (deprecated)', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - let complete = false - await wait(() => { - expect(result.current).toBe('third') - complete = true - }) - expect(complete).toBe(true) - }) - - test('should not hang if expectation is already passing (deprecated)', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second')) - - expect(result.current).toBe('first') - - let complete = false - await wait(() => { - expect(result.current).toBe('first') - complete = true - }) - expect(complete).toBe(true) - }) - - test('should reject if callback throws error (deprecated)', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - wait( - () => { - if (result.current === 'second') { - throw new Error('Something Unexpected') - } - return result.current === 'third' - }, - { - suppressErrors: false - } - ) - ).rejects.toThrow(Error('Something Unexpected')) - }) - - test('should reject if callback immediately throws error (deprecated)', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - wait( - () => { - throw new Error('Something Unexpected') - }, - { - suppressErrors: false - } - ) - ).rejects.toThrow(Error('Something Unexpected')) - }) - - test('should wait for truthy value (deprecated)', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await wait(() => result.current === 'third') - - expect(result.current).toBe('third') - }) - - test('should reject if timeout exceeded when waiting for expectation to pass (deprecated)', async () => { - const { result, wait } = renderHook(() => useSequence('first', 'second', 'third')) - - expect(result.current).toBe('first') - - await expect( - wait( - () => { - expect(result.current).toBe('third') - }, - { timeout: 75 } - ) - ).rejects.toThrow(Error('Timed out in wait after 75ms.')) - }) }) diff --git a/test/autoCleanup.disabled.js b/test/autoCleanup.disabled.ts similarity index 89% rename from test/autoCleanup.disabled.js rename to test/autoCleanup.disabled.ts index d11f9314..35cbf91a 100644 --- a/test/autoCleanup.disabled.js +++ b/test/autoCleanup.disabled.ts @@ -4,10 +4,11 @@ import { useEffect } from 'react' // then we DON'T auto-wire up the afterEach for folks describe('skip auto cleanup (disabled) tests', () => { let cleanupCalled = false - let renderHook + let renderHook: (arg0: () => void) => void beforeAll(() => { process.env.RHTL_SKIP_AUTO_CLEANUP = 'true' + // eslint-disable-next-line renderHook = require('../src').renderHook }) diff --git a/test/autoCleanup.noAfterEach.js b/test/autoCleanup.noAfterEach.ts similarity index 80% rename from test/autoCleanup.noAfterEach.js rename to test/autoCleanup.noAfterEach.ts index 9b894e00..cd30a841 100644 --- a/test/autoCleanup.noAfterEach.js +++ b/test/autoCleanup.noAfterEach.ts @@ -4,11 +4,13 @@ import { useEffect } from 'react' // then we DON'T auto-wire up the afterEach for folks describe('skip auto cleanup (no afterEach) tests', () => { let cleanupCalled = false - let renderHook + let renderHook: (arg0: () => void) => void beforeAll(() => { + // @ts-expect-error Turning off AfterEach -- ignore Jest LifeCycle Type // eslint-disable-next-line no-global-assign afterEach = false + // eslint-disable-next-line renderHook = require('../').renderHook }) diff --git a/test/autoCleanup.js b/test/autoCleanup.ts similarity index 100% rename from test/autoCleanup.js rename to test/autoCleanup.ts diff --git a/test/cleanup.js b/test/cleanup.ts similarity index 92% rename from test/cleanup.js rename to test/cleanup.ts index 05dba6dc..1eafffbf 100644 --- a/test/cleanup.js +++ b/test/cleanup.ts @@ -21,8 +21,8 @@ describe('cleanup tests', () => { }) test('should cleanup all rendered hooks', async () => { - const cleanupCalled = [] - const hookWithCleanup = (id) => { + const cleanupCalled: boolean[] = [] + const hookWithCleanup = (id: number) => { useEffect(() => { return () => { cleanupCalled[id] = true @@ -40,7 +40,7 @@ describe('cleanup tests', () => { }) test('should call cleanups in reverse order', async () => { - const callSequence = [] + const callSequence: string[] = [] addCleanup(() => { callSequence.push('cleanup') }) @@ -62,7 +62,7 @@ describe('cleanup tests', () => { }) test('should wait for async cleanup', async () => { - const callSequence = [] + const callSequence: string[] = [] addCleanup(() => { callSequence.push('cleanup') }) @@ -85,7 +85,7 @@ describe('cleanup tests', () => { }) test('should remove cleanup using removeCleanup', async () => { - const callSequence = [] + const callSequence: string[] = [] addCleanup(() => { callSequence.push('cleanup') }) @@ -110,7 +110,7 @@ describe('cleanup tests', () => { }) test('should remove cleanup using returned handler', async () => { - const callSequence = [] + const callSequence: string[] = [] addCleanup(() => { callSequence.push('cleanup') }) diff --git a/test/customHook.js b/test/customHook.ts similarity index 100% rename from test/customHook.js rename to test/customHook.ts diff --git a/test/errorHook.js b/test/errorHook.ts similarity index 95% rename from test/errorHook.js rename to test/errorHook.ts index 55e425e2..e507bb92 100644 --- a/test/errorHook.js +++ b/test/errorHook.ts @@ -2,15 +2,15 @@ import { useState, useEffect } from 'react' import { renderHook } from '../src' describe('error hook tests', () => { - function useError(throwError) { + function useError(throwError: boolean) { if (throwError) { throw new Error('expected') } return true } - function useAsyncError(throwError) { - const [value, setValue] = useState() + function useAsyncError(throwError: boolean) { + const [value, setValue] = useState() useEffect(() => { const timeout = setTimeout(() => setValue(throwError), 100) return () => clearTimeout(timeout) @@ -18,7 +18,7 @@ describe('error hook tests', () => { return useError(value) } - function useEffectError(throwError) { + function useEffectError(throwError: boolean) { useEffect(() => { useError(throwError) }, [throwError]) diff --git a/test/resultHistory.js b/test/resultHistory.ts similarity index 100% rename from test/resultHistory.js rename to test/resultHistory.ts diff --git a/test/suspenseHook.js b/test/suspenseHook.ts similarity index 76% rename from test/suspenseHook.js rename to test/suspenseHook.ts index 6dcfdeae..8d696927 100644 --- a/test/suspenseHook.js +++ b/test/suspenseHook.ts @@ -1,10 +1,10 @@ import { renderHook } from '../src' describe('suspense hook tests', () => { - const cache = {} - const fetchName = (isSuccessful) => { + const cache: { value?: Promise | string | Error } = {} + const fetchName = (isSuccessful: boolean) => { if (!cache.value) { - cache.value = new Promise((resolve, reject) => { + cache.value = new Promise((resolve, reject) => { setTimeout(() => { if (isSuccessful) { resolve('Bob') @@ -14,15 +14,15 @@ describe('suspense hook tests', () => { }, 50) }) .then((value) => (cache.value = value)) - .catch((e) => (cache.value = e)) + .catch((e: Error) => (cache.value = e)) } return cache.value } const useFetchName = (isSuccessful = true) => { const name = fetchName(isSuccessful) - if (typeof name.then === 'function' || name instanceof Error) { - throw name + if (name instanceof Promise || name instanceof Error) { + throw name as unknown } return name } diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..48209b56 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "declaration": false + }, + "exclude": [], + "include": ["."] +} diff --git a/test/useContext.js b/test/useContext.tsx similarity index 89% rename from test/useContext.js rename to test/useContext.tsx index 4bcbe774..03bc19f4 100644 --- a/test/useContext.js +++ b/test/useContext.tsx @@ -15,7 +15,7 @@ describe('useContext tests', () => { test('should get value from context provider', () => { const TestContext = createContext('foo') - const wrapper = ({ children }) => ( + const wrapper: React.FC = ({ children } ) => ( {children} ) @@ -29,7 +29,7 @@ describe('useContext tests', () => { const value = { current: 'bar' } - const wrapper = ({ children }) => ( + const wrapper: React.FC = ({ children }) => ( {children} ) @@ -45,7 +45,8 @@ describe('useContext tests', () => { test('should update value in context when props are updated', () => { const TestContext = createContext('foo') - const wrapper = ({ current, children }) => ( + + const wrapper: React.FC<{current: string}> = ({ current, children }) => ( {children} ) diff --git a/test/useEffect.js b/test/useEffect.ts similarity index 89% rename from test/useEffect.js rename to test/useEffect.ts index 9e120e07..cad9d0f3 100644 --- a/test/useEffect.js +++ b/test/useEffect.ts @@ -3,7 +3,7 @@ import { renderHook } from '../src' describe('useEffect tests', () => { test('should handle useEffect hook', () => { - const sideEffect = { 1: false, 2: false } + const sideEffect: { [key: number]: boolean } = { 1: false, 2: false } const { rerender, unmount } = renderHook( ({ id }) => { @@ -32,7 +32,7 @@ describe('useEffect tests', () => { }) test('should handle useLayoutEffect hook', () => { - const sideEffect = { 1: false, 2: false } + const sideEffect: { [key: number]: boolean } = { 1: false, 2: false } const { rerender, unmount } = renderHook( ({ id }) => { diff --git a/test/useMemo.js b/test/useMemo.ts similarity index 100% rename from test/useMemo.js rename to test/useMemo.ts diff --git a/test/useReducer.js b/test/useReducer.ts similarity index 79% rename from test/useReducer.js rename to test/useReducer.ts index 114f579b..7b98431a 100644 --- a/test/useReducer.js +++ b/test/useReducer.ts @@ -3,7 +3,8 @@ import { renderHook, act } from '../src' describe('useReducer tests', () => { test('should handle useReducer hook', () => { - const reducer = (state, action) => (action.type === 'inc' ? state + 1 : state) + const reducer = (state: number, action: { type: string }) => + action.type === 'inc' ? state + 1 : state const { result } = renderHook(() => useReducer(reducer, 0)) const [initialState, dispatch] = result.current diff --git a/test/useRef.js b/test/useRef.ts similarity index 92% rename from test/useRef.js rename to test/useRef.ts index b9dbefe3..9d3851ff 100644 --- a/test/useRef.js +++ b/test/useRef.ts @@ -13,7 +13,7 @@ describe('useHook tests', () => { test('should handle useImperativeHandle hook', () => { const { result } = renderHook(() => { - const ref = useRef() + const ref = useRef boolean>>({}) useImperativeHandle(ref, () => ({ fakeImperativeMethod: () => true })) diff --git a/test/useState.js b/test/useState.ts similarity index 100% rename from test/useState.js rename to test/useState.ts diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1337ac30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/kcd-scripts/shared-tsconfig.json", + "compilerOptions": { + "allowJs": true, + "target": "ES6" + }, + "exclude": ["./test"] +}