summaryrefslogtreecommitdiff
path: root/packages/web-util/src/tests/hook.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/web-util/src/tests/hook.ts')
-rw-r--r--packages/web-util/src/tests/hook.ts325
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",
+ };
+}