moving testing sdk to web-utils
This commit is contained in:
parent
ae2df08abd
commit
e97c808b41
@ -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',
|
||||
};
|
||||
|
@ -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"
|
||||
|
@ -1,2 +1 @@
|
||||
|
||||
export * as utils from "./utils.js";
|
||||
|
@ -1,2 +1,5 @@
|
||||
|
||||
export { InternationalizationAPI, TranslationProvider, useTranslationContext } from "./translation.js";
|
||||
export {
|
||||
InternationalizationAPI,
|
||||
TranslationProvider,
|
||||
useTranslationContext,
|
||||
} from "./translation.js";
|
||||
|
@ -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(() => {
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
|
@ -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";
|
||||
|
@ -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);
|
||||
|
@ -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");
|
||||
|
@ -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() }
|
136
packages/web-util/src/tests/axios.ts
Normal file
136
packages/web-util/src/tests/axios.ts
Normal 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];
|
||||
}
|
||||
}
|
310
packages/web-util/src/tests/hook.ts
Normal file
310
packages/web-util/src/tests/hook.ts
Normal 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",
|
||||
};
|
||||
}
|
2
packages/web-util/src/tests/index.ts
Normal file
2
packages/web-util/src/tests/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./hook.js";
|
||||
// export * from "./axios.js"
|
458
packages/web-util/src/tests/mock.ts
Normal file
458
packages/web-util/src/tests/mock.ts
Normal 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;
|
||||
}
|
82
packages/web-util/src/tests/swr.ts
Normal file
82
packages/web-util/src/tests/swr.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
79
packages/web-util/src/utils/axios.ts
Normal file
79
packages/web-util/src/utils/axios.ts
Normal 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;
|
||||
}
|
1
packages/web-util/src/utils/index.ts
Normal file
1
packages/web-util/src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./axios.js";
|
1014
pnpm-lock.yaml
1014
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user