Merge pull request #15 from meienberger/tests/jest-setup
Tests/jest setup
This commit is contained in:
commit
fa0a7d0764
35 changed files with 2158 additions and 4635 deletions
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,48 @@
|
|||
name: Tipi CI
|
||||
on:
|
||||
push:
|
||||
|
||||
env:
|
||||
ROOT_FOLDER: /test
|
||||
|
||||
jobs:
|
||||
cache-and-install:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- uses: pnpm/action-setup@v2.0.1
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r lint
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm -r test
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
.pnpm-debug.log
|
||||
.env
|
||||
.env*
|
||||
node_modules/
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "File Browser",
|
||||
"available": true,
|
||||
"port": 8096,
|
||||
"id": "filebrowser",
|
||||
"description": "Reliable and Performant File Management Desktop Sync and File Sharing",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "FreshRSS",
|
||||
"available": true,
|
||||
"port": 8086,
|
||||
"id": "freshrss",
|
||||
"description": "FreshRSS is a self-hosted RSS feed aggregator like Leed or Kriss Feed.\nIt is lightweight, easy to work with, powerful, and customizable.\n\nIt is a multi-user application with an anonymous reading mode. It supports custom tags. There is an API for (mobile) clients, and a Command-Line Interface.\n\nThanks to the WebSub standard (formerly PubSubHubbub), FreshRSS is able to receive instant push notifications from compatible sources, such as Mastodon, Friendica, WordPress, Blogger, FeedBurner, etc.\n\nFreshRSS natively supports basic Web scraping, based on XPath, for Web sites not providing any RSS / Atom feed.\n\nFinally, it supports extensions for further tuning.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Invidious",
|
||||
"available": true,
|
||||
"port": 8095,
|
||||
"id": "invidious",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Jackett",
|
||||
"available": true,
|
||||
"port": 8097,
|
||||
"id": "jackett",
|
||||
"description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
|
||||
|
@ -7,7 +8,5 @@
|
|||
"author": "",
|
||||
"source": "https://github.com/Jackett/Jackett",
|
||||
"image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
|
||||
"form_fields": {
|
||||
|
||||
}
|
||||
"form_fields": {}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Jellyfin",
|
||||
"available": true,
|
||||
"port": 8091,
|
||||
"id": "jellyfin",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Joplin Server",
|
||||
"available": true,
|
||||
"port": 8099,
|
||||
"id": "joplin",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"available": true,
|
||||
"port": 8094,
|
||||
"id": "n8n",
|
||||
"description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Nextcloud",
|
||||
"available": true,
|
||||
"port": 8083,
|
||||
"id": "nextcloud",
|
||||
"description": "Nextcloud is a self-hosted, open source, and fully-featured cloud storage solution for your personal files, office documents, and photos.",
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
{
|
||||
"name": "PiHole",
|
||||
"port": 8081,
|
||||
"requirements": {
|
||||
"ports": [53]
|
||||
},
|
||||
"id": "pihole",
|
||||
"description": "",
|
||||
"short_desc": "",
|
||||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
|
||||
"form_fields": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"max": 50,
|
||||
"min": 3,
|
||||
"required": true,
|
||||
"env_variable": "APP_PASSWORD"
|
||||
}
|
||||
"name": "PiHole",
|
||||
"available": true,
|
||||
"port": 8081,
|
||||
"requirements": {
|
||||
"ports": [53]
|
||||
},
|
||||
"id": "pihole",
|
||||
"description": "",
|
||||
"short_desc": "",
|
||||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
|
||||
"form_fields": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"max": 50,
|
||||
"min": 3,
|
||||
"required": true,
|
||||
"env_variable": "APP_PASSWORD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Radarr",
|
||||
"available": true,
|
||||
"port": 8088,
|
||||
"id": "radarr",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Sonarr",
|
||||
"available": true,
|
||||
"port": 8098,
|
||||
"id": "sonarr",
|
||||
"description": "",
|
||||
|
@ -7,7 +8,5 @@
|
|||
"author": "",
|
||||
"source": "",
|
||||
"image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
|
||||
"form_fields": {
|
||||
|
||||
}
|
||||
"form_fields": {}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Syncthing",
|
||||
"available": true,
|
||||
"port": 8090,
|
||||
"id": "syncthing",
|
||||
"description": "Syncthing is a peer-to-peer continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet.\n\nInstall the Syncthing app on your Umbrel and pair it with the Syncthing app on your phone or computer for a self hosted peer-to-peer backup solution.",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Tailscale",
|
||||
"available": true,
|
||||
"port": 8093,
|
||||
"id": "tailscale",
|
||||
"description": "",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Transmission",
|
||||
"available": true,
|
||||
"port": 8089,
|
||||
"requirements": {
|
||||
"ports": [51413]
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"name": "Wireguard",
|
||||
"available": true,
|
||||
"port": 8082,
|
||||
"requirements": {
|
||||
"ports": [51820]
|
||||
|
|
4
clean.sh
4
clean.sh
|
@ -1,4 +0,0 @@
|
|||
# Script to clean up the setup
|
||||
./scripts/stop.sh
|
||||
|
||||
sudo rm -rf app-data/**
|
|
@ -30,12 +30,15 @@
|
|||
"zustand": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@types/node": "17.0.31",
|
||||
"@types/react": "18.0.8",
|
||||
"@types/react-dom": "18.0.3",
|
||||
"@types/validator": "^13.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"eslint": "8.12.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.cjs
|
||||
*.cjs
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
env: { node: true },
|
||||
env: { node: true, jest: true },
|
||||
extends: ['airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
|
|
9
packages/system-api/__mocks__/child_process.ts
Normal file
9
packages/system-api/__mocks__/child_process.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
const childProcess: { execFile: typeof execFile } = jest.genMockFromModule('child_process');
|
||||
|
||||
const execFile = (path: string, args: string[], thing: any, callback: Function) => {
|
||||
callback();
|
||||
};
|
||||
|
||||
childProcess.execFile = execFile;
|
||||
|
||||
module.exports = childProcess;
|
86
packages/system-api/__mocks__/fs.ts
Normal file
86
packages/system-api/__mocks__/fs.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import path from 'path';
|
||||
const fs: {
|
||||
__createMockFiles: typeof createMockFiles;
|
||||
readFileSync: typeof readFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
writeFileSync: typeof writeFileSync;
|
||||
mkdirSync: typeof mkdirSync;
|
||||
rmSync: typeof rmSync;
|
||||
readdirSync: typeof readdirSync;
|
||||
copyFileSync: typeof copyFileSync;
|
||||
} = jest.genMockFromModule('fs');
|
||||
|
||||
let mockFiles = Object.create(null);
|
||||
|
||||
const createMockFiles = (newMockFiles: Record<string, string>) => {
|
||||
mockFiles = Object.create(null);
|
||||
|
||||
// Create folder tree
|
||||
for (const file in newMockFiles) {
|
||||
const dir = path.dirname(file);
|
||||
|
||||
if (!mockFiles[dir]) {
|
||||
mockFiles[dir] = [];
|
||||
}
|
||||
|
||||
mockFiles[dir].push(path.basename(file));
|
||||
mockFiles[file] = newMockFiles[file];
|
||||
}
|
||||
};
|
||||
|
||||
const readFileSync = (p: string) => {
|
||||
return mockFiles[p];
|
||||
};
|
||||
|
||||
const existsSync = (p: string) => {
|
||||
return mockFiles[p] !== undefined;
|
||||
};
|
||||
|
||||
const writeFileSync = (p: string, data: any) => {
|
||||
mockFiles[p] = data;
|
||||
};
|
||||
|
||||
const mkdirSync = (p: string) => {
|
||||
mockFiles[p] = Object.create(null);
|
||||
};
|
||||
|
||||
const rmSync = (p: string, options: { recursive: boolean }) => {
|
||||
if (options.recursive) {
|
||||
delete mockFiles[p];
|
||||
} else {
|
||||
delete mockFiles[p][Object.keys(mockFiles[p])[0]];
|
||||
}
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
fs.readdirSync = readdirSync;
|
||||
fs.existsSync = existsSync;
|
||||
fs.readFileSync = readFileSync;
|
||||
fs.writeFileSync = writeFileSync;
|
||||
fs.mkdirSync = mkdirSync;
|
||||
fs.rmSync = rmSync;
|
||||
fs.copyFileSync = copyFileSync;
|
||||
fs.__createMockFiles = createMockFiles;
|
||||
|
||||
module.exports = fs;
|
7
packages/system-api/jest.config.cjs
Normal file
7
packages/system-api/jest.config.cjs
Normal file
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
|
||||
};
|
|
@ -2,12 +2,15 @@
|
|||
"name": "system-api",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/server.ts",
|
||||
"exports": "./dist/server.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest",
|
||||
"build-prod": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --minify --analyze=verbose --external:./node_modules/* --format=esm",
|
||||
"build:watch": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --external:./node_modules/* --format=esm --watch",
|
||||
"start:dev": "NODE_ENV=development nodemon --trace-deprecation --trace-warnings --watch dist dist/server.js",
|
||||
|
@ -24,7 +27,7 @@
|
|||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.3",
|
||||
"helmet": "^5.0.2",
|
||||
"internal-ip": "^7.0.0",
|
||||
"internal-ip": "^6.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"node-port-scanner": "^3.0.1",
|
||||
"p-iteration": "^1.1.8",
|
||||
|
@ -33,7 +36,8 @@
|
|||
"passport-http-bearer": "^1.0.1",
|
||||
"public-ip": "^5.0.0",
|
||||
"systeminformation": "^5.11.9",
|
||||
"tcp-port-used": "^1.0.2"
|
||||
"tcp-port-used": "^1.0.2",
|
||||
"mock-fs": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
|
@ -41,23 +45,26 @@
|
|||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/passport": "^1.0.7",
|
||||
"@types/passport-http-bearer": "^1.0.37",
|
||||
"@types/tcp-port-used": "^1.0.1",
|
||||
"@types/validator": "^13.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.22.0",
|
||||
"concurrently": "^7.1.0",
|
||||
"esbuild": "^0.14.32",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-hardcore": "^24.5.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-react": "^1.1.7",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-unicorn": "^42.0.0",
|
||||
"jest": "^28.1.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"prettier": "2.6.2"
|
||||
"prettier": "2.6.2",
|
||||
"ts-jest": "^28.0.2",
|
||||
"typescript": "4.6.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ export type Maybe<T> = T | null | undefined;
|
|||
|
||||
export interface AppConfig {
|
||||
id: string;
|
||||
available: boolean;
|
||||
port: number;
|
||||
name: string;
|
||||
requirements?: {
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
import AppsService from '../apps.service';
|
||||
import fs from 'fs';
|
||||
import config from '../../../config';
|
||||
import { AppConfig, FieldTypes } from '../../../config/types';
|
||||
import childProcess from 'child_process';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('child_process');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const testApp: Partial<AppConfig> = {
|
||||
id: 'test-app',
|
||||
port: 3000,
|
||||
available: true,
|
||||
form_fields: {
|
||||
test: {
|
||||
type: FieldTypes.text,
|
||||
label: 'Test field',
|
||||
required: true,
|
||||
env_variable: 'TEST_FIELD',
|
||||
},
|
||||
test2: {
|
||||
type: FieldTypes.text,
|
||||
label: 'Test field 2',
|
||||
required: false,
|
||||
env_variable: 'TEST_FIELD_2',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const testApp2: Partial<AppConfig> = {
|
||||
available: true,
|
||||
id: 'test-app2',
|
||||
};
|
||||
|
||||
const MOCK_FILE_EMPTY = {
|
||||
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
|
||||
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
|
||||
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": ""}',
|
||||
};
|
||||
|
||||
const MOCK_FILE_INSTALLED = {
|
||||
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
|
||||
[`${config.ROOT_FOLDER}/apps/test-app2/config.json`]: JSON.stringify(testApp2),
|
||||
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
|
||||
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": "test-app"}',
|
||||
[`${config.ROOT_FOLDER}/app-data/test-app`]: '',
|
||||
[`${config.ROOT_FOLDER}/app-data/test-app/app.env`]: 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test',
|
||||
};
|
||||
|
||||
describe('Install app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_EMPTY);
|
||||
});
|
||||
|
||||
it('Should correctly generate env file for app', async () => {
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
|
||||
});
|
||||
|
||||
it('Should add app to state file', async () => {
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
|
||||
|
||||
expect(stateFile.installed).toBe(' test-app');
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should start app if already installed', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
await AppsService.installApp('test-app', { test: 'test' });
|
||||
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
|
||||
expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if required form fields are missing', async () => {
|
||||
await expect(AppsService.installApp('test-app', {})).rejects.toThrowError('Variable test is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Uninstall app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly remove app from state file', async () => {
|
||||
await AppsService.uninstallApp('test-app');
|
||||
|
||||
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
|
||||
|
||||
expect(stateFile.installed).toBe('');
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.uninstallApp('test-app');
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.uninstallApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.startApp('test-app');
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
|
||||
it('Should restart if app is already running', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.startApp('test-app');
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
await AppsService.startApp('test-app');
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
|
||||
it('Regenerate env file', async () => {
|
||||
fs.writeFile(`${config.ROOT_FOLDER}/app-data/test-app/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
|
||||
|
||||
await AppsService.startApp('test-app');
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop app', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly run app script', async () => {
|
||||
const spy = jest.spyOn(childProcess, 'execFile');
|
||||
|
||||
await AppsService.stopApp('test-app');
|
||||
|
||||
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', 'test-app'], {}, expect.any(Function)]);
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.stopApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update app config', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly update app config', async () => {
|
||||
await AppsService.updateAppConfig('test-app', { test: 'test', test2: 'test2' });
|
||||
|
||||
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
|
||||
|
||||
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test\nTEST_FIELD_2=test2');
|
||||
});
|
||||
|
||||
it('Should throw if app is not installed', async () => {
|
||||
await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not installed');
|
||||
});
|
||||
|
||||
it('Should throw if required form fields are missing', async () => {
|
||||
await expect(AppsService.updateAppConfig('test-app', {})).rejects.toThrowError('Variable test is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get app config', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly get app config', async () => {
|
||||
const appconfig = await AppsService.getAppInfo('test-app');
|
||||
|
||||
expect(appconfig).toEqual({ ...testApp, installed: true, status: 'stopped' });
|
||||
});
|
||||
|
||||
it('Should have installed false if app is not installed', async () => {
|
||||
const appconfig = await AppsService.getAppInfo('test-app2');
|
||||
|
||||
expect(appconfig).toEqual({ ...testApp2, installed: false, status: 'stopped' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('List apps', () => {
|
||||
beforeEach(() => {
|
||||
// @ts-ignore
|
||||
fs.__createMockFiles(MOCK_FILE_INSTALLED);
|
||||
});
|
||||
|
||||
it('Should correctly list apps', async () => {
|
||||
const apps = await AppsService.listApps();
|
||||
|
||||
expect(apps).toEqual([
|
||||
{ ...testApp, installed: true, status: 'stopped' },
|
||||
{ ...testApp2, installed: false, status: 'stopped' },
|
||||
]);
|
||||
expect(apps.length).toBe(2);
|
||||
expect(apps[0].id).toBe('test-app');
|
||||
expect(apps[1].id).toBe('test-app2');
|
||||
});
|
||||
});
|
|
@ -1,34 +1,7 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import si from 'systeminformation';
|
||||
import { appNames } from '../../config/apps';
|
||||
import AppsService from './apps.service';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { createFolder, fileExists, readJsonFile, writeFile, readFile } from '../fs/fs.helpers';
|
||||
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, getInitalFormValues, runAppScript } from './apps.helpers';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
const getStateFile = (): AppsState => {
|
||||
return readJsonFile('/state/apps.json');
|
||||
};
|
||||
|
||||
const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
|
||||
Object.keys(configFile.form_fields).forEach((key) => {
|
||||
const value = form[key];
|
||||
|
||||
if (value) {
|
||||
const envVar = configFile.form_fields[key].env_variable;
|
||||
envFile += `${envVar}=${value}\n`;
|
||||
} else if (configFile.form_fields[key].required) {
|
||||
throw new Error(`Variable ${key} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
};
|
||||
import { getInitalFormValues } from './apps.helpers';
|
||||
|
||||
const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
@ -38,11 +11,7 @@ const uninstallApp = async (req: Request, res: Response, next: NextFunction) =>
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
ensureAppState(appName, false);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['uninstall', appName]);
|
||||
await AppsService.uninstallApp(appName);
|
||||
|
||||
res.status(200).json({ message: 'App uninstalled successfully' });
|
||||
} catch (e) {
|
||||
|
@ -58,9 +27,7 @@ const stopApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
// Run script
|
||||
await runAppScript(['stop', appName]);
|
||||
await AppsService.stopApp(appName);
|
||||
|
||||
res.status(200).json({ message: 'App stopped successfully' });
|
||||
} catch (e) {
|
||||
|
@ -77,8 +44,7 @@ const updateAppConfig = async (req: Request, res: Response, next: NextFunction)
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
generateEnvFile(appName, form);
|
||||
AppsService.updateAppConfig(appName, form);
|
||||
|
||||
res.status(200).json({ message: 'App updated successfully' });
|
||||
} catch (e) {
|
||||
|
@ -94,15 +60,9 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
|
||||
const appInfo = await AppsService.getAppInfo(id);
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
configFile.installed = installed.includes(id);
|
||||
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
|
||||
|
||||
res.status(200).json(configFile);
|
||||
res.status(200).json(appInfo);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
@ -110,25 +70,7 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
|
|||
|
||||
const listApps = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const apps = appNames
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/apps/${app}/config.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.installed = installed.includes(app.id);
|
||||
app.status = dockerContainers.find((container) => container.name === `${app.id}`)?.state || 'stopped';
|
||||
});
|
||||
const apps = await AppsService.listApps();
|
||||
|
||||
res.status(200).json(apps);
|
||||
} catch (e) {
|
||||
|
@ -138,23 +80,13 @@ const listApps = async (req: Request, res: Response, next: NextFunction) => {
|
|||
|
||||
const startApp = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id: appName } = req.params;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!appName) {
|
||||
if (!id) {
|
||||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
ensureAppState(appName, true);
|
||||
await AppsService.startApp(id);
|
||||
|
||||
res.status(200).json({ message: 'App started successfully' });
|
||||
} catch (e) {
|
||||
|
@ -171,35 +103,7 @@ const installApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
const appIsAvailable = appNames.includes(id);
|
||||
|
||||
if (!appIsAvailable) {
|
||||
throw new Error(`App ${id} not available`);
|
||||
}
|
||||
|
||||
const appExists = fileExists(`/app-data/${id}`);
|
||||
|
||||
if (appExists) {
|
||||
await startApp(req, res, next);
|
||||
} else {
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
throw new Error(`App ${id} requirements not met`);
|
||||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
ensureAppState(id, true);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['install', id]);
|
||||
|
||||
res.status(200).json({ message: 'App installed successfully' });
|
||||
}
|
||||
await AppsService.installApp(id, form);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import portUsed from 'tcp-port-used';
|
||||
import p from 'p-iteration';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { fileExists, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import { fileExists, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
|
||||
import InternalIp from 'internal-ip';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
export const checkAppRequirements = async (appName: string) => {
|
||||
let valid = true;
|
||||
|
@ -10,7 +12,7 @@ export const checkAppRequirements = async (appName: string) => {
|
|||
|
||||
if (configFile.requirements?.ports) {
|
||||
await p.forEachSeries(configFile.requirements.ports, async (port: number) => {
|
||||
const ip = await internalIpV4();
|
||||
const ip = await InternalIp.v4();
|
||||
const used = await portUsed.check(port, ip);
|
||||
|
||||
if (used) valid = false;
|
||||
|
@ -94,8 +96,49 @@ export const ensureAppState = (appName: string, installed: boolean) => {
|
|||
}
|
||||
} else {
|
||||
if (state.installed.indexOf(appName) !== -1) {
|
||||
state.installed = state.installed.replace(` ${appName}`, '');
|
||||
state.installed = state.installed.replace(`${appName}`, '');
|
||||
writeFile('/state/apps.json', JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
|
||||
Object.keys(configFile.form_fields).forEach((key) => {
|
||||
const value = form[key];
|
||||
|
||||
if (value) {
|
||||
const envVar = configFile.form_fields[key].env_variable;
|
||||
envFile += `${envVar}=${value}\n`;
|
||||
} else if (configFile.form_fields[key].required) {
|
||||
throw new Error(`Variable ${key} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
};
|
||||
|
||||
export const getStateFile = (): AppsState => {
|
||||
return readJsonFile('/state/apps.json');
|
||||
};
|
||||
|
||||
export const getAvailableApps = (): string[] => {
|
||||
const apps: string[] = [];
|
||||
|
||||
const appsDir = readdirSync('/apps');
|
||||
|
||||
appsDir.forEach((app) => {
|
||||
if (fileExists(`/apps/${app}/config.json`)) {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${app}/config.json`);
|
||||
|
||||
if (configFile.available) {
|
||||
apps.push(app);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
|
101
packages/system-api/src/modules/apps/apps.service.ts
Normal file
101
packages/system-api/src/modules/apps/apps.service.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import si from 'systeminformation';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
|
||||
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getAvailableApps, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
|
||||
|
||||
const startApp = async (appName: string): Promise<void> => {
|
||||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
ensureAppState(appName, true);
|
||||
};
|
||||
|
||||
const installApp = async (id: string, form: Record<string, string>): Promise<void> => {
|
||||
const appExists = fileExists(`/app-data/${id}`);
|
||||
|
||||
if (appExists) {
|
||||
await startApp(id);
|
||||
} else {
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
throw new Error(`App ${id} requirements not met`);
|
||||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
ensureAppState(id, true);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['install', id]);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const listApps = async (): Promise<AppConfig[]> => {
|
||||
const apps: AppConfig[] = getAvailableApps()
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/apps/${app}/config.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.installed = installed.includes(app.id);
|
||||
app.status = (dockerContainers.find((container) => container.name === `${app.id}`)?.state as 'running') || 'stopped';
|
||||
});
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
const getAppInfo = async (id: string): Promise<AppConfig> => {
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
configFile.installed = installed.includes(id);
|
||||
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
|
||||
|
||||
return configFile;
|
||||
};
|
||||
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
generateEnvFile(id, form);
|
||||
};
|
||||
|
||||
const stopApp = async (id: string): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
// Run script
|
||||
await runAppScript(['stop', id]);
|
||||
};
|
||||
|
||||
const uninstallApp = async (id: string): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
ensureAppState(id, false);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['uninstall', id]);
|
||||
};
|
||||
|
||||
export default { installApp, startApp, listApps, getAppInfo, updateAppConfig, stopApp, uninstallApp };
|
|
@ -11,6 +11,8 @@ export const readJsonFile = (path: string): any => {
|
|||
|
||||
export const readFile = (path: string): string => fs.readFileSync(getAbsolutePath(path)).toString();
|
||||
|
||||
export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
|
||||
|
||||
export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
|
||||
|
||||
export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response } from 'express';
|
||||
import publicIp from 'public-ip';
|
||||
import portScanner from 'node-port-scanner';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import internalIp from 'internal-ip';
|
||||
|
||||
const isPortOpen = async (req: Request, res: Response<boolean>) => {
|
||||
const { port } = req.params;
|
||||
|
@ -14,7 +14,7 @@ const isPortOpen = async (req: Request, res: Response<boolean>) => {
|
|||
};
|
||||
|
||||
const getInternalIp = async (req: Request, res: Response<string>) => {
|
||||
const ip = await internalIpV4();
|
||||
const ip = await internalIp.v4();
|
||||
|
||||
res.status(200).send(ip);
|
||||
};
|
||||
|
|
3
packages/system-api/tests/dotenv-config.ts
Normal file
3
packages/system-api/tests/dotenv-config.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env.test' });
|
|
@ -8,13 +8,13 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"isolatedModules": false,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "jest.config.cjs"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
5995
pnpm-lock.yaml
generated
5995
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue