/* 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 */ 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 = { component: FunctionalComponent; 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( Component: FunctionalComponent, props: Partial | (() => Partial), contextProps?: T | (() => T), ): ExampleItemSetup { 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, 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 | (() => RecursiveState); interface Mounted { pullLastResultOrThrow: () => Exclude; assertNoPendingUpdate: () => Promise; waitForStateUpdate: () => Promise; } /** * Manual API mount the hook and return testing API * Consider using hookBehaveLikeThis() function * * @param hookToBeTested * @param Context * * @returns testing API */ function mountHook( hookToBeTested: () => RecursiveState, Context?: ({ children }: { children: any }) => VNode | null, ): Mounted { let lastResult: Exclude | 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 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 { const copy: Exclude = lastResult; lastResult = null; return copy; } function pullLastResultOrThrow(): Exclude { 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 { 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 { 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 => { 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( hookFunction: (p: PropsType) => RecursiveState, props: PropsType, checks: Array<(state: Exclude) => void>, Context?: ({ children }: { children: any }) => VNode | null, ): Promise { const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } = mountHook(() => 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", }; }