moving testing sdk to web-utils

This commit is contained in:
Sebastian 2022-12-14 15:17:15 -03:00
parent ae2df08abd
commit e97c808b41
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
19 changed files with 1308 additions and 1122 deletions

View File

@ -78,13 +78,13 @@ const buildConfigNode = {
const buildConfigBrowser = {
...buildConfigBase,
entryPoints: ["src/index.browser.ts", "src/live-reload.ts", 'src/stories.tsx'],
entryPoints: ["src/tests/axios.ts", "src/tests/swr.ts", "src/index.browser.ts", "src/live-reload.ts", 'src/stories.tsx'],
outExtension: {
'.js': '.mjs'
},
format: 'esm',
platform: 'browser',
external: ["preact", "@gnu-taler/taler-util", "jed"],
external: ["preact", "@gnu-taler/taler-util", "jed","swr","axios"],
jsxFactory: 'h',
jsxFragment: 'Fragment',
};

View File

@ -12,6 +12,8 @@
"license": "AGPL-3.0-or-later",
"private": false,
"exports": {
"./lib/tests/swr": "./lib/tests/swr.mjs",
"./lib/tests/axios": "./lib/tests/axios.mjs",
"./lib/index.browser": "./lib/index.browser.mjs",
"./lib/index.node": "./lib/index.node.cjs"
},
@ -27,6 +29,7 @@
"@types/node": "^18.11.9",
"@types/web": "^0.0.82",
"@types/ws": "^8.5.3",
"axios": "^1.2.1",
"chokidar": "^3.5.3",
"esbuild": "^0.14.21",
"express": "^4.18.2",
@ -34,6 +37,7 @@
"preact-render-to-string": "^5.2.6",
"prettier": "^2.5.1",
"rimraf": "^3.0.2",
"swr": "1.3.0",
"tslib": "^2.4.0",
"typescript": "^4.8.4",
"ws": "7.4.5"

View File

@ -1,2 +1 @@
export * as utils from "./utils.js";

View File

@ -1,2 +1,5 @@
export { InternationalizationAPI, TranslationProvider, useTranslationContext } from "./translation.js";
export {
InternationalizationAPI,
TranslationProvider,
useTranslationContext,
} from "./translation.js";

View File

@ -19,7 +19,7 @@ import { ComponentChildren, createContext, h, VNode } from "preact";
import { useContext, useEffect } from "preact/hooks";
import { useLang } from "../hooks/index.js";
export type InternationalizationAPI = typeof i18n
export type InternationalizationAPI = typeof i18n;
interface Type {
lang: string;
@ -54,7 +54,7 @@ interface Props {
initial?: string;
children: ComponentChildren;
forceLang?: string;
source: Record<string, any>
source: Record<string, any>;
}
// Outmost UI wrapper.
@ -62,7 +62,7 @@ export const TranslationProvider = ({
initial,
children,
forceLang,
source
source,
}: Props): VNode => {
const [lang, changeLanguage, isSaved] = useLang(initial);
useEffect(() => {

View File

@ -1,4 +1,11 @@
export { useLang } from "./useLang.js";
export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js"
export { useAsyncAsHook, HookError, HookOk, HookResponse, HookResponseWithRetry, HookGenericError, HookOperationalError } from "./useAsyncAsHook.js"
export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js";
export {
useAsyncAsHook,
HookError,
HookOk,
HookResponse,
HookResponseWithRetry,
HookGenericError,
HookOperationalError,
} from "./useAsyncAsHook.js";

View File

@ -35,13 +35,13 @@ export function useLocalStorage(
useEffect(() => {
const listener = buildListenerForKey(key, (newValue) => {
setStoredValue(newValue ?? initialValue)
})
window.addEventListener('storage', listener)
setStoredValue(newValue ?? initialValue);
});
window.addEventListener("storage", listener);
return () => {
window.removeEventListener('storage', listener)
}
}, [])
window.removeEventListener("storage", listener);
};
}, []);
const setValue = (
value?: string | ((val?: string) => string | undefined),
@ -62,11 +62,14 @@ export function useLocalStorage(
return [storedValue, setValue];
}
function buildListenerForKey(key: string, onUpdate: (newValue: string | undefined) => void): () => void {
function buildListenerForKey(
key: string,
onUpdate: (newValue: string | undefined) => void,
): () => void {
return function listenKeyChange() {
const value = window.localStorage.getItem(key)
onUpdate(value ?? undefined)
}
const value = window.localStorage.getItem(key);
onUpdate(value ?? undefined);
};
}
//TODO: merge with the above function
@ -80,16 +83,15 @@ export function useNotNullLocalStorage(
: initialValue;
});
useEffect(() => {
const listener = buildListenerForKey(key, (newValue) => {
setStoredValue(newValue ?? initialValue)
})
window.addEventListener('storage', listener)
setStoredValue(newValue ?? initialValue);
});
window.addEventListener("storage", listener);
return () => {
window.removeEventListener('storage', listener)
}
})
window.removeEventListener("storage", listener);
};
});
const setValue = (value: string | ((val: string) => string)): void => {
const valueToStore = value instanceof Function ? value(storedValue) : value;

View File

@ -1,5 +1,5 @@
export * from "./hooks/index.js";
export * from "./context/index.js";
export * from "./components/index.js";
export * as test from "./test/index.js";
export * as tests from "./tests/index.js";
export { renderStories, parseGroupImport } from "./stories.js";

View File

@ -15,24 +15,24 @@ function setupLiveReload(): void {
return;
}
if (event.type === "file-updated-failed") {
const h1 = document.getElementById("overlay-text")
const h1 = document.getElementById("overlay-text");
if (h1) {
h1.innerHTML = "compilation failed"
h1.style.color = 'red'
h1.style.margin = ''
h1.innerHTML = "compilation failed";
h1.style.color = "red";
h1.style.margin = "";
}
const div = document.getElementById("overlay")
const div = document.getElementById("overlay");
if (div) {
const content = JSON.stringify(event.data, undefined, 2)
const content = JSON.stringify(event.data, undefined, 2);
const pre = document.createElement("pre");
pre.id = "error-text"
pre.id = "error-text";
pre.style.margin = "";
pre.textContent = content;
div.style.backgroundColor = "rgba(0,0,0,0.8)";
div.style.flexDirection = 'column'
div.style.flexDirection = "column";
div.appendChild(pre);
}
console.error(event.data.error)
console.error(event.data.error);
return;
}
if (event.type === "file-updated") {
@ -56,17 +56,17 @@ setupLiveReload();
function showReloadOverlay(): void {
const d = document.createElement("div");
d.id = "overlay"
d.id = "overlay";
d.style.position = "absolute";
d.style.width = "100%";
d.style.height = "100%";
d.style.color = "white";
d.style.backgroundColor = "rgba(0,0,0,0.5)";
d.style.display = "flex";
d.style.zIndex = String(Number.MAX_SAFE_INTEGER)
d.style.zIndex = String(Number.MAX_SAFE_INTEGER);
d.style.justifyContent = "center";
const h = document.createElement("h1");
h.id = "overlay-text"
h.id = "overlay-text";
h.style.margin = "auto";
h.innerHTML = "reloading...";
d.appendChild(h);

View File

@ -77,23 +77,26 @@ export async function serve(opts: {
if (opts.onUpdate) {
sendToAllClients({ type: "file-updated-start", data: { path } });
opts.onUpdate().then((result) => {
sendToAllClients({
type: "file-updated-done",
data: { path, result },
opts
.onUpdate()
.then((result) => {
sendToAllClients({
type: "file-updated-done",
data: { path, result },
});
})
.catch((error) => {
sendToAllClients({
type: "file-updated-failed",
data: { path, error },
});
});
}).catch((error) => {
sendToAllClients({
type: "file-updated-failed",
data: { path, error },
});
});
} else {
sendToAllClients({ type: "file-change", data: { path } });
}
});
if (opts.onUpdate) opts.onUpdate()
if (opts.onUpdate) opts.onUpdate();
app.get(PATHS.EXAMPLE, function (req: any, res: any) {
res.set("Content-Type", "text/html");

View File

@ -1,224 +0,0 @@
/*
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 { NotificationType } from "@gnu-taler/taler-util";
// import {
// WalletCoreApiClient,
// WalletCoreOpKeys,
// WalletCoreRequestType,
// WalletCoreResponseType,
// } from "@gnu-taler/taler-wallet-core";
import {
ComponentChildren,
Fragment,
FunctionalComponent,
h as create,
options,
render as renderIntoDom,
VNode,
} from "preact";
import { render as renderToString } from "preact-render-to-string";
// import { BackgroundApiClient, wxApi } from "./wxApi.js";
// 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<Props>(
Component: FunctionalComponent<Props>,
props: Partial<Props> | (() => Partial<Props>),
): ComponentChildren {
const evaluatedProps = typeof props === "function" ? props() : props;
const Render = (args: any): VNode => create(Component, args);
return {
component: Render,
props: evaluatedProps
};
}
export function createExampleWithCustomContext<Props, ContextProps>(
Component: FunctionalComponent<Props>,
props: Partial<Props> | (() => Partial<Props>),
ContextProvider: FunctionalComponent<ContextProps>,
contextProps: Partial<ContextProps>,
): ComponentChildren {
/**
* FIXME:
* This may not be useful since the example can be created with context
* already
*/
const evaluatedProps = typeof props === "function" ? props() : props;
const Render = (args: any): VNode => create(Component, args);
const WithContext = (args: any): VNode =>
create(ContextProvider, {
...contextProps,
children: [Render(args)],
} as any);
return {
component: WithContext,
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): void {
const vdom = create(Component, args);
if (isNode) {
renderToString(vdom);
} else {
const div = document.createElement("div");
document.body.appendChild(div);
renderIntoDom(vdom, div);
renderIntoDom(null, div);
document.body.removeChild(div);
}
}
type RecursiveState<S> = S | (() => RecursiveState<S>);
interface Mounted<T> {
unmount: () => void;
pullLastResultOrThrow: () => Exclude<T, VoidFunction>;
assertNoPendingUpdate: () => void;
// waitNextUpdate: (s?: string) => Promise<void>;
waitForStateUpdate: () => Promise<boolean>;
}
/**
* Main test API, mount the hook and return testing API
* @param callback
* @param Context
* @returns
*/
export function mountHook<T extends object>(
callback: () => RecursiveState<T>,
Context?: ({ children }: { children: any }) => VNode,
): 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 = callback();
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, {});
}
// create the vdom with context if required
const vdom = !Context
? create(Component, {})
: create(Context, { children: [create(Component, {})] });
const customElement = {} as Element;
const parentElement = isNode ? customElement : document.createElement("div");
if (!isNode) {
document.body.appendChild(parentElement);
}
renderIntoDom(vdom, parentElement);
// clean up callback
function unmount(): void {
if (!isNode) {
document.body.removeChild(parentElement);
}
}
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;
if (!r) throw Error("there was no last result");
return r;
}
async function assertNoPendingUpdate(): Promise<void> {
await new Promise((res, rej) => {
const tid = setTimeout(() => {
res(undefined);
}, 10);
listener.push(() => {
clearTimeout(tid);
rej(
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)
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<boolean> {
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<void> => { return Promise.resolve() }

View File

@ -0,0 +1,136 @@
/*
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 axios, { AxiosPromise, AxiosRequestConfig } from "axios";
import * as axios from "axios";
import {
setAxiosRequestAsTestingEnvironment,
mockAxiosOnce,
} from "../utils/axios.js";
const TESTING_DEBUG_LOG = process.env["TESTING_DEBUG_LOG"] !== undefined;
const defaultCallback = (
actualQuery?: axios.AxiosRequestConfig,
): axios.AxiosPromise<any> => {
if (TESTING_DEBUG_LOG) {
console.log("UNEXPECTED QUERY", actualQuery);
}
throw Error(
"Default Axios mock callback is called, this mean that the test did a tried to use axios but there was no expectation in place, try using JEST_DEBUG_LOG env",
);
};
setAxiosRequestAsTestingEnvironment(defaultCallback);
export type Query<Req, Res> = {
method: axios.Method;
url: string;
code?: number;
};
type ExpectationValues = {
query: Query<any, any>;
params?: {
auth?: string;
request?: object;
qparam?: Record<string, string>;
response?: object;
};
};
type TestValues = [
axios.AxiosRequestConfig | undefined,
ExpectationValues | undefined,
];
export class AxiosMockEnvironment {
expectations: Array<
| {
query: Query<any, any>;
auth?: string;
params?: {
request?: object;
qparam?: Record<string, string>;
response?: object;
};
result: { args: axios.AxiosRequestConfig | undefined };
}
| undefined
> = [];
// axiosMock: jest.MockedFunction<axios.AxiosStatic>
addRequestExpectation<
RequestType extends object,
ResponseType extends object,
>(
expectedQuery: Query<RequestType, ResponseType>,
params: {
auth?: string;
request?: RequestType;
qparam?: any;
response?: ResponseType;
},
): void {
const result = mockAxiosOnce(function (
actualQuery?: axios.AxiosRequestConfig,
): axios.AxiosPromise {
if (TESTING_DEBUG_LOG) {
console.log("query to the backend is made", actualQuery);
}
if (!expectedQuery) {
return Promise.reject("a query was made but it was not expected");
}
if (TESTING_DEBUG_LOG) {
console.log("expected query:", params?.request);
console.log("expected qparams:", params?.qparam);
console.log("sending response:", params?.response);
}
const responseCode = expectedQuery.code || 200;
//This response is what buildRequestOk is expecting in file hook/backend.ts
if (responseCode >= 200 && responseCode < 300) {
return Promise.resolve({
data: params?.response,
config: {
data: params?.response,
params: actualQuery?.params || {},
},
request: { params: actualQuery?.params || {} },
} as any);
}
//This response is what buildRequestFailed is expecting in file hook/backend.ts
return Promise.reject({
response: {
status: responseCode,
},
request: {
data: params?.response,
params: actualQuery?.params || {},
},
});
} as any);
this.expectations.push({ query: expectedQuery, params, result });
}
getLastTestValues(): TestValues {
const expectedQuery = this.expectations.shift();
return [expectedQuery?.result.args, expectedQuery];
}
}

View File

@ -0,0 +1,310 @@
/*
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 {
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<Props>(
Component: FunctionalComponent<Props>,
props: Partial<Props> | (() => Partial<Props>),
): ComponentChildren {
const evaluatedProps = typeof props === "function" ? props() : props;
const Render = (args: any): VNode => create(Component, args);
return {
component: Render,
props: evaluatedProps,
};
}
// export function createExampleWithCustomContext<Props, ContextProps>(
// Component: FunctionalComponent<Props>,
// props: Partial<Props> | (() => Partial<Props>),
// ContextProvider: FunctionalComponent<ContextProps>,
// contextProps: Partial<ContextProps>,
// ): ComponentChildren {
// /**
// * FIXME:
// * This may not be useful since the example can be created with context
// * already
// */
// const evaluatedProps = typeof props === "function" ? props() : props;
// const Render = (args: any): VNode => create(Component, args);
// const WithContext = (args: any): VNode =>
// create(ContextProvider, {
// ...contextProps,
// children: [Render(args)],
// } as any);
// return {
// component: WithContext,
// 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> = S | (() => RecursiveState<S>);
interface Mounted<T> {
// unmount: () => void;
pullLastResultOrThrow: () => Exclude<T, VoidFunction>;
assertNoPendingUpdate: () => Promise<boolean>;
// waitNextUpdate: (s?: string) => Promise<void>;
waitForStateUpdate: () => Promise<boolean>;
}
/**
* Manual API mount the hook and return testing API
* Consider using hookBehaveLikeThis() function
*
* @param hookToBeTested
* @param Context
*
* @returns testing API
*/
export 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();
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, {});
}
renderNodeOrBrowser(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);
// 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<boolean> {
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<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"
*/
export async function hookBehaveLikeThis<T extends object, PropsType>(
hookFunction: (p: PropsType) => RecursiveState<T>,
props: PropsType,
checks: Array<(state: T) => void>,
Context?: ({ children }: { children: any }) => VNode | null,
): Promise<HookTestResult> {
const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } =
mountHook<T>(() => 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",
};
}

View File

@ -0,0 +1,2 @@
export * from "./hook.js";
// export * from "./axios.js"

View File

@ -0,0 +1,458 @@
/*
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 { Logger } from "@gnu-taler/taler-util";
type HttpMethod =
| "get"
| "GET"
| "delete"
| "DELETE"
| "head"
| "HEAD"
| "options"
| "OPTIONS"
| "post"
| "POST"
| "put"
| "PUT"
| "patch"
| "PATCH"
| "purge"
| "PURGE"
| "link"
| "LINK"
| "unlink"
| "UNLINK";
export type Query<Req, Res> = {
method: HttpMethod;
url: string;
code?: number;
};
type ExpectationValues = {
query: Query<any, any>;
auth?: string;
params?: {
request?: object;
qparam?: Record<string, string>;
response?: object;
};
};
type TestValues = {
currentExpectedQuery: ExpectationValues | undefined;
lastQuery: ExpectationValues | undefined;
};
const logger = new Logger("testing/swr.ts");
export abstract class MockEnvironment {
expectations: Array<ExpectationValues> = [];
queriesMade: Array<ExpectationValues> = [];
index = 0;
debug: boolean;
constructor(debug: boolean) {
this.debug = debug;
this.registerRequest.bind(this);
}
public addRequestExpectation<
RequestType extends object,
ResponseType extends object,
>(
query: Query<RequestType, ResponseType>,
params: {
auth?: string;
request?: RequestType;
qparam?: any;
response?: ResponseType;
},
): void {
const expected = { query, params, auth: params.auth };
this.expectations.push(expected);
if (this.debug) {
logger.info("saving query as expected", expected);
}
this.mockApiIfNeeded();
}
abstract mockApiIfNeeded(): void;
public registerRequest<
RequestType extends object,
ResponseType extends object,
>(
query: Query<RequestType, ResponseType>,
params: {
auth?: string;
request?: RequestType;
qparam?: any;
response?: ResponseType;
},
): { status: number; payload: ResponseType } | undefined {
const queryMade = { query, params, auth: params.auth };
this.queriesMade.push(queryMade);
const expectedQuery = this.expectations[this.index];
if (!expectedQuery) {
if (this.debug) {
logger.info("unexpected query made", queryMade);
}
return undefined;
}
const responseCode = this.expectations[this.index].query.code ?? 200;
const mockedResponse = this.expectations[this.index].params
?.response as ResponseType;
if (this.debug) {
logger.info("tracking query made", {
queryMade,
expectedQuery,
});
}
this.index++;
return { status: responseCode, payload: mockedResponse };
}
public assertJustExpectedRequestWereMade(): AssertStatus {
let queryNumber = 0;
while (queryNumber < this.expectations.length) {
const r = this.assertNextRequest(queryNumber);
if (r.result !== "ok") return r;
queryNumber++;
}
return this.assertNoMoreRequestWereMade(queryNumber);
}
private getLastTestValues(idx: number): TestValues {
const currentExpectedQuery = this.expectations[idx];
const lastQuery = this.queriesMade[idx];
return { currentExpectedQuery, lastQuery };
}
private assertNoMoreRequestWereMade(idx: number): AssertStatus {
const { currentExpectedQuery, lastQuery } = this.getLastTestValues(idx);
if (lastQuery !== undefined) {
return {
result: "error-did-one-more",
made: lastQuery,
};
}
if (currentExpectedQuery !== undefined) {
return {
result: "error-did-one-less",
expected: currentExpectedQuery,
};
}
return {
result: "ok",
};
}
private assertNextRequest(idx: number): AssertStatus {
const { currentExpectedQuery, lastQuery } = this.getLastTestValues(idx);
if (!currentExpectedQuery) {
return {
result: "error-query-missing",
};
}
if (!lastQuery) {
return {
result: "error-did-one-less",
expected: currentExpectedQuery,
};
}
if (lastQuery.query.method) {
if (currentExpectedQuery.query.method !== lastQuery.query.method) {
return {
result: "error-difference",
diff: "method",
};
}
if (currentExpectedQuery.query.url !== lastQuery.query.url) {
return {
result: "error-difference",
diff: "url",
};
}
}
if (
!deepEquals(
currentExpectedQuery.params?.request,
lastQuery.params?.request,
)
) {
return {
result: "error-difference",
diff: "query-body",
};
}
if (
!deepEquals(currentExpectedQuery.params?.qparam, lastQuery.params?.qparam)
) {
return {
result: "error-difference",
diff: "query-params",
};
}
if (!deepEquals(currentExpectedQuery.auth, lastQuery.auth)) {
return {
result: "error-difference",
diff: "query-auth",
};
}
return {
result: "ok",
};
}
}
type AssertStatus =
| AssertOk
| AssertQueryNotMadeButExpected
| AssertQueryMadeButNotExpected
| AssertQueryMissing
| AssertExpectedQueryMethodMismatch
| AssertExpectedQueryUrlMismatch
| AssertExpectedQueryAuthMismatch
| AssertExpectedQueryBodyMismatch
| AssertExpectedQueryParamsMismatch;
interface AssertOk {
result: "ok";
}
//trying to assert for a expected query but there is
//no expected query in the queue
interface AssertQueryMissing {
result: "error-query-missing";
}
//tested component did one more query that expected
interface AssertQueryNotMadeButExpected {
result: "error-did-one-more";
made: ExpectationValues;
}
//tested component didn't make an expected query
interface AssertQueryMadeButNotExpected {
result: "error-did-one-less";
expected: ExpectationValues;
}
interface AssertExpectedQueryMethodMismatch {
result: "error-difference";
diff: "method";
}
interface AssertExpectedQueryUrlMismatch {
result: "error-difference";
diff: "url";
}
interface AssertExpectedQueryAuthMismatch {
result: "error-difference";
diff: "query-auth";
}
interface AssertExpectedQueryBodyMismatch {
result: "error-difference";
diff: "query-body";
}
interface AssertExpectedQueryParamsMismatch {
result: "error-difference";
diff: "query-params";
}
/**
* helpers
*
*/
export type Tester = (a: any, b: any) => boolean | undefined;
function deepEquals(
a: unknown,
b: unknown,
aStack: Array<unknown> = [],
bStack: Array<unknown> = [],
): boolean {
//one if the element is null or undefined
if (a === null || b === null || b === undefined || a === undefined) {
return a === b;
}
//both are errors
if (a instanceof Error && b instanceof Error) {
return a.message == b.message;
}
//is the same object
if (Object.is(a, b)) {
return true;
}
//both the same class
const name = Object.prototype.toString.call(a);
if (name != Object.prototype.toString.call(b)) {
return false;
}
//
switch (name) {
case "[object Boolean]":
case "[object String]":
case "[object Number]":
if (typeof a !== typeof b) {
// One is a primitive, one a `new Primitive()`
return false;
} else if (typeof a !== "object" && typeof b !== "object") {
// both are proper primitives
return Object.is(a, b);
} else {
// both are `new Primitive()`s
return Object.is(a.valueOf(), b.valueOf());
}
case "[object Date]": {
const _a = a as Date;
const _b = b as Date;
return _a == _b;
}
case "[object RegExp]": {
const _a = a as RegExp;
const _b = b as RegExp;
return _a.source === _b.source && _a.flags === _b.flags;
}
case "[object Array]": {
const _a = a as Array<any>;
const _b = b as Array<any>;
if (_a.length !== _b.length) {
return false;
}
}
}
if (typeof a !== "object" || typeof b !== "object") {
return false;
}
if (
typeof a === "object" &&
typeof b === "object" &&
!Array.isArray(a) &&
!Array.isArray(b) &&
hasIterator(a) &&
hasIterator(b)
) {
return iterable(a, b);
}
// Used to detect circular references.
let length = aStack.length;
while (length--) {
if (aStack[length] === a) {
return bStack[length] === b;
} else if (bStack[length] === b) {
return false;
}
}
aStack.push(a);
bStack.push(b);
const aKeys = allKeysFromObject(a);
const bKeys = allKeysFromObject(b);
let keySize = aKeys.length;
//same number of keys
if (bKeys.length !== keySize) {
return false;
}
let keyIterator: string;
while (keySize--) {
const _a = a as Record<string, object>;
const _b = b as Record<string, object>;
keyIterator = aKeys[keySize];
const de = deepEquals(_a[keyIterator], _b[keyIterator], aStack, bStack);
if (!de) {
return false;
}
}
aStack.pop();
bStack.pop();
return true;
}
function allKeysFromObject(obj: object): Array<string> {
const keys = [];
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
keys.push(key);
}
}
return keys;
}
const IteratorSymbol = Symbol.iterator;
function hasIterator(object: any): boolean {
return !!(object != null && object[IteratorSymbol]);
}
function iterable(
a: unknown,
b: unknown,
aStack: Array<unknown> = [],
bStack: Array<unknown> = [],
): boolean {
if (a === null || b === null || b === undefined || a === undefined) {
return a === b;
}
if (a.constructor !== b.constructor) {
return false;
}
let length = aStack.length;
while (length--) {
if (aStack[length] === a) {
return bStack[length] === b;
}
}
aStack.push(a);
bStack.push(b);
const aIterator = (a as any)[IteratorSymbol]();
const bIterator = (b as any)[IteratorSymbol]();
const nextA = aIterator.next();
while (nextA.done) {
const nextB = bIterator.next();
if (nextB.done || !deepEquals(nextA.value, nextB.value)) {
return false;
}
}
if (!bIterator.next().done) {
return false;
}
// Remove the first value from the stack of traversed values.
aStack.pop();
bStack.pop();
return true;
}

View File

@ -0,0 +1,82 @@
/*
This file is part of GNU Taler
(C) 2021 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 { ComponentChildren, FunctionalComponent, h, VNode } from "preact";
import { MockEnvironment, Query } from "./mock.js";
import { SWRConfig } from "swr";
export { Query };
/**
* Helper for hook that use SWR inside.
*
* buildTestingContext() will return a testing context
*
*/
export class SwrMockEnvironment extends MockEnvironment {
constructor(debug = false) {
super(debug);
}
mockApiIfNeeded(): void {
null; // do nothing
}
public buildTestingContext(): FunctionalComponent<{
children: ComponentChildren;
}> {
const __REGISTER_REQUEST = this.registerRequest.bind(this);
return function TestingContext({
children,
}: {
children: ComponentChildren;
}): VNode {
return h(
SWRConfig,
{
value: {
fetcher: (url: string, options: object) => {
const mocked = __REGISTER_REQUEST(
{
method: "get",
url,
},
{},
);
if (!mocked) return undefined;
if (mocked.status > 400) {
const e: any = Error("simulated error for testing");
//example error handling from https://swr.vercel.app/docs/error-handling
e.status = mocked.status;
throw e;
}
return mocked.payload;
},
//These options are set for ending the test faster
//otherwise SWR will create timeouts that will live after the test finished
loadingTimeout: 0,
dedupingInterval: 0,
shouldRetryOnError: false,
errorRetryInterval: 0,
errorRetryCount: 0,
//clean cache for every test
provider: () => new Map(),
},
},
children,
);
};
}
}

View File

@ -0,0 +1,79 @@
/*
This file is part of GNU Taler
(C) 2021 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 axios, { AxiosPromise, AxiosRequestConfig } from "axios";
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
//FIXME: remove this, since it is not used anymore
/**
* @deprecated
*/
export let removeAxiosCancelToken = false;
export let axiosHandler = function doAxiosRequest(
config: AxiosRequestConfig,
): AxiosPromise<any> {
return axios(config);
};
const listOfHandlersToUseOnce = new Array<AxiosHandler>();
/**
* Set this backend library to testing mode.
* Instead of calling the axios library the @handler will be called
*
* @param handler callback that will mock axios
*/
export function setAxiosRequestAsTestingEnvironment(
handler: AxiosHandler,
): void {
removeAxiosCancelToken = true;
axiosHandler = function defaultTestingHandler(config) {
const currentHanlder = listOfHandlersToUseOnce.shift();
if (!currentHanlder) {
return handler(config);
}
return currentHanlder(config);
};
}
type AxiosHandler = (config: AxiosRequestConfig) => AxiosPromise<any>;
type AxiosArguments = { args: AxiosRequestConfig | undefined };
/**
* Replace Axios handler with a mock.
* Throw if is called more than once
*
* @param handler mock function
* @returns savedArgs
*/
export function mockAxiosOnce(handler: AxiosHandler): {
args: AxiosRequestConfig | undefined;
} {
const savedArgs: AxiosArguments = { args: undefined };
listOfHandlersToUseOnce.push(
(config: AxiosRequestConfig): AxiosPromise<any> => {
savedArgs.args = config;
return handler(config);
},
);
return savedArgs;
}

View File

@ -0,0 +1 @@
export * from "./axios.js";

File diff suppressed because it is too large Load Diff