moving testing sdk to web-utils
This commit is contained in:
parent
ae2df08abd
commit
e97c808b41
@ -78,13 +78,13 @@ const buildConfigNode = {
|
|||||||
|
|
||||||
const buildConfigBrowser = {
|
const buildConfigBrowser = {
|
||||||
...buildConfigBase,
|
...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: {
|
outExtension: {
|
||||||
'.js': '.mjs'
|
'.js': '.mjs'
|
||||||
},
|
},
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
platform: 'browser',
|
platform: 'browser',
|
||||||
external: ["preact", "@gnu-taler/taler-util", "jed"],
|
external: ["preact", "@gnu-taler/taler-util", "jed","swr","axios"],
|
||||||
jsxFactory: 'h',
|
jsxFactory: 'h',
|
||||||
jsxFragment: 'Fragment',
|
jsxFragment: 'Fragment',
|
||||||
};
|
};
|
||||||
|
@ -12,6 +12,8 @@
|
|||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": false,
|
"private": false,
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./lib/tests/swr": "./lib/tests/swr.mjs",
|
||||||
|
"./lib/tests/axios": "./lib/tests/axios.mjs",
|
||||||
"./lib/index.browser": "./lib/index.browser.mjs",
|
"./lib/index.browser": "./lib/index.browser.mjs",
|
||||||
"./lib/index.node": "./lib/index.node.cjs"
|
"./lib/index.node": "./lib/index.node.cjs"
|
||||||
},
|
},
|
||||||
@ -27,6 +29,7 @@
|
|||||||
"@types/node": "^18.11.9",
|
"@types/node": "^18.11.9",
|
||||||
"@types/web": "^0.0.82",
|
"@types/web": "^0.0.82",
|
||||||
"@types/ws": "^8.5.3",
|
"@types/ws": "^8.5.3",
|
||||||
|
"axios": "^1.2.1",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"esbuild": "^0.14.21",
|
"esbuild": "^0.14.21",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@ -34,6 +37,7 @@
|
|||||||
"preact-render-to-string": "^5.2.6",
|
"preact-render-to-string": "^5.2.6",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
|
"swr": "1.3.0",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"typescript": "^4.8.4",
|
"typescript": "^4.8.4",
|
||||||
"ws": "7.4.5"
|
"ws": "7.4.5"
|
||||||
|
@ -1,2 +1 @@
|
|||||||
|
|
||||||
export * as utils from "./utils.js";
|
export * as utils from "./utils.js";
|
||||||
|
@ -1,2 +1,5 @@
|
|||||||
|
export {
|
||||||
export { InternationalizationAPI, TranslationProvider, useTranslationContext } from "./translation.js";
|
InternationalizationAPI,
|
||||||
|
TranslationProvider,
|
||||||
|
useTranslationContext,
|
||||||
|
} from "./translation.js";
|
||||||
|
@ -19,7 +19,7 @@ import { ComponentChildren, createContext, h, VNode } from "preact";
|
|||||||
import { useContext, useEffect } from "preact/hooks";
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
import { useLang } from "../hooks/index.js";
|
import { useLang } from "../hooks/index.js";
|
||||||
|
|
||||||
export type InternationalizationAPI = typeof i18n
|
export type InternationalizationAPI = typeof i18n;
|
||||||
|
|
||||||
interface Type {
|
interface Type {
|
||||||
lang: string;
|
lang: string;
|
||||||
@ -54,7 +54,7 @@ interface Props {
|
|||||||
initial?: string;
|
initial?: string;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
forceLang?: string;
|
forceLang?: string;
|
||||||
source: Record<string, any>
|
source: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outmost UI wrapper.
|
// Outmost UI wrapper.
|
||||||
@ -62,7 +62,7 @@ export const TranslationProvider = ({
|
|||||||
initial,
|
initial,
|
||||||
children,
|
children,
|
||||||
forceLang,
|
forceLang,
|
||||||
source
|
source,
|
||||||
}: Props): VNode => {
|
}: Props): VNode => {
|
||||||
const [lang, changeLanguage, isSaved] = useLang(initial);
|
const [lang, changeLanguage, isSaved] = useLang(initial);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
|
|
||||||
export { useLang } from "./useLang.js";
|
export { useLang } from "./useLang.js";
|
||||||
export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js"
|
export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js";
|
||||||
export { useAsyncAsHook, HookError, HookOk, HookResponse, HookResponseWithRetry, HookGenericError, HookOperationalError } from "./useAsyncAsHook.js"
|
export {
|
||||||
|
useAsyncAsHook,
|
||||||
|
HookError,
|
||||||
|
HookOk,
|
||||||
|
HookResponse,
|
||||||
|
HookResponseWithRetry,
|
||||||
|
HookGenericError,
|
||||||
|
HookOperationalError,
|
||||||
|
} from "./useAsyncAsHook.js";
|
||||||
|
@ -35,13 +35,13 @@ export function useLocalStorage(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = buildListenerForKey(key, (newValue) => {
|
const listener = buildListenerForKey(key, (newValue) => {
|
||||||
setStoredValue(newValue ?? initialValue)
|
setStoredValue(newValue ?? initialValue);
|
||||||
})
|
});
|
||||||
window.addEventListener('storage', listener)
|
window.addEventListener("storage", listener);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('storage', listener)
|
window.removeEventListener("storage", listener);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const setValue = (
|
const setValue = (
|
||||||
value?: string | ((val?: string) => string | undefined),
|
value?: string | ((val?: string) => string | undefined),
|
||||||
@ -62,11 +62,14 @@ export function useLocalStorage(
|
|||||||
return [storedValue, setValue];
|
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() {
|
return function listenKeyChange() {
|
||||||
const value = window.localStorage.getItem(key)
|
const value = window.localStorage.getItem(key);
|
||||||
onUpdate(value ?? undefined)
|
onUpdate(value ?? undefined);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: merge with the above function
|
//TODO: merge with the above function
|
||||||
@ -80,16 +83,15 @@ export function useNotNullLocalStorage(
|
|||||||
: initialValue;
|
: initialValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = buildListenerForKey(key, (newValue) => {
|
const listener = buildListenerForKey(key, (newValue) => {
|
||||||
setStoredValue(newValue ?? initialValue)
|
setStoredValue(newValue ?? initialValue);
|
||||||
})
|
});
|
||||||
window.addEventListener('storage', listener)
|
window.addEventListener("storage", listener);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('storage', listener)
|
window.removeEventListener("storage", listener);
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const setValue = (value: string | ((val: string) => string)): void => {
|
const setValue = (value: string | ((val: string) => string)): void => {
|
||||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export * from "./hooks/index.js";
|
export * from "./hooks/index.js";
|
||||||
export * from "./context/index.js";
|
export * from "./context/index.js";
|
||||||
export * from "./components/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";
|
export { renderStories, parseGroupImport } from "./stories.js";
|
||||||
|
@ -15,24 +15,24 @@ function setupLiveReload(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === "file-updated-failed") {
|
if (event.type === "file-updated-failed") {
|
||||||
const h1 = document.getElementById("overlay-text")
|
const h1 = document.getElementById("overlay-text");
|
||||||
if (h1) {
|
if (h1) {
|
||||||
h1.innerHTML = "compilation failed"
|
h1.innerHTML = "compilation failed";
|
||||||
h1.style.color = 'red'
|
h1.style.color = "red";
|
||||||
h1.style.margin = ''
|
h1.style.margin = "";
|
||||||
}
|
}
|
||||||
const div = document.getElementById("overlay")
|
const div = document.getElementById("overlay");
|
||||||
if (div) {
|
if (div) {
|
||||||
const content = JSON.stringify(event.data, undefined, 2)
|
const content = JSON.stringify(event.data, undefined, 2);
|
||||||
const pre = document.createElement("pre");
|
const pre = document.createElement("pre");
|
||||||
pre.id = "error-text"
|
pre.id = "error-text";
|
||||||
pre.style.margin = "";
|
pre.style.margin = "";
|
||||||
pre.textContent = content;
|
pre.textContent = content;
|
||||||
div.style.backgroundColor = "rgba(0,0,0,0.8)";
|
div.style.backgroundColor = "rgba(0,0,0,0.8)";
|
||||||
div.style.flexDirection = 'column'
|
div.style.flexDirection = "column";
|
||||||
div.appendChild(pre);
|
div.appendChild(pre);
|
||||||
}
|
}
|
||||||
console.error(event.data.error)
|
console.error(event.data.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === "file-updated") {
|
if (event.type === "file-updated") {
|
||||||
@ -56,17 +56,17 @@ setupLiveReload();
|
|||||||
|
|
||||||
function showReloadOverlay(): void {
|
function showReloadOverlay(): void {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.id = "overlay"
|
d.id = "overlay";
|
||||||
d.style.position = "absolute";
|
d.style.position = "absolute";
|
||||||
d.style.width = "100%";
|
d.style.width = "100%";
|
||||||
d.style.height = "100%";
|
d.style.height = "100%";
|
||||||
d.style.color = "white";
|
d.style.color = "white";
|
||||||
d.style.backgroundColor = "rgba(0,0,0,0.5)";
|
d.style.backgroundColor = "rgba(0,0,0,0.5)";
|
||||||
d.style.display = "flex";
|
d.style.display = "flex";
|
||||||
d.style.zIndex = String(Number.MAX_SAFE_INTEGER)
|
d.style.zIndex = String(Number.MAX_SAFE_INTEGER);
|
||||||
d.style.justifyContent = "center";
|
d.style.justifyContent = "center";
|
||||||
const h = document.createElement("h1");
|
const h = document.createElement("h1");
|
||||||
h.id = "overlay-text"
|
h.id = "overlay-text";
|
||||||
h.style.margin = "auto";
|
h.style.margin = "auto";
|
||||||
h.innerHTML = "reloading...";
|
h.innerHTML = "reloading...";
|
||||||
d.appendChild(h);
|
d.appendChild(h);
|
||||||
|
@ -77,23 +77,26 @@ export async function serve(opts: {
|
|||||||
|
|
||||||
if (opts.onUpdate) {
|
if (opts.onUpdate) {
|
||||||
sendToAllClients({ type: "file-updated-start", data: { path } });
|
sendToAllClients({ type: "file-updated-start", data: { path } });
|
||||||
opts.onUpdate().then((result) => {
|
opts
|
||||||
sendToAllClients({
|
.onUpdate()
|
||||||
type: "file-updated-done",
|
.then((result) => {
|
||||||
data: { path, 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 {
|
} else {
|
||||||
sendToAllClients({ type: "file-change", data: { path } });
|
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) {
|
app.get(PATHS.EXAMPLE, function (req: any, res: any) {
|
||||||
res.set("Content-Type", "text/html");
|
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