/*
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",
};
}