diff --git a/.eslintrc.js b/.eslintrc.js index d57feead..b83877e2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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}', ], }, ], diff --git a/__mocks__/fs-extra.ts b/__mocks__/fs-extra.ts index 4eb941f6..e7f76d2f 100644 --- a/__mocks__/fs-extra.ts +++ b/__mocks__/fs-extra.ts @@ -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) => { +export default { + ...fs, + copySync: (src: string, dest: string) => { + copyFolderRecursiveSync(src, dest); + }, + __resetAllMocks: () => { + vol.reset(); + }, + __applyMockFiles: (newMockFiles: Record) => { // 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) => { - this.mockFiles = Object.create(null); - + vol.fromJSON(newMockFiles, 'utf8'); + }, + __createMockFiles: (newMockFiles: Record) => { + 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()), +}; diff --git a/jest.config.ts b/jest.config.ts index 5471650a..438aff40 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -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}'], diff --git a/package.json b/package.json index e3bf1807..bf901214 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 282ec51d..fe59c807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/client/components/hoc/StatusProvider/StatusProvider.test.tsx b/src/client/components/hoc/StatusProvider/StatusProvider.test.tsx index 5f2c74dc..79b38323 100644 --- a/src/client/components/hoc/StatusProvider/StatusProvider.test.tsx +++ b/src/client/components/hoc/StatusProvider/StatusProvider.test.tsx @@ -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(
system running
@@ -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(); }); }); diff --git a/src/server/core/EventDispatcher/EventDispatcher.test.ts b/src/server/core/EventDispatcher/EventDispatcher.test.ts index 305e8f56..6a096fce 100644 --- a/src/server/core/EventDispatcher/EventDispatcher.test.ts +++ b/src/server/core/EventDispatcher/EventDispatcher.test.ts @@ -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', () => { diff --git a/src/server/core/TipiConfig/TipiConfig.test.ts b/src/server/core/TipiConfig/TipiConfig.test.ts index c703e7aa..3d950c6d 100644 --- a/src/server/core/TipiConfig/TipiConfig.test.ts +++ b/src/server/core/TipiConfig/TipiConfig.test.ts @@ -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 diff --git a/src/server/services/apps/apps.helpers.test.ts b/src/server/services/apps/apps.helpers.test.ts index c74ffa23..42ed2a24 100644 --- a/src/server/services/apps/apps.helpers.test.ts +++ b/src/server/services/apps/apps.helpers.test.ts @@ -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'); diff --git a/src/server/services/apps/apps.helpers.ts b/src/server/services/apps/apps.helpers.ts index 63e00a81..f300ce83 100644 --- a/src/server/services/apps/apps.helpers.ts +++ b/src/server/services/apps/apps.helpers.ts @@ -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(); - - 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 => { * @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) => { * @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()) { diff --git a/src/server/services/apps/apps.service.test.ts b/src/server/services/apps/apps.service.test.ts index 8e03a240..d751fb36 100644 --- a/src/server/services/apps/apps.service.test.ts +++ b/src/server/services/apps/apps.service.test.ts @@ -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', () => { diff --git a/src/server/services/apps/apps.service.ts b/src/server/services/apps/apps.service.ts index 044e533a..c4f9803b 100644 --- a/src/server/services/apps/apps.service.ts +++ b/src/server/services/apps/apps.service.ts @@ -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' }); diff --git a/src/server/services/system/system.service.test.ts b/src/server/services/system/system.service.test.ts index 2bc8dc91..68cbc4a2 100644 --- a/src/server/services/system/system.service.test.ts +++ b/src/server/services/system/system.service.test.ts @@ -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(); diff --git a/src/server/tests/apps.factory.ts b/src/server/tests/apps.factory.ts index 414290a0..4762d78d 100644 --- a/src/server/tests/apps.factory.ts +++ b/src/server/tests/apps.factory.ts @@ -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) => { - const mockFiles: Record = {}; - const appInfo = appInfoSchema.parse({ id: faker.string.alphanumeric(32), available: true, @@ -37,13 +35,13 @@ const createAppConfig = (props?: Partial) => { ...props, }); + const mockFiles: Record = {}; 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 = {}; - 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 = {}; + 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, appInfo: AppInfo, database: TestDatabase) => { @@ -149,15 +147,15 @@ const insertApp = async (data: Partial, appInfo: AppInfo, database: Test const mockFiles: Record = {}; 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(); diff --git a/src/server/utils/env-generation.ts b/src/server/utils/env-generation.ts index 25993d15..5292e89a 100644 --- a/src/server/utils/env-generation.ts +++ b/src/server/utils/env-generation.ts @@ -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(); + 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} envMap - Map of environment variables + */ +export const envMapToString = (envMap: Map) => { + 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(); + + envVars.forEach((envVar) => { + const [key, value] = envVar.split('='); + if (key && value) envVarsMap.set(key, value); + }); + + return envVarsMap; + } catch (e) { + return new Map(); + } +}; /** * Generate VAPID keys diff --git a/tests/server/jest.setup.ts b/tests/server/jest.setup.ts index e71840a8..a2e3783b 100644 --- a/tests/server/jest.setup.ts +++ b/tests/server/jest.setup.ts @@ -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: {