feat: add APP_HOST variable and refactor fs mocks with memfs

This commit is contained in:
Nicolas Meienberger 2023-07-05 21:24:25 +02:00 committed by Nicolas Meienberger
parent db4923b9e7
commit 8453eebcd1
16 changed files with 442 additions and 419 deletions

View File

@ -41,10 +41,12 @@ module.exports = {
'**/*.spec.{ts,tsx}',
'**/*.factory.{ts,tsx}',
'**/mocks/**',
'**/__mocks__/**',
'tests/**',
'**/*.d.ts',
'**/*.workspace.ts',
'**/*.setup.{ts,js}',
'**/*.config.{ts,js}',
],
},
],

View File

@ -1,179 +1,35 @@
import path from 'path';
import { fs, vol } from 'memfs';
class FsMock {
private static instance: FsMock;
private mockFiles = Object.create(null);
// private constructor() {}
static getInstance(): FsMock {
if (!FsMock.instance) {
FsMock.instance = new FsMock();
}
return FsMock.instance;
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);
}
};
__applyMockFiles = (newMockFiles: Record<string, string>) => {
export default {
...fs,
copySync: (src: string, dest: string) => {
copyFolderRecursiveSync(src, dest);
},
__resetAllMocks: () => {
vol.reset();
},
__applyMockFiles: (newMockFiles: Record<string, string>) => {
// Create folder tree
Object.keys(newMockFiles).forEach((file) => {
const dir = path.dirname(file);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(file));
this.mockFiles[file] = newMockFiles[file];
});
};
__createMockFiles = (newMockFiles: Record<string, string>) => {
this.mockFiles = Object.create(null);
vol.fromJSON(newMockFiles, 'utf8');
},
__createMockFiles: (newMockFiles: Record<string, string>) => {
vol.reset();
// Create folder tree
Object.keys(newMockFiles).forEach((file) => {
const dir = path.dirname(file);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(file));
this.mockFiles[file] = newMockFiles[file];
});
};
__resetAllMocks = () => {
this.mockFiles = Object.create(null);
};
readFileSync = (p: string) => this.mockFiles[p];
existsSync = (p: string) => this.mockFiles[p] !== undefined;
writeFileSync = (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
};
mkdirSync = (p: string) => {
if (!this.mockFiles[p]) {
this.mockFiles[p] = [];
}
};
rmSync = (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
};
readdirSync = (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(this.mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
};
copyFileSync = (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
};
copySync = (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
if (this.mockFiles[source] instanceof Array) {
this.mockFiles[source].forEach((file: string) => {
this.mockFiles[`${destination}/${file}`] = this.mockFiles[`${source}/${file}`];
});
}
};
createFileSync = (p: string) => {
this.mockFiles[p] = '';
};
unlinkSync = (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
};
getMockFiles = () => this.mockFiles;
promises = {
unlink: async (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
},
writeFile: async (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
const dir = path.dirname(p);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(p));
},
mkdir: async (p: string) => {
if (!this.mockFiles[p]) {
this.mockFiles[p] = [];
}
},
readdir: async (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(this.mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
},
lstat: async (p: string) => {
return {
isDirectory: () => {
return this.mockFiles[p] instanceof Array;
},
};
},
readFile: async (p: string) => {
return this.mockFiles[p];
},
copyFile: async (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
},
};
}
export default FsMock.getInstance();
vol.fromJSON(newMockFiles, 'utf8');
},
__printVol: () => console.log(vol.toTree()),
};

View File

@ -22,6 +22,7 @@ export default async () => {
const serverConfig = await createJestConfig(customServerConfig)();
return {
randomize: true,
verbose: true,
collectCoverage: true,
collectCoverageFrom: ['src/server/**/*.{ts,tsx}', 'src/client/**/*.{ts,tsx}', '!src/**/mocks/**/*.{ts,tsx}', '!**/*.{spec,test}.{ts,tsx}', '!**/index.{ts,tsx}'],

View File

@ -137,6 +137,7 @@
"eslint-plugin-testing-library": "^5.11.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"memfs": "^4.2.0",
"msw": "^1.2.2",
"next-router-mock": "^0.9.7",
"nodemon": "^2.0.22",

View File

@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@hookform/resolvers':
specifier: ^3.1.1
@ -319,6 +315,9 @@ devDependencies:
jest-environment-jsdom:
specifier: ^29.5.0
version: 29.5.0
memfs:
specifier: ^4.2.0
version: 4.2.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3)
msw:
specifier: ^1.2.2
version: 1.2.2(typescript@5.1.5)
@ -1615,10 +1614,10 @@ packages:
resolution: {integrity: sha512-NEpkObxPwyw/XxZVLPmAGKE89IQRp4puc6IQRPru6JKd1M3fW9v1xM1AnzIJE65hbCkzQAdnL8P47e9hzhiYLQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
slash: 3.0.0
dev: true
@ -1636,7 +1635,7 @@ packages:
'@jest/reporters': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
ansi-escapes: 4.3.2
chalk: 4.1.2
@ -1646,7 +1645,7 @@ packages:
jest-changed-files: 29.5.0
jest-config: 29.5.0(@types/node@20.3.2)(ts-node@10.9.1)
jest-haste-map: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
jest-resolve-dependencies: 29.5.0
@ -1657,7 +1656,7 @@ packages:
jest-validate: 29.5.0
jest-watcher: 29.5.0
micromatch: 4.0.5
pretty-format: 29.5.0
pretty-format: 29.6.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
@ -1670,7 +1669,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/fake-timers': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
jest-mock: 29.5.0
dev: true
@ -1696,10 +1695,10 @@ packages:
resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@sinonjs/fake-timers': 10.0.2
'@types/node': 20.3.2
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-mock: 29.5.0
jest-util: 29.5.0
dev: true
@ -1710,7 +1709,7 @@ packages:
dependencies:
'@jest/environment': 29.5.0
'@jest/expect': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
jest-mock: 29.5.0
transitivePeerDependencies:
- supports-color
@ -1729,7 +1728,7 @@ packages:
'@jest/console': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@jridgewell/trace-mapping': 0.3.17
'@types/node': 20.3.2
chalk: 4.1.2
@ -1742,7 +1741,7 @@ packages:
istanbul-lib-report: 3.0.0
istanbul-lib-source-maps: 4.0.1
istanbul-reports: 3.1.5
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
jest-worker: 29.5.0
slash: 3.0.0
@ -1760,6 +1759,13 @@ packages:
'@sinclair/typebox': 0.25.23
dev: true
/@jest/schemas@29.6.0:
resolution: {integrity: sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@sinclair/typebox': 0.27.8
dev: true
/@jest/source-map@29.4.3:
resolution: {integrity: sha512-qyt/mb6rLyd9j1jUts4EQncvS6Yy3PM9HghnNv86QBlV+zdL2inCdK1tuVlL+J+lpiw2BI67qXOrX3UurBqQ1w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -1774,7 +1780,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/console': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/istanbul-lib-coverage': 2.0.4
collect-v8-coverage: 1.0.1
dev: true
@ -1794,7 +1800,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@babel/core': 7.22.5
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@jridgewell/trace-mapping': 0.3.17
babel-plugin-istanbul: 6.1.1
chalk: 4.1.2
@ -1824,6 +1830,18 @@ packages:
chalk: 4.1.2
dev: true
/@jest/types@29.6.0:
resolution: {integrity: sha512-8XCgL9JhqbJTFnMRjEAO+TuW251+MoMd5BSzLiE3vvzpQ8RlBxy8NoyNkDhs3K3OL3HeVinlOl9or5p7GTeOLg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/schemas': 29.6.0
'@types/istanbul-lib-coverage': 2.0.4
'@types/istanbul-reports': 3.0.1
'@types/node': 20.3.2
'@types/yargs': 17.0.22
chalk: 4.1.2
dev: true
/@jridgewell/gen-mapping@0.1.1:
resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
engines: {node: '>=6.0.0'}
@ -2766,6 +2784,10 @@ packages:
resolution: {integrity: sha512-VEB8ygeP42CFLWyAJhN5OklpxUliqdNEUcXb4xZ/CINqtYGTjL5ukluKdKzQ0iWdUxyQ7B0539PAUhHKrCNWSQ==}
dev: true
/@sinclair/typebox@0.27.8:
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
dev: true
/@sinonjs/commons@2.0.0:
resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==}
dependencies:
@ -3813,6 +3835,10 @@ packages:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
dev: true
/arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
dev: true
/argon2@0.30.3:
resolution: {integrity: sha512-DoH/kv8c9127ueJSBxAVJXinW9+EuPA3EMUxoV2sAY1qDE5H9BjTyVF/aD2XyHqbqUWabgBkIfcP3ZZuGhbJdg==}
engines: {node: '>=14.0.0'}
@ -5708,7 +5734,7 @@ packages:
'@jest/expect-utils': 29.5.0
jest-get-type: 29.4.3
jest-matcher-utils: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
dev: true
@ -6322,6 +6348,11 @@ packages:
engines: {node: '>=10.17.0'}
dev: true
/hyperdyperid@1.2.0:
resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
engines: {node: '>=10.18'}
dev: true
/iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -6753,7 +6784,7 @@ packages:
'@jest/environment': 29.5.0
'@jest/expect': 29.5.0
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
co: 4.6.0
@ -6761,12 +6792,12 @@ packages:
is-generator-fn: 2.1.0
jest-each: 29.5.0
jest-matcher-utils: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-runtime: 29.5.0
jest-snapshot: 29.5.0
jest-util: 29.5.0
p-limit: 3.1.0
pretty-format: 29.5.0
pretty-format: 29.6.0
pure-rand: 6.0.1
slash: 3.0.0
stack-utils: 2.0.6
@ -6786,7 +6817,7 @@ packages:
dependencies:
'@jest/core': 29.5.0(ts-node@10.9.1)
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.10
@ -6816,7 +6847,7 @@ packages:
dependencies:
'@babel/core': 7.22.5
'@jest/test-sequencer': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
babel-jest: 29.5.0(@babel/core@7.22.5)
chalk: 4.1.2
@ -6834,7 +6865,7 @@ packages:
jest-validate: 29.5.0
micromatch: 4.0.5
parse-json: 5.2.0
pretty-format: 29.5.0
pretty-format: 29.6.0
slash: 3.0.0
strip-json-comments: 3.1.1
ts-node: 10.9.1(@types/node@20.3.2)(typescript@5.1.5)
@ -6849,7 +6880,7 @@ packages:
chalk: 4.1.2
diff-sequences: 29.4.3
jest-get-type: 29.4.3
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-docblock@29.4.3:
@ -6863,11 +6894,11 @@ packages:
resolution: {integrity: sha512-HM5kIJ1BTnVt+DQZ2ALp3rzXEl+g726csObrW/jpEGl+CDSSQpOJJX2KE/vEg8cxcMXdyEPu6U4QX5eruQv5hA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
chalk: 4.1.2
jest-get-type: 29.4.3
jest-util: 29.5.0
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-environment-jsdom@29.5.0:
@ -6899,7 +6930,7 @@ packages:
dependencies:
'@jest/environment': 29.5.0
'@jest/fake-timers': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
jest-mock: 29.5.0
jest-util: 29.5.0
@ -6914,7 +6945,7 @@ packages:
resolution: {integrity: sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/graceful-fs': 4.1.6
'@types/node': 20.3.2
anymatch: 3.1.3
@ -6934,7 +6965,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
jest-get-type: 29.4.3
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-matcher-utils@29.5.0:
@ -6944,20 +6975,20 @@ packages:
chalk: 4.1.2
jest-diff: 29.5.0
jest-get-type: 29.4.3
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-message-util@29.5.0:
resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==}
/jest-message-util@29.6.0:
resolution: {integrity: sha512-mkCp56cETbpoNtsaeWVy6SKzk228mMi9FPHSObaRIhbR2Ujw9PqjW/yqVHD2tN1bHbC8ol6h3UEo7dOPmIYwIA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@babel/code-frame': 7.22.5
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/stack-utils': 2.0.1
chalk: 4.1.2
graceful-fs: 4.2.10
micromatch: 4.0.5
pretty-format: 29.5.0
pretty-format: 29.6.0
slash: 3.0.0
stack-utils: 2.0.6
dev: true
@ -6966,7 +6997,7 @@ packages:
resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
jest-util: 29.5.0
dev: true
@ -7021,7 +7052,7 @@ packages:
'@jest/environment': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
emittery: 0.13.1
@ -7030,7 +7061,7 @@ packages:
jest-environment-node: 29.5.0
jest-haste-map: 29.5.0
jest-leak-detector: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-resolve: 29.5.0
jest-runtime: 29.5.0
jest-util: 29.5.0
@ -7052,7 +7083,7 @@ packages:
'@jest/source-map': 29.4.3
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
cjs-module-lexer: 1.2.2
@ -7060,7 +7091,7 @@ packages:
glob: 7.2.3
graceful-fs: 4.2.10
jest-haste-map: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-mock: 29.5.0
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
@ -7084,7 +7115,7 @@ packages:
'@babel/types': 7.22.5
'@jest/expect-utils': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/babel__traverse': 7.18.3
'@types/prettier': 2.7.2
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.22.5)
@ -7094,10 +7125,10 @@ packages:
jest-diff: 29.5.0
jest-get-type: 29.4.3
jest-matcher-utils: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
natural-compare: 1.4.0
pretty-format: 29.5.0
pretty-format: 29.6.0
semver: 7.5.3
transitivePeerDependencies:
- supports-color
@ -7107,7 +7138,7 @@ packages:
resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
ci-info: 3.8.0
@ -7119,12 +7150,12 @@ packages:
resolution: {integrity: sha512-pC26etNIi+y3HV8A+tUGr/lph9B18GnzSRAkPaaZJIE1eFdiYm6/CewuiJQ8/RlfHd1u/8Ioi8/sJ+CmbA+zAQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
camelcase: 6.3.0
chalk: 4.1.2
jest-get-type: 29.4.3
leven: 3.1.0
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-watcher@29.5.0:
@ -7132,7 +7163,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
ansi-escapes: 4.3.2
chalk: 4.1.2
@ -7259,6 +7290,22 @@ packages:
dreamopt: 0.8.0
dev: true
/json-joy@9.3.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3):
resolution: {integrity: sha512-ZQiyMcbcfqki5Bsk0kWfne/Ixl4Q6cLBzCd3VE/TSp7jhns/WDBrIMTuyzDfwmLxuFtQdojiLSLX8MxTyK23QA==}
engines: {node: '>=10.0'}
hasBin: true
peerDependencies:
quill-delta: ^5
rxjs: '7'
tslib: '2'
dependencies:
arg: 5.0.2
hyperdyperid: 1.2.0
quill-delta: 5.1.0
rxjs: 7.8.0
tslib: 2.5.3
dev: true
/json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@ -7384,6 +7431,14 @@ packages:
p-locate: 5.0.0
dev: true
/lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
dev: true
/lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
dev: true
/lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: true
@ -7633,6 +7688,20 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/memfs@4.2.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3):
resolution: {integrity: sha512-V5/xE+zl6+soWxlBjiVTQSkfXybTwhEBj2I8sK9LaS5lcZsTuhRftakrcRpDY7Ycac2NTK/VzEtpKMp+gpymrQ==}
engines: {node: '>= 4.0.0'}
peerDependencies:
tslib: '2'
dependencies:
json-joy: 9.3.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3)
thingies: 1.12.0(tslib@2.5.3)
tslib: 2.5.3
transitivePeerDependencies:
- quill-delta
- rxjs
dev: true
/memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
dev: false
@ -8761,6 +8830,15 @@ packages:
react-is: 18.2.0
dev: true
/pretty-format@29.6.0:
resolution: {integrity: sha512-XH+D4n7Ey0iSR6PdAnBs99cWMZdGsdKrR33iUHQNr79w1szKTCIZDVdXuccAsHVwDBp0XeWPfNEoaxP9EZgRmQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/schemas': 29.6.0
ansi-styles: 5.2.0
react-is: 18.2.0
dev: true
/prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@ -8834,6 +8912,15 @@ packages:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/quill-delta@5.1.0:
resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==}
engines: {node: '>= 12.0.0'}
dependencies:
fast-diff: 1.3.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
dev: true
/random-bytes@1.0.0:
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
engines: {node: '>= 0.8'}
@ -9783,6 +9870,15 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thingies@1.12.0(tslib@2.5.3):
resolution: {integrity: sha512-AiGqfYC1jLmJagbzQGuoZRM48JPsr9yB734a7K6wzr34NMhjUPrWSQrkF7ZBybf3yCerCL2Gcr02kMv4NmaZfA==}
engines: {node: '>=10.18'}
peerDependencies:
tslib: ^2
dependencies:
tslib: 2.5.3
dev: true
/thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
@ -10768,3 +10864,7 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@ -16,6 +16,11 @@ jest.mock('next/router', () => {
describe('Test: StatusProvider', () => {
it("should render it's children when system is RUNNING", async () => {
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('RUNNING');
});
render(
<StatusProvider>
<div>system running</div>
@ -25,10 +30,12 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(screen.getByText('system running')).toBeInTheDocument();
});
unmount();
});
it('should render StatusScreen when system is RESTARTING', async () => {
const { result } = renderHook(() => useSystemStore());
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('RESTARTING');
});
@ -41,10 +48,12 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
});
unmount();
});
it('should render StatusScreen when system is UPDATING', async () => {
const { result } = renderHook(() => useSystemStore());
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('UPDATING');
});
@ -58,10 +67,12 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
});
unmount();
});
it('should reload the page when system is RUNNING after being something else than RUNNING', async () => {
const { result } = renderHook(() => useSystemStore());
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('UPDATING');
});
@ -82,5 +93,6 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(reloadFn).toHaveBeenCalled();
});
unmount();
});
});

View File

@ -8,10 +8,13 @@ jest.mock('fs-extra');
// eslint-disable-next-line no-promise-executor-return
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
beforeEach(async () => {
await fs.promises.mkdir('/runtipi/state', { recursive: true });
await fs.promises.mkdir('/app/logs', { recursive: true });
await fs.promises.writeFile(WATCH_FILE, '');
await fs.promises.writeFile('/app/logs/123.log', 'test');
EventDispatcher.clear();
fs.writeFileSync(WATCH_FILE, '');
fs.writeFileSync('/app/logs/123.log', 'test');
});
describe('EventDispatcher - dispatchEvent', () => {

View File

@ -3,11 +3,7 @@ import fs from 'fs-extra';
import { getConfig, setConfig, getSettings, setSettings, TipiConfig } from '.';
import { readJsonFile } from '../../common/fs.helpers';
beforeEach(async () => {
// @ts-expect-error - We are mocking fs
fs.__resetAllMocks();
jest.mock('fs-extra');
});
jest.mock('fs-extra');
jest.mock('next/config', () =>
jest.fn(() => ({
@ -124,9 +120,9 @@ describe('Test: setConfig', () => {
expect(error).toBeDefined();
});
it('Should write config to json file', () => {
it('Should write config to json file', async () => {
const randomWord = faker.internet.url();
setConfig('appsRepoUrl', randomWord, true);
await setConfig('appsRepoUrl', randomWord, true);
const config = getConfig();
expect(config).toBeDefined();
@ -175,14 +171,14 @@ describe('Test: getSettings', () => {
});
describe('Test: setSettings', () => {
it('should write settings to json file', () => {
it('should write settings to json file', async () => {
// arrange
const fakeSettings = {
appsRepoUrl: faker.internet.url(),
};
// act
setSettings(fakeSettings);
await setSettings(fakeSettings);
const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string };
// assert

View File

@ -2,21 +2,20 @@ import fs from 'fs-extra';
import { fromAny, fromPartial } from '@total-typescript/shoehorn';
import { faker } from '@faker-js/faker';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { getAppEnvMap } from '@/server/utils/env-generation';
import { setConfig } from '../../core/TipiConfig';
import { appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from './apps.helpers';
import { appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getUpdateInfo } from './apps.helpers';
import { createAppConfig, insertApp } from '../../tests/apps.factory';
let db: TestDatabase;
const TEST_SUITE = 'appshelpers';
jest.mock('fs-extra');
beforeAll(async () => {
db = await createDatabase(TEST_SUITE);
});
beforeEach(async () => {
jest.mock('fs-extra');
// @ts-expect-error - fs-extra mock is not typed
fs.__resetAllMocks();
await clearDatabase(db);
});
@ -50,20 +49,6 @@ describe('Test: checkAppRequirements()', () => {
});
});
describe('Test: getEnvMap()', () => {
it('should return a map of env vars', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] });
insertApp({ config: { TEST_FIELD: 'test' } }, appConfig, db);
// act
const envMap = getEnvMap(appConfig.id);
// assert
expect(envMap.get('TEST_FIELD')).toBe('test');
});
});
describe('Test: checkEnvFile()', () => {
it('Should not throw if all required fields are present', async () => {
// arrange
@ -71,7 +56,7 @@ describe('Test: checkEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
checkEnvFile(app.id);
await checkEnvFile(app.id);
});
it('Should throw if a required field is missing', async () => {
@ -83,7 +68,7 @@ describe('Test: checkEnvFile()', () => {
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, newAppEnv);
// act & assert
expect(() => checkEnvFile(app.id)).toThrowError('New info needed. App config needs to be updated');
await expect(checkEnvFile(app.id)).rejects.toThrowError('New info needed. App config needs to be updated');
});
it('Should throw if config.json is incorrect', async () => {
@ -93,7 +78,7 @@ describe('Test: checkEnvFile()', () => {
fs.writeFileSync(`/runtipi/apps/${app.id}/config.json`, 'invalid json');
// act & assert
expect(() => checkEnvFile(app.id)).toThrowError(`App ${app.id} has invalid config.json file`);
await expect(checkEnvFile(app.id)).rejects.toThrowError(`App ${app.id} has invalid config.json file`);
});
});
@ -101,7 +86,8 @@ describe('Test: appInfoSchema', () => {
it('should default form_field type to text if it is wrong', async () => {
// arrange
const appConfig = createAppConfig(fromAny({ form_fields: [{ env_variable: 'test', type: 'wrong', label: 'yo', required: true }] }));
fs.writeFileSync(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
await fs.promises.mkdir(`/app/storage/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
// act
const appInfo = appInfoSchema.safeParse(appConfig);
@ -118,7 +104,8 @@ describe('Test: appInfoSchema', () => {
it('should default categories to ["utilities"] if it is wrong', async () => {
// arrange
const appConfig = createAppConfig(fromAny({ categories: 'wrong' }));
fs.writeFileSync(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
await fs.promises.mkdir(`/app/storage/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
// act
const appInfo = appInfoSchema.safeParse(appConfig);
@ -141,8 +128,8 @@ describe('Test: generateEnvFile()', () => {
const fakevalue = faker.string.alphanumeric(10);
// act
generateEnvFile(Object.assign(app, { config: { TEST_FIELD: fakevalue } }));
const envmap = getEnvMap(app.id);
await generateEnvFile(Object.assign(app, { config: { TEST_FIELD: fakevalue } }));
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('TEST_FIELD')).toBe(fakevalue);
@ -154,8 +141,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('RANDOM_FIELD')).toBeDefined();
@ -170,8 +157,8 @@ describe('Test: generateEnvFile()', () => {
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `RANDOM_FIELD=${randomField}`);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('RANDOM_FIELD')).toBe(randomField);
@ -183,12 +170,12 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act & assert
expect(() => generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).toThrowError('Variable test is required');
await expect(generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).rejects.toThrowError('Variable test is required');
});
it('Should throw an error if app does not exist', async () => {
// act & assert
expect(() => generateEnvFile(fromPartial({ id: 'not-existing-app' }))).toThrowError('App not-existing-app has invalid config.json file');
await expect(generateEnvFile(fromPartial({ id: 'not-existing-app' }))).rejects.toThrowError('App not-existing-app has invalid config.json file');
});
it('Should add APP_EXPOSED to env file if domain is provided and app is exposed', async () => {
@ -198,8 +185,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({ domain, exposed: true }, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBe('true');
@ -212,8 +199,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({ exposed: true }, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
@ -225,8 +212,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({ exposed: false, domain: faker.internet.domainName() }, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
@ -240,7 +227,7 @@ describe('Test: generateEnvFile()', () => {
fs.rmSync(`/app/storage/app-data/${app.id}`, { recursive: true });
// act
generateEnvFile(app);
await generateEnvFile(app);
// assert
expect(fs.existsSync(`/app/storage/app-data/${app.id}`)).toBe(true);
@ -252,8 +239,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeDefined();
@ -266,8 +253,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeUndefined();
@ -284,8 +271,8 @@ describe('Test: generateEnvFile()', () => {
// act
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`);
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBe(vapidPrivateKey);
@ -428,15 +415,10 @@ describe('Test: getUpdateInfo()', () => {
});
describe('Test: ensureAppFolder()', () => {
beforeEach(() => {
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
});
it('should copy the folder from repo', () => {
it('should copy the folder from repo', async () => {
// arrange
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
// act
ensureAppFolder('test');
@ -445,15 +427,12 @@ describe('Test: ensureAppFolder()', () => {
expect(files).toEqual(['test.yml']);
});
it('should not copy the folder if it already exists', () => {
it('should not copy the folder if it already exists', async () => {
// arrange
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
// act
ensureAppFolder('test');
@ -463,15 +442,12 @@ describe('Test: ensureAppFolder()', () => {
expect(files).toEqual(['docker-compose.yml']);
});
it('Should overwrite the folder if clean up is true', () => {
it('Should overwrite the folder if clean up is true', async () => {
// arrange
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
// act
ensureAppFolder('test', true);
@ -481,15 +457,13 @@ describe('Test: ensureAppFolder()', () => {
expect(files).toEqual(['test.yml']);
});
it('Should delete folder if it exists but has no docker-compose.yml file', () => {
it('Should delete folder if it exists but has no docker-compose.yml file', async () => {
// arrange
const randomFileName = `${faker.lorem.word()}.yml`;
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: [randomFileName],
'/runtipi/apps/test': ['test.yml'],
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/test/${randomFileName}`, 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/test.yml', 'test');
// act
ensureAppFolder('test');

View File

@ -2,8 +2,8 @@ import crypto from 'crypto';
import fs from 'fs-extra';
import { z } from 'zod';
import { App } from '@/server/db/schema';
import { generateVapidKeys } from '@/server/utils/env-generation';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers';
import { envMapToString, envStringToMap, generateVapidKeys, getAppEnvMap } from '@/server/utils/env-generation';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile } from '../../common/fs.helpers';
import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
import { getConfig } from '../../core/TipiConfig';
import { Logger } from '../../core/Logger';
@ -82,25 +82,6 @@ export const checkAppRequirements = (appName: string) => {
return parsedConfig.data;
};
/**
* This function reads the env file for the app with the provided name and returns a Map containing the key-value pairs of the environment variables.
* It reads the file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value.
*
* @param {string} appName - The name of the app.
*/
export const getEnvMap = (appName: string) => {
const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
const envVars = envFile.split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
if (key && value) envVarsMap.set(key, value);
});
return envVarsMap;
};
/**
* This function checks if the env file for the app with the provided name is valid.
* It reads the config.json file for the app, parses it,
@ -111,15 +92,23 @@ export const getEnvMap = (appName: string) => {
* @param {string} appName - The name of the app.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing in the env file.
*/
export const checkEnvFile = (appName: string) => {
const configFile = readJsonFile(`/runtipi/apps/${appName}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
export const checkEnvFile = async (appName: string) => {
const configFile = await fs.promises.readFile(`/runtipi/apps/${appName}/config.json`);
let jsonConfig: unknown;
try {
jsonConfig = JSON.parse(configFile.toString());
} catch (e) {
throw new Error(`App ${appName} has invalid config.json file`);
}
const parsedConfig = appInfoSchema.safeParse(jsonConfig);
if (!parsedConfig.success) {
throw new Error(`App ${appName} has invalid config.json file`);
}
const envMap = getEnvMap(appName);
const envMap = await getAppEnvMap(appName);
parsedConfig.data.form_fields.forEach((field) => {
const envVar = field.env_variable;
@ -170,7 +159,7 @@ const castAppConfig = (json: unknown): Record<string, unknown> => {
* @param {App} app - The app for which the env file is generated.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing.
*/
export const generateEnvFile = (app: App) => {
export const generateEnvFile = async (app: App) => {
const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
@ -179,17 +168,22 @@ export const generateEnvFile = (app: App) => {
}
const baseEnvFile = readFile('/runtipi/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\nAPP_ID=${app.id}\n`;
const envMap = getEnvMap(app.id);
const envMap = envStringToMap(baseEnvFile);
// Default always present env variables
envMap.set('APP_PORT', String(parsedConfig.data.port));
envMap.set('APP_ID', app.id);
const existingEnvMap = await getAppEnvMap(app.id);
if (parsedConfig.data.generate_vapid_keys) {
if (envMap.has('VAPID_PUBLIC_KEY') && envMap.has('VAPID_PRIVATE_KEY')) {
envFile += `VAPID_PUBLIC_KEY=${envMap.get('VAPID_PUBLIC_KEY')}\n`;
envFile += `VAPID_PRIVATE_KEY=${envMap.get('VAPID_PRIVATE_KEY')}\n`;
if (existingEnvMap.has('VAPID_PUBLIC_KEY') && existingEnvMap.has('VAPID_PRIVATE_KEY')) {
envMap.set('VAPID_PUBLIC_KEY', existingEnvMap.get('VAPID_PUBLIC_KEY') as string);
envMap.set('VAPID_PRIVATE_KEY', existingEnvMap.get('VAPID_PRIVATE_KEY') as string);
} else {
const vapidKeys = generateVapidKeys();
envFile += `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}\n`;
envFile += `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`;
envMap.set('VAPID_PUBLIC_KEY', vapidKeys.publicKey);
envMap.set('VAPID_PRIVATE_KEY', vapidKeys.privateKey);
}
}
@ -198,15 +192,15 @@ export const generateEnvFile = (app: App) => {
const envVar = field.env_variable;
if (formValue || typeof formValue === 'boolean') {
envFile += `${envVar}=${String(formValue)}\n`;
envMap.set(envVar, String(formValue));
} else if (field.type === 'random') {
if (envMap.has(envVar)) {
envFile += `${envVar}=${envMap.get(envVar)}\n`;
if (existingEnvMap.has(envVar)) {
envMap.set(envVar, existingEnvMap.get(envVar) as string);
} else {
const length = field.min || 32;
const randomString = getEntropy(field.env_variable, length);
envFile += `${envVar}=${randomString}\n`;
envMap.set(envVar, randomString);
}
} else if (field.required) {
throw new Error(`Variable ${field.label || field.env_variable} is required`);
@ -214,19 +208,22 @@ export const generateEnvFile = (app: App) => {
});
if (app.exposed && app.domain) {
envFile += 'APP_EXPOSED=true\n';
envFile += `APP_DOMAIN=${app.domain}\n`;
envFile += 'APP_PROTOCOL=https\n';
envMap.set('APP_EXPOSED', 'true');
envMap.set('APP_DOMAIN', app.domain);
envMap.set('APP_PROTOCOL', 'https');
envMap.set('APP_HOST', app.domain);
} else {
envFile += `APP_DOMAIN=${getConfig().internalIp}:${parsedConfig.data.port}\n`;
envMap.set('APP_DOMAIN', `${getConfig().internalIp}:${parsedConfig.data.port}`);
envMap.set('APP_HOST', getConfig().internalIp);
}
// Create app-data folder if it doesn't exist
if (!fs.existsSync(`/app/storage/app-data/${app.id}`)) {
fs.mkdirSync(`/app/storage/app-data/${app.id}`, { recursive: true });
const appDataDirectoryExists = await fs.promises.stat(`/app/storage/app-data/${app.id}`).catch(() => false);
if (!appDataDirectoryExists) {
await fs.promises.mkdir(`/app/storage/app-data/${app.id}`, { recursive: true });
}
writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
await fs.promises.writeFile(`/app/storage/app-data/${app.id}/app.env`, envMapToString(envMap));
};
/**
@ -253,7 +250,7 @@ const renderTemplate = (template: string, envMap: Map<string, string>) => {
* @param {string} id - The id of the app.
*/
export const copyDataDir = async (id: string) => {
const envMap = getEnvMap(id);
const envMap = await getAppEnvMap(id);
const appDataDirExists = (await fs.promises.lstat(`/runtipi/apps/${id}/data`).catch(() => false)) as fs.Stats;
if (!appDataDirExists || !appDataDirExists.isDirectory()) {

View File

@ -2,9 +2,9 @@ import fs from 'fs-extra';
import waitForExpect from 'wait-for-expect';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { faker } from '@faker-js/faker';
import { getAppEnvMap } from '@/server/utils/env-generation';
import { AppServiceClass } from './apps.service';
import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
import { getEnvMap } from './apps.helpers';
import { getAllApps, getAppById, updateApp, createAppConfig, insertApp } from '../../tests/apps.factory';
import { setConfig } from '../../core/TipiConfig';
@ -18,11 +18,7 @@ beforeAll(async () => {
});
beforeEach(async () => {
jest.mock('fs-extra');
await clearDatabase(db);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - we are mocking fs
fs.__resetAllMocks();
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
});
@ -37,10 +33,13 @@ describe('Install app', () => {
// act
await AppsService.installApp(appConfig.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envMap.get('TEST_FIELD')).toBe('test');
expect(envMap.get('APP_PORT')).toBe(appConfig.port.toString());
expect(envMap.get('APP_ID')).toBe(appConfig.id);
expect(envMap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should add app in database', async () => {
@ -102,7 +101,7 @@ describe('Install app', () => {
// act
await AppsService.installApp(appConfig.id, {});
const envMap = getEnvMap(appConfig.id);
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envMap.get('RANDOM_FIELD')).toBeDefined();
@ -243,8 +242,9 @@ describe('Install app', () => {
it('should replace env variables in .templates files in data folder', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST', type: 'text', label: 'test', required: true }] });
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test2.txt`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/repos/repo-id/apps/${appConfig.id}/data`, { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test2.txt`, 'test {{TEST}}');
// act
await AppsService.installApp(appConfig.id, { TEST: 'test' });
@ -259,10 +259,10 @@ describe('Install app', () => {
it('should copy and replace env variables in deeply nested .templates files in data folder', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST', type: 'text', label: 'test', required: true }] });
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/apps/${appConfig.id}/data/test`);
await fs.promises.mkdir(`/runtipi/apps/${appConfig.id}/data/test/test`);
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test/test/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/repos/repo-id/apps/${appConfig.id}/data`, { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test/test`, { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test/test/test.txt.template`, 'test {{TEST}}');
// act
await AppsService.installApp(appConfig.id, { TEST: 'test' });
@ -365,10 +365,14 @@ describe('Start app', () => {
// act
await AppsService.startApp(appConfig.id);
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envMap.get('TEST_FIELD')).toBe('test');
expect(envMap.get('APP_PORT')).toBe(appConfig.port.toString());
expect(envMap.get('APP_ID')).toBe(appConfig.id);
expect(envMap.get('TEST')).toBe('test');
expect(envMap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should throw if start script fails', async () => {
@ -382,6 +386,18 @@ describe('Start app', () => {
const app = await getAppById(appConfig.id, db);
expect(app?.status).toBe('stopped');
});
it('Should throw if app has invalid config.json', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'stopped' }, appConfig, db);
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/config.json`, 'test');
// act & assert
await expect(AppsService.startApp(appConfig.id)).rejects.toThrow(`App ${appConfig.id} has invalid config.json`);
const app = await getAppById(appConfig.id, db);
expect(app?.status).toBe('stopped');
});
});
describe('Stop app', () => {
@ -424,10 +440,13 @@ describe('Update app config', () => {
// act
await AppsService.updateAppConfig(appConfig.id, { TEST_FIELD: word });
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=${word}\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envMap.get('TEST_FIELD')).toBe(word);
expect(envMap.get('APP_PORT')).toBe(appConfig.port.toString());
expect(envMap.get('APP_ID')).toBe(appConfig.id);
expect(envMap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should throw if required field is missing', async () => {
@ -454,7 +473,7 @@ describe('Update app config', () => {
// act
await AppsService.updateAppConfig(appConfig.id, { TEST_FIELD: 'test' });
const envMap = getEnvMap(appConfig.id);
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envMap.get(field)).toBe('test');
@ -478,15 +497,6 @@ describe('Update app config', () => {
expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
});
it('Should throw if app is exposed and config does not allow it', async () => {
// arrange
const appConfig = createAppConfig({ exposable: false });
await insertApp({}, appConfig, db);
// act & assert
expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
});
it('Should throw if app is exposed and domain is already used', async () => {
// arrange
const domain = faker.internet.domainName();
@ -518,6 +528,15 @@ describe('Update app config', () => {
// act & assert
await expect(AppsService.updateAppConfig(appConfig.id, {})).rejects.toThrowError('server-messages.errors.app-force-exposed');
});
it('Should throw if app is exposed and config does not allow it', async () => {
// arrange
const appConfig = createAppConfig({ exposable: false });
await insertApp({}, appConfig, db);
// act & assert
await expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
});
});
describe('Get app config', () => {

View File

@ -29,6 +29,12 @@ export class AppServiceClass {
this.queries = new AppQueries(p);
}
async regenerateEnvFile(app: App) {
ensureAppFolder(app.id);
await generateEnvFile(app);
await checkEnvFile(app.id);
}
/**
* This function starts all apps that are in the 'running' status.
* It finds all the running apps and starts them by regenerating the env file, checking the env file and dispatching the start event.
@ -43,11 +49,9 @@ export class AppServiceClass {
await Promise.all(
apps.map(async (app) => {
// Regenerate env file
try {
ensureAppFolder(app.id);
generateEnvFile(app);
checkEnvFile(app.id);
// Regenerate env file
await this.regenerateEnvFile(app);
await this.queries.updateApp(app.id, { status: 'starting' });
@ -79,10 +83,8 @@ export class AppServiceClass {
throw new TranslatedError('server-messages.errors.app-not-found', { id: appName });
}
ensureAppFolder(appName);
// Regenerate env file
generateEnvFile(app);
checkEnvFile(appName);
await this.regenerateEnvFile(app);
await this.queries.updateApp(appName, { status: 'starting' });
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['start', app.id]);
@ -153,7 +155,7 @@ export class AppServiceClass {
if (newApp) {
// Create env file
generateEnvFile(newApp);
await generateEnvFile(newApp);
await copyDataDir(id);
}
@ -229,7 +231,7 @@ export class AppServiceClass {
const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form });
if (updatedApp) {
generateEnvFile(updatedApp);
await generateEnvFile(updatedApp);
}
return updatedApp;
@ -248,8 +250,7 @@ export class AppServiceClass {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
}
ensureAppFolder(id);
generateEnvFile(app);
await this.regenerateEnvFile(app);
// Run script
await this.queries.updateApp(id, { status: 'stopping' });
@ -284,8 +285,7 @@ export class AppServiceClass {
await this.stopApp(id);
}
ensureAppFolder(id);
generateEnvFile(app);
await this.regenerateEnvFile(app);
await this.queries.updateApp(id, { status: 'uninstalling' });
@ -336,8 +336,7 @@ export class AppServiceClass {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
}
ensureAppFolder(id);
generateEnvFile(app);
await this.regenerateEnvFile(app);
await this.queries.updateApp(id, { status: 'updating' });

View File

@ -15,6 +15,8 @@ const SystemService = new SystemServiceClass();
const server = setupServer();
beforeEach(async () => {
await setConfig('demoMode', false);
jest.mock('fs-extra');
jest.resetModules();
jest.resetAllMocks();

View File

@ -1,6 +1,6 @@
import fs from 'fs-extra';
import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import fs from 'fs-extra';
import { Architecture } from '../core/TipiConfig/TipiConfig';
import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
import { APP_CATEGORIES } from '../services/apps/apps.types';
@ -21,8 +21,6 @@ interface IProps {
}
const createAppConfig = (props?: Partial<AppInfo>) => {
const mockFiles: Record<string, string | string[]> = {};
const appInfo = appInfoSchema.parse({
id: faker.string.alphanumeric(32),
available: true,
@ -37,13 +35,13 @@ const createAppConfig = (props?: Partial<AppInfo>) => {
...props,
});
const mockFiles: Record<string, string | string[]> = {};
mockFiles['/runtipi/.env'] = 'TEST=test';
mockFiles['/runtipi/repos/repo-id'] = '';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
// @ts-expect-error - fs-extra mock is not typed
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
return appInfo;
@ -103,12 +101,11 @@ const createApp = async (props: IProps, database: TestDatabase) => {
});
}
const MockFiles: Record<string, string | string[]> = {};
MockFiles['/runtipi/.env'] = 'TEST=test';
MockFiles['/runtipi/repos/repo-id'] = '';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
const mockFiles: Record<string, string | string[]> = {};
mockFiles['/runtipi/.env'] = 'TEST=test';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
let appEntity: App = {} as App;
if (installed) {
@ -126,14 +123,15 @@ const createApp = async (props: IProps, database: TestDatabase) => {
// eslint-disable-next-line prefer-destructuring
appEntity = insertedApp[0] as App;
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
MockFiles[`/runtipi/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
mockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
mockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
mockFiles[`/runtipi/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
}
return { appInfo, MockFiles, appEntity };
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
return { appInfo, MockFiles: mockFiles, appEntity };
};
const insertApp = async (data: Partial<NewApp>, appInfo: AppInfo, database: TestDatabase) => {
@ -149,15 +147,15 @@ const insertApp = async (data: Partial<NewApp>, appInfo: AppInfo, database: Test
const mockFiles: Record<string, string | string[]> = {};
if (data.status !== 'missing') {
mockFiles[`/app/storage/app-data/${values.id}`] = '';
mockFiles[`/app/storage/app-data/${values.id}/app.env`] = `TEST=test\nAPP_PORT=3000\n${Object.entries(data.config || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n')}`;
mockFiles[`/runtipi/apps/${values.id}/config.json`] = JSON.stringify(appInfo);
mockFiles[`/runtipi/apps/${values.id}/metadata/description.md`] = 'md desc';
mockFiles[`/runtipi/apps/${values.id}/docker-compose.yml`] = 'compose';
}
// @ts-expect-error - fs-extra mock is not typed
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
const insertedApp = await database.db.insert(appTable).values(values).returning();

View File

@ -1,4 +1,58 @@
import webpush from 'web-push';
import fs from 'fs-extra';
/**
* Convert a string of environment variables to a Map
*
* @param {string} envString - String of environment variables
*/
export const envStringToMap = (envString: string) => {
const envMap = new Map<string, string>();
const envArray = envString.split('\n');
envArray.forEach((env) => {
const [key, value] = env.split('=');
if (key && value) {
envMap.set(key, value);
}
});
return envMap;
};
/**
* Convert a Map of environment variables to a valid string of environment variables
* that can be used in a .env file
*
* @param {Map<string, string>} envMap - Map of environment variables
*/
export const envMapToString = (envMap: Map<string, string>) => {
const envArray = Array.from(envMap).map(([key, value]) => `${key}=${value}`);
return envArray.join('\n');
};
/**
* This function reads the env file for the app with the provided id and returns a Map containing the key-value pairs of the environment variables.
* It reads the app.env file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value.
*
* @param {string} id - App ID
*/
export const getAppEnvMap = async (id: string) => {
try {
const envFile = await fs.promises.readFile(`/app/storage/app-data/${id}/app.env`);
const envVars = envFile.toString().split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
if (key && value) envVarsMap.set(key, value);
});
return envVarsMap;
} catch (e) {
return new Map<string, string>();
}
};
/**
* Generate VAPID keys

View File

@ -1,3 +1,4 @@
import fs from 'fs-extra';
import { fromPartial } from '@total-typescript/shoehorn';
import { EventDispatcher } from '../../src/server/core/EventDispatcher';
@ -14,6 +15,14 @@ jest.mock('vitest', () => ({
console.error = jest.fn();
beforeEach(async () => {
// @ts-expect-error - custom mock method
fs.__resetAllMocks();
await fs.promises.mkdir('/runtipi/state', { recursive: true });
await fs.promises.writeFile('/runtipi/state/settings.json', '{}');
await fs.promises.mkdir('/app/logs', { recursive: true });
});
// Mock Logger
jest.mock('../../src/server/core/Logger', () => ({
Logger: {