diff options
Diffstat (limited to 'packages/anastasis-webui/src')
7 files changed, 278 insertions, 25 deletions
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 <http://www.gnu.org/licenses/> + */ + +/** + * + * @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 { </div> </div> </div> - - {/* {theCountry && <div class="field"> - <label class="label">Available currencies:</label> - <div class="control"> - <input class="input is-small" type="text" readonly value={theCountry.currency} /> - </div> - </div>} */} </div> <div class="column is-two-third"> <p> @@ -151,18 +144,11 @@ export function ContinentSelectionScreen(): VNode { }} > <p> - 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 <b>Testcontinent</b> with <b>Demoland</b>. For this special country, you will be asked for a simple number and not real, personal identifiable information. </p> - {/* - <p> - 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,{" "} - <a href="mailto:contact@anastasis.lu">contact us</a>. - </p> */} </div> </div> </div> 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 ( - <dd id={eId} key={r.name} data-selected={isSelected}> - <a - href={`#${eId}`} - onClick={(e) => { - e.preventDefault(); - location.hash = `#${eId}`; - onSelectStory(r, eId); - }} - > + <dd + id={eId} + key={r.name} + data-selected={isSelected} + onClick={doSelection} + > + <a href={`#${eId}`} onClick={doSelection}> {r.name} </a> </dd> 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 <http://www.gnu.org/licenses/> + */ + +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<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props> | (() => Partial<Props>), +): 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<Props, ContextProps>( + Component: FunctionalComponent<Props>, + props: Partial<Props> | (() => Partial<Props>), + ContextProvider: FunctionalComponent<ContextProps>, + contextProps: Partial<ContextProps>, +): 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<T> { + unmount: () => void; + getLastResultOrThrow: () => T; + assertNoPendingUpdate: () => void; + waitNextUpdate: (s?: string) => Promise<void>; +} + +const isNode = typeof window === "undefined"; + +export function mountHook<T>( + callback: () => T, + Context?: ({ children }: { children: any }) => VNode, +): Mounted<T> { + // 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<void> { + 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<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 = 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, }; |