/* 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 { ComponentChildren, Fragment, FunctionalComponent, h as create, options, render as renderIntoDom, VNode } from "preact"; // 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 function createExample( Component: FunctionalComponent, props: Partial | (() => Partial), ): ComponentChildren { const evaluatedProps = typeof props === "function" ? props() : props; const Render = (args: any): VNode => create(Component, args); return { component: Render, props: evaluatedProps, }; } const isNode = typeof window === "undefined"; /** * To be used on automated unit test. * So test will run under node or browser * @param Component * @param args */ export function renderNodeOrBrowser( Component: any, args: any, Context?: any, ): void { const vdom = !Context ? create(Component, args) : create(Context, { children: [create(Component, args)] }); const customElement = {} as Element; const parentElement = isNode ? customElement : document.createElement("div"); if (!isNode) { document.body.appendChild(parentElement); } // renderIntoDom works also in nodejs // if the VirtualDOM is composed only by functional components // then no called is going to be made to the DOM api. // vdom should not have any 'div' or other html component renderIntoDom(vdom, parentElement); if (!isNode) { document.body.removeChild(parentElement); } } type RecursiveState = S | (() => RecursiveState); interface Mounted { // unmount: () => void; pullLastResultOrThrow: () => Exclude; assertNoPendingUpdate: () => Promise; // waitNextUpdate: (s?: string) => Promise; waitForStateUpdate: () => Promise; } /** * Manual API mount the hook and return testing API * Consider using hookBehaveLikeThis() function * * @param hookToBeTested * @param Context * * @returns testing API */ // eslint-disable-next-line @typescript-eslint/ban-types export 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(); 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, {}); } renderNodeOrBrowser(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); // throw Error(`There are still pending results. // 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 { // unmount, 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, ...resultOfTheChecks] = checks; { const state = pullLastResultOrThrow(); const checkError = firstCheck(state); if (checkError !== undefined) { return { result: "fail", index: 0, error: `Check return not undefined error: ${checkError}`, }; } } let index = 1; for (const check of resultOfTheChecks) { 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 return not undefined 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", }; }