test(worker): implement test suites with vitest

This commit is contained in:
Nicolas Meienberger 2023-11-15 12:14:34 +01:00 committed by Nicolas Meienberger
parent 55e0cd155e
commit af8509aacc
24 changed files with 2438 additions and 58 deletions

1
.gitignore vendored
View file

@ -56,6 +56,7 @@ node_modules/
/apps/
traefik/shared
traefik/tls
./traefik/
# media folder
media

View file

@ -55,6 +55,45 @@ services:
networks:
- tipi_main_network
tipi-worker:
build:
context: .
dockerfile: ./packages/worker/Dockerfile.dev
container_name: tipi-worker
user: root
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s
timeout: 10s
retries: 120
start_period: 5s
depends_on:
tipi-db:
condition: service_healthy
tipi-redis:
condition: service_healthy
env_file:
- .env
environment:
NODE_ENV: development
volumes:
# Dev mode
- ${PWD}/packages/worker/src:/app/packages/worker/src
# Production mode
- /:/mnt/host:ro
- /proc:/host/proc:ro
- /var/run/docker.sock:/var/run/docker.sock
- ${PWD}/.env:/app/.env
- ${PWD}/state:/app/state
- ${PWD}/repos:/app/repos
- ${PWD}/apps:/app/apps
- ${STORAGE_PATH:-$PWD}/app-data:/storage/app-data
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/app/traefik
- ${PWD}/user-config:/app/user-config
networks:
- tipi_main_network
tipi-dashboard:
build:
context: .
@ -65,6 +104,8 @@ services:
condition: service_healthy
tipi-redis:
condition: service_healthy
tipi-worker:
condition: service_healthy
env_file:
- .env
environment:
@ -84,7 +125,7 @@ services:
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/runtipi/traefik
- ${STORAGE_PATH}:/app/storage
- ${STORAGE_PATH:-$PWD}:/app/storage
labels:
traefik.enable: true
traefik.http.services.dashboard.loadbalancer.server.port: 3000

View file

@ -55,6 +55,42 @@ services:
networks:
- tipi_main_network
tipi-worker:
build:
context: .
dockerfile: ./packages/worker/Dockerfile
container_name: tipi-worker
user: root
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s
timeout: 10s
retries: 120
start_period: 5s
depends_on:
tipi-db:
condition: service_healthy
tipi-redis:
condition: service_healthy
env_file:
- .env
environment:
NODE_ENV: production
volumes:
- /:/host/root:ro
- /proc:/host/proc
- /var/run/docker.sock:/var/run/docker.sock
- ${PWD}/.env:/app/.env
- ${PWD}/state:/app/state
- ${PWD}/repos:/app/repos
- ${PWD}/apps:/app/apps
- ${STORAGE_PATH:-$PWD}/app-data:/storage/app-data
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/app/traefik
- ${PWD}/user-config:/app/user-config
networks:
- tipi_main_network
tipi-dashboard:
build:
context: .
@ -65,6 +101,8 @@ services:
condition: service_healthy
tipi-redis:
condition: service_healthy
tipi-worker:
condition: service_healthy
env_file:
- .env
environment:

View file

@ -57,6 +57,7 @@ services:
tipi-worker:
container_name: tipi-worker
image: ghcr.io/runtipi/runtipi-worker:${TIPI_VERSION}
user: root
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s
@ -83,6 +84,7 @@ services:
- ${STORAGE_PATH:-$PWD}/app-data:/storage/app-data
- ${PWD}/logs:/app/logs
- ${PWD}/traefik:/app/traefik
- ${PWD}/user-config:/app/user-config
networks:
- tipi_main_network

View file

@ -5,7 +5,7 @@
"main": "index.js",
"bin": "dist/index.js",
"scripts": {
"test": "dotenv -e .env.test vitest -- --coverage --watch=false",
"test": "dotenv -e .env.test vitest -- --coverage --watch=false --passWithNoTests",
"test:watch": "dotenv -e .env.test vitest",
"package": "npm run build && pkg package.json && chmod +x dist/bin/cli-x64 && chmod +x dist/bin/cli-arm64",
"package:m1": "npm run build && pkg package.json -t node18-darwin-arm64",

View file

@ -33,7 +33,7 @@ export class AppExecutors {
const jobid = this.generateJobId({ appId, action: 'stop' });
const event = { type: 'app', command: 'stop', appid: appId, form: {} } satisfies SystemEvent;
const event = { type: 'app', command: 'stop', appid: appId, form: {}, skipEnv: true } satisfies SystemEvent;
const job = await this.queue.add(jobid, eventSchema.parse(event));
const result = await job.waitUntilFinished(this.queueEvents, 1000 * 60 * 5);
@ -51,7 +51,7 @@ export class AppExecutors {
const jobid = this.generateJobId({ appId, action: 'start' });
const event = { type: 'app', command: 'start', appid: appId, form: {} } satisfies SystemEvent;
const event = { type: 'app', command: 'start', appid: appId, form: {}, skipEnv: true } satisfies SystemEvent;
const job = await this.queue.add(jobid, eventSchema.parse(event));
const result = await job.waitUntilFinished(this.queueEvents, 1000 * 60 * 5);

View file

@ -12,6 +12,7 @@ const appCommandSchema = z.object({
type: z.literal(EVENT_TYPES.APP),
command: z.union([z.literal('start'), z.literal('stop'), z.literal('install'), z.literal('uninstall'), z.literal('update'), z.literal('generate_env')]),
appid: z.string(),
skipEnv: z.boolean().optional().default(false),
form: z.object({}).catchall(z.any()),
});
@ -33,4 +34,4 @@ export const eventResultSchema = z.object({
stdout: z.string(),
});
export type SystemEvent = z.infer<typeof eventSchema>;
export type SystemEvent = z.input<typeof eventSchema>;

14
packages/worker/.env.test Normal file
View file

@ -0,0 +1,14 @@
INTERNAL_IP=localhost
ARCHITECTURE=arm64
APPS_REPO_ID=repo-id
APPS_REPO_URL=https://test.com/test
ROOT_FOLDER_HOST=/runtipi
STORAGE_PATH=/runtipi
TIPI_VERSION=1
REDIS_PASSWORD=redis
REDIS_HOST=localhost
POSTGRES_HOST=localhost
POSTGRES_DBNAME=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433

View file

@ -1 +1,2 @@
dist/
coverage/

View file

@ -4,7 +4,8 @@
"description": "",
"main": "src/index.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "dotenv -e .env.test vitest -- --coverage --watch=false",
"test:watch": "dotenv -e .env.test vitest",
"build": "node build.js",
"tsc": "tsc",
"dev": "dotenv -e ../../.env nodemon",
@ -14,13 +15,17 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@faker-js/faker": "^8.2.0",
"@types/web-push": "^3.6.2",
"dotenv-cli": "^7.3.0",
"esbuild": "^0.19.4",
"knip": "^2.39.0",
"memfs": "^4.6.0",
"nodemon": "^3.0.1",
"tsx": "^3.14.0",
"typescript": "^5.2.2"
"typescript": "^5.2.2",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^0.34.6"
},
"dependencies": {
"@runtipi/postgres-migrations": "^5.3.0",

View file

@ -0,0 +1,125 @@
// const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: '', stderr: randomError }));
import { vi, it, describe, expect } from 'vitest';
import { faker } from '@faker-js/faker';
import fs from 'fs';
import { compose } from './docker-helpers';
const execAsync = vi.fn().mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' }));
vi.mock('@runtipi/shared', async (importOriginal) => {
const mod = (await importOriginal()) as object;
return {
...mod,
FileLogger: vi.fn().mockImplementation(() => ({
flush: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
execAsync: (cmd: string) => execAsync(cmd),
};
});
describe('docker helpers', async () => {
it('should call execAsync with correct args', async () => {
// arrange
const appId = faker.word.noun().toLowerCase();
const command = faker.word.noun().toLowerCase();
// act
await compose(appId, command);
// assert
const expected = [
'docker compose',
`--env-file /storage/app-data/${appId}/app.env`,
`--project-name ${appId}`,
`-f /app/apps/${appId}/docker-compose.yml`,
'-f /app/repos/repo-id/apps/docker-compose.common.yml',
command,
].join(' ');
expect(execAsync).toHaveBeenCalledWith(expected);
});
it('should add user env file if exists', async () => {
// arrange
const appId = faker.word.noun().toLowerCase();
const command = faker.word.noun().toLowerCase();
await fs.promises.mkdir(`/app/user-config/${appId}`, { recursive: true });
const userEnvFile = `/app/user-config/${appId}/app.env`;
await fs.promises.writeFile(userEnvFile, 'test');
// act
await compose(appId, command);
// assert
const expected = [
'docker compose',
`--env-file /storage/app-data/${appId}/app.env`,
`--env-file ${userEnvFile}`,
`--project-name ${appId}`,
`-f /app/apps/${appId}/docker-compose.yml`,
'-f /app/repos/repo-id/apps/docker-compose.common.yml',
command,
].join(' ');
expect(execAsync).toHaveBeenCalledWith(expected);
});
it('should add user compose file if exists', async () => {
// arrange
const appId = faker.word.noun().toLowerCase();
const command = faker.word.noun().toLowerCase();
await fs.promises.mkdir(`/app/user-config/${appId}`, { recursive: true });
const userComposeFile = `/app/user-config/${appId}/docker-compose.yml`;
await fs.promises.writeFile(userComposeFile, 'test');
// act
await compose(appId, command);
// assert
const expected = [
'docker compose',
`--env-file /storage/app-data/${appId}/app.env`,
`--project-name ${appId}`,
`-f /app/apps/${appId}/docker-compose.yml`,
'-f /app/repos/repo-id/apps/docker-compose.common.yml',
`--file ${userComposeFile}`,
command,
].join(' ');
expect(execAsync).toHaveBeenCalledWith(expected);
});
it('should add arm64 compose file if exists and arch is arm64', async () => {
// arrange
vi.mock('@/lib/environment', async (importOriginal) => {
const mod = (await importOriginal()) as object;
return { ...mod, getEnv: () => ({ arch: 'arm64', appsRepoId: 'repo-id' }) };
});
const appId = faker.word.noun().toLowerCase();
const command = faker.word.noun().toLowerCase();
await fs.promises.mkdir(`/app/apps/${appId}`, { recursive: true });
const arm64ComposeFile = `/app/apps/${appId}/docker-compose.arm64.yml`;
await fs.promises.writeFile(arm64ComposeFile, 'test');
// act
await compose(appId, command);
// assert
const expected = [
'docker compose',
`--env-file /storage/app-data/${appId}/app.env`,
`--project-name ${appId}`,
`-f ${arm64ComposeFile}`,
`-f /app/repos/repo-id/apps/docker-compose.common.yml`,
command,
].join(' ');
expect(execAsync).toHaveBeenCalledWith(expected);
});
});

View file

@ -1 +1 @@
export { setEnvVariable, copySystemFiles, generateSystemEnvFile, ensureFilePermissions, generateTlsCertificates } from './system.helpers';
export { copySystemFiles, generateSystemEnvFile, ensureFilePermissions, generateTlsCertificates } from './system.helpers';

View file

@ -2,13 +2,14 @@ import fs from 'fs';
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import { faker } from '@faker-js/faker';
import { pathExists } from '@runtipi/shared';
import { AppExecutors } from '../app.executors';
import { createAppConfig } from '@/tests/apps.factory';
import * as dockerHelpers from '@/utils/docker-helpers';
import { getEnv } from '@/utils/environment/environment';
import { pathExists } from '@/utils/fs-helpers';
import * as dockerHelpers from '@/lib/docker';
import { getEnv } from '@/lib/environment';
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
const { storagePath, rootFolderHost, appsRepoId } = getEnv();
const { appsRepoId } = getEnv();
describe('test: app executors', () => {
const appExecutors = new AppExecutors();
@ -23,7 +24,7 @@ describe('test: app executors', () => {
const { message, success } = await appExecutors.installApp(config.id, config);
// assert
const envExists = await pathExists(path.join(storagePath, 'app-data', config.id, 'app.env'));
const envExists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, 'app.env'));
expect(success).toBe(true);
expect(message).toBe(`App ${config.id} installed successfully`);
@ -32,17 +33,32 @@ describe('test: app executors', () => {
spy.mockRestore();
});
it('should return error if compose script fails', async () => {
// arrange
const randomError = faker.system.fileName();
const spy = vi.spyOn(dockerHelpers, 'compose').mockImplementation(() => Promise.resolve({ stdout: '', stderr: randomError }));
const config = createAppConfig({}, false);
// act
const { message, success } = await appExecutors.installApp(config.id, config);
// assert
expect(success).toBe(false);
expect(message).toContain(randomError);
spy.mockRestore();
});
it('should delete existing app folder', async () => {
// arrange
const config = createAppConfig();
await fs.promises.mkdir(path.join(rootFolderHost, 'apps', config.id), { recursive: true });
await fs.promises.writeFile(path.join(rootFolderHost, 'apps', config.id, 'test.txt'), 'test');
await fs.promises.mkdir(path.join(ROOT_FOLDER, 'apps', config.id), { recursive: true });
await fs.promises.writeFile(path.join(ROOT_FOLDER, 'apps', config.id, 'test.txt'), 'test');
// act
await appExecutors.installApp(config.id, config);
// assert
const exists = await pathExists(path.join(storagePath, 'apps', config.id, 'test.txt'));
const exists = await pathExists(path.join(STORAGE_FOLDER, 'apps', config.id, 'test.txt'));
expect(exists).toBe(false);
});
@ -51,13 +67,13 @@ describe('test: app executors', () => {
// arrange
const config = createAppConfig();
const filename = faker.system.fileName();
await fs.promises.writeFile(path.join(storagePath, 'app-data', config.id, filename), 'test');
await fs.promises.writeFile(path.join(STORAGE_FOLDER, 'app-data', config.id, filename), 'test');
// act
await appExecutors.installApp(config.id, config);
// assert
const exists = await pathExists(path.join(storagePath, 'app-data', config.id, filename));
const exists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, filename));
expect(exists).toBe(true);
});
@ -66,15 +82,15 @@ describe('test: app executors', () => {
// arrange
const config = createAppConfig({}, false);
const filename = faker.system.fileName();
await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true });
await fs.promises.writeFile(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test');
await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true });
await fs.promises.writeFile(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'test');
// act
await appExecutors.installApp(config.id, config);
// assert
const exists = await pathExists(path.join(storagePath, 'app-data', config.id, 'data', filename));
const data = await fs.promises.readFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'utf-8');
const exists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename));
const data = await fs.promises.readFile(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename), 'utf-8');
expect(exists).toBe(true);
expect(data).toBe('test');
@ -84,16 +100,16 @@ describe('test: app executors', () => {
// arrange
const config = createAppConfig();
const filename = faker.system.fileName();
await fs.promises.writeFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'test');
await fs.promises.mkdir(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true });
await fs.promises.writeFile(path.join(rootFolderHost, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah');
await fs.promises.writeFile(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename), 'test');
await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data'), { recursive: true });
await fs.promises.writeFile(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps', config.id, 'data', filename), 'yeah');
// act
await appExecutors.installApp(config.id, config);
// assert
const exists = await pathExists(path.join(storagePath, 'app-data', config.id, 'data', filename));
const data = await fs.promises.readFile(path.join(storagePath, 'app-data', config.id, 'data', filename), 'utf-8');
const exists = await pathExists(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename));
const data = await fs.promises.readFile(path.join(STORAGE_FOLDER, 'app-data', config.id, 'data', filename), 'utf-8');
expect(exists).toBe(true);
expect(data).toBe('test');

View file

@ -1,20 +1,18 @@
import fs from 'fs';
import { describe, it, expect } from 'vitest';
import { faker } from '@faker-js/faker';
import { pathExists } from '@runtipi/shared';
import { copyDataDir, generateEnvFile } from '../app.helpers';
import { createAppConfig } from '@/tests/apps.factory';
import { getAppEnvMap } from '../env.helpers';
import { getEnv } from '@/utils/environment/environment';
import { pathExists } from '@/utils/fs-helpers';
const { rootFolderHost, storagePath } = getEnv();
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
describe('app helpers', () => {
describe('Test: generateEnvFile()', () => {
it('should throw an error if the app has an invalid config.json file', async () => {
// arrange
const appConfig = createAppConfig();
await fs.promises.writeFile(`${rootFolderHost}/apps/${appConfig.id}/config.json`, '{}');
await fs.promises.writeFile(`${ROOT_FOLDER}/apps/${appConfig.id}/config.json`, '{}');
// act & assert
expect(generateEnvFile(appConfig.id, {})).rejects.toThrowError(`App ${appConfig.id} has invalid config.json file`);
@ -50,8 +48,8 @@ describe('app helpers', () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] });
const randomField = faker.string.alphanumeric(32);
await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`${rootFolderHost}/app-data/${appConfig.id}/app.env`, `RANDOM_FIELD=${randomField}`);
await fs.promises.mkdir(`${STORAGE_FOLDER}/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`${STORAGE_FOLDER}/app-data/${appConfig.id}/app.env`, `RANDOM_FIELD=${randomField}`);
// act
await generateEnvFile(appConfig.id, {});
@ -117,7 +115,7 @@ describe('app helpers', () => {
it('Should not re-create app-data folder if it already exists', async () => {
// arrange
const appConfig = createAppConfig({});
await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.mkdir(`${ROOT_FOLDER}/app-data/${appConfig.id}`, { recursive: true });
// act
await generateEnvFile(appConfig.id, {});
@ -161,8 +159,8 @@ describe('app helpers', () => {
const vapidPublicKey = faker.string.alphanumeric(32);
// act
await fs.promises.mkdir(`${rootFolderHost}/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`${rootFolderHost}/app-data/${appConfig.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`);
await fs.promises.mkdir(`${STORAGE_FOLDER}/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`${STORAGE_FOLDER}/app-data/${appConfig.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`);
await generateEnvFile(appConfig.id, {});
const envmap = await getAppEnvMap(appConfig.id);
@ -181,13 +179,13 @@ describe('app helpers', () => {
await copyDataDir(appConfig.id);
// assert
expect(await pathExists(`${rootFolderHost}/apps/${appConfig.id}/data`)).toBe(false);
expect(await pathExists(`${ROOT_FOLDER}/apps/${appConfig.id}/data`)).toBe(false);
});
it('should copy data dir to app-data folder', async () => {
// arrange
const appConfig = createAppConfig({});
const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`;
const dataDir = `${ROOT_FOLDER}/apps/${appConfig.id}/data`;
await fs.promises.mkdir(dataDir, { recursive: true });
await fs.promises.writeFile(`${dataDir}/test.txt`, 'test');
@ -196,14 +194,14 @@ describe('app helpers', () => {
await copyDataDir(appConfig.id);
// assert
const appDataDir = `${storagePath}/app-data/${appConfig.id}`;
const appDataDir = `${STORAGE_FOLDER}/app-data/${appConfig.id}`;
expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test');
});
it('should copy folders recursively', async () => {
// arrange
const appConfig = createAppConfig({});
const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`;
const dataDir = `${ROOT_FOLDER}/apps/${appConfig.id}/data`;
await fs.promises.mkdir(dataDir, { recursive: true });
@ -217,7 +215,7 @@ describe('app helpers', () => {
await copyDataDir(appConfig.id);
// assert
const appDataDir = `${storagePath}/app-data/${appConfig.id}`;
const appDataDir = `${STORAGE_FOLDER}/app-data/${appConfig.id}`;
expect(await fs.promises.readFile(`${appDataDir}/data/subdir/subsubdir/test.txt`, 'utf8')).toBe('test');
expect(await fs.promises.readFile(`${appDataDir}/data/test.txt`, 'utf8')).toBe('test');
});
@ -225,8 +223,8 @@ describe('app helpers', () => {
it('should replace the content of .template files with the content of the app.env file', async () => {
// arrange
const appConfig = createAppConfig({});
const dataDir = `${rootFolderHost}/apps/${appConfig.id}/data`;
const appDataDir = `${storagePath}/app-data/${appConfig.id}`;
const dataDir = `${ROOT_FOLDER}/apps/${appConfig.id}/data`;
const appDataDir = `${STORAGE_FOLDER}/app-data/${appConfig.id}`;
await fs.promises.mkdir(dataDir, { recursive: true });
await fs.promises.mkdir(appDataDir, { recursive: true });

View file

@ -130,7 +130,12 @@ export class AppExecutors {
// run docker-compose up
this.logger.info(`Running docker-compose up for app ${appId}`);
await compose(appId, 'up -d');
const { stderr } = await compose(appId, 'up -d');
if (stderr) {
this.logger.error(`Error running docker-compose up for app ${appId}: ${stderr}`);
return { success: false, message: `Error running docker-compose up for app ${appId}: ${stderr}` };
}
this.logger.info(`Docker-compose up for app ${appId} finished`);
@ -164,15 +169,18 @@ export class AppExecutors {
}
};
public startApp = async (appId: string, config: Record<string, unknown>) => {
public startApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
try {
const { appDataDirPath } = this.getAppPaths(appId);
this.logger.info(`Starting app ${appId}`);
this.logger.info(`Regenerating app.env file for app ${appId}`);
await this.ensureAppDir(appId);
await generateEnvFile(appId, config);
if (!skipEnvGeneration) {
this.logger.info(`Regenerating app.env file for app ${appId}`);
await generateEnvFile(appId, config);
}
await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always');

View file

@ -26,11 +26,11 @@ const runCommand = async (jobData: unknown) => {
}
if (data.command === 'stop') {
({ success, message } = await stopApp(data.appid, data.form));
({ success, message } = await stopApp(data.appid, data.form, data.skipEnv));
}
if (data.command === 'start') {
({ success, message } = await startApp(data.appid, data.form));
({ success, message } = await startApp(data.appid, data.form, data.skipEnv));
}
if (data.command === 'uninstall') {

View file

@ -0,0 +1,38 @@
import { faker } from '@faker-js/faker';
import fs from 'fs';
import { APP_CATEGORIES, AppInfo, appInfoSchema } from '@runtipi/shared';
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
export const createAppConfig = (props?: Partial<AppInfo>, isInstalled = true) => {
const appInfo = appInfoSchema.parse({
id: faker.string.alphanumeric(32),
available: true,
port: faker.number.int({ min: 30, max: 65535 }),
name: faker.string.alphanumeric(32),
description: faker.string.alphanumeric(32),
tipi_version: 1,
short_desc: faker.string.alphanumeric(32),
author: faker.string.alphanumeric(32),
source: faker.internet.url(),
categories: [APP_CATEGORIES.AUTOMATION],
...props,
});
const mockFiles: Record<string, string | string[]> = {};
mockFiles[`${ROOT_FOLDER}/.env`] = 'TEST=test';
mockFiles[`${ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
mockFiles[`${ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
mockFiles[`${ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
if (isInstalled) {
mockFiles[`${ROOT_FOLDER}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
mockFiles[`${ROOT_FOLDER}/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
mockFiles[`${ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
mockFiles[`${STORAGE_FOLDER}/app-data/${appInfo.id}/data/test.txt`] = 'data';
}
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
return appInfo;
};

View file

@ -0,0 +1,41 @@
import { fs, vol } from 'memfs';
const copyFolderRecursiveSync = (src: string, dest: string) => {
const exists = vol.existsSync(src);
const stats = vol.statSync(src);
const isDirectory = exists && stats.isDirectory();
if (isDirectory) {
vol.mkdirSync(dest, { recursive: true });
vol.readdirSync(src).forEach((childItemName) => {
copyFolderRecursiveSync(`${src}/${childItemName}`, `${dest}/${childItemName}`);
});
} else {
vol.copyFileSync(src, dest);
}
};
export const fsMock = {
default: {
...fs,
promises: {
...fs.promises,
cp: copyFolderRecursiveSync,
},
copySync: (src: string, dest: string) => {
copyFolderRecursiveSync(src, dest);
},
__resetAllMocks: () => {
vol.reset();
},
__applyMockFiles: (newMockFiles: Record<string, string>) => {
// Create folder tree
vol.fromJSON(newMockFiles, 'utf8');
},
__createMockFiles: (newMockFiles: Record<string, string>) => {
vol.reset();
// Create folder tree
vol.fromJSON(newMockFiles, 'utf8');
},
__printVol: () => console.log(vol.toTree()),
},
};

View file

@ -0,0 +1,42 @@
import fs from 'fs';
import path from 'path';
import { vi, beforeEach } from 'vitest';
import { getEnv } from '@/lib/environment';
import { ROOT_FOLDER } from '@/config/constants';
vi.mock('@runtipi/shared', async (importOriginal) => {
const mod = (await importOriginal()) as object;
return {
...mod,
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
}),
FileLogger: vi.fn().mockImplementation(() => ({
flush: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
vi.mock('fs', async () => {
const { fsMock } = await import('@/tests/mocks/fs');
return {
...fsMock,
};
});
beforeEach(async () => {
// @ts-expect-error - custom mock method
fs.__resetAllMocks();
const { appsRepoId } = getEnv();
await fs.promises.mkdir(path.join(ROOT_FOLDER, 'state'), { recursive: true });
await fs.promises.writeFile(path.join(ROOT_FOLDER, 'state', 'seed'), 'seed');
await fs.promises.mkdir(path.join(ROOT_FOLDER, 'repos', appsRepoId, 'apps'), { recursive: true });
});

View file

@ -13,6 +13,9 @@
"@/config/*": [
"./src/config/*"
],
"@/tests/*": [
"./tests/*"
],
},
"lib": [
"dom",

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
setupFiles: ['./tests/vite.setup.ts'],
coverage: { all: true, reporter: ['lcov', 'text-summary'] },
},
});

File diff suppressed because it is too large Load diff

View file

@ -36,11 +36,11 @@ export const runPostgresMigrations = async (dbName?: string) => {
Logger.info('Running migrations');
try {
await migrate({ client }, path.join(__dirname, '../../packages/cli/assets/migrations'), { skipCreateMigrationTable: true });
await migrate({ client }, path.join(__dirname, '../../packages/worker/assets/migrations'), { skipCreateMigrationTable: true });
} catch (e) {
Logger.error('Error running migrations. Dropping table migrations and trying again');
await client.query('DROP TABLE migrations');
await migrate({ client }, path.join(__dirname, '../../packages/cli/assets/migrations'), { skipCreateMigrationTable: true });
await migrate({ client }, path.join(__dirname, '../../packages/worker/assets/migrations'), { skipCreateMigrationTable: true });
}
Logger.info('Migration complete');

View file

@ -89,10 +89,8 @@ export class SystemServiceClass {
await cache.close();
const dispatcher = new EventDispatcher('restart');
dispatcher.dispatchEvent({ type: 'system', command: 'restart' });
await dispatcher.close();
return true;
throw new Error('Implement restart');
};
public static status = (): { status: SystemStatus } => ({