From b419db505b8cd5e7aa92043696f42a0d710d9226 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Jun 2022 15:18:41 -0300 Subject: ui testing --- packages/anastasis-webui/src/main.test.ts | 48 +++++ .../src/pages/home/AddingProviderScreen.tsx | 2 +- .../src/pages/home/ContinentSelectionScreen.tsx | 16 +- .../pages/home/authMethod/AuthMethodTotpSetup.tsx | 1 + packages/anastasis-webui/src/stories.tsx | 21 ++- packages/anastasis-webui/src/test-utils.ts | 201 +++++++++++++++++++++ packages/anastasis-webui/src/utils/index.tsx | 14 ++ 7 files changed, 278 insertions(+), 25 deletions(-) create mode 100644 packages/anastasis-webui/src/main.test.ts create mode 100644 packages/anastasis-webui/src/test-utils.ts (limited to 'packages/anastasis-webui/src') diff --git a/packages/anastasis-webui/src/main.test.ts b/packages/anastasis-webui/src/main.test.ts new file mode 100644 index 000000000..e65cca454 --- /dev/null +++ b/packages/anastasis-webui/src/main.test.ts @@ -0,0 +1,48 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { setupI18n } from "@gnu-taler/taler-util"; +import { renderNodeOrBrowser } from "./test-utils.js"; +import * as pages from "./pages/home/index.storiesNo.js"; + +setupI18n("en", { en: {} }); + +function testThisStory(st: any): any { + describe(`render examples for ${(st as any).default.title}`, () => { + Object.keys(st).forEach((k) => { + const Component = (st as any)[k]; + if (k === "default" || !Component) return; + + it(`example: ${k}`, () => { + renderNodeOrBrowser(Component, Component.args); + }); + }); + }); +} + +describe("render every storybook example", () => { + [pages].forEach(function testAll(st: any) { + if (Array.isArray(st.default)) { + st.default.forEach(testAll); + } else { + testThisStory(st); + } + }); +}); diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx index feeac274d..d4675f9da 100644 --- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx @@ -223,7 +223,7 @@ function TableRow({ onDelete: (s: string) => void; url: string; info: AuthenticationProviderStatusOk; -}) { +}): VNode { const [status, setStatus] = useState("checking"); useEffect(function () { testProvider(url.endsWith("/") ? url.substring(0, url.length - 1) : url) diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx index 43d865b48..534f9136d 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -125,13 +125,6 @@ export function ContinentSelectionScreen(): VNode { - - {/* {theCountry &&
- -
- -
-
} */}

@@ -151,18 +144,11 @@ export function ContinentSelectionScreen(): VNode { }} >

- If you just want to try out Anastasis, we recomment that you + If you just want to try out Anastasis, we recommend that you choose Testcontinent with Demoland. For this special country, you will be asked for a simple number and not real, personal identifiable information.

- {/* -

- Because of the diversity of personally identifying information in - different countries and cultures, we do not support all countries - yet. If you want to improve the supported countries,{" "} - contact us. -

*/}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx index f9b292d94..0285c87e1 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx @@ -36,6 +36,7 @@ export function AuthMethodTotpSetup({ const [test, setTest] = useState(""); const secretKey = useMemo(() => { const array = new Uint8Array(32); + if (typeof window === "undefined") return array; return window.crypto.getRandomValues(array); }, []); diff --git a/packages/anastasis-webui/src/stories.tsx b/packages/anastasis-webui/src/stories.tsx index a51dfb20f..351d6f37b 100644 --- a/packages/anastasis-webui/src/stories.tsx +++ b/packages/anastasis-webui/src/stories.tsx @@ -143,21 +143,24 @@ function ExampleList({ {k.examples.map((r) => { const e = encodeURIComponent; const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; + function doSelection(e: any): void { + e.preventDefault(); + location.hash = `#${eId}`; + onSelectStory(r, eId); + } const isSelected = selected && selected.component === r.component && selected.group === r.group && selected.name === r.name; return ( -
- { - e.preventDefault(); - location.hash = `#${eId}`; - onSelectStory(r, eId); - }} - > +
+ {r.name}
diff --git a/packages/anastasis-webui/src/test-utils.ts b/packages/anastasis-webui/src/test-utils.ts new file mode 100644 index 000000000..1fcc753ee --- /dev/null +++ b/packages/anastasis-webui/src/test-utils.ts @@ -0,0 +1,201 @@ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see + */ + +import { + ComponentChildren, + Fragment, + FunctionalComponent, + h as create, + options, + render as renderIntoDom, + VNode, +} from "preact"; +import { render as renderToString } from "preact-render-to-string"; + +// 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) => { + // console.log("RAF called") + return fn(); +}; + +export function createExample( + Component: FunctionalComponent, + props: Partial | (() => Partial), +): ComponentChildren { + //FIXME: props are evaluated on build time + // in some cases we want to evaluated the props on render time so we can get some relative timestamp + // check how we can build evaluatedProps in render time + const evaluatedProps = typeof props === "function" ? props() : props; + const Render = (args: any): VNode => create(Component, args); + Render.args = evaluatedProps; + return Render; +} + +export function createExampleWithCustomContext( + Component: FunctionalComponent, + props: Partial | (() => Partial), + ContextProvider: FunctionalComponent, + contextProps: Partial, +): ComponentChildren { + 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); + WithContext.args = evaluatedProps; + return WithContext; +} + +export function NullLink({ + children, +}: { + children?: ComponentChildren; +}): VNode { + return create("a", { children, href: "javascript:void(0);" }); +} + +export function renderNodeOrBrowser(Component: any, args: any): void { + const vdom = create(Component, args); + if (typeof window === "undefined") { + renderToString(vdom); + } else { + const div = document.createElement("div"); + document.body.appendChild(div); + renderIntoDom(vdom, div); + renderIntoDom(null, div); + document.body.removeChild(div); + } +} + +interface Mounted { + unmount: () => void; + getLastResultOrThrow: () => T; + assertNoPendingUpdate: () => void; + waitNextUpdate: (s?: string) => Promise; +} + +const isNode = typeof window === "undefined"; + +export function mountHook( + callback: () => T, + Context?: ({ children }: { children: any }) => VNode, +): Mounted { + // const result: { current: T | null } = { + // current: null + // } + let lastResult: T | Error | null = null; + + const listener: Array<() => void> = []; + + // component that's going to hold the hook + function Component(): VNode { + try { + lastResult = callback(); + } 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, {})] }); + + // waiter callback + async function waitNextUpdate(_label = ""): Promise { + if (_label) _label = `. label: "${_label}"`; + await new Promise((res, rej) => { + const tid = setTimeout(() => { + rej( + Error(`waiting for an update but the hook didn't make one${_label}`), + ); + }, 100); + + listener.push(() => { + clearTimeout(tid); + res(undefined); + }); + }); + } + + 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 getLastResult(): T | Error | null { + const copy = lastResult; + lastResult = null; + return copy; + } + + function getLastResultOrThrow(): T { + const r = getLastResult(); + if (r instanceof Error) throw r; + if (!r) throw Error("there was no last result"); + return r; + } + + async function assertNoPendingUpdate(): Promise { + 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 = getLastResult(); + 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 getLastResult`); + } + return { + unmount, + getLastResultOrThrow, + waitNextUpdate, + assertNoPendingUpdate, + }; +} diff --git a/packages/anastasis-webui/src/utils/index.tsx b/packages/anastasis-webui/src/utils/index.tsx index ce21071f7..204c48d18 100644 --- a/packages/anastasis-webui/src/utils/index.tsx +++ b/packages/anastasis-webui/src/utils/index.tsx @@ -209,10 +209,12 @@ export const reducerStatesExample = { initial: undefined, recoverySelectCountry: { ...base, + reducer_type: "recovery", recovery_state: RecoveryStates.CountrySelecting, } as ReducerState, recoverySelectContinent: { ...base, + reducer_type: "recovery", recovery_state: RecoveryStates.ContinentSelecting, } as ReducerState, secretSelection: { @@ -222,10 +224,12 @@ export const reducerStatesExample = { } as ReducerState, recoveryFinished: { ...base, + reducer_type: "recovery", recovery_state: RecoveryStates.RecoveryFinished, } as ReducerState, challengeSelecting: { ...base, + reducer_type: "recovery", recovery_state: RecoveryStates.ChallengeSelecting, } as ReducerState, challengeSolving: { @@ -235,34 +239,42 @@ export const reducerStatesExample = { } as ReducerStateRecovery, challengePaying: { ...base, + reducer_type: "recovery", recovery_state: RecoveryStates.ChallengePaying, } as ReducerState, recoveryAttributeEditing: { ...base, + reducer_type: "recovery", recovery_state: RecoveryStates.UserAttributesCollecting, } as ReducerState, backupSelectCountry: { ...base, + reducer_type: "backup", backup_state: BackupStates.CountrySelecting, } as ReducerState, backupSelectContinent: { ...base, + reducer_type: "backup", backup_state: BackupStates.ContinentSelecting, } as ReducerState, secretEdition: { ...base, + reducer_type: "backup", backup_state: BackupStates.SecretEditing, } as ReducerState, policyReview: { ...base, + reducer_type: "backup", backup_state: BackupStates.PoliciesReviewing, } as ReducerState, policyPay: { ...base, + reducer_type: "backup", backup_state: BackupStates.PoliciesPaying, } as ReducerState, backupFinished: { ...base, + reducer_type: "backup", backup_state: BackupStates.BackupFinished, } as ReducerState, authEditing: { @@ -272,10 +284,12 @@ export const reducerStatesExample = { } as ReducerState, backupAttributeEditing: { ...base, + reducer_type: "backup", backup_state: BackupStates.UserAttributesCollecting, } as ReducerState, truthsPaying: { ...base, + reducer_type: "backup", backup_state: BackupStates.TruthsPaying, } as ReducerState, }; -- cgit v1.2.3