test: split jest config for client and server

This commit is contained in:
Nicolas Meienberger 2022-12-26 04:52:48 +01:00 committed by Nicolas Meienberger
parent d4f507ced3
commit ce6662bef5
20 changed files with 312 additions and 69 deletions

View file

@ -7,8 +7,12 @@ env:
JWT_SECRET: "secret"
ROOT_FOLDER_HOST: /tipi
APPS_REPO_ID: repo-id
INTERNAL_IP: 192.168.1.10
INTERNAL_IP: localhost
REDIS_HOST: redis
APPS_REPO_URL: https://repo.github.com/
DOMAIN: localhost
TIPI_VERSION: 0.0.1
jobs:
ci:
runs-on: ubuntu-latest
@ -62,10 +66,10 @@ jobs:
- name: Run linter
run: pnpm -r lint
- name: Run tests
run: pnpm -r test
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }}

2
.gitignore vendored
View file

@ -7,6 +7,8 @@
logs
.pnpm-debug.log
.env*
!.env.example
!.env.test
github.secrets
node_modules/
app-data/*

View file

@ -29,9 +29,11 @@ module.exports = {
'react/no-unused-prop-types': 0,
'react/button-has-type': 0,
'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/mocks/**', 'tests/**'] }],
'no-underscore-dangle': 0,
},
globals: {
JSX: true,
NodeJS: true,
},
env: {
'jest/globals': true,

View file

@ -0,0 +1,120 @@
import path from 'path';
const fs: {
__createMockFiles: typeof createMockFiles;
__resetAllMocks: typeof resetAllMocks;
readFileSync: typeof readFileSync;
existsSync: typeof existsSync;
writeFileSync: typeof writeFileSync;
mkdirSync: typeof mkdirSync;
rmSync: typeof rmSync;
readdirSync: typeof readdirSync;
copyFileSync: typeof copyFileSync;
copySync: typeof copyFileSync;
createFileSync: typeof createFileSync;
unlinkSync: typeof unlinkSync;
} = jest.genMockFromModule('fs-extra');
let mockFiles = Object.create(null);
const createMockFiles = (newMockFiles: Record<string, string>) => {
mockFiles = Object.create(null);
// Create folder tree
Object.keys(newMockFiles).forEach((file) => {
const dir = path.dirname(file);
if (!mockFiles[dir]) {
mockFiles[dir] = [];
}
mockFiles[dir].push(path.basename(file));
mockFiles[file] = newMockFiles[file];
});
};
const readFileSync = (p: string) => mockFiles[p];
const existsSync = (p: string) => mockFiles[p] !== undefined;
const writeFileSync = (p: string, data: string | string[]) => {
mockFiles[p] = data;
};
const mkdirSync = (p: string) => {
mockFiles[p] = Object.create(null);
};
const rmSync = (p: string) => {
if (mockFiles[p] instanceof Array) {
mockFiles[p].forEach((file: string) => {
delete mockFiles[path.join(p, file)];
});
}
delete mockFiles[p];
};
const readdirSync = (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
};
const copyFileSync = (source: string, destination: string) => {
mockFiles[destination] = mockFiles[source];
};
const copySync = (source: string, destination: string) => {
mockFiles[destination] = mockFiles[source];
if (mockFiles[source] instanceof Array) {
mockFiles[source].forEach((file: string) => {
mockFiles[`${destination}/${file}`] = mockFiles[`${source}/${file}`];
});
}
};
const createFileSync = (p: string) => {
mockFiles[p] = '';
};
const resetAllMocks = () => {
mockFiles = Object.create(null);
};
const unlinkSync = (p: string) => {
if (mockFiles[p] instanceof Array) {
mockFiles[p].forEach((file: string) => {
delete mockFiles[path.join(p, file)];
});
}
delete mockFiles[p];
};
fs.unlinkSync = unlinkSync;
fs.readdirSync = readdirSync;
fs.existsSync = existsSync;
fs.readFileSync = readFileSync;
fs.writeFileSync = writeFileSync;
fs.mkdirSync = mkdirSync;
fs.rmSync = rmSync;
fs.copyFileSync = copyFileSync;
fs.copySync = copySync;
fs.createFileSync = createFileSync;
fs.__createMockFiles = createMockFiles;
fs.__resetAllMocks = resetAllMocks;
export default fs;

View file

@ -0,0 +1,16 @@
export const createClient = jest.fn(() => {
const values = new Map();
const expirations = new Map();
return {
isOpen: true,
connect: jest.fn(),
set: (key: string, value: string, exp: number) => {
values.set(key, value);
expirations.set(key, exp);
},
get: (key: string) => values.get(key),
quit: jest.fn(),
del: (key: string) => values.delete(key),
ttl: (key: string) => expirations.get(key),
};
});

View file

@ -1,18 +0,0 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.tsx'],
testEnvironment: 'jest-environment-jsdom',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/index.ts', '!**/src/pages/**/*.{ts,tsx}', '!**/src/mocks/**', '!**/src/core/apollo/**'],
testMatch: ['<rootDir>/src/**/*.{spec,test}.{ts,tsx}'],
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

View file

@ -0,0 +1,39 @@
import nextJest from 'next/jest';
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
const customClientConfig = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/tests/client/jest.setup.tsx'],
testMatch: ['<rootDir>/src/client/**/*.{spec,test}.{ts,tsx}', '!<rootDir>/src/server/**/*.{spec,test}.{ts,tsx}'],
};
const customServerConfig = {
testEnvironment: 'node',
testMatch: ['<rootDir>/src/server/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/server/jest.setup.ts'],
};
export default async () => {
const clientConfig = await createJestConfig(customClientConfig)();
const serverConfig = await createJestConfig(customServerConfig)();
return {
verbose: true,
collectCoverage: true,
collectCoverageFrom: ['src/server/**/*.{ts,tsx}', 'src/client/**/*.{ts,tsx}', '!src/**/mocks/**/*.{ts,tsx}', '!**/*.{spec,test}.{ts,tsx}', '!**/index.{ts,tsx}'],
projects: [
{
displayName: 'client',
...clientConfig,
},
{
displayName: 'server',
...serverConfig,
},
],
};
};

View file

@ -1,8 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
swcMinify: true,
};
module.exports = nextConfig;

View file

@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
swcMinify: true,
output: 'standalone',
reactStrictMode: true,
serverRuntimeConfig: {
INTERNAL_IP: process.env.INTERNAL_IP,
TIPI_VERSION: process.env.TIPI_VERSION,
JWT_SECRET: process.env.JWT_SECRET,
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD,
POSTGRES_USERNAME: process.env.POSTGRES_USERNAME,
POSTGRES_DBNAME: process.env.POSTGRES_DBNAME,
POSTGRES_HOST: process.env.POSTGRES_HOST,
APPS_REPO_ID: process.env.APPS_REPO_ID,
APPS_REPO_URL: process.env.APPS_REPO_URL,
DOMAIN: process.env.DOMAIN,
ARCHITECTURE: process.env.ARCHITECTURE,
NODE_ENV: process.env.NODE_ENV,
REDIS_HOST: process.env.REDIS_HOST,
},
};
export default nextConfig;

View file

@ -1,6 +1,5 @@
import fs from 'fs-extra';
import semver from 'semver';
import axios from 'axios';
import { faker } from '@faker-js/faker';
import { SystemService } from '.';
import { EventDispatcher } from '../../core/EventDispatcher';
@ -66,9 +65,8 @@ describe('Test: getVersion', () => {
it('It should return version', async () => {
// Arrange
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
});
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` }) }));
// Act
const version = await SystemService.getVersion();
@ -77,14 +75,11 @@ describe('Test: getVersion', () => {
expect(version).toBeDefined();
expect(version.current).toBeDefined();
expect(semver.valid(version.latest)).toBeTruthy();
spy.mockRestore();
});
it('Should return undefined for latest if request fails', async () => {
jest.spyOn(axios, 'get').mockImplementation(() => {
throw new Error('Error');
});
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.reject(new Error('API is down')));
const version = await SystemService.getVersion();
@ -95,9 +90,8 @@ describe('Test: getVersion', () => {
it('Should return cached version', async () => {
// Arrange
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
});
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` }) }));
// Act
const version = await SystemService.getVersion();
@ -111,10 +105,6 @@ describe('Test: getVersion', () => {
expect(version2.latest).toBe(version.latest);
expect(version2.current).toBeDefined();
expect(semver.valid(version2.latest)).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
@ -148,14 +138,12 @@ describe('Test: update', () => {
it('Should throw an error if latest version is not set', async () => {
// Arrange
TipiCache.del('latestVersion');
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: null },
});
// @ts-expect-error Mocking fetch
fetch.mockImplementationOnce(() => Promise.resolve({ json: () => Promise.resolve({ name: null }) }));
setConfig('version', '0.0.1');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
spy.mockRestore();
});
it('Should throw if current version is higher than latest', async () => {

View file

@ -1,4 +1,3 @@
import axios from 'axios';
import semver from 'semver';
import { z } from 'zod';
import { readJsonFile } from '../../common/fs.helpers';
@ -6,7 +5,6 @@ import { EventDispatcher, EventTypes } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
import TipiCache from '../../core/TipiCache';
import { getConfig, setConfig } from '../../core/TipiConfig';
import { env } from '../../../env/server.mjs';
import { SystemStatus } from '../../../client/state/systemStore';
const systemInfoSchema = z.object({
@ -38,9 +36,10 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
let version = await TipiCache.get('latestVersion');
if (!version) {
const { data } = await axios.get('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const data = await fetch('https://api.github.com/repos/meienberger/runtipi/releases/latest');
const release = await data.json();
version = data.name.replace('v', '');
version = release.name.replace('v', '');
await TipiCache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
}
@ -62,7 +61,7 @@ const systemInfo = (): z.infer<typeof systemInfoSchema> => {
};
const restart = async (): Promise<boolean> => {
if (env.NODE_ENV === 'development') {
if (getConfig().NODE_ENV === 'development') {
throw new Error('Cannot restart in development mode');
}
@ -75,7 +74,7 @@ const restart = async (): Promise<boolean> => {
const update = async (): Promise<boolean> => {
const { current, latest } = await getVersion();
if (env.NODE_ENV === 'development') {
if (getConfig().NODE_ENV === 'development') {
throw new Error('Cannot update in development mode');
}

View file

@ -0,0 +1,49 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCReact, httpBatchLink, loggerLink } from '@trpc/react-query';
import SuperJSON from 'superjson';
import React from 'react';
import fetch from 'isomorphic-fetch';
import type { AppRouter } from '../src/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>({
unstable_overrides: {
useMutation: {
async onSuccess(opts) {
await opts.originalFn();
await opts.queryClient.invalidateQueries();
},
},
},
});
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [
loggerLink({
enabled: () => false,
}),
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
headers() {
return {};
},
fetch: async (input, init?) =>
fetch(input, {
...init,
}),
}),
],
transformer: SuperJSON,
});
export function TRPCTestClientProvider(props: { children: React.ReactNode }) {
const { children } = props;
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}

View file

@ -1,9 +1,9 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import 'whatwg-fetch';
import { server } from '../src/client/mocks/server';
import { mockApolloClient } from './test-utils';
import { useToastStore } from '../src/client/state/toastStore';
import { server } from '../../src/client/mocks/server';
import { mockApolloClient } from '../test-utils';
import { useToastStore } from '../../src/client/state/toastStore';
// Mock next/router
// eslint-disable-next-line global-require

View file

@ -0,0 +1,20 @@
import { EventDispatcher } from '../../src/server/core/EventDispatcher';
global.fetch = jest.fn();
// Mock Logger
jest.mock('../../src/server/core/Logger', () => ({
Logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('next/config', () => () => ({
serverRuntimeConfig: {
...process.env,
},
}));
afterAll(() => {
EventDispatcher.clearInterval();
});

View file

@ -3,6 +3,7 @@ import { render, RenderOptions, renderHook } from '@testing-library/react';
import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client';
import fetch from 'isomorphic-fetch';
import { SWRConfig } from 'swr';
import { TRPCTestClientProvider } from './TRPCTestClientProvider';
const link = new HttpLink({
uri: 'http://localhost:3000/graphql',
@ -18,9 +19,11 @@ export const mockApolloClient = new ApolloClient({
});
const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
<ApolloProvider client={mockApolloClient}>{children}</ApolloProvider>
</SWRConfig>
<TRPCTestClientProvider>
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
<ApolloProvider client={mockApolloClient}>{children}</ApolloProvider>
</SWRConfig>
</TRPCTestClientProvider>
);
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
@ -16,8 +16,9 @@
"incremental": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
"noUncheckedIndexedAccess": true,
"types": ["jest", "@testing-library/jest-dom"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.mjs", "**/*.js", "**/*.jsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,3 @@
APPS_REPO_ID=repo-id
INTERNAL_IP=localhost
JWT_SECRET=secret

View file

@ -21,7 +21,7 @@ describe('Test: getConfig', () => {
expect(config.logs.LOGS_ERROR).toBe('error.log');
expect(config.dnsIp).toBe('9.9.9.9');
expect(config.rootFolder).toBe('/runtipi');
expect(config.internalIp).toBe('192.168.1.10');
expect(config.internalIp).toBe('localhost');
});
});

View file

@ -254,7 +254,7 @@ describe('Test: generateEnvFile', () => {
const envmap = await getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
expect(envmap.get('APP_DOMAIN')).toBe(`192.168.1.10:${appInfo.port}`);
expect(envmap.get('APP_DOMAIN')).toBe(`localhost:${appInfo.port}`);
});
it('Should create app folder if it does not exist', async () => {

View file

@ -47,7 +47,7 @@ describe('Install app', () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should add app in database', async () => {
@ -315,7 +315,7 @@ describe('Start app', () => {
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should throw if start script fails', async () => {
@ -377,7 +377,7 @@ describe('Update app config', () => {
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should throw if required field is missing', async () => {