浏览代码

test: fix tests and bump various dependencies

Nicolas Meienberger 2 年之前
父节点
当前提交
79f1da00d0

+ 3 - 2
packages/dashboard/.eslintrc.js

@@ -1,5 +1,5 @@
 module.exports = {
-  plugins: ['@typescript-eslint', 'import', 'react', 'jest'],
+  plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc'],
   extends: [
     'plugin:@typescript-eslint/recommended',
     'next/core-web-vitals',
@@ -10,6 +10,7 @@ module.exports = {
     'plugin:import/typescript',
     'prettier',
     'plugin:react/recommended',
+    'plugin:jsdoc/recommended',
   ],
   parser: '@typescript-eslint/parser',
   parserOptions: {
@@ -28,7 +29,7 @@ module.exports = {
     'react/jsx-props-no-spreading': 0,
     '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/**'] }],
+    'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', '**/*.factory.{ts,tsx}', '**/mocks/**', 'tests/**'] }],
     'no-underscore-dangle': 0,
   },
   globals: {

+ 84 - 97
packages/dashboard/__mocks__/fs-extra.ts

@@ -1,120 +1,107 @@
 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] = [];
-    }
+class FsMock {
+  private static instance: FsMock;
 
-    mockFiles[dir].push(path.basename(file));
-    mockFiles[file] = newMockFiles[file];
-  });
-};
+  private mockFiles = Object.create(null);
 
-const readFileSync = (p: string) => mockFiles[p];
+  // private constructor() {}
 
-const existsSync = (p: string) => mockFiles[p] !== undefined;
+  static getInstance(): FsMock {
+    if (!FsMock.instance) {
+      FsMock.instance = new FsMock();
+    }
+    return FsMock.instance;
+  }
 
-const writeFileSync = (p: string, data: string | string[]) => {
-  mockFiles[p] = data;
-};
+  __createMockFiles = (newMockFiles: Record<string, string>) => {
+    this.mockFiles = Object.create(null);
 
-const mkdirSync = (p: string) => {
-  mockFiles[p] = Object.create(null);
-};
+    // Create folder tree
+    Object.keys(newMockFiles).forEach((file) => {
+      const dir = path.dirname(file);
+
+      if (!this.mockFiles[dir]) {
+        this.mockFiles[dir] = [];
+      }
 
-const rmSync = (p: string) => {
-  if (mockFiles[p] instanceof Array) {
-    mockFiles[p].forEach((file: string) => {
-      delete mockFiles[path.join(p, file)];
+      this.mockFiles[dir].push(path.basename(file));
+      this.mockFiles[file] = newMockFiles[file];
     });
-  }
+  };
 
-  delete mockFiles[p];
-};
+  __resetAllMocks = () => {
+    this.mockFiles = Object.create(null);
+  };
 
-const readdirSync = (p: string) => {
-  const files: string[] = [];
+  readFileSync = (p: string) => this.mockFiles[p];
 
-  const depth = p.split('/').length;
+  existsSync = (p: string) => this.mockFiles[p] !== undefined;
 
-  Object.keys(mockFiles).forEach((file) => {
-    if (file.startsWith(p)) {
-      const fileDepth = file.split('/').length;
+  writeFileSync = (p: string, data: string | string[]) => {
+    this.mockFiles[p] = data;
+  };
 
-      if (fileDepth === depth + 1) {
-        files.push(file.split('/').pop() || '');
-      }
+  mkdirSync = (p: string) => {
+    this.mockFiles[p] = Object.create(null);
+  };
+
+  rmSync = (p: string) => {
+    if (this.mockFiles[p] instanceof Array) {
+      this.mockFiles[p].forEach((file: string) => {
+        delete this.mockFiles[path.join(p, file)];
+      });
     }
-  });
 
-  return files;
-};
+    delete this.mockFiles[p];
+  };
 
-const copyFileSync = (source: string, destination: string) => {
-  mockFiles[destination] = mockFiles[source];
-};
+  readdirSync = (p: string) => {
+    const files: string[] = [];
 
-const copySync = (source: string, destination: string) => {
-  mockFiles[destination] = mockFiles[source];
+    const depth = p.split('/').length;
 
-  if (mockFiles[source] instanceof Array) {
-    mockFiles[source].forEach((file: string) => {
-      mockFiles[`${destination}/${file}`] = mockFiles[`${source}/${file}`];
+    Object.keys(this.mockFiles).forEach((file) => {
+      if (file.startsWith(p)) {
+        const fileDepth = file.split('/').length;
+
+        if (fileDepth === depth + 1) {
+          files.push(file.split('/').pop() || '');
+        }
+      }
     });
-  }
-};
 
-const createFileSync = (p: string) => {
-  mockFiles[p] = '';
-};
+    return files;
+  };
 
-const resetAllMocks = () => {
-  mockFiles = Object.create(null);
-};
+  copyFileSync = (source: string, destination: string) => {
+    this.mockFiles[destination] = this.mockFiles[source];
+  };
 
-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;
+  copySync = (source: string, destination: string) => {
+    this.mockFiles[destination] = this.mockFiles[source];
+
+    if (this.mockFiles[source] instanceof Array) {
+      this.mockFiles[source].forEach((file: string) => {
+        this.mockFiles[`${destination}/${file}`] = this.mockFiles[`${source}/${file}`];
+      });
+    }
+  };
+
+  createFileSync = (p: string) => {
+    this.mockFiles[p] = '';
+  };
+
+  unlinkSync = (p: string) => {
+    if (this.mockFiles[p] instanceof Array) {
+      this.mockFiles[p].forEach((file: string) => {
+        delete this.mockFiles[path.join(p, file)];
+      });
+    }
+    delete this.mockFiles[p];
+  };
+
+  getMockFiles = () => this.mockFiles;
+}
+
+export default FsMock.getInstance();

+ 6 - 5
packages/dashboard/package.json

@@ -22,11 +22,11 @@
     "@runtipi/postgres-migrations": "^5.3.0",
     "@tabler/core": "1.0.0-beta16",
     "@tabler/icons": "^1.109.0",
-    "@tanstack/react-query": "^4.20.4",
+    "@tanstack/react-query": "^4.24.4",
     "@trpc/client": "^10.7.0",
-    "@trpc/next": "^10.7.0",
-    "@trpc/react-query": "^10.7.0",
-    "@trpc/server": "^10.7.0",
+    "@trpc/next": "^10.9.1",
+    "@trpc/react-query": "^10.9.1",
+    "@trpc/server": "^10.9.1",
     "argon2": "^0.29.1",
     "clsx": "^1.1.1",
     "fs-extra": "^10.1.0",
@@ -90,12 +90,13 @@
     "eslint-config-next": "13.1.1",
     "eslint-plugin-import": "^2.25.3",
     "eslint-plugin-jest": "^27.1.7",
+    "eslint-plugin-jsdoc": "^39.6.9",
     "eslint-plugin-jsx-a11y": "^6.6.1",
     "eslint-plugin-react": "^7.31.10",
     "eslint-plugin-react-hooks": "^4.6.0",
     "jest": "^29.3.1",
     "jest-environment-jsdom": "^29.3.1",
-    "msw": "^0.49.2",
+    "msw": "^1.0.0",
     "next-router-mock": "^0.8.0",
     "nodemon": "^2.0.15",
     "prisma": "^4.8.0",

+ 8 - 17
packages/dashboard/src/client/modules/Settings/containers/SettingsContainer/SettingsContainer.test.tsx

@@ -6,10 +6,6 @@ import { server } from '../../../../mocks/server';
 import { useToastStore } from '../../../../state/toastStore';
 import { SettingsContainer } from './SettingsContainer';
 
-beforeEach(() => {
-  localStorage.removeItem('token');
-});
-
 describe('Test: SettingsContainer', () => {
   describe('UI', () => {
     it('renders without crashing', () => {
@@ -58,8 +54,7 @@ describe('Test: SettingsContainer', () => {
     it('should remove token from local storage on success', async () => {
       const current = '0.0.1';
       const latest = faker.system.semver();
-      localStorage.setItem('token', 'token');
-
+      const removeItem = jest.spyOn(localStorage, 'removeItem');
       render(<SettingsContainer data={{ current, latest }} />);
 
       const updateButton = screen.getByText('Update');
@@ -67,11 +62,9 @@ describe('Test: SettingsContainer', () => {
         fireEvent.click(updateButton);
       });
 
-      // wait 500 ms because localStore cannot be awaited in tests
-      // eslint-disable-next-line no-promise-executor-return
-      await new Promise((resolve) => setTimeout(resolve, 500));
-
-      expect(localStorage.getItem('token')).toBeNull();
+      await waitFor(() => {
+        expect(removeItem).toBeCalledWith('token');
+      });
     });
 
     it('should display error toast on error', async () => {
@@ -98,7 +91,7 @@ describe('Test: SettingsContainer', () => {
   describe('Restart', () => {
     it('should remove token from local storage on success', async () => {
       const current = faker.system.semver();
-      localStorage.setItem('token', 'token');
+      const removeItem = jest.spyOn(localStorage, 'removeItem');
 
       render(<SettingsContainer data={{ current }} />);
       const restartButton = screen.getByTestId('settings-modal-restart-button');
@@ -106,11 +99,9 @@ describe('Test: SettingsContainer', () => {
         fireEvent.click(restartButton);
       });
 
-      // wait 500 ms because localStore cannot be awaited in tests
-      // eslint-disable-next-line no-promise-executor-return
-      await new Promise((resolve) => setTimeout(resolve, 500));
-
-      expect(localStorage.getItem('token')).toBeNull();
+      await waitFor(() => {
+        expect(removeItem).toBeCalledWith('token');
+      });
     });
 
     it('should display error toast on error', async () => {

+ 1 - 1
packages/dashboard/src/pages/_app.tsx

@@ -17,7 +17,7 @@ function MyApp({ Component, pageProps }: AppProps) {
   const { setDarkMode } = useUIStore();
   const { setStatus, setVersion } = useSystemStore();
 
-  // trpc.system.status.useQuery(undefined, { refetchInterval: 1000, networkMode: 'online', onSuccess: (d) => setStatus(d.status || SystemStatus.RUNNING) });
+  trpc.system.status.useQuery(undefined, { refetchInterval: 1000, networkMode: 'online', onSuccess: (d) => setStatus(d.status || SystemStatus.RUNNING) });
   const version = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
 
   useEffect(() => {

+ 1 - 0
packages/dashboard/src/server/common/typescript.helpers.ts

@@ -0,0 +1 @@
+export const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => value !== null && value !== undefined;

+ 16 - 16
packages/dashboard/src/server/core/EventDispatcher/EventDispatcher.test.ts

@@ -2,7 +2,7 @@
  * @jest-environment node
  */
 import fs from 'fs-extra';
-import { EventDispatcher, EventTypes } from '.';
+import { EventDispatcher } from '.';
 
 const WATCH_FILE = '/runtipi/state/events';
 
@@ -19,18 +19,18 @@ beforeEach(() => {
 
 describe('EventDispatcher - dispatchEvent', () => {
   it('should dispatch an event', () => {
-    const event = EventDispatcher.dispatchEvent(EventTypes.APP);
+    const event = EventDispatcher.dispatchEvent('app');
     expect(event.id).toBeDefined();
   });
 
   it('should dispatch an event with args', () => {
-    const event = EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    const event = EventDispatcher.dispatchEvent('app', ['--help']);
     expect(event.id).toBeDefined();
   });
 
   it('Should put events into queue', async () => {
-    EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
-    EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    EventDispatcher.dispatchEvent('app', ['--help']);
+    EventDispatcher.dispatchEvent('app', ['--help']);
 
     // @ts-expect-error - private method
     const { queue } = EventDispatcher;
@@ -39,8 +39,8 @@ describe('EventDispatcher - dispatchEvent', () => {
   });
 
   it('Should put first event into lock after 1 sec', async () => {
-    EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
-    EventDispatcher.dispatchEvent(EventTypes.UPDATE, ['--help']);
+    EventDispatcher.dispatchEvent('app', ['--help']);
+    EventDispatcher.dispatchEvent('update', ['--help']);
 
     // @ts-expect-error - private method
     const { queue } = EventDispatcher;
@@ -52,13 +52,13 @@ describe('EventDispatcher - dispatchEvent', () => {
 
     expect(queue.length).toBe(2);
     expect(lock).toBeDefined();
-    expect(lock?.type).toBe(EventTypes.APP);
+    expect(lock?.type).toBe('app');
   });
 
   it('Should clear event once its status is success', async () => {
     // @ts-expect-error - private method
     jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
-    EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    EventDispatcher.dispatchEvent('app', ['--help']);
 
     await wait(1050);
 
@@ -71,7 +71,7 @@ describe('EventDispatcher - dispatchEvent', () => {
   it('Should clear event once its status is error', async () => {
     // @ts-expect-error - private method
     jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
-    EventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    EventDispatcher.dispatchEvent('app', ['--help']);
 
     await wait(1050);
 
@@ -86,7 +86,7 @@ describe('EventDispatcher - dispatchEventAsync', () => {
   it('Should dispatch an event and wait for it to finish', async () => {
     // @ts-expect-error - private method
     jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
-    const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
+    const { success } = await EventDispatcher.dispatchEventAsync('app', ['--help']);
 
     expect(success).toBe(true);
   });
@@ -95,7 +95,7 @@ describe('EventDispatcher - dispatchEventAsync', () => {
     // @ts-expect-error - private method
     jest.spyOn(EventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
 
-    const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
+    const { success } = await EventDispatcher.dispatchEventAsync('app', ['--help']);
 
     expect(success).toBe(false);
   });
@@ -104,7 +104,7 @@ describe('EventDispatcher - dispatchEventAsync', () => {
 describe('EventDispatcher - runEvent', () => {
   it('Should do nothing if there is a lock', async () => {
     // @ts-expect-error - private method
-    EventDispatcher.lock = { id: '123', type: EventTypes.APP, args: [] };
+    EventDispatcher.lock = { id: '123', type: 'app', args: [] };
     // @ts-expect-error - private method
     await EventDispatcher.runEvent();
 
@@ -136,7 +136,7 @@ describe('EventDispatcher - getEventStatus', () => {
   it('Should return error if event is expired', async () => {
     const dateFiveMinutesAgo = new Date(new Date().getTime() - 5 * 60 * 10000);
     // @ts-expect-error - private method
-    EventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: dateFiveMinutesAgo }];
+    EventDispatcher.queue = [{ id: '123', type: 'app', args: [], creationDate: dateFiveMinutesAgo }];
     // @ts-expect-error - private method
     const status = EventDispatcher.getEventStatus('123');
 
@@ -145,7 +145,7 @@ describe('EventDispatcher - getEventStatus', () => {
 
   it('Should be waiting if line is not found in the file', async () => {
     // @ts-expect-error - private method
-    EventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: new Date() }];
+    EventDispatcher.queue = [{ id: '123', type: 'app', args: [], creationDate: new Date() }];
     // @ts-expect-error - private method
     const status = EventDispatcher.getEventStatus('123');
 
@@ -155,7 +155,7 @@ describe('EventDispatcher - getEventStatus', () => {
 
 describe('EventDispatcher - clearEvent', () => {
   it('Should clear event', async () => {
-    const event = { id: '123', type: EventTypes.APP, args: [], creationDate: new Date() };
+    const event = { id: '123', type: 'app', args: [], creationDate: new Date() };
     // @ts-expect-error - private method
     EventDispatcher.queue = [event];
     // @ts-expect-error - private method

+ 28 - 13
packages/dashboard/src/server/core/EventDispatcher/EventDispatcher.ts

@@ -1,19 +1,28 @@
+/* eslint-disable vars-on-top */
 import fs from 'fs-extra';
 import { Logger } from '../Logger';
+import { getConfig } from '../TipiConfig';
 
-export enum EventTypes {
-  // System events
-  RESTART = 'restart',
-  UPDATE = 'update',
-  CLONE_REPO = 'clone_repo',
-  UPDATE_REPO = 'update_repo',
-  APP = 'app',
-  SYSTEM_INFO = 'system_info',
+declare global {
+  // eslint-disable-next-line no-var
+  var EventDispatcher: EventDispatcher | undefined;
 }
 
+export const EVENT_TYPES = {
+  // System events
+  RESTART: 'restart',
+  UPDATE: 'update',
+  CLONE_REPO: 'clone_repo',
+  UPDATE_REPO: 'update_repo',
+  APP: 'app',
+  SYSTEM_INFO: 'system_info',
+} as const;
+
+export type EventType = typeof EVENT_TYPES[keyof typeof EVENT_TYPES];
+
 type SystemEvent = {
   id: string;
-  type: EventTypes;
+  type: EventType;
   args: string[];
   creationDate: Date;
 };
@@ -27,6 +36,8 @@ const WATCH_FILE = '/runtipi/state/events';
 class EventDispatcher {
   private static instance: EventDispatcher | null;
 
+  private dispatcherId = EventDispatcher.generateId();
+
   private queue: SystemEvent[] = [];
 
   private lock: SystemEvent | null = null;
@@ -77,7 +88,7 @@ class EventDispatcher {
    * Poll queue and run events
    */
   private pollQueue() {
-    Logger.info('EventDispatcher: Polling queue...');
+    Logger.info(`EventDispatcher(${this.dispatcherId}): Polling queue...`);
 
     if (!this.interval) {
       const id = setInterval(() => {
@@ -148,7 +159,7 @@ class EventDispatcher {
    * @param args - Event arguments
    * @returns - Event object
    */
-  public dispatchEvent(type: EventTypes, args?: string[]): SystemEvent {
+  public dispatchEvent(type: EventType, args?: string[]): SystemEvent {
     const event: SystemEvent = {
       id: EventDispatcher.generateId(),
       type,
@@ -185,7 +196,7 @@ class EventDispatcher {
    * @param args - Event arguments
    * @returns - Promise that resolves when the event is done
    */
-  public async dispatchEventAsync(type: EventTypes, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
+  public async dispatchEventAsync(type: EventType, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
     const event = this.dispatchEvent(type, args);
 
     return new Promise((resolve) => {
@@ -222,4 +233,8 @@ class EventDispatcher {
   }
 }
 
-export const EventDispatcherInstance = EventDispatcher.getInstance();
+export const EventDispatcherInstance = global.EventDispatcher || EventDispatcher.getInstance();
+
+if (getConfig().NODE_ENV !== 'production') {
+  global.EventDispatcher = EventDispatcherInstance;
+}

+ 2 - 1
packages/dashboard/src/server/core/EventDispatcher/index.ts

@@ -1,2 +1,3 @@
 export { EventDispatcherInstance as EventDispatcher } from './EventDispatcher';
-export { EventTypes } from './EventDispatcher';
+export type { EventType } from './EventDispatcher';
+export { EVENT_TYPES } from './EventDispatcher';

+ 1 - 2
packages/dashboard/src/server/core/TipiConfig/TipiConfig.test.ts

@@ -3,13 +3,12 @@ import fs from 'fs-extra';
 import { getConfig, setConfig, TipiConfig } from '.';
 import { readJsonFile } from '../../common/fs.helpers';
 
-jest.mock('fs-extra');
-
 beforeEach(async () => {
   jest.resetModules();
   jest.resetAllMocks();
   // @ts-expect-error - We are mocking fs
   fs.__resetAllMocks();
+  jest.mock('fs-extra');
 });
 
 describe('Test: getConfig', () => {

+ 8 - 7
packages/dashboard/src/server/core/TipiConfig/TipiConfig.ts

@@ -4,11 +4,12 @@ import nextConfig from 'next/config';
 import { readJsonFile } from '../../common/fs.helpers';
 import { Logger } from '../Logger';
 
-enum AppSupportedArchitecturesEnum {
-  ARM = 'arm',
-  ARM64 = 'arm64',
-  AMD64 = 'amd64',
-}
+export const ARCHITECTURES = {
+  ARM: 'arm',
+  ARM64: 'arm64',
+  AMD64: 'amd64',
+} as const;
+export type Architecture = typeof ARCHITECTURES[keyof typeof ARCHITECTURES];
 
 const {
   NODE_ENV,
@@ -27,13 +28,13 @@ const {
   POSTGRES_PASSWORD,
   POSTGRES_PORT = 5432,
 } = nextConfig()?.serverRuntimeConfig || process.env;
-// Use process.env if nextConfig is not available (e.g. in in server-preload.ts)
+// Use process.env if nextConfig is not available
 
 const configSchema = z.object({
   NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
   REDIS_HOST: z.string(),
   status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
-  architecture: z.nativeEnum(AppSupportedArchitecturesEnum),
+  architecture: z.nativeEnum(ARCHITECTURES),
   dnsIp: z.string(),
   rootFolder: z.string(),
   internalIp: z.string(),

+ 1 - 1
packages/dashboard/src/server/services/system/system.service.test.ts

@@ -6,11 +6,11 @@ import { EventDispatcher } from '../../core/EventDispatcher';
 import { setConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 
-jest.mock('fs-extra');
 jest.mock('axios');
 jest.mock('redis');
 
 beforeEach(async () => {
+  jest.mock('fs-extra');
   jest.resetModules();
   jest.resetAllMocks();
 });

+ 4 - 4
packages/dashboard/src/server/services/system/system.service.ts

@@ -1,7 +1,7 @@
 import semver from 'semver';
 import { z } from 'zod';
 import { readJsonFile } from '../../common/fs.helpers';
-import { EventDispatcher, EventTypes } from '../../core/EventDispatcher';
+import { EventDispatcher } from '../../core/EventDispatcher';
 import { Logger } from '../../core/Logger';
 import TipiCache from '../../core/TipiCache';
 import { getConfig, setConfig } from '../../core/TipiConfig';
@@ -66,7 +66,7 @@ const restart = async (): Promise<boolean> => {
   }
 
   setConfig('status', 'RESTARTING');
-  EventDispatcher.dispatchEventAsync(EventTypes.RESTART);
+  EventDispatcher.dispatchEventAsync('restart');
 
   return true;
 };
@@ -91,12 +91,12 @@ const update = async (): Promise<boolean> => {
   }
 
   if (semver.major(current) !== semver.major(latest)) {
-    throw new Error('The major version has changed. Please update manually');
+    throw new Error('The major version has changed. Please update manually (instructions on GitHub)');
   }
 
   setConfig('status', 'UPDATING');
 
-  EventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
+  EventDispatcher.dispatchEventAsync('update');
 
   return true;
 };

+ 30 - 22
packages/dashboard/tests/TRPCTestClientProvider.tsx

@@ -1,8 +1,8 @@
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { createTRPCReact, httpLink, loggerLink } from '@trpc/react-query';
-import SuperJSON from 'superjson';
-import React from 'react';
+import React, { useState } from 'react';
 import fetch from 'isomorphic-fetch';
+import superjson from 'superjson';
 
 import type { AppRouter } from '../src/server/routers/_app';
 
@@ -17,28 +17,36 @@ export const trpc = createTRPCReact<AppRouter>({
   },
 });
 
-const queryClient = new QueryClient();
-const trpcClient = trpc.createClient({
-  links: [
-    loggerLink({
-      enabled: () => false,
-    }),
-    httpLink({
-      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;
+  const [queryClient] = useState(
+    () =>
+      new QueryClient({
+        defaultOptions: {
+          queries: {
+            retry: false,
+          },
+        },
+      }),
+  );
+
+  const [trpcClient] = useState(() =>
+    trpc.createClient({
+      transformer: superjson,
+      links: [
+        loggerLink({
+          enabled: () => false,
+        }),
+        httpLink({
+          url: 'http://localhost:3000/api/trpc',
+          fetch: async (input, init?) =>
+            fetch(input, {
+              ...init,
+            }),
+        }),
+      ],
+    }),
+  );
 
   return (
     <trpc.Provider client={trpcClient} queryClient={queryClient}>

+ 21 - 6
packages/dashboard/tests/client/jest.setup.tsx

@@ -1,8 +1,6 @@
 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';
 
 // Mock next/router
@@ -18,6 +16,27 @@ jest.mock('remark-mdx', () => () => ({}));
 
 console.error = jest.fn();
 
+// Mock localStorage
+const localStorageMock = (() => {
+  let store: Record<string, string> = {};
+  return {
+    getItem(key: string) {
+      return store[key] || null;
+    },
+    setItem(key: string, value: string) {
+      store[key] = value.toString();
+    },
+    removeItem(key: string) {
+      delete store[key];
+    },
+    clear() {
+      store = {};
+    },
+  };
+})();
+
+Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+
 beforeAll(() => {
   // Enable the mocking in tests.
   server.listen();
@@ -25,10 +44,6 @@ beforeAll(() => {
 
 beforeEach(async () => {
   useToastStore.getState().clearToasts();
-  // Ensure Apollo cache is cleared between tests.
-  // https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.clearStore
-  await mockApolloClient.clearStore();
-  await mockApolloClient.cache.reset();
 });
 
 afterEach(() => {

+ 1 - 20
packages/dashboard/tests/test-utils.tsx

@@ -1,27 +1,8 @@
 import React, { FC, ReactElement } from 'react';
 import { render, RenderOptions, renderHook } from '@testing-library/react';
-import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache } from '@apollo/client';
-import fetch from 'isomorphic-fetch';
 import { TRPCTestClientProvider } from './TRPCTestClientProvider';
 
-const link = new HttpLink({
-  uri: 'http://localhost:3000/graphql',
-  // Use explicit `window.fetch` so tha outgoing requests
-  // are captured and deferred until the Service Worker is ready.
-  fetch: (...args) => fetch(...args),
-});
-
-// create a mock of Apollo Client
-export const mockApolloClient = new ApolloClient({
-  cache: new InMemoryCache({}),
-  link,
-});
-
-const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
-  <TRPCTestClientProvider>
-    <ApolloProvider client={mockApolloClient}>{children}</ApolloProvider>
-  </TRPCTestClientProvider>
-);
+const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => <TRPCTestClientProvider>{children}</TRPCTestClientProvider>;
 
 const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
 const customRenderHook = (callback: () => any, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });

+ 82 - 98
packages/system-api/__mocks__/fs-extra.ts

@@ -1,121 +1,105 @@
 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] = [];
-    }
+class FsMock {
+  private static instance: FsMock;
 
-    mockFiles[dir].push(path.basename(file));
-    mockFiles[file] = newMockFiles[file];
-  });
-};
+  private mockFiles = Object.create(null);
 
-const readFileSync = (p: string) => mockFiles[p];
+  static getInstance(): FsMock {
+    if (!FsMock.instance) {
+      FsMock.instance = new FsMock();
+    }
+    return FsMock.instance;
+  }
 
-const existsSync = (p: string) => mockFiles[p] !== undefined;
+  __createMockFiles = (newMockFiles: Record<string, string>) => {
+    this.mockFiles = Object.create(null);
 
-const writeFileSync = (p: string, data: string | string[]) => {
-  mockFiles[p] = data;
-};
+    // Create folder tree
+    Object.keys(newMockFiles).forEach((file) => {
+      const dir = path.dirname(file);
 
-const mkdirSync = (p: string) => {
-  mockFiles[p] = Object.create(null);
-};
+      if (!this.mockFiles[dir]) {
+        this.mockFiles[dir] = [];
+      }
 
-const rmSync = (p: string) => {
-  if (mockFiles[p] instanceof Array) {
-    mockFiles[p].forEach((file: string) => {
-      delete mockFiles[path.join(p, file)];
+      this.mockFiles[dir].push(path.basename(file));
+      this.mockFiles[file] = newMockFiles[file];
     });
-  }
+  };
 
-  delete mockFiles[p];
-};
+  __resetAllMocks = () => {
+    this.mockFiles = Object.create(null);
+  };
 
-const readdirSync = (p: string) => {
-  const files: string[] = [];
+  readFileSync = (p: string) => this.mockFiles[p];
 
-  const depth = p.split('/').length;
+  existsSync = (p: string) => this.mockFiles[p] !== undefined;
 
-  Object.keys(mockFiles).forEach((file) => {
-    if (file.startsWith(p)) {
-      const fileDepth = file.split('/').length;
+  writeFileSync = (p: string, data: string | string[]) => {
+    this.mockFiles[p] = data;
+  };
 
-      if (fileDepth === depth + 1) {
-        files.push(file.split('/').pop() || '');
-      }
+  mkdirSync = (p: string) => {
+    this.mockFiles[p] = Object.create(null);
+  };
+
+  rmSync = (p: string) => {
+    if (this.mockFiles[p] instanceof Array) {
+      this.mockFiles[p].forEach((file: string) => {
+        delete this.mockFiles[path.join(p, file)];
+      });
     }
-  });
 
-  return files;
-};
+    delete this.mockFiles[p];
+  };
+
+  readdirSync = (p: string) => {
+    const files: string[] = [];
 
-const copyFileSync = (source: string, destination: string) => {
-  mockFiles[destination] = mockFiles[source];
-};
+    const depth = p.split('/').length;
 
-const copySync = (source: string, destination: string) => {
-  mockFiles[destination] = mockFiles[source];
+    Object.keys(this.mockFiles).forEach((file) => {
+      if (file.startsWith(p)) {
+        const fileDepth = file.split('/').length;
 
-  if (mockFiles[source] instanceof Array) {
-    mockFiles[source].forEach((file: string) => {
-      mockFiles[`${destination}/${file}`] = mockFiles[`${source}/${file}`];
+        if (fileDepth === depth + 1) {
+          files.push(file.split('/').pop() || '');
+        }
+      }
     });
-  }
-};
 
-const createFileSync = (p: string) => {
-  mockFiles[p] = '';
-};
+    return files;
+  };
 
-const resetAllMocks = () => {
-  mockFiles = Object.create(null);
-};
+  copyFileSync = (source: string, destination: string) => {
+    this.mockFiles[destination] = this.mockFiles[source];
+  };
 
-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;
-// module.exports = fs;
+  copySync = (source: string, destination: string) => {
+    this.mockFiles[destination] = this.mockFiles[source];
+
+    if (this.mockFiles[source] instanceof Array) {
+      this.mockFiles[source].forEach((file: string) => {
+        this.mockFiles[`${destination}/${file}`] = this.mockFiles[`${source}/${file}`];
+      });
+    }
+  };
+
+  createFileSync = (p: string) => {
+    this.mockFiles[p] = '';
+  };
+
+  unlinkSync = (p: string) => {
+    if (this.mockFiles[p] instanceof Array) {
+      this.mockFiles[p].forEach((file: string) => {
+        delete this.mockFiles[path.join(p, file)];
+      });
+    }
+    delete this.mockFiles[p];
+  };
+
+  getMockFiles = () => this.mockFiles;
+}
+
+export default FsMock.getInstance();