Added frontend tests

This commit is contained in:
Sebastian Seedorf
2020-11-17 00:06:10 +01:00
parent 37c34f99ac
commit b79c2f96cd
16 changed files with 2712 additions and 84 deletions

2436
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,14 @@
"debug-client": "tsc-watch --project ./public/js-source",
"debug-server": "tsc-watch --project . --onSuccess \"node --enable-source-maps --use-openssl-ca --unhandled-rejections=strict ./out/index\"",
"debug": "concurrently npm:debug-*",
"test": "nyc mocha -r ts-node/register ./public/js-source/test/**/*.ts",
"build": "tsc",
"production": "node --use-openssl-ca --unhandled-rejections=strict ./out/index",
"install-debug": "npm install && npm run build",
"install-prod": "npm install --only=dev && npm install --only=prod && npm run build && rm -r node_modules && npm install --only=prod",
"install-prod-win": "npm install --only=dev && npm install --only=prod && npm run build && rmdir /Q/S node_modules && npm install --only=prod"
"install-prod": "npm install --only=dev && npm install --only=prod && npm run build && npm run del-node-module && npm install --only=prod",
"del-node-module:default": "rm -r node_modules",
"del-node-module:windows": "if exist node_modules rmdir /Q/S node_modules",
"del-node-module": "run-script-os"
},
"dependencies": {
"compression": "^1.7.4",
@@ -25,18 +28,27 @@
"role-acl": "^4.5.4"
},
"devDependencies": {
"@types/chai": "^4.2.14",
"@types/compression": "^1.7.0",
"@types/express": "^4.17.8",
"@types/http-errors": "^1.8.0",
"@types/mocha": "^8.0.4",
"@types/node": "^14.14.7",
"@types/node-sass-middleware": "0.0.31",
"@typescript-eslint/eslint-plugin": "^4.7.0",
"@typescript-eslint/parser": "^4.7.0",
"chai": "^4.2.0",
"concurrently": "^5.3.0",
"eslint": "^7.13.0",
"eslint-plugin-no-null": "^1.0.2",
"eslint-plugin-promise": "^4.2.1",
"fetch-mock": "^9.10.7",
"jsdom": "^16.4.0",
"mocha": "^8.2.1",
"node-watch": "^0.7.0",
"nyc": "^15.1.0",
"rewire": "^5.0.0",
"ts-node": "^9.0.0",
"tsc-watch": "^4.2.9",
"typescript": "^4.0.5"
}

View File

@@ -3,8 +3,10 @@ export {setConfig} from './utils/utils';
import {getUserName} from './SomeModule';
getUserName().then((name) => {
function updateUserName(name: string): void {
const node = document.createElement('span');
node.innerText = `This user name is fetched with Javascript: ${name}`;
document.getElementsByTagName("body")[0].appendChild(node);
});
}
getUserName().then(updateUserName);

View File

@@ -5,21 +5,27 @@ enum ResolvableState {
DONE
}
class FetchOnce<T> {
class FetchOnce<T, U extends Array<unknown>> {
protected data: T|undefined;
protected error: unknown|undefined;
protected state: ResolvableState = ResolvableState.WAITING;
protected pendings: [(res: Promise<T>|T) => void, (reason: unknown) => void][] = [];
constructor(protected fetchMethod?: () => Promise<T>) { }
constructor(protected fetchMethod?: (...args: U) => Promise<T>) { }
public resolve(): Promise<T> {
public reset(): void {
this.data = undefined;
this.error = undefined;
this.state = ResolvableState.WAITING;
}
public resolve(...args: U): Promise<T> {
return new Promise((resolve, reject) => {
switch (this.state) {
case ResolvableState.WAITING:
this.state = ResolvableState.PENDING;
this.pendings.push([resolve, reject]);
if (this.fetchMethod) this.parsePromise(this.fetchMethod());
if (this.fetchMethod) this.parsePromise(this.fetchMethod(...args));
break;
case ResolvableState.PENDING:
this.pendings.push([resolve, reject]);
@@ -51,13 +57,13 @@ class FetchOnce<T> {
}
}
export class Resolvable<T> extends FetchOnce<T> {
constructor(fetchMethod: () => Promise<T>) {
export class Resolvable<T, U extends Array<unknown>> extends FetchOnce<T, U> {
constructor(fetchMethod: (...args: U) => Promise<T>) {
super(fetchMethod);
}
}
export class WaitForSync<T> extends FetchOnce<T> {
export class WaitForSync<T> extends FetchOnce<T, never> {
protected state: ResolvableState = ResolvableState.PENDING;
constructor() {
@@ -75,4 +81,9 @@ export class WaitForSync<T> extends FetchOnce<T> {
this.parsePromise((async () => { throw error; })());
}
}
public reset(): void {
super.reset();
this.state = ResolvableState.PENDING;
}
}

View File

@@ -16,8 +16,23 @@ export function getConfig(): Promise<ClientConfig> {
return configWaiter.resolve();
}
export async function getUserInfo(): Promise<UserInfo|undefined> {
export function resetConfig(): void {
return configWaiter.reset();
}
export async function getUserInfo(): Promise<Partial<UserInfo>|undefined> {
const config = await getConfig();
const res = await fetch(config.EXTERNAL_BASE_URL + "/api/user");
return res.json();
}
export type UserInfo = {
email: string,
email_verified: boolean,
family_name: string,
given_name: string,
groups: string[],
name: string,
preferred_username: string,
sub: string,
};

View File

@@ -0,0 +1,57 @@
import {describe, it} from 'mocha';
import {expect} from 'chai';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as fetchMock from 'fetch-mock';
import {getUserName} from '../src/SomeModule';
import {setConfig, UserInfo} from '../src/utils/utils';
describe('frontend:SomeModule', () => {
const CONFIG = {EXTERNAL_BASE_URL: "http://demo.url"};
before(() => {
setConfig(CONFIG);
fetchMock.config.overwriteRoutes = true;
});
it('should return username', async () => {
const tests: [Partial<UserInfo>, string][] = [
[
{name: "John Doe"},
"John Doe",
], [
{name: "John Doe", email: "some.mail@example.com"},
"John Doe",
], [
{name: "", email: "some.mail@example.com"},
"",
],
];
for (const [USER_INFO, RESULT] of tests) {
fetchMock.mock('http://demo.url/api/user', {
status: 200,
body: JSON.stringify(USER_INFO),
});
const name = await getUserName();
expect(name).to.deep.equal(RESULT);
}
});
it('should return default string', async () => {
const RESULT = "No name found!";
const tests: (Partial<UserInfo>|unknown)[] = [
// eslint-disable-next-line no-null/no-null
{name: null},
// eslint-disable-next-line no-null/no-null
null,
{email: "some.mail@example.com"},
{name: undefined},
{},
];
for (const USER_INFO of tests) {
fetchMock.mock('http://demo.url/api/user', {
status: 200,
body: JSON.stringify(USER_INFO),
});
const name = await getUserName();
expect(name).to.deep.equal(RESULT);
}
});
});

View File

@@ -0,0 +1,43 @@
import {describe, it} from 'mocha';
import {expect} from 'chai';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import {JSDOM} from 'jsdom';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as rewire from 'rewire';
describe("frontend:index", () => {
const updateUserName = rewire("../src/index").__get__("updateUserName") as (name: string) => void;
beforeEach(() => {
const dom = new JSDOM(
`
<html>
<body>
</body>
</html>
`,
{url: 'http://localhost'},
);
// noinspection JSConstantReassignment
global.window = dom.window;
// noinspection JSConstantReassignment
global.document = dom.window.document;
});
it("updateUserName", (done) => {
const NAME = "Patrick Star";
const RESULT = "This user name is fetched with Javascript: Patrick Star";
updateUserName(NAME);
// give the browser a chance to update the DOM
setTimeout(() => {
const span = document.getElementsByTagName("span");
expect(span).to.not.be.null;
expect(span.length).to.be.greaterThan(0);
expect(span[0].innerText).to.deep.equal(RESULT);
done();
}, 5);
});
});

View File

@@ -0,0 +1,123 @@
import {setConfig, getConfig, resetConfig, getUserInfo, UserInfo} from '../src/utils/utils';
import {Resolvable, WaitForSync} from '../src/utils/resolvable';
import {describe, it} from 'mocha';
import {expect} from 'chai';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as fetchMock from 'fetch-mock';
describe('frontend:utils - setConfig/getConfig', () => {
const CONFIG = {EXTERNAL_BASE_URL: "http://demo.url"};
afterEach(() => resetConfig());
it('should return config (afterwards)', async () => {
setConfig(CONFIG);
const config = await getConfig();
expect(config).to.deep.equal(CONFIG);
});
it('should return config (beforehand)', (done) => {
getConfig().then((config) => {
expect(config).to.deep.equal(CONFIG);
done();
});
setConfig(CONFIG);
});
});
describe('frontend:utils - getUserInfo', () => {
const CONFIG = {EXTERNAL_BASE_URL: "http://demo.url"};
const USER_INFO: Partial<UserInfo> = {
name: "John Doe",
};
beforeEach(() => setConfig(CONFIG));
afterEach(() => resetConfig());
it('should equal fetched', async () => {
setConfig(CONFIG);
fetchMock.mock('http://demo.url/api/user', {
status: 200,
body: JSON.stringify(USER_INFO),
});
const userInfo = await getUserInfo();
expect(userInfo).to.deep.equal(USER_INFO);
});
});
describe('frontend:utils - resolvable', () => {
const DATA = 5;
const ERROR = new Error("Custom error!");
it('waitForSync should return data (afterwards)', async () => {
const resolvable = new WaitForSync<number>();
resolvable.setData(DATA);
const data = await resolvable.resolve();
expect(data).to.deep.equal(DATA);
});
it('waitForSync should return data (beforehand)', (done) => {
const resolvable = new WaitForSync<number>();
resolvable.resolve().then((data) => {
expect(data).to.deep.equal(DATA);
done();
});
resolvable.setData(DATA);
});
it('waitForSync should error (afterwards)', async () => {
const resolvable = new WaitForSync<number>();
resolvable.setError(ERROR);
try {
await resolvable.resolve();
} catch (err) {
expect(err).to.deep.equal(ERROR);
}
});
it('waitForSync should error (beforehand)', (done) => {
const resolvable = new WaitForSync<number>();
resolvable.resolve().catch((err) => {
expect(err).to.deep.equal(ERROR);
done();
});
resolvable.setError(ERROR);
});
it('waitForSync should resolve data twice', async () => {
const resolvable = new WaitForSync<number>();
resolvable.setData(DATA);
const data1 = await resolvable.resolve();
const data2 = await resolvable.resolve();
expect(data1).to.deep.equal(DATA);
expect(data2).to.deep.equal(DATA);
});
it('waitForSync should error twice', async () => {
const resolvable = new WaitForSync<number>();
resolvable.setError(ERROR);
try {
await resolvable.resolve();
} catch (err) {
expect(err).to.deep.equal(ERROR);
}
try {
await resolvable.resolve();
} catch (err) {
expect(err).to.deep.equal(ERROR);
}
});
it('waitForSync should wait for resolution twice', (done) => {
const resolvable = new WaitForSync<number>();
Promise.all([
resolvable.resolve().then((data) => {
expect(data).to.deep.equal(DATA);
}), resolvable.resolve().then((data) => {
expect(data).to.deep.equal(DATA);
}),
]).then(() => done());
resolvable.setData(DATA);
});
it('resolvable should resolve', async () => {
const resolvable = new Resolvable(async () => DATA);
const data = await resolvable.resolve();
expect(data).to.deep.equal(DATA);
});
});

View File

@@ -1,10 +0,0 @@
type UserInfo = {
email: string,
email_verified: boolean,
family_name: string,
given_name: string,
groups: string[],
name: string,
preferred_username: string,
sub: string,
};

View File

@@ -14,7 +14,7 @@ declare global {
}
interface Request {
getUserInfo(): Promise<UserInfo|undefined>;
getUserInfo(): Promise<Partial<UserInfo>|undefined>;
noLogging: boolean|undefined;
permissionDetails?: import('role-acl').Permission;
}

View File

@@ -1,9 +0,0 @@
declare namespace Express {
interface Request {
getUserInfo(): Promise<import('pkg-express-utils').UserInfo|undefined>;
}
interface Response {
initLogout(): boolean;
}
}

View File

@@ -1,5 +0,0 @@
declare module "json-prune" {
function prune(object: unknown): string;
export = prune;
}

5
types/logging.d.ts vendored
View File

@@ -1,5 +0,0 @@
declare namespace Express {
interface Request {
noLogging: boolean|undefined;
}
}

View File

@@ -1,5 +0,0 @@
declare namespace Express {
interface Request {
permissionDetails?: import('role-acl').Permission;
}
}

37
types/polyfill.d.ts vendored
View File

@@ -1,37 +0,0 @@
declare module "polyfill-library" {
import {Readable} from 'stream';
function listAllPolyfills(): string[];
function describePolyfill(featureName: string): Promise<PolyfillMetadata|undefined>;
function getOptions(opts: Partial<PolyfillOptions>): PolyfillOptions;
function getPolyfills(opts: Partial<PolyfillOptions>): Promise<PolyfillFeatures>;
function getPolyfillString(opts: Partial<PolyfillOptions>&{stream?: true}): Readable;
function getPolyfillString(opts: Partial<PolyfillOptions>&{stream: false}): Promise<string>;
type PolyfillMetadata = {
};
type PolyfillFeatureList = {
[featureName: string]: {
flags?: string[]
}
};
type PolyfillOptions = {
minify: boolean,
unknown: 'polyfill'|'ignore',
features: PolyfillFeatureList,
excludes: string[],
uaString: string,
rum: boolean,
};
type PolyfillFeature = {
flags: string[],
dependencyOf: string[],
aliasOf: string[],
}
type PolyfillFeatures = { [featureName: string]: PolyfillFeature };
}