diff options
Diffstat (limited to 'packages/web-util/src/tests/hook.ts')
-rw-r--r-- | packages/web-util/src/tests/hook.ts | 325 |
1 files changed, 325 insertions, 0 deletions
diff --git a/packages/web-util/src/tests/hook.ts b/packages/web-util/src/tests/hook.ts new file mode 100644 index 000000000..59f17ba8d --- /dev/null +++ b/packages/web-util/src/tests/hook.ts @@ -0,0 +1,325 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Fragment, + FunctionComponent, + FunctionalComponent, + VNode, + h as create, + options, + render as renderIntoDom, +} from "preact"; +import { render as renderToString } from "preact-render-to-string"; + +// This library is expected to be included in testing environment only +// When doing tests we want the requestAnimationFrame to be as fast as possible. +// without this option the RAF will timeout after 100ms making the tests slower +options.requestAnimationFrame = (fn: () => void) => { + return fn(); +}; + +export type ExampleItemSetup<Props extends object = {}> = { + component: FunctionalComponent<Props>; + props: Props; + contextProps: object; +}; + +/** + * + * @param Component component to be tested + * @param props allow partial props for easier example setup + * @param contextProps if the context requires params for this example + * @returns + */ +export function createExample<T extends object, Props extends object>( + Component: FunctionalComponent<Props>, + props: Partial<Props> | (() => Partial<Props>), + contextProps?: T | (() => T), +): ExampleItemSetup<Props> { + const evaluatedProps = typeof props === "function" ? props() : props; + const Render = (args: any): VNode => create(Component, args); + const evaluatedContextProps = + typeof contextProps === "function" ? contextProps() : contextProps; + return { + component: Render, + props: evaluatedProps as Props, + contextProps: !evaluatedContextProps ? {} : evaluatedContextProps, + }; +} + +/** + * Should render HTML on node and browser + * Browser: mount update and unmount + * Node: render to string + * + * @param Component + * @param args + */ +export function renderUI(example: ExampleItemSetup<any>, Context?: any): void { + const vdom = !Context + ? create(example.component, example.props) + : create(Context, { + ...example.contextProps, + children: [create(example.component, example.props)], + }); + + if (typeof window === "undefined") { + renderToString(vdom); + } else { + const div = document.createElement("div"); + document.body.appendChild(div); + renderIntoDom(vdom, div); + renderIntoDom(null, div); + document.body.removeChild(div); + } +} + +/** + * No need to render. + * Should mount, update and run effects. + * + * Browser: mount update and unmount + * Node: mount on a mock virtual dom + * + * Mounting hook doesn't use DOM api so is + * safe to use normal mounting api in node + * + * @param Component + * @param props + * @param Context + */ +function renderHook( + Component: FunctionComponent, + Context?: ({ children }: { children: any }) => VNode | null, +): void { + const vdom = !Context + ? create(Component, {}) + : create(Context, { children: [create(Component, {})] }); + + //use normal mounting API since we expect + //useEffect to be called ( and similar APIs ) + renderIntoDom(vdom, {} as Element); +} + +type RecursiveState<S> = S | (() => RecursiveState<S>); + +interface Mounted<T> { + pullLastResultOrThrow: () => Exclude<T, VoidFunction>; + assertNoPendingUpdate: () => Promise<boolean>; + waitForStateUpdate: () => Promise<boolean>; +} + +/** + * Manual API mount the hook and return testing API + * Consider using hookBehaveLikeThis() function + * + * @param hookToBeTested + * @param Context + * + * @returns testing API + */ +function mountHook<T extends object>( + hookToBeTested: () => RecursiveState<T>, + Context?: ({ children }: { children: any }) => VNode | null, +): Mounted<T> { + let lastResult: Exclude<T, VoidFunction> | Error | null = null; + + const listener: Array<() => void> = []; + + // component that's going to hold the hook + function Component(): VNode { + try { + let componentOrResult = hookToBeTested(); + + // special loop + // since Taler use a special type of hook that can return + // a function and it will be treated as a composed component + // then tests should be aware of it and reproduce the same behavior + while (typeof componentOrResult === "function") { + componentOrResult = componentOrResult(); + } + //typecheck fails here + const l: Exclude<T, () => void> = componentOrResult as any; + lastResult = l; + } catch (e) { + if (e instanceof Error) { + lastResult = e; + } else { + lastResult = new Error(`mounting the hook throw an exception: ${e}`); + } + } + + // notify to everyone waiting for an update and clean the queue + listener.splice(0, listener.length).forEach((cb) => cb()); + return create(Fragment, {}); + } + + renderHook(Component, Context); + + function pullLastResult(): Exclude<T | Error | null, VoidFunction> { + const copy: Exclude<T | Error | null, VoidFunction> = lastResult; + lastResult = null; + return copy; + } + + function pullLastResultOrThrow(): Exclude<T, VoidFunction> { + const r = pullLastResult(); + if (r instanceof Error) throw r; + //sanity check + if (!r) throw Error("there was no last result"); + return r; + } + + async function assertNoPendingUpdate(): Promise<boolean> { + await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(true); + }, 10); + + listener.push(() => { + clearTimeout(tid); + res(false); + // Error(`Expecting no pending result but the hook got updated. + // If the update was not intended you need to check the hook dependencies + // (or dependencies of the internal state) but otherwise make + // sure to consume the result before ending the test.`), + // ); + }); + }); + + const r = pullLastResult(); + if (r) { + return Promise.resolve(false); + } + return Promise.resolve(true); + // This may happen because the hook did a new update but the test didn't consume the result using pullLastResult`); + } + async function waitForStateUpdate(): Promise<boolean> { + return await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(false); + }, 10); + + listener.push(() => { + clearTimeout(tid); + res(true); + }); + }); + } + + return { + pullLastResultOrThrow, + waitForStateUpdate, + assertNoPendingUpdate, + }; +} + +export const nullFunction = (): void => { + null; +}; +export const nullAsyncFunction = (): Promise<void> => { + return Promise.resolve(); +}; + +type HookTestResult = HookTestResultOk | HookTestResultError; + +interface HookTestResultOk { + result: "ok"; +} +interface HookTestResultError { + result: "fail"; + error: string; + index: number; +} + +/** + * Main testing driver. + * It will assert that there are no more and no less hook updates than expected. + * + * @param hookFunction hook function to be tested + * @param props initial props for the hook + * @param checks step by step state validation + * @param Context additional testing context for overrides + * + * @returns testing result, should also be checked to be "ok" + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export async function hookBehaveLikeThis<T extends object, PropsType>( + hookFunction: (p: PropsType) => RecursiveState<T>, + props: PropsType, + checks: Array<(state: Exclude<T, VoidFunction>) => void>, + Context?: ({ children }: { children: any }) => VNode | null, +): Promise<HookTestResult> { + const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } = + mountHook<T>(() => hookFunction(props), Context); + + const [firstCheck, ...restOfTheChecks] = checks; + { + const state = pullLastResultOrThrow(); + const checkError = firstCheck(state); + if (checkError !== undefined) { + return { + result: "fail", + index: 0, + error: `First check returned with error: ${checkError}`, + }; + } + } + + let index = 1; + for (const check of restOfTheChecks) { + const hasNext = await waitForStateUpdate(); + if (!hasNext) { + return { + result: "fail", + error: "Component didn't update and the test expected one more state", + index, + }; + } + const state = pullLastResultOrThrow(); + const checkError = check(state); + if (checkError !== undefined) { + return { + result: "fail", + index, + error: `Check returned with error: ${checkError}`, + }; + } + index++; + } + + const hasNext = await waitForStateUpdate(); + if (hasNext) { + return { + result: "fail", + index, + error: "Component updated and test didn't expect more states", + }; + } + const noMoreUpdates = await assertNoPendingUpdate(); + if (noMoreUpdates === false) { + return { + result: "fail", + index, + error: "Component was updated but the test does not cover the update", + }; + } + + return { + result: "ok", + }; +} |