Browse Source

refactor: make event dispatcher a singleton and update app accordingly

Nicolas Meienberger 2 years ago
parent
commit
a024b03508

+ 2 - 0
.gitignore

@@ -1,6 +1,8 @@
 *.swo
 *.swo
 *.swp
 *.swp
 
 
+.DS_Store
+
 logs
 logs
 .pnpm-debug.log
 .pnpm-debug.log
 .env*
 .env*

+ 3 - 0
packages/system-api/.eslintrc.cjs

@@ -19,4 +19,7 @@ module.exports = {
     'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
   },
   },
+  globals: {
+    NodeJS: true,
+  },
 };
 };

+ 2 - 0
packages/system-api/.gitignore

@@ -1,6 +1,8 @@
 node_modules/
 node_modules/
 dist/
 dist/
 
 
+.DS_Store
+
 # testing
 # testing
 coverage/
 coverage/
 logs/
 logs/

+ 11 - 0
packages/system-api/__mocks__/fs-extra.ts

@@ -11,6 +11,7 @@ const fs: {
   copyFileSync: typeof copyFileSync;
   copyFileSync: typeof copyFileSync;
   copySync: typeof copyFileSync;
   copySync: typeof copyFileSync;
   createFileSync: typeof createFileSync;
   createFileSync: typeof createFileSync;
+  unlinkSync: typeof unlinkSync;
 } = jest.genMockFromModule('fs-extra');
 } = jest.genMockFromModule('fs-extra');
 
 
 let mockFiles = Object.create(null);
 let mockFiles = Object.create(null);
@@ -97,6 +98,16 @@ const resetAllMocks = () => {
   mockFiles = Object.create(null);
   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.readdirSync = readdirSync;
 fs.existsSync = existsSync;
 fs.existsSync = existsSync;
 fs.readFileSync = readFileSync;
 fs.readFileSync = readFileSync;

+ 20 - 5
packages/system-api/src/core/config/EventDispatcher.ts

@@ -25,12 +25,24 @@ const WATCH_FILE = '/runtipi/state/events';
 // File state example:
 // File state example:
 // restart 1631231231231 running "arg1 arg2"
 // restart 1631231231231 running "arg1 arg2"
 class EventDispatcher {
 class EventDispatcher {
+  private static instance: EventDispatcher | null;
+
   private queue: SystemEvent[] = [];
   private queue: SystemEvent[] = [];
 
 
   private lock: SystemEvent | null = null;
   private lock: SystemEvent | null = null;
 
 
+  private interval: NodeJS.Timer;
+
   constructor() {
   constructor() {
-    this.pollQueue();
+    const timer = this.pollQueue();
+    this.interval = timer;
+  }
+
+  public static getInstance(): EventDispatcher {
+    if (!EventDispatcher.instance) {
+      EventDispatcher.instance = new EventDispatcher();
+    }
+    return EventDispatcher.instance;
   }
   }
 
 
   /**
   /**
@@ -38,7 +50,7 @@ class EventDispatcher {
    * @returns - Random id
    * @returns - Random id
    */
    */
   private generateId() {
   private generateId() {
-    return Math.random().toString(36).substr(2, 9);
+    return Math.random().toString(36).substring(2, 9);
   }
   }
 
 
   /**
   /**
@@ -65,7 +77,7 @@ class EventDispatcher {
    */
    */
   private pollQueue() {
   private pollQueue() {
     logger.info('EventDispatcher: Polling queue...');
     logger.info('EventDispatcher: Polling queue...');
-    setInterval(() => {
+    return setInterval(() => {
       this.runEvent();
       this.runEvent();
       this.collectLockStatusAndClean();
       this.collectLockStatusAndClean();
     }, 1000);
     }, 1000);
@@ -89,7 +101,6 @@ class EventDispatcher {
     // Write event to state file
     // Write event to state file
     const args = event.args.join(' ');
     const args = event.args.join(' ');
     const line = `${event.type} ${event.id} waiting ${args}`;
     const line = `${event.type} ${event.id} waiting ${args}`;
-    console.log('Writing line: ', line);
     fs.writeFileSync(WATCH_FILE, `${line}`);
     fs.writeFileSync(WATCH_FILE, `${line}`);
   }
   }
 
 
@@ -190,8 +201,12 @@ class EventDispatcher {
   public clear() {
   public clear() {
     this.queue = [];
     this.queue = [];
     this.lock = null;
     this.lock = null;
+    clearInterval(this.interval);
+    EventDispatcher.instance = null;
     fs.writeFileSync(WATCH_FILE, '');
     fs.writeFileSync(WATCH_FILE, '');
   }
   }
 }
 }
 
 
-export default new EventDispatcher();
+export const eventDispatcher = EventDispatcher.getInstance();
+
+export default EventDispatcher;

+ 0 - 3
packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts

@@ -1,3 +0,0 @@
-import EventDispatcher from '../EventDispatcher';
-
-describe('EventDispatcher', () => {});

+ 9 - 4
packages/system-api/src/core/jobs/__tests__/jobs.test.ts

@@ -1,7 +1,7 @@
 import cron from 'node-cron';
 import cron from 'node-cron';
-import * as repoHelpers from '../../../helpers/repo-helpers';
 import { getConfig } from '../../config/TipiConfig';
 import { getConfig } from '../../config/TipiConfig';
 import startJobs from '../jobs';
 import startJobs from '../jobs';
+import { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
 
 
 jest.mock('node-cron');
 jest.mock('node-cron');
 jest.mock('child_process');
 jest.mock('child_process');
@@ -17,16 +17,21 @@ describe('Test: startJobs', () => {
 
 
     startJobs();
     startJobs();
     expect(spy).toHaveBeenCalled();
     expect(spy).toHaveBeenCalled();
-    expect(spy).toHaveBeenCalledWith('0 * * * *', expect.any(Function));
+    expect(spy).toHaveBeenCalledWith('*/30 * * * *', expect.any(Function));
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
   it('Should update apps repo on cron trigger', () => {
   it('Should update apps repo on cron trigger', () => {
-    const spy = jest.spyOn(repoHelpers, 'updateRepo');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEvent');
 
 
+    // Act
     startJobs();
     startJobs();
 
 
-    expect(spy).toHaveBeenCalledWith(getConfig().appsRepoUrl);
+    // Assert
+    expect(spy.mock.calls.length).toBe(2);
+    expect(spy.mock.calls[0]).toEqual([EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]]);
+    expect(spy.mock.calls[1]).toEqual([EventTypes.SYSTEM_INFO, []]);
+
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 });
 });

+ 3 - 3
packages/system-api/src/core/jobs/jobs.ts

@@ -1,19 +1,19 @@
 import cron from 'node-cron';
 import cron from 'node-cron';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import { getConfig } from '../../core/config/TipiConfig';
 import { getConfig } from '../../core/config/TipiConfig';
-import EventDispatcher, { EventTypes } from '../config/EventDispatcher';
+import { eventDispatcher, EventTypes } from '../config/EventDispatcher';
 
 
 const startJobs = () => {
 const startJobs = () => {
   logger.info('Starting cron jobs...');
   logger.info('Starting cron jobs...');
 
 
   // Every 30 minutes
   // Every 30 minutes
   cron.schedule('*/30 * * * *', async () => {
   cron.schedule('*/30 * * * *', async () => {
-    EventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
+    eventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
   });
   });
 
 
   // every minute
   // every minute
   cron.schedule('* * * * *', () => {
   cron.schedule('* * * * *', () => {
-    EventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []);
+    eventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []);
   });
   });
 };
 };
 
 

+ 1 - 44
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -1,11 +1,10 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
-import childProcess from 'child_process';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
 import logger from '../../../config/logger/logger';
 import logger from '../../../config/logger/logger';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import App from '../app.entity';
 import App from '../app.entity';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
 import { AppInfo } from '../apps.types';
 import { AppInfo } from '../apps.types';
 import { createApp } from './apps.factory';
 import { createApp } from './apps.factory';
 
 
@@ -108,48 +107,6 @@ describe('checkEnvFile', () => {
   });
   });
 });
 });
 
 
-describe('Test: runAppScript', () => {
-  let app1: AppInfo;
-
-  beforeEach(async () => {
-    const app1create = await createApp({ installed: true });
-    app1 = app1create.appInfo;
-    // @ts-ignore
-    fs.__createMockFiles(app1create.MockFiles);
-  });
-
-  it('Should run the app script', async () => {
-    const { MockFiles } = await createApp({ installed: true });
-    // @ts-ignore
-    fs.__createMockFiles(MockFiles);
-
-    await runAppScript(['install', app1.id]);
-  });
-
-  it('Should log the error if the script fails', async () => {
-    const log = jest.spyOn(logger, 'error');
-    const spy = jest.spyOn(childProcess, 'execFile');
-    const randomWord = faker.random.word();
-
-    // @ts-ignore
-    spy.mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb(randomWord, null, null);
-    });
-
-    try {
-      await runAppScript(['install', app1.id]);
-      expect(true).toBe(false);
-    } catch (e: any) {
-      expect(e).toBe(randomWord);
-      expect(log).toHaveBeenCalledWith(`Error running app script: ${randomWord}`);
-    }
-
-    log.mockRestore();
-    spy.mockRestore();
-  });
-});
-
 describe('Test: generateEnvFile', () => {
 describe('Test: generateEnvFile', () => {
   let app1: AppInfo;
   let app1: AppInfo;
   let appEntity1: App;
   let appEntity1: App;

+ 2 - 0
packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts

@@ -10,6 +10,7 @@ import { createUser } from '../../auth/__tests__/user.factory';
 import User from '../../auth/user.entity';
 import User from '../../auth/user.entity';
 import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
 import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
+import EventDispatcher from '../../../core/config/EventDispatcher';
 
 
 jest.mock('fs');
 jest.mock('fs');
 jest.mock('child_process');
 jest.mock('child_process');
@@ -36,6 +37,7 @@ beforeEach(async () => {
   jest.resetModules();
   jest.resetModules();
   jest.resetAllMocks();
   jest.resetAllMocks();
   jest.restoreAllMocks();
   jest.restoreAllMocks();
+  EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
   await App.clear();
   await App.clear();
   await User.clear();
   await User.clear();
 });
 });

+ 51 - 72
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -1,13 +1,12 @@
 import AppsService from '../apps.service';
 import AppsService from '../apps.service';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
-import childProcess from 'child_process';
 import { AppInfo, AppStatusEnum } from '../apps.types';
 import { AppInfo, AppStatusEnum } from '../apps.types';
 import App from '../app.entity';
 import App from '../app.entity';
 import { createApp } from './apps.factory';
 import { createApp } from './apps.factory';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
 import { getEnvMap } from '../apps.helpers';
 import { getEnvMap } from '../apps.helpers';
-import { getConfig } from '../../../core/config/TipiConfig';
+import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
 
 
 jest.mock('fs-extra');
 jest.mock('fs-extra');
 jest.mock('child_process');
 jest.mock('child_process');
@@ -23,6 +22,7 @@ beforeEach(async () => {
   jest.resetModules();
   jest.resetModules();
   jest.resetAllMocks();
   jest.resetAllMocks();
   jest.restoreAllMocks();
   jest.restoreAllMocks();
+  EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
   await App.clear();
   await App.clear();
 });
 });
 
 
@@ -42,6 +42,7 @@ describe('Install app', () => {
   });
   });
 
 
   it('Should correctly generate env file for app', async () => {
   it('Should correctly generate env file for app', async () => {
+    // EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
     const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
 
 
@@ -59,39 +60,28 @@ describe('Install app', () => {
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
-
-    expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['install', app1.id], {}, expect.any(Function)]);
-    spy.mockRestore();
-  });
-
   it('Should start app if already installed', async () => {
   it('Should start app if already installed', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
 
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls.length).toBe(2);
-    expect(spy.mock.calls[0]).toEqual(['/runtipi/scripts/app.sh', ['install', app1.id], {}, expect.any(Function)]);
-    expect(spy.mock.calls[1]).toEqual(['/runtipi/scripts/app.sh', ['start', app1.id], {}, expect.any(Function)]);
+    expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['install', app1.id]]);
+    expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['start', app1.id]]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
   it('Should delete app if install script fails', async () => {
   it('Should delete app if install script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
-    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow('Test error');
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
 
 
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
 
 
     expect(app).toBeNull();
     expect(app).toBeNull();
-    spy.mockRestore();
   });
   });
 
 
   it('Should throw if required form fields are missing', async () => {
   it('Should throw if required form fields are missing', async () => {
@@ -175,56 +165,51 @@ describe('Uninstall app', () => {
   });
   });
 
 
   it('App should be installed by default', async () => {
   it('App should be installed by default', async () => {
+    // Act
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
+
+    // Assert
     expect(app).toBeDefined();
     expect(app).toBeDefined();
     expect(app!.id).toBe(app1.id);
     expect(app!.id).toBe(app1.id);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
   });
 
 
   it('Should correctly remove app from database', async () => {
   it('Should correctly remove app from database', async () => {
+    // Act
     await AppsService.uninstallApp(app1.id);
     await AppsService.uninstallApp(app1.id);
-
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
 
 
+    // Assert
     expect(app).toBeNull();
     expect(app).toBeNull();
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-
-    await AppsService.uninstallApp(app1.id);
-
-    expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['uninstall', app1.id], {}, expect.any(Function)]);
-
-    spy.mockRestore();
-  });
-
   it('Should stop app if it is running', async () => {
   it('Should stop app if it is running', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    // Arrange
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
+    // Act
     await AppsService.uninstallApp(app1.id);
     await AppsService.uninstallApp(app1.id);
 
 
+    // Assert
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls.length).toBe(2);
-    expect(spy.mock.calls[0]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['stop', app1.id], {}, expect.any(Function)]);
-    expect(spy.mock.calls[1]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['uninstall', app1.id], {}, expect.any(Function)]);
+    expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['stop', app1.id]]);
+    expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['uninstall', app1.id]]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
   it('Should throw if app is not installed', async () => {
   it('Should throw if app is not installed', async () => {
+    // Act & Assert
     await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
     await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
   });
   });
 
 
   it('Should throw if uninstall script fails', async () => {
   it('Should throw if uninstall script fails', async () => {
-    // Update app
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
     await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
     await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
 
 
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
-
-    await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow('Test error');
+    // Act & Assert
+    await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
   });
   });
@@ -240,12 +225,12 @@ describe('Start app', () => {
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+  it('Should correctly dispatch event', async () => {
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.startApp(app1.id);
     await AppsService.startApp(app1.id);
 
 
-    expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['start', app1.id]]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
@@ -255,7 +240,7 @@ describe('Start app', () => {
   });
   });
 
 
   it('Should restart if app is already running', async () => {
   it('Should restart if app is already running', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.startApp(app1.id);
     await AppsService.startApp(app1.id);
     expect(spy.mock.calls.length).toBe(1);
     expect(spy.mock.calls.length).toBe(1);
@@ -276,12 +261,11 @@ describe('Start app', () => {
   });
   });
 
 
   it('Should throw if start script fails', async () => {
   it('Should throw if start script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
 
 
-    await expect(AppsService.startApp(app1.id)).rejects.toThrow('Test error');
+    // Act & Assert
+    await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
   });
   });
@@ -297,12 +281,12 @@ describe('Stop app', () => {
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+  it('Should correctly dispatch stop event', async () => {
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.stopApp(app1.id);
     await AppsService.stopApp(app1.id);
 
 
-    expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['stop', app1.id], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['stop', app1.id]]);
   });
   });
 
 
   it('Should throw if app is not installed', async () => {
   it('Should throw if app is not installed', async () => {
@@ -310,12 +294,11 @@ describe('Stop app', () => {
   });
   });
 
 
   it('Should throw if stop script fails', async () => {
   it('Should throw if stop script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
 
 
-    await expect(AppsService.stopApp(app1.id)).rejects.toThrow('Test error');
+    // Act & Assert
+    await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
   });
@@ -464,19 +447,19 @@ describe('Start all apps', () => {
   });
   });
 
 
   it('Should correctly start all apps', async () => {
   it('Should correctly start all apps', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.startAllApps();
     await AppsService.startAllApps();
 
 
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls).toEqual([
     expect(spy.mock.calls).toEqual([
-      [`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)],
-      [`${getConfig().rootFolder}/scripts/app.sh`, ['start', app2.id], {}, expect.any(Function)],
+      [EventTypes.APP, ['start', app1.id]],
+      [EventTypes.APP, ['start', app2.id]],
     ]);
     ]);
   });
   });
 
 
   it('Should not start app which has not status RUNNING', async () => {
   it('Should not start app which has not status RUNNING', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
     await createApp({ installed: true, status: AppStatusEnum.STOPPED });
     await createApp({ installed: true, status: AppStatusEnum.STOPPED });
 
 
     await AppsService.startAllApps();
     await AppsService.startAllApps();
@@ -487,16 +470,14 @@ describe('Start all apps', () => {
   });
   });
 
 
   it('Should put app status to STOPPED if start script fails', async () => {
   it('Should put app status to STOPPED if start script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
+    // Act
     await AppsService.startAllApps();
     await AppsService.startAllApps();
-
     const apps = await App.find();
     const apps = await App.find();
 
 
-    expect(spy.mock.calls.length).toBe(2);
+    // Assert
     expect(apps.length).toBe(2);
     expect(apps.length).toBe(2);
     expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
     expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
     expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
     expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
@@ -529,12 +510,10 @@ describe('Update app', () => {
   });
   });
 
 
   it('Should throw if update script fails', async () => {
   it('Should throw if update script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
-    await expect(AppsService.updateApp(app1.id)).rejects.toThrow('Test error');
+    await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
   });
   });

+ 14 - 9
packages/system-api/src/modules/apps/apps.service.ts

@@ -6,7 +6,7 @@ import App from './app.entity';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import { Not } from 'typeorm';
 import { Not } from 'typeorm';
 import { getConfig } from '../../core/config/TipiConfig';
 import { getConfig } from '../../core/config/TipiConfig';
-import EventDispatcher, { EventTypes } from '../../core/config/EventDispatcher';
+import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 
 
@@ -26,9 +26,13 @@ const startAllApps = async (): Promise<void> => {
 
 
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
 
 
-        EventDispatcher.dispatchEvent(EventTypes.APP, ['start', app.id]);
-
-        await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
+        eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]).then(({ success }) => {
+          if (success) {
+            App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
+          } else {
+            App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
+          }
+        });
       } catch (e) {
       } catch (e) {
         await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
         await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
         logger.error(e);
         logger.error(e);
@@ -55,7 +59,7 @@ const startApp = async (appName: string): Promise<App> => {
   checkEnvFile(appName);
   checkEnvFile(appName);
 
 
   await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
   await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
-  const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]);
 
 
   if (success) {
   if (success) {
     await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
     await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
@@ -120,7 +124,7 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     generateEnvFile(app);
     generateEnvFile(app);
 
 
     // Run script
     // Run script
-    const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]);
+    const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]);
 
 
     if (!success) {
     if (!success) {
       await App.delete({ id });
       await App.delete({ id });
@@ -221,7 +225,7 @@ const stopApp = async (id: string): Promise<App> => {
   // Run script
   // Run script
   await App.update({ id }, { status: AppStatusEnum.STOPPING });
   await App.update({ id }, { status: AppStatusEnum.STOPPING });
 
 
-  const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]);
 
 
   if (success) {
   if (success) {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
@@ -255,7 +259,7 @@ const uninstallApp = async (id: string): Promise<App> => {
 
 
   await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
   await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
 
 
-  const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]);
 
 
   if (!success) {
   if (!success) {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
@@ -299,12 +303,13 @@ const updateApp = async (id: string) => {
 
 
   await App.update({ id }, { status: AppStatusEnum.UPDATING });
   await App.update({ id }, { status: AppStatusEnum.UPDATING });
 
 
-  const { success, stdout } = await EventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
 
 
   if (success) {
   if (success) {
     const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
     const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
     await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
     await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
   } else {
   } else {
+    await App.update({ id }, { status: AppStatusEnum.STOPPED });
     throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
     throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
   }
   }
 
 

+ 29 - 4
packages/system-api/src/modules/system/__tests__/system.resolver.test.ts

@@ -12,6 +12,7 @@ import { systemInfoQuery, versionQuery } from '../../../test/queries';
 import User from '../../auth/user.entity';
 import User from '../../auth/user.entity';
 import { createUser } from '../../auth/__tests__/user.factory';
 import { createUser } from '../../auth/__tests__/user.factory';
 import { SystemInfoResponse } from '../system.types';
 import { SystemInfoResponse } from '../system.types';
+import EventDispatcher from '../../../core/config/EventDispatcher';
 
 
 jest.mock('fs-extra');
 jest.mock('fs-extra');
 jest.mock('axios');
 jest.mock('axios');
@@ -133,38 +134,50 @@ describe('Test: restart', () => {
   });
   });
 
 
   it('Should return true', async () => {
   it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+
+    // Act
     const user = await createUser();
     const user = await createUser();
     const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
     const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
 
 
+    // Assert
     expect(data?.restart).toBeDefined();
     expect(data?.restart).toBeDefined();
     expect(data?.restart).toBe(true);
     expect(data?.restart).toBe(true);
   });
   });
 
 
   it("Should return an error if user doesn't exist", async () => {
   it("Should return an error if user doesn't exist", async () => {
+    // Arrange
     const { data, errors } = await gcall<{ restart: boolean }>({
     const { data, errors } = await gcall<{ restart: boolean }>({
       source: restartMutation,
       source: restartMutation,
       userId: 1,
       userId: 1,
     });
     });
 
 
+    // Assert
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(data?.restart).toBeUndefined();
     expect(data?.restart).toBeUndefined();
   });
   });
 
 
   it('Should throw an error if no userId is not provided', async () => {
   it('Should throw an error if no userId is not provided', async () => {
+    // Arrange
     const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation });
     const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation });
 
 
+    // Assert
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(data?.restart).toBeUndefined();
     expect(data?.restart).toBeUndefined();
   });
   });
 
 
   it('Should set app status to restarting', async () => {
   it('Should set app status to restarting', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
     const spy = jest.spyOn(TipiConfig, 'setConfig');
     const spy = jest.spyOn(TipiConfig, 'setConfig');
-
     const user = await createUser();
     const user = await createUser();
+
+    // Act
     await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
     await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
 
 
+    // Assert
     expect(spy).toHaveBeenCalledTimes(2);
     expect(spy).toHaveBeenCalledTimes(2);
-
     expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING');
     expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING');
     expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
     expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
 
 
@@ -180,35 +193,47 @@ describe('Test: update', () => {
   });
   });
 
 
   it('Should return true', async () => {
   it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
     const user = await createUser();
     const user = await createUser();
+
+    // Act
     const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
     const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
 
 
+    // Assert
     expect(data?.update).toBeDefined();
     expect(data?.update).toBeDefined();
     expect(data?.update).toBe(true);
     expect(data?.update).toBe(true);
   });
   });
 
 
   it("Should return an error if user doesn't exist", async () => {
   it("Should return an error if user doesn't exist", async () => {
+    // Act
     const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 });
     const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 });
 
 
+    // Assert
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(data?.update).toBeUndefined();
     expect(data?.update).toBeUndefined();
   });
   });
 
 
   it('Should throw an error if no userId is not provided', async () => {
   it('Should throw an error if no userId is not provided', async () => {
+    // Act
     const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation });
     const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation });
 
 
+    // Assert
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
     expect(data?.update).toBeUndefined();
     expect(data?.update).toBeUndefined();
   });
   });
 
 
   it('Should set app status to updating', async () => {
   it('Should set app status to updating', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
     const spy = jest.spyOn(TipiConfig, 'setConfig');
     const spy = jest.spyOn(TipiConfig, 'setConfig');
-
     const user = await createUser();
     const user = await createUser();
+
+    // Act
     await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
     await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
 
 
+    // Assert
     expect(spy).toHaveBeenCalledTimes(2);
     expect(spy).toHaveBeenCalledTimes(2);
-
     expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING');
     expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING');
     expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
     expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
 
 

+ 45 - 27
packages/system-api/src/modules/system/__tests__/system.service.test.ts

@@ -1,15 +1,14 @@
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import semver from 'semver';
 import semver from 'semver';
-import childProcess from 'child_process';
 import axios from 'axios';
 import axios from 'axios';
 import SystemService from '../system.service';
 import SystemService from '../system.service';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import TipiCache from '../../../config/TipiCache';
 import TipiCache from '../../../config/TipiCache';
 import { setConfig } from '../../../core/config/TipiConfig';
 import { setConfig } from '../../../core/config/TipiConfig';
 import logger from '../../../config/logger/logger';
 import logger from '../../../config/logger/logger';
+import EventDispatcher from '../../../core/config/EventDispatcher';
 
 
 jest.mock('fs-extra');
 jest.mock('fs-extra');
-jest.mock('child_process');
 jest.mock('axios');
 jest.mock('axios');
 
 
 beforeEach(async () => {
 beforeEach(async () => {
@@ -21,14 +20,14 @@ describe('Test: systemInfo', () => {
   it('Should throw if system-info.json does not exist', () => {
   it('Should throw if system-info.json does not exist', () => {
     try {
     try {
       SystemService.systemInfo();
       SystemService.systemInfo();
-    } catch (e) {
+    } catch (e: any) {
       expect(e).toBeDefined();
       expect(e).toBeDefined();
-      // @ts-ignore
       expect(e.message).toBe('Error parsing system info');
       expect(e.message).toBe('Error parsing system info');
     }
     }
   });
   });
 
 
   it('It should return system info', async () => {
   it('It should return system info', async () => {
+    // Arrange
     const info = {
     const info = {
       cpu: { load: 0.1 },
       cpu: { load: 0.1 },
       memory: { available: 1000, total: 2000, used: 1000 },
       memory: { available: 1000, total: 2000, used: 1000 },
@@ -42,8 +41,10 @@ describe('Test: systemInfo', () => {
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
+    // Act
     const systemInfo = SystemService.systemInfo();
     const systemInfo = SystemService.systemInfo();
 
 
+    // Assert
     expect(systemInfo).toBeDefined();
     expect(systemInfo).toBeDefined();
     expect(systemInfo.cpu).toBeDefined();
     expect(systemInfo.cpu).toBeDefined();
     expect(systemInfo.memory).toBeDefined();
     expect(systemInfo.memory).toBeDefined();
@@ -60,11 +61,15 @@ describe('Test: getVersion', () => {
   });
   });
 
 
   it('It should return version', async () => {
   it('It should return version', async () => {
+    // Arrange
     const spy = jest.spyOn(axios, 'get').mockResolvedValue({
     const spy = jest.spyOn(axios, 'get').mockResolvedValue({
       data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
       data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
     });
     });
+
+    // Act
     const version = await SystemService.getVersion();
     const version = await SystemService.getVersion();
 
 
+    // Assert
     expect(version).toBeDefined();
     expect(version).toBeDefined();
     expect(version.current).toBeDefined();
     expect(version.current).toBeDefined();
     expect(semver.valid(version.latest)).toBeTruthy();
     expect(semver.valid(version.latest)).toBeTruthy();
@@ -85,17 +90,20 @@ describe('Test: getVersion', () => {
   });
   });
 
 
   it('Should return cached version', async () => {
   it('Should return cached version', async () => {
+    // Arrange
     const spy = jest.spyOn(axios, 'get').mockResolvedValue({
     const spy = jest.spyOn(axios, 'get').mockResolvedValue({
       data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
       data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
     });
     });
+
+    // Act
     const version = await SystemService.getVersion();
     const version = await SystemService.getVersion();
+    const version2 = await SystemService.getVersion();
 
 
+    // Assert
     expect(version).toBeDefined();
     expect(version).toBeDefined();
     expect(version.current).toBeDefined();
     expect(version.current).toBeDefined();
     expect(semver.valid(version.latest)).toBeTruthy();
     expect(semver.valid(version.latest)).toBeTruthy();
 
 
-    const version2 = await SystemService.getVersion();
-
     expect(version2.latest).toBe(version.latest);
     expect(version2.latest).toBe(version.latest);
     expect(version2.current).toBeDefined();
     expect(version2.current).toBeDefined();
     expect(semver.valid(version2.latest)).toBeTruthy();
     expect(semver.valid(version2.latest)).toBeTruthy();
@@ -108,88 +116,98 @@ describe('Test: getVersion', () => {
 
 
 describe('Test: restart', () => {
 describe('Test: restart', () => {
   it('Should return true', async () => {
   it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+
+    // Act
     const restart = await SystemService.restart();
     const restart = await SystemService.restart();
 
 
+    // Assert
     expect(restart).toBeTruthy();
     expect(restart).toBeTruthy();
   });
   });
 
 
   it('Should log error if fails', async () => {
   it('Should log error if fails', async () => {
-    // @ts-ignore
-    const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb('error', null, null);
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake' });
     const log = jest.spyOn(logger, 'error');
     const log = jest.spyOn(logger, 'error');
 
 
+    // Act
     const restart = await SystemService.restart();
     const restart = await SystemService.restart();
 
 
-    expect(restart).toBeTruthy();
-    expect(log).toHaveBeenCalledWith('Error restarting: error');
-
-    spy.mockRestore();
+    // Assert
+    expect(restart).toBeFalsy();
+    expect(log).toHaveBeenCalledWith('Error restarting system: fake');
+    log.mockRestore();
   });
   });
 });
 });
 
 
 describe('Test: update', () => {
 describe('Test: update', () => {
   it('Should return true', async () => {
   it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
     setConfig('version', '0.0.1');
     setConfig('version', '0.0.1');
     TipiCache.set('latestVersion', '0.0.2');
     TipiCache.set('latestVersion', '0.0.2');
 
 
+    // Act
     const update = await SystemService.update();
     const update = await SystemService.update();
 
 
+    // Assert
     expect(update).toBeTruthy();
     expect(update).toBeTruthy();
   });
   });
 
 
   it('Should throw an error if latest version is not set', async () => {
   it('Should throw an error if latest version is not set', async () => {
+    // Arrange
     TipiCache.del('latestVersion');
     TipiCache.del('latestVersion');
     const spy = jest.spyOn(axios, 'get').mockResolvedValue({
     const spy = jest.spyOn(axios, 'get').mockResolvedValue({
       data: { name: null },
       data: { name: null },
     });
     });
-
     setConfig('version', '0.0.1');
     setConfig('version', '0.0.1');
 
 
+    // Act & Assert
     await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
     await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
-
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
   it('Should throw if current version is higher than latest', async () => {
   it('Should throw if current version is higher than latest', async () => {
+    // Arrange
     setConfig('version', '0.0.2');
     setConfig('version', '0.0.2');
     TipiCache.set('latestVersion', '0.0.1');
     TipiCache.set('latestVersion', '0.0.1');
 
 
+    // Act & Assert
     await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
     await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
   });
   });
 
 
   it('Should throw if current version is equal to latest', async () => {
   it('Should throw if current version is equal to latest', async () => {
+    // Arrange
     setConfig('version', '0.0.1');
     setConfig('version', '0.0.1');
     TipiCache.set('latestVersion', '0.0.1');
     TipiCache.set('latestVersion', '0.0.1');
 
 
+    // Act & Assert
     await expect(SystemService.update()).rejects.toThrow('Current version is already up to date');
     await expect(SystemService.update()).rejects.toThrow('Current version is already up to date');
   });
   });
 
 
   it('Should throw an error if there is a major version difference', async () => {
   it('Should throw an error if there is a major version difference', async () => {
+    // Arrange
     setConfig('version', '0.0.1');
     setConfig('version', '0.0.1');
     TipiCache.set('latestVersion', '1.0.0');
     TipiCache.set('latestVersion', '1.0.0');
 
 
+    // Act & Assert
     await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
     await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
   });
   });
 
 
   it('Should log error if fails', async () => {
   it('Should log error if fails', async () => {
-    // @ts-ignore
-    const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb('error', null, null);
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake2' });
     const log = jest.spyOn(logger, 'error');
     const log = jest.spyOn(logger, 'error');
 
 
+    // Act
     setConfig('version', '0.0.1');
     setConfig('version', '0.0.1');
     TipiCache.set('latestVersion', '0.0.2');
     TipiCache.set('latestVersion', '0.0.2');
-
     const update = await SystemService.update();
     const update = await SystemService.update();
 
 
-    expect(update).toBeTruthy();
-    expect(log).toHaveBeenCalledWith('Error updating: error');
-
-    spy.mockRestore();
+    // Assert
+    expect(update).toBeFalsy();
+    expect(log).toHaveBeenCalledWith('Error updating system: fake2');
+    log.mockRestore();
   });
   });
 });
 });

+ 5 - 5
packages/system-api/src/modules/system/system.service.ts

@@ -5,7 +5,7 @@ import logger from '../../config/logger/logger';
 import TipiCache from '../../config/TipiCache';
 import TipiCache from '../../config/TipiCache';
 import { getConfig, setConfig } from '../../core/config/TipiConfig';
 import { getConfig, setConfig } from '../../core/config/TipiConfig';
 import { readJsonFile } from '../fs/fs.helpers';
 import { readJsonFile } from '../fs/fs.helpers';
-import EventDispatcher, { EventTypes } from '../../core/config/EventDispatcher';
+import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 
 
 const systemInfoSchema = z.object({
 const systemInfoSchema = z.object({
   cpu: z.object({
   cpu: z.object({
@@ -58,10 +58,10 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
 const restart = async (): Promise<boolean> => {
 const restart = async (): Promise<boolean> => {
   setConfig('status', 'RESTARTING');
   setConfig('status', 'RESTARTING');
 
 
-  const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.RESTART);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.RESTART);
 
 
   if (!success) {
   if (!success) {
-    logger.error('Error restarting system');
+    logger.error(`Error restarting system: ${stdout}`);
     return false;
     return false;
   }
   }
 
 
@@ -91,10 +91,10 @@ const update = async (): Promise<boolean> => {
 
 
   setConfig('status', 'UPDATING');
   setConfig('status', 'UPDATING');
 
 
-  const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
 
 
   if (!success) {
   if (!success) {
-    logger.error('Error updating system');
+    logger.error(`Error updating system: ${stdout}`);
     return false;
     return false;
   }
   }
 
 

+ 4 - 4
packages/system-api/src/server.ts

@@ -18,7 +18,7 @@ import startJobs from './core/jobs/jobs';
 import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
 import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
 import { ZodError } from 'zod';
 import { ZodError } from 'zod';
 import systemController from './modules/system/system.controller';
 import systemController from './modules/system/system.controller';
-import EventDispatcher, { EventTypes } from './core/config/EventDispatcher';
+import { eventDispatcher, EventTypes } from './core/config/EventDispatcher';
 
 
 let corsOptions = {
 let corsOptions = {
   credentials: true,
   credentials: true,
@@ -53,7 +53,7 @@ const applyCustomConfig = () => {
 
 
 const main = async () => {
 const main = async () => {
   try {
   try {
-    EventDispatcher.clear();
+    eventDispatcher.clear();
     applyCustomConfig();
     applyCustomConfig();
 
 
     const app = express();
     const app = express();
@@ -94,8 +94,8 @@ const main = async () => {
     await runUpdates();
     await runUpdates();
 
 
     httpServer.listen(port, async () => {
     httpServer.listen(port, async () => {
-      await EventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
-      await EventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
+      await eventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
+      await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
 
 
       startJobs();
       startJobs();
       setConfig('status', 'RUNNING');
       setConfig('status', 'RUNNING');

+ 6 - 0
packages/system-api/src/test/jest-setup.ts

@@ -1,5 +1,11 @@
+import { eventDispatcher } from '../core/config/EventDispatcher';
+
 jest.mock('../config/logger/logger', () => ({
 jest.mock('../config/logger/logger', () => ({
   error: jest.fn(),
   error: jest.fn(),
   info: jest.fn(),
   info: jest.fn(),
   warn: jest.fn(),
   warn: jest.fn(),
 }));
 }));
+
+afterAll(() => {
+  eventDispatcher.clear();
+});