feature(jest) writing tests for express endpoints (#27)

This commit is contained in:
AtHeartEngineer
2023-08-04 09:53:56 -04:00
committed by GitHub
16 changed files with 3603 additions and 434 deletions

9
jest.config.cjs Normal file
View File

@@ -0,0 +1,9 @@
module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
"transform": {
"^.+\\.jsx?$": "babel-jest",
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "./tsconfig.tests.json" }]
},
}

3541
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"watch": "rollup --config rollup.config.mjs --watch",
"serve": "nodemon -q dist/server.jcs",
"dev": "concurrently \"npm run watch\" \"npm run serve\"",
"test": "jest --verbose",
"lint": "eslint ."
},
"engines": {
@@ -24,7 +25,6 @@
"dependencies": {
"@faker-js/faker": "^8.0.2",
"@prisma/client": "^5.0.0",
"atob": "^2.1.2",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"discreetly-claimcodes": "^1.1.3",
@@ -35,29 +35,32 @@
"helmet": "^7.0.0",
"mongodb": "^5.7.0",
"poseidon-lite": "^0.2.0",
"prisma-cache-middleware": "^0.1.4",
"prisma-redis-middleware": "4.8.0",
"redis": "^4.6.7",
"rlnjs": "^3.1.4",
"socket.io": "^4.6.2"
},
"devDependencies": {
"@jest/globals": "^29.6.2",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.3",
"@types/node": "^20.4.5",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"concurrently": "^8.2.0",
"eslint": "^8.46.0",
"jest": "^29.6.2",
"nodemon": "^3.0.1",
"prisma": "^5.0.0",
"prisma": "^5.1.1",
"rollup": "^3.26.2",
"rollup-plugin-cleaner": "^1.0.0",
"rollup-plugin-include-sourcemaps": "^0.7.0",
"rollup-plugin-typescript2": "^0.35.0",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
}

View File

@@ -55,7 +55,7 @@ model Messages {
model Epoch {
id String @id @default(auto()) @map("_id") @db.ObjectId
epoch Int
epoch String
messages Messages[]
rooms Rooms? @relation(fields: [roomsId], references: [id])
roomsId String? @db.ObjectId

View File

@@ -1,15 +1,15 @@
import type { ServerI } from 'discreetly-interfaces';
import 'dotenv/config';
let SERVER_ID: bigint;
let NAME: string;
let SERVER_ID = 0n;
let NAME = 'undefined';
try {
SERVER_ID = process.env.SERVER_ID ? (process.env.SERVER_ID as unknown as bigint) : 0n;
console.log('SERVERID:', SERVER_ID);
} catch (error) {
console.error('Error reading serverID from .env file!');
}
console.log('SERVERID:', SERVER_ID);
try {
NAME = process.env.SERVER_NAME ? process.env.SERVER_NAME : 'localhost';

View File

@@ -1,13 +1,17 @@
import type { MessageI, RoomI } from 'discreetly-interfaces';
import { str2BigInt } from 'discreetly-interfaces';
import { RLNVerifier } from 'rlnjs';
import vkey from './verification_key.js';
import vkey from './verification_key';
import { Group } from '@semaphore-protocol/group';
const v = new RLNVerifier(vkey);
async function verifyProof(msg: MessageI, room: RoomI, epochErrorRange = 5): Promise<boolean> {
console.log('check room', room);
if (!msg.roomId || !msg.message || !msg.proof || !msg.epoch) {
console.warn('Missing required fields:', msg);
return false;
}
console.debug(`Verifying message ${msg.messageId} for room ${room.roomId}`);
const timestamp = Date.now();
const rateLimit = room.rateLimit ? room.rateLimit : 1000;
const currentEpoch = Math.floor(timestamp / rateLimit);

View File

@@ -4,9 +4,7 @@
import { PrismaClient } from '@prisma/client';
import { RoomI, genId } from 'discreetly-interfaces';
import { serverConfig } from '../config/serverConfig';
import { randn_bm } from '../utils';
import { generateClaimCodes } from 'discreetly-claimcodes';
import type { ClaimCodeT } from 'discreetly-claimcodes';
import { genMockUsers, genClaimCodeArray, pp } from '../utils';
const prisma = new PrismaClient();
@@ -15,11 +13,11 @@ interface CodeStatus {
roomIds: string[];
}
interface ClaimCode {
interface RoomsFromClaimCode {
roomIds: string[];
}
export function getRoomByID(id: string): Promise<RoomI> {
export function getRoomByID(id: string): Promise<RoomI | null> {
return prisma.rooms
.findUnique({
where: {
@@ -70,13 +68,13 @@ export async function getRoomsByIdentity(identity: string): Promise<string[]> {
}
}
export function findClaimCode(code: string): Promise<CodeStatus> {
export function findClaimCode(code: string): Promise<CodeStatus | null> {
return prisma.claimCodes.findUnique({
where: { claimcode: code }
});
}
export function updateClaimCode(code: string): Promise<ClaimCode> {
export function updateClaimCode(code: string): Promise<RoomsFromClaimCode> {
return prisma.claimCodes.update({
where: { claimcode: code },
data: { claimed: true }
@@ -84,14 +82,23 @@ export function updateClaimCode(code: string): Promise<ClaimCode> {
}
export function updateRoomIdentities(idc: string, roomIds: string[]): Promise<any> {
return prisma.rooms.updateMany({
return prisma.rooms.findMany({
where: { id: { in: roomIds } },
data: {
identities: {
push: idc
}
})
.then((rooms) => {
const roomsToUpdate = rooms
.filter(room => !room.identities.includes(idc))
.map(room => room.id);
if (roomsToUpdate) {
return prisma.rooms.updateMany({
where: { id: { in: roomsToUpdate } },
data: { identities: { push: idc } }
});
}
});
}).catch(err => {
pp(err, 'error')
})
}
export function findUpdatedRooms(roomIds: string[]): Promise<RoomI[]> {
@@ -108,40 +115,15 @@ export function findUpdatedRooms(roomIds: string[]): Promise<RoomI[]> {
* @param {number} [numClaimCodes=0] - The number of claim codes to generate for the room.
* @param {number} [approxNumMockUsers=20] - The approximate number of mock users to generate for the room.
*/
export function createRoom(
export async function createRoom(
name: string,
rateLimit = 1000,
userMessageLimit = 1,
numClaimCodes = 0,
approxNumMockUsers = 20
): boolean {
function genMockUsers(numMockUsers: number): string[] {
// Generates random number of mock users between 0.5 x numMockusers and 2 x numMockUsers
const newNumMockUsers = randn_bm(numMockUsers / 2, numMockUsers * 2);
const mockUsers: string[] = [];
for (let i = 0; i < newNumMockUsers; i++) {
mockUsers.push(
genId(
serverConfig.id,
// Generates a random string of length 10
Math.random()
.toString(36)
.substring(2, 2 + 10) + i
).toString()
);
}
return mockUsers;
}
function genClaimCodeArray(numClaimCodes: number): { claimcode: string }[] {
const claimCodes = generateClaimCodes(numClaimCodes);
const codeArr: { claimcode: string }[] = claimCodes.map((code: ClaimCodeT) => ({
claimcode: code.code
}));
return codeArr;
}
): Promise<boolean> {
const claimCodes: { claimcode: string }[] = genClaimCodeArray(numClaimCodes);
console.log(claimCodes);
const mockUsers: string[] = genMockUsers(approxNumMockUsers);
const roomData = {
where: {
@@ -160,7 +142,7 @@ export function createRoom(
}
};
prisma.rooms
await prisma.rooms
.upsert(roomData)
.then(() => {
return true;

View File

@@ -4,7 +4,10 @@ import { MessageI } from 'discreetly-interfaces';
const prisma = new PrismaClient();
function updateRoom(roomId: string, message: MessageI, epoch: number): Promise<unknown> {
function addMessageToRoom(roomId: string, message: MessageI): Promise<unknown> {
if (!message.epoch) {
throw new Error('Epoch not provided');
}
return prisma.rooms.update({
where: {
roomId: roomId
@@ -12,7 +15,7 @@ function updateRoom(roomId: string, message: MessageI, epoch: number): Promise<u
data: {
epochs: {
create: {
epoch: epoch,
epoch: String(message.epoch),
messages: {
create: {
message: message.message ? message.message.toString() : '',
@@ -27,22 +30,28 @@ function updateRoom(roomId: string, message: MessageI, epoch: number): Promise<u
});
}
export function createMessage(roomId: string, message: MessageI) {
export function createMessage(roomId: string, message: MessageI): boolean {
getRoomByID(roomId)
.then((room) => {
if (room) {
updateRoom(roomId, message)
// Todo This should check that there is no duplicate messageId with in this room and epoch, if there is, we need to return an error and reconstruct the secret from both messages, and ban the user
addMessageToRoom(roomId, message)
.then((roomToUpdate) => {
console.log(roomToUpdate);
return true;
})
.catch((error) => {
console.error(`Error updating room: ${error}`);
return false;
});
} else {
console.log('Room not found');
return false;
}
})
.catch((error) => {
console.error(`Error getting room: ${error}`);
return false;
});
return false;
}

View File

@@ -1,7 +1,7 @@
import type { Express, RequestHandler } from 'express';
import type { Express, RequestHandler, Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import { serverConfig } from '../config/serverConfig';
import { pp } from '../utils.js';
import { pp } from '../utils';
import {
getRoomByID,
getRoomsByIdentity,
@@ -13,26 +13,45 @@ import {
} from '../data/db';
import { RoomI } from 'discreetly-interfaces';
export function initEndpoints(app: Express, adminAuth: RequestHandler) {
const prisma = new PrismaClient();
const prisma = new PrismaClient();
function asyncHandler(fn: {
(req: Request, res: Response): Promise<void>;
(arg0: unknown, arg1: unknown): unknown;
}) {
return (req, res) => {
void Promise.resolve(fn(req, res)).catch((err) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
throw new Error(err);
});
};
}
export function initEndpoints(app: Express, adminAuth: RequestHandler) {
app.get(['/', '/api'], (req, res) => {
pp('Express: fetching server info');
res.json(serverConfig);
res.status(200).json(serverConfig);
});
app.get(['/room/:id', '/api/room/:id'], (req, res) => {
pp(String('Express: fetching room info for ' + req.params.id));
getRoomByID(req.params.id)
.then((room: RoomI) => {
if (!room) {
// This is set as a timeout to prevent someone from trying to brute force room ids
setTimeout(() => res.status(500).json({ error: 'Internal Server Error' }), 1000);
} else {
res.status(200).json(room);
}
})
.catch((err) => console.error(err));
if (!req.params.id) {
res.status(400).json({ error: 'Bad Request' });
} else {
const requestRoomId = req.params.id ?? '0';
pp(String('Express: fetching room info for ' + req.params.id));
getRoomByID(requestRoomId)
.then((room: RoomI) => {
if (!room) {
// This is set as a timeout to prevent someone from trying to brute force room ids
setTimeout(() => res.status(500).json({ error: 'Internal Server Error' }), 1000);
} else {
// Add null check before accessing properties of room object
const { roomId, name, rateLimit, userMessageLimit } = room || {};
res.status(200).json({ roomId, name, rateLimit, userMessageLimit });
}
})
.catch((err) => console.error(err));
}
});
app.get(['/rooms/:idc', '/api/rooms/:idc'], (req, res) => {
@@ -45,54 +64,70 @@ export function initEndpoints(app: Express, adminAuth: RequestHandler) {
idc: string;
}
app.post(['/join', '/api/join'], (req, res) => {
const { code, idc } = req.body as JoinData;
console.log('Invite Code:', code);
findClaimCode(code)
.then((codeStatus) => {
if (codeStatus && codeStatus.claimed === false) {
return updateClaimCode(code).then((claimCode) => {
const roomIds = claimCode.roomIds.map((room) => room.roomId);
return updateRoomIdentities(idc, roomIds).then(() => {
return findUpdatedRooms(roomIds).then((updatedRooms: RoomI[]) => {
return res.status(200).json({
status: 'valid',
roomIds: updatedRooms.map((room: RoomI) => room.roomId)
});
});
});
});
} else {
return res.status(400).json({ message: 'Claim code already used' });
}
})
.catch((err: Error) => {
console.error(err);
return res.status(500).json({ error: 'Internal Server Error' });
app.post(
['/join', '/api/join'],
asyncHandler(async (req: Request, res: Response) => {
const parsedBody: JoinData = req.body as JoinData;
if (!parsedBody.code || !parsedBody.idc) {
res.status(400).json({ message: '{code: string, idc: string} expected' });
}
const { code, idc } = parsedBody;
console.log('Invite Code:', code);
// Check if claim code is valid and not used before
const codeStatus = await findClaimCode(code);
if (!codeStatus || codeStatus.claimed) {
res.status(400).json({ message: 'Claim code already used' });
return;
}
// Update claim code
const claimCode = await updateClaimCode(code);
const roomIds = claimCode.roomIds;
// Update Room Identities
await updateRoomIdentities(idc, roomIds);
// Find updated rooms
const updatedRooms: RoomI[] = await findUpdatedRooms(roomIds);
// Return the room ids of the updated rooms
res.status(200).json({
status: 'valid',
roomIds: updatedRooms.map((room: RoomI) => room.roomId)
});
});
})
);
interface addRoomData {
roomName: string;
rateLimit: number;
userMessageLimit: number;
numClaimCodes?: number;
}
/* ~~~~ ADMIN ENDPOINTS ~~~~ */
app.post(['/room/add', '/api/room/add'], adminAuth, (req, res) => {
interface RoomData {
roomName: string;
rateLimit: number;
userMessageLimit: number;
numClaimCodes?: number;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const roomMetadata = req.body.data as RoomData;
const roomMetadata = req.body as addRoomData;
console.log(roomMetadata);
const roomName = roomMetadata.roomName;
const rateLimit = roomMetadata.rateLimit;
const userMessageLimit = roomMetadata.userMessageLimit;
const numClaimCodes = roomMetadata.numClaimCodes || 0;
const result = createRoom(roomName, rateLimit, userMessageLimit, numClaimCodes);
if (result) {
// TODO should return roomID and claim codes if they are generated
res.status(200).json({ message: 'Room created successfully' });
} else {
return res.status(500).json({ error: 'Internal Server Error' });
}
createRoom(roomName, rateLimit, userMessageLimit, numClaimCodes)
.then((result) => {
if (result) {
// TODO should return roomID and claim codes if they are generated
res.status(200).json({ message: 'Room created successfully' });
} else {
res.status(500).json({ error: 'Internal Server Error' });
}
})
.catch((err) => {
console.error(err);
res.status(500).json({ error: String(err) });
});
});
app.get('/api/room/:id/messages', (req, res) => {

View File

@@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
export function listEndpoints(app) {
const table = [];
const table: unknown[] = [];
for (const r of app._router.stack) {
if (r.route?.path) {
const methods = Object.keys(r.route.methods).join(', ').toUpperCase();

View File

@@ -3,7 +3,9 @@ import http from 'http';
import cors from 'cors';
import helmet from 'helmet';
import basicAuth from 'express-basic-auth';
import type { Server } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import type { Server as SocketIOServerT } from 'socket.io';
import { serverConfig } from './config/serverConfig';
import { pp, shim } from './utils';
import mock from './data/mock';
@@ -41,27 +43,43 @@ function initAppListeners(PORT) {
const httpServer = http.createServer(app).listen(PORT, () => {
pp(`Server is running at port ${PORT}`);
});
const io = new SocketIOServer(httpServer);
return io;
return httpServer;
}
/**
* This is the main entry point for the server
*/
let _app: Server;
let io: SocketIOServerT;
interface ServerConfigStartupI {
id?: string;
name?: string;
version?: string;
port?: number | string;
admin_password?: string;
}
const serverConfigStartup: ServerConfigStartupI = serverConfig as unknown as ServerConfigStartupI;
if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') {
console.log('~~~~DEVELOPMENT MODE~~~~');
console.log(serverConfig);
const PORT = 3001;
serverConfigStartup.port = PORT;
serverConfigStartup.admin_password = admin_password;
initEndpoints(app, adminAuth);
const io = initAppListeners(PORT);
initWebsockets(io);
_app = initAppListeners(PORT);
listEndpoints(app);
io = new SocketIOServer(_app, {});
initWebsockets(io);
mock(io);
// TODO! This is dangerous and only for development
console.log('Admin password: ' + admin_password);
} else {
const PORT = process.env.PORT;
serverConfigStartup.port = PORT;
initEndpoints(app, adminAuth);
const io = initAppListeners(process.env.PORT);
_app = initAppListeners(PORT);
io = new SocketIOServer(_app, {});
initWebsockets(io);
}
pp(serverConfigStartup, 'table');
export default _app;

View File

@@ -1,3 +1,7 @@
import { genId } from 'discreetly-interfaces';
import { serverConfig } from './config/serverConfig';
import { generateClaimCodes } from 'discreetly-claimcodes';
import type { ClaimCodeT } from 'discreetly-claimcodes';
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
@@ -9,37 +13,75 @@ export function shim() {
};
}
export function genMockUsers(numMockUsers: number): string[] {
// Generates random number of mock users between 0.5 x numMockusers and 2 x numMockUsers
const newNumMockUsers = randn_bm(numMockUsers / 2, numMockUsers * 2);
const mockUsers: string[] = [];
for (let i = 0; i < newNumMockUsers; i++) {
mockUsers.push(
genId(
serverConfig.id,
// Generates a random string of length 10
Math.random()
.toString(36)
.substring(2, 2 + 10) + i
).toString()
);
}
return mockUsers;
}
export function genClaimCodeArray(numClaimCodes: number): { claimcode: string }[] {
const claimCodes: ClaimCodeT[] = generateClaimCodes(numClaimCodes) as ClaimCodeT[];
const codeArr: { claimcode: string }[] = claimCodes.map((code: ClaimCodeT) => ({
claimcode: String(code.code)
}));
return codeArr;
}
/**
* Logs the provided string to the console with the specified log level.
* @param {any} str - The string to log.
* @param {any} data - The string to log.
* @param {string} [level='log'] - The log level to use. Can be one of 'log', 'debug', 'info', 'warn', 'warning', 'error', 'err', 'table', or 'assert'.
*/
export const pp = (str: any, level = 'log') => {
str = JSON.stringify(str, null, 2);
export const pp = (data: any, level = 'log') => {
switch (level) {
case 'log':
console.log(str);
data = JSON.stringify(data, null, 2);
console.log(data);
break;
case 'debug':
console.debug(str);
data = JSON.stringify(data, null, 2);
console.debug(data);
break;
case 'info':
console.info(str);
data = JSON.stringify(data, null, 2);
console.info(data);
break;
case 'warn' || 'warning':
console.warn(str);
data = JSON.stringify(data, null, 2);
console.warn(data);
break;
case 'error' || 'err':
console.error(str);
data = JSON.stringify(data, null, 2);
console.error(data);
break;
case 'table':
console.table(str);
if (typeof data === 'object') {
// converts an object into a table of keys and values
const newData: { key: any; value: any }[] = [];
Object.entries(data as Record<string, unknown>).forEach(([key, value]) => {
newData.push({ key: key, value: value });
});
console.table(newData);
} else {
console.table(data);
}
break;
case 'assert':
console.assert(str);
console.assert(data);
break;
default:
console.log(str);
console.log(data);
}
};

View File

@@ -12,8 +12,8 @@ export function websocketSetup(io: SocketIOServer) {
pp('SocketIO: a user connected', 'debug');
socket.on('validateMessage', (msg: MessageI) => {
pp({ 'VALIDATING MESSAGE ID': msg.id.slice(0, 11), 'MSG:': msg.message });
let valid: boolean;
pp({ 'VALIDATING MESSAGE ID': String(msg.roomId).slice(0, 11), 'MSG:': msg.message });
let validProof: boolean;
getRoomByID(String(msg.roomId))
.then((room: RoomI) => {
if (!room) {
@@ -22,17 +22,17 @@ export function websocketSetup(io: SocketIOServer) {
}
verifyProof(msg, room)
.then((v) => {
valid = v;
createMessage(String(msg.roomId), msg);
validProof = v;
const validMessage: boolean = createMessage(String(msg.roomId), msg);
if (!validProof || !validMessage) {
pp('INVALID MESSAGE', 'warn');
return;
}
io.emit('messageBroadcast', msg);
})
.catch((err) => {
err;
});
if (!valid) {
pp('INVALID MESSAGE', 'warn');
return;
}
})
.catch((err) => pp(err, 'error'));
});

103
tests/express.test.ts Normal file
View File

@@ -0,0 +1,103 @@
const request = require('supertest');
import _app from '../src/server'
import { genId } from 'discreetly-interfaces';
import { serverConfig } from '../src/config/serverConfig';
import { describe } from 'node:test';
import expressBasicAuth from 'express-basic-auth';
import { transferableAbortController } from 'node:util';
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST
const room = {
roomName: 'Test-room',
rateLimit: 1000,
userMessageLimit: 1,
numClaimCodes: 5,
approxNumMockUsers: 20,
}
const roomByIdTest = genId(serverConfig.id, room.roomName).toString();
const joinTest = {
code: "coast-filter-noise-feature", //needs to be changed to a valid code
idc: "12345678901234567890"
}
describe('GET /', () => {
test('It should respond with server info', () => {
request(_app).get('/').expect('Content-Type', 'application/json; charset=utf-8').then(res => {
})
})
})
describe("POST /room/add", () => {
test("It should add a new room to the database", async () => {
const username = 'admin';
const password = process.env.PASSWORD;
const base64Credentials = Buffer.from(`${username}:${password}`).toString('base64');
await request(_app)
.post("/room/add")
.set('Authorization', `Basic ${base64Credentials}`)
.send(room)
.then(res => {
expect(res.json).toBe('{message :"Room created successfully"}')
});
});
});
describe("GET /api/room/:id", () => {
test("It should return the room with the given id", async () => {
await request(_app)
.get(`/api/room/${roomByIdTest}`)
.then(res => {
console.log(res.body);
expect(res.body.roomName).toBe(room.roomName)
});
});
});
describe("GET /api/rooms", () => {
test("It should return all rooms", async () => {
const username = 'admin';
const password = process.env.PASSWORD;
const base64Credentials = Buffer.from(`${username}:${password}`).toString('base64');
await request(_app)
.get("/api/rooms")
.set('Authorization', `Basic ${base64Credentials}`)
.then(res => {
expect(res.status).toBe(200)
expect(res.bodyname).toBe(room.roomName)
});
});
})
describe("GET /logclaimcodes", () => {
test("It should return all claim codes", async () => {
const username = 'admin';
const password = process.env.PASSWORD;
const base64Credentials = Buffer.from(`${username}:${password}`).toString('base64');
await request(_app)
.get("/logclaimcodes")
.set('Authorization', `Basic ${base64Credentials}`)
.then(res => {
expect(res.status).toBe(401)
expect(res.body.length).toBeGreaterThan(0)
});
});
});
describe("POST /join", () => {
test("It should add a users identity to the rooms the claim code is associated with", async () => {
await request(_app)
.post("/join")
.send(joinTest)
.then(res => {
expect(res.statusCode).toBe(200)
expect(res.body.status).toBe('valid')
})
})
})

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"rootDir": "./",
"target": "ES2022",
"module": "ES2022",
"esModuleInterop": true,

5
tsconfig.tests.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"strictNullChecks": true,
"skipLibCheck": true
}