diff --git a/manifests/plugins/csv-lookup/region-metadata/success.yml b/manifests/plugins/csv-lookup/region-metadata/success.yml index f57b4718a..c6fc07d7b 100644 --- a/manifests/plugins/csv-lookup/region-metadata/success.yml +++ b/manifests/plugins/csv-lookup/region-metadata/success.yml @@ -23,4 +23,4 @@ tree: - timestamp: 2023-08-06T00:00 duration: 3600 cloud/provider: Google Cloud - cloud/region: asia-east-1 + cloud/region: asia-east1 diff --git a/manifests/plugins/divide/success.yml b/manifests/plugins/divide/success.yml index 24626c8f4..4d35e5fc1 100644 --- a/manifests/plugins/divide/success.yml +++ b/manifests/plugins/divide/success.yml @@ -2,7 +2,7 @@ name: divide description: success path tags: initialize: -# outputs: ['yaml'] + outputs: ['yaml'] plugins: cloud-metadata: path: builtin diff --git a/manifests/plugins/sum/success.yml b/manifests/plugins/sum/success.yml index fc0661bf5..454efc9e0 100644 --- a/manifests/plugins/sum/success.yml +++ b/manifests/plugins/sum/success.yml @@ -2,7 +2,7 @@ name: sum description: successful path tags: initialize: - outputs: ['yaml'] + # outputs: ['yaml'] plugins: sum: method: Sum diff --git a/package-lock.json b/package-lock.json index 8d3fc6f29..3e66c802a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@commitlint/cli": "^18.6.0", "@commitlint/config-conventional": "^18.6.0", - "@grnsft/if-core": "^0.0.7", + "@grnsft/if-core": "^0.0.9", "axios": "^1.7.2", "csv-parse": "^5.5.6", "csv-stringify": "^6.4.6", @@ -25,6 +25,7 @@ }, "bin": { "if-diff": "build/diff.js", + "if-env": "build/env.js", "if-run": "build/index.js" }, "devDependencies": { @@ -36,6 +37,7 @@ "@types/luxon": "^3.4.2", "@types/node": "^20.8.9", "axios-mock-adapter": "^1.22.0", + "cross-env": "7.0.3", "fixpack": "^4.0.0", "gts": "^5.0.0", "husky": "^8.0.0", @@ -1181,9 +1183,9 @@ } }, "node_modules/@grnsft/if-core": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@grnsft/if-core/-/if-core-0.0.7.tgz", - "integrity": "sha512-+4w8Sq1KRPDb+Jy638wgrTXlXIZzewOBceT+rAy3Oaov1M/veY3gu3AV15SXcPHrsBoFmZ6QeI9g1rF3RKB0ww==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@grnsft/if-core/-/if-core-0.0.9.tgz", + "integrity": "sha512-F0niYe1j+NfhH+okz5sjP/CD7w/9BeXXe13bHkCsdOnk0WfXrq0DGKwpqh9TiLbKc9f1P3/XuojeFANMQu5nig==", "dependencies": { "typescript": "^5.1.6" }, @@ -3771,6 +3773,24 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "license": "MIT", diff --git a/package.json b/package.json index c79c5861a..e9f2f2777 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "email": "info@gsf.com" }, "bin": { + "if-diff": "./build/diff.js", "if-run": "./build/index.js", - "if-diff": "./build/diff.js" + "if-env": "./build/env.js" }, "bugs": { "url": "https://github.com/Green-Software-Foundation/if/issues/new?assignees=&labels=feedback&projects=&template=feedback.md&title=Feedback+-+" @@ -16,7 +17,7 @@ "dependencies": { "@commitlint/cli": "^18.6.0", "@commitlint/config-conventional": "^18.6.0", - "@grnsft/if-core": "^0.0.7", + "@grnsft/if-core": "^0.0.9", "axios": "^1.7.2", "csv-parse": "^5.5.6", "csv-stringify": "^6.4.6", @@ -37,6 +38,7 @@ "@types/luxon": "^3.4.2", "@types/node": "^20.8.9", "axios-mock-adapter": "^1.22.0", + "cross-env": "7.0.3", "fixpack": "^4.0.0", "gts": "^5.0.0", "husky": "^8.0.0", @@ -74,6 +76,7 @@ "fix": "gts fix", "fix:package": "fixpack", "if-diff": "npx ts-node src/diff.ts", + "if-env": "cross-env CURRENT_DIR=$(node -p \"process.env.INIT_CWD\") npx ts-node src/env.ts", "if-run": "npx ts-node src/index.ts", "lint": "gts lint", "pre-commit": "lint-staged", diff --git a/src/__mocks__/fs/index.ts b/src/__mocks__/fs/index.ts index ec83ff675..3853ad7ee 100644 --- a/src/__mocks__/fs/index.ts +++ b/src/__mocks__/fs/index.ts @@ -1,10 +1,20 @@ import * as YAML from 'js-yaml'; +import * as fs from 'fs'; +import * as fsAsync from 'fs/promises'; +import * as path from 'path'; export const readFile = async (filePath: string) => { + /** mock for util/npm */ + if (filePath.includes('package.json-npm')) { + const updatedPath = filePath.replace('-npm', ''); + return fs.readFileSync(updatedPath, 'utf8'); + } + /** mock for util/json */ if (filePath.includes('json-reject')) { return Promise.reject(new Error('rejected')); } + if (filePath.includes('json')) { if (filePath.includes('param')) { return JSON.stringify({ @@ -80,16 +90,48 @@ cpu-cores-available,cpu-cores-utilized,cpu-manufacturer,cpu-model-name,cpu-tdp,g export const mkdir = (dirPath: string) => dirPath; export const writeFile = async (pathToFile: string, content: string) => { - if (pathToFile === 'reject') { - throw new Error('Wrong file path'); - } + if (pathToFile.includes('package.json-npm1')) { + const updatedPath = pathToFile.replace('-npm1', ''); + const fileContent = await fsAsync.readFile(updatedPath, 'utf8'); + const fileContentObject = JSON.parse(fileContent); + const parsedContent = JSON.parse(content); - const mockPathToFile = 'mock-pathToFile'; - const mockContent = { - name: 'mock-name', - }; - const mockObject = YAML.dump(mockContent, {noRefs: true}); + for (const property in fileContentObject) { + expect(parsedContent).toHaveProperty(property); + } + } else if (pathToFile.includes('package.json-npm')) { + const updatedPath = pathToFile.replace('-npm', ''); + const fileContent = await fsAsync.readFile(updatedPath, 'utf8'); + + expect(content).toBe(fileContent); + } else if (pathToFile.includes('/manifest.yml')) { + const templateManifest = path.resolve( + __dirname, + '../../config/env-template.yml' + ); + const fileContent = await fsAsync.readFile(templateManifest, 'utf8'); - expect(pathToFile).toBe(mockPathToFile); - expect(content).toBe(mockObject); + expect(content).toBe(fileContent); + } else { + if (pathToFile === 'reject') { + throw new Error('Wrong file path'); + } + + const mockPathToFile = 'mock-pathToFile'; + const mockContent = { + name: 'mock-name', + }; + const mockObject = YAML.dump(mockContent, {noRefs: true}); + + expect(pathToFile).toBe(mockPathToFile); + expect(content).toBe(mockObject); + } +}; + +export const stat = async (filePath: string) => { + if (filePath === 'true') { + return true; + } else { + throw new Error('File not found.'); + } }; diff --git a/src/__tests__/unit/util/args.test.ts b/src/__tests__/unit/util/args.test.ts index 61c8c4577..03752acc4 100644 --- a/src/__tests__/unit/util/args.test.ts +++ b/src/__tests__/unit/util/args.test.ts @@ -1,5 +1,14 @@ const processRunningPath = process.cwd(); +jest.mock('../../../util/fs', () => ({ + isFileExists: () => { + if (process.env.fileExists === 'true') { + return true; + } + return false; + }, +})); + jest.mock('ts-command-line-args', () => ({ __esModule: true, parse: () => { @@ -60,6 +69,22 @@ jest.mock('ts-command-line-args', () => ({ throw new Error('mock-error'); case 'diff-throw': throw 'mock-error'; + /** If-env mocks */ + // case 'env-manifest-is-missing': + // return; + case 'manifest-install-provided': + return { + install: true, + manifest: 'mock-manifest.yaml', + }; + case 'manifest-is-not-yaml': + return {manifest: 'manifest'}; + case 'manifest-path-invalid': + throw new Error(MANIFEST_NOT_FOUND); + case 'env-throw-error': + throw new Error('mock-error'); + case 'env-throw': + throw 'mock-error'; default: return { manifest: 'mock-manifest.yaml', @@ -72,7 +97,11 @@ jest.mock('ts-command-line-args', () => ({ import * as path from 'node:path'; import {ERRORS} from '@grnsft/if-core/utils'; -import {parseIEProcessArgs, parseIfDiffArgs} from '../../../util/args'; +import { + parseIEProcessArgs, + parseIfDiffArgs, + parseIfEnvArgs, +} from '../../../util/args'; import {STRINGS} from '../../../config'; @@ -83,6 +112,7 @@ const { TARGET_IS_NOT_YAML, INVALID_TARGET, SOURCE_IS_NOT_YAML, + MANIFEST_NOT_FOUND, } = STRINGS; describe('util/args: ', () => { @@ -298,5 +328,79 @@ describe('util/args: ', () => { }); }); + describe('parseIfEnvArgs(): ', () => { + it('executes if `manifest` is missing.', async () => { + process.env.fileExists = 'true'; + process.env.result = 'manifest-is-missing'; + const response = await parseIfEnvArgs(); + + expect.assertions(1); + + expect(response).toEqual({install: undefined}); + }); + + it('executes if `manifest` and `install` are provided.', async () => { + process.env.fileExists = 'true'; + process.env.result = 'manifest-install-provided'; + + const response = await parseIfEnvArgs(); + + expect.assertions(2); + expect(response).toHaveProperty('install'); + expect(response).toHaveProperty('manifest'); + }); + + it('throws an error if `manifest` is not a yaml.', async () => { + process.env.fileExists = 'true'; + process.env.result = 'manifest-is-not-yaml'; + expect.assertions(1); + + try { + await parseIfEnvArgs(); + } catch (error) { + if (error instanceof Error) { + expect(error).toEqual(new CliSourceFileError(SOURCE_IS_NOT_YAML)); + } + } + }); + + it('throws an error if `manifest` path is invalid.', async () => { + process.env.fileExists = 'false'; + expect.assertions(1); + + try { + await parseIfEnvArgs(); + } catch (error) { + if (error instanceof Error) { + expect(error).toEqual(new ParseCliParamsError(MANIFEST_NOT_FOUND)); + } + } + }); + + it('throws an error if parsing failed.', async () => { + process.env.result = 'env-throw-error'; + expect.assertions(1); + + try { + await parseIfEnvArgs(); + } catch (error) { + if (error instanceof Error) { + expect(error).toEqual(new ParseCliParamsError('mock-error')); + } + } + }); + + it('throws error if parsing failed (not instance of error).', async () => { + process.env.result = 'env-throw'; + expect.assertions(1); + + try { + await parseIfEnvArgs(); + } catch (error) { + expect(error).toEqual('mock-error'); + } + }); + }); + process.env = originalEnv; }); diff --git a/src/__tests__/unit/util/fs.test.ts b/src/__tests__/unit/util/fs.test.ts new file mode 100644 index 000000000..82f28bc75 --- /dev/null +++ b/src/__tests__/unit/util/fs.test.ts @@ -0,0 +1,21 @@ +import {isFileExists} from '../../../util/fs'; + +jest.mock('fs/promises', () => require('../../../__mocks__/fs')); + +describe('util/fs: ', () => { + describe('isFileExists(): ', () => { + it('returns true if the file exists.', async () => { + const result = await isFileExists('true'); + + expect.assertions(1); + expect(result).toEqual(true); + }); + + it('returns fale if the file does not exist.', async () => { + const result = await isFileExists('false'); + + expect.assertions(1); + expect(result).toEqual(false); + }); + }); +}); diff --git a/src/__tests__/unit/util/helpers.test.ts b/src/__tests__/unit/util/helpers.test.ts index d8dfe2096..d04ad43d2 100644 --- a/src/__tests__/unit/util/helpers.test.ts +++ b/src/__tests__/unit/util/helpers.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ const mockWarn = jest.fn(); const mockError = jest.fn(); @@ -10,7 +11,131 @@ jest.mock('../../../util/logger', () => ({ error: mockError, }, })); + +jest.mock('path', () => { + const actualPath = jest.requireActual('path') as Record; + return { + __esModule: true, + ...actualPath, + dirname: jest.fn(() => './mock-path'), + }; +}); + +jest.mock('fs/promises', () => require('../../../__mocks__/fs')); + +jest.mock('../../../lib/load', () => ({ + load: jest.fn(() => { + if (process.env.manifest === 'true') { + return { + rawManifest: { + name: 'divide', + initialize: { + plugins: { + 'cloud-metadata': { + path: '@grnsft/if-plugins', + method: 'CloudMetadata', + }, + divide: { + path: 'builtin', + method: 'Divide', + 'global-config': { + numerator: 'vcpus-allocated', + denominator: 2, + output: 'cpu/number-cores', + }, + }, + }, + }, + execution: { + environment: { + dependencies: [ + '@grnsft/if-core@0.0.7', + '@grnsft/if-plugins@v0.3.2 extraneous -> file:../../../if-models', + '@grnsft/if-unofficial-plugins@v0.3.0 extraneous -> file:../../../if-unofficial-models', + ], + }, + }, + }, + }; + } + return { + rawManifest: { + initialize: { + plugins: {'@grnsft/if-plugins': '1.0.0'}, + }, + execution: { + environment: { + dependencies: [], + }, + }, + }, + }; + }), +})); + +const initPackage = jest.fn(() => Promise.resolve('mock-path')); +const updatePackage = jest.fn(() => Promise.resolve(true)); +const installdeps = jest.fn(); +const updatedeps = jest.fn(); +jest.mock('../../../util/npm', () => { + const actualNPMUtil = jest.requireActual('../../../util/npm'); + + return { + ...actualNPMUtil, + initPackageJsonIfNotExists: (folderPath: string) => { + if (process.env.NPM_MOCK === 'true') { + return initPackage(); + } + + if (process.env.NPM_MOCK === 'error') { + throw new Error('mock-error'); + } + + return actualNPMUtil.initPackageJsonIfNotExists(folderPath); + }, + updatePackageJsonProperties: ( + newPackageJsonPath: string, + appendDependencies: boolean + ) => { + if (process.env.NPM_MOCK === 'true') { + return updatePackage(); + } + + return actualNPMUtil.updatePackageJsonProperties( + newPackageJsonPath, + appendDependencies + ); + }, + installDependencies: ( + folderPath: string, + dependencies: {[path: string]: string} + ) => { + if (process.env.NPM_MOCK === 'true') { + return installdeps(); + } + + return actualNPMUtil.installDependencies(folderPath, dependencies); + }, + updatePackageJsonDependencies: ( + packageJsonPath: string, + dependencies: any, + cwd: boolean + ) => { + if (process.env.NPM_MOCK === 'true') { + return updatedeps(); + } + + return actualNPMUtil.updatePackageJsonDependencies( + packageJsonPath, + dependencies, + cwd + ); + }, + }; +}); + import {ERRORS} from '@grnsft/if-core/utils'; + import { andHandle, checkIfEqual, @@ -18,9 +143,17 @@ import { mergeObjects, oneIsPrimitive, parseManifestFromStdin, + getOptionsFromArgs, + addTemplateManifest, + initializeAndInstallLibs, + // initializeAndInstallLibs, } from '../../../util/helpers'; +import {CONFIG} from '../../../config'; import {Difference} from '../../../types/lib/compare'; +const {IF_ENV} = CONFIG; +const {FAILURE_MESSAGE_DEPENDENCIES, FAILURE_MESSAGE} = IF_ENV; + const {WriteFileError} = ERRORS; describe('util/helpers: ', () => { @@ -38,9 +171,7 @@ describe('util/helpers: ', () => { expect(mockError).toHaveBeenCalledTimes(1); }); }); -}); -describe('util/helpers: ', () => { describe('mergeObjects(): ', () => { it('does not override input.', () => { expect.assertions(1); @@ -399,4 +530,141 @@ description: mock-description expect(response).toBeFalsy(); }); }); + + describe('getOptionsFromArgs(): ', () => { + it('returns the correct options when dependencies are present.', async () => { + const commandArgs = { + manifest: '/path/to/mock-manifest.json', + install: false, + }; + + process.env.manifest = 'true'; + + const result = await getOptionsFromArgs(commandArgs); + expect.assertions(1); + + expect(result).toEqual({ + folderPath: './mock-path', + dependencies: { + '@grnsft/if-plugins': '^v0.3.2', + }, + install: false, + }); + }); + + it('throws an error when there are no dependencies.', async () => { + const commandArgs = { + manifest: '/path/to/mock-manifest.json', + install: false, + }; + + process.env.manifest = 'false'; + + expect.assertions(1); + try { + await getOptionsFromArgs(commandArgs); + } catch (error) { + expect(error).toEqual(new Error(FAILURE_MESSAGE_DEPENDENCIES)); + } + }); + }); + + describe('addTemplateManifest(): ', () => { + it('successfully adds the template manifest to the directory.', async () => { + await addTemplateManifest('./'); + + expect.assertions(1); + }); + + it('throws an error when the manifest is not added into the directory.', async () => { + expect.assertions(1); + + try { + await addTemplateManifest(''); + } catch (error) { + const logSpy = jest.spyOn(global.console, 'log'); + expect(logSpy).toEqual(FAILURE_MESSAGE); + } + }); + }); + + describe('initializeAndInstallLibs(): ', () => { + beforeEach(() => { + initPackage.mockReset(); + updatePackage.mockReset(); + installdeps.mockReset(); + updatedeps.mockReset(); + }); + + it('installs dependencies if install flag is truthy.', async () => { + process.env.NPM_MOCK = 'true'; + // @ts-ignore + process.exit = (code: any) => code; + const options = { + folderPath: 'mock-folderPath', + install: true, + cwd: true, + dependencies: { + mock: 'mock-dependencies', + }, + }; + + expect.assertions(4); + await initializeAndInstallLibs(options); + + expect(initPackage).toHaveBeenCalledTimes(1); + expect(updatePackage).toHaveBeenCalledTimes(1); + expect(installdeps).toHaveBeenCalledTimes(1); + expect(updatedeps).toHaveBeenCalledTimes(0); + }); + + it('updates dependencies if install flag is falsy.', async () => { + process.env.NPM_MOCK = 'true'; + // @ts-ignore + process.exit = (code: any) => code; + const options = { + folderPath: 'mock-folderPath', + install: false, + cwd: true, + dependencies: { + mock: 'mock-dependencies', + }, + }; + + expect.assertions(4); + await initializeAndInstallLibs(options); + + expect(initPackage).toHaveBeenCalledTimes(1); + expect(updatePackage).toHaveBeenCalledTimes(1); + expect(installdeps).toHaveBeenCalledTimes(0); + expect(updatedeps).toHaveBeenCalledTimes(1); + }); + + it('exits process if error is thrown.', async () => { + process.env.NPM_MOCK = 'error'; + const originalProcessExit = process.exit; + const mockExit = jest.fn(); + // @ts-ignore + process.exit = mockExit; + const options = { + folderPath: 'mock-folderPath', + install: false, + cwd: true, + dependencies: { + mock: 'mock-dependencies', + }, + }; + + expect.assertions(5); + await initializeAndInstallLibs(options); + + expect(initPackage).toHaveBeenCalledTimes(0); + expect(updatePackage).toHaveBeenCalledTimes(0); + expect(installdeps).toHaveBeenCalledTimes(0); + expect(updatedeps).toHaveBeenCalledTimes(0); + expect(mockExit).toHaveBeenCalledTimes(1); + + process.exit = originalProcessExit; + }); + }); }); diff --git a/src/__tests__/unit/util/npm.test.ts b/src/__tests__/unit/util/npm.test.ts new file mode 100644 index 000000000..403a1138c --- /dev/null +++ b/src/__tests__/unit/util/npm.test.ts @@ -0,0 +1,249 @@ +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as path from 'path'; + +jest.mock('fs/promises', () => require('../../../__mocks__/fs')); + +const mockInfo = jest.fn(); + +jest.mock('node:readline/promises', () => + require('../../../__mocks__/readline') +); +jest.mock('../../../util/logger', () => ({ + logger: { + info: mockInfo, + }, +})); + +import { + installDependencies, + initPackageJsonIfNotExists, + updatePackageJsonDependencies, + extractPathsWithVersion, + updatePackageJsonProperties, +} from '../../../util/npm'; +import {isFileExists} from '../../../util/fs'; + +import {STRINGS} from '../../../config/strings'; +import {ManifestPlugin} from '../../../types/npm'; + +const {INITIALIZING_PACKAGE_JSON, INSTALLING_NPM_PACKAGES} = STRINGS; + +describe('util/npm: ', () => { + const helpers = require('../../../util/helpers'); + const folderPath = path.resolve(__dirname, 'npm-test'); + + beforeAll(() => { + if (!fsSync.existsSync(folderPath)) { + fsSync.mkdirSync(folderPath, {recursive: true}); + } + }); + + afterAll(() => { + if (fsSync.existsSync(folderPath)) { + fsSync.rmSync(folderPath, {recursive: true, force: true}); + } + }); + + describe('initPackageJsonIfNotExists(): ', () => { + it('initializes package.json if it does not exist.', async () => { + const spyExecPromise = jest.spyOn(helpers, 'execPromise'); + isFileExists('true'); + + await initPackageJsonIfNotExists(folderPath); + + expect.assertions(2); + expect(mockInfo).toHaveBeenCalledWith(INITIALIZING_PACKAGE_JSON); + expect(spyExecPromise).toHaveBeenCalledWith('npm init -y', { + cwd: folderPath, + }); + }); + + it('returns the package.json path if it exists.', async () => { + const packageJsonPath = path.resolve(folderPath, 'package.json'); + isFileExists('false'); + + const result = await initPackageJsonIfNotExists(folderPath); + + expect.assertions(1); + expect(result).toBe(packageJsonPath); + }); + }); + + describe('installDependencies(): ', () => { + const dependencies = { + '@grnsft/if': '^0.3.3-beta.0', + }; + + it('calls execPromise with the correct arguments.', async () => { + const spyExecPromise = jest.spyOn(helpers, 'execPromise'); + const formattedDependencies = ['@grnsft/if@0.3.3-beta.0']; + expect.assertions(1); + + await installDependencies(folderPath, dependencies); + + expect(spyExecPromise).toHaveBeenCalledWith( + `npm install ${formattedDependencies.join(' ')}`, + {cwd: folderPath} + ); + }, 30000); + + it('logs the installation message.', async () => { + const dependencies = { + '@grnsft/if': '^0.3.3-beta.0', + }; + + await installDependencies(folderPath, dependencies); + + expect.assertions(1); + expect(mockInfo).toHaveBeenCalledWith(INSTALLING_NPM_PACKAGES); + }); + }); + + describe('updatePackageJsonDependencies(): ', () => { + it('successfully updates the package.json dependencies when cwd is false.', async () => { + const dependencies = { + '@grnsft/if-plugins': '^0.3.3-beta.0', + }; + const packageJsonPath = path.join(folderPath, 'package.json-npm'); + + const expectedPackageJsonContent = JSON.stringify( + { + dependencies: { + '@grnsft/if-plugins': '^0.3.3-beta.0', + }, + }, + null, + 2 + ); + + const fsReadSpy = jest + .spyOn(fs, 'readFile') + .mockResolvedValue(expectedPackageJsonContent); + await updatePackageJsonDependencies(packageJsonPath, dependencies, false); + + expect.assertions(2); + + expect(fsReadSpy).toHaveBeenCalledWith(packageJsonPath, 'utf8'); + }); + + it('successfully updates the package.json dependencies when cwd is true.', async () => { + const dependencies = { + '@grnsft/if-plugins': '^0.3.3-beta.0', + }; + const packageJsonPath = path.join(folderPath, 'package.json-npm'); + + const expectedPackageJsonContent = JSON.stringify( + { + dependencies: { + '@grnsft/if-plugins': '^0.3.3-beta.0', + }, + }, + null, + 2 + ); + + const fsReadSpy = jest + .spyOn(fs, 'readFile') + .mockResolvedValue(expectedPackageJsonContent); + await updatePackageJsonDependencies(packageJsonPath, dependencies, true); + + expect.assertions(2); + + expect(fsReadSpy).toHaveBeenCalledWith(packageJsonPath, 'utf8'); + }); + }); + + describe('extractPathsWithVersion(): ', () => { + it('extracts paths with correct versions.', () => { + const plugins: ManifestPlugin = { + 'cloud-metadata': { + path: '@grnsft/if-plugins', + method: 'CloudMetadata', + }, + divide: { + path: 'builtin', + method: 'Divide', + }, + 'boavizta-cpu': { + path: '@grnsft/if-unofficial-plugins', + method: 'BoaviztaCpuOutput', + }, + }; + const dependencies = [ + '@babel/core@7.22.10', + '@babel/preset-typescript@7.23.3', + '@commitlint/cli@18.6.0', + '@commitlint/config-conventional@18.6.0', + '@grnsft/if-core@0.0.7', + '@grnsft/if-plugins@v0.3.2 extraneous -> file:../../../if-models', + '@grnsft/if-unofficial-plugins@v0.3.0 extraneous -> file:../../../if-unofficial-models', + '@jest/globals@29.7.0', + ]; + + const result = extractPathsWithVersion(plugins, dependencies); + + expect.assertions(1); + expect(result).toEqual({ + '@grnsft/if-plugins': '^v0.3.2', + '@grnsft/if-unofficial-plugins': '^v0.3.0', + }); + }); + + it('returns an empty object if no matches found', () => { + const plugins: ManifestPlugin = { + 'cloud-metadata': { + path: '@grnsft/if-plugins', + method: 'CloudMetadata', + }, + divide: { + path: 'builtin', + method: 'Divide', + }, + 'boavizta-cpu': { + path: '@grnsft/if-unofficial-plugins', + method: 'BoaviztaCpuOutput', + }, + }; + const dependencies = [ + '@babel/core@7.22.10', + '@babel/preset-typescript@7.23.3', + ]; + + expect.assertions(1); + const result = extractPathsWithVersion(plugins, dependencies); + expect(result).toEqual({}); + }); + }); + + describe('updatePackageJsonProperties(): ', () => { + it('updates the package.json properties correctly.', async () => { + const packageJsonPath = path.join(folderPath, 'package.json-npm1'); + + const expectedPackageJsonContent = JSON.stringify( + { + name: 'if-environment', + description: 'mock-description', + author: {}, + bugs: {}, + engines: {}, + homepage: 'mock-homepage', + dependencies: { + '@grnsft/if-plugins': '^0.3.3-beta.0', + }, + }, + null, + 2 + ); + + const fsReadSpy = jest + .spyOn(fs, 'readFile') + .mockResolvedValue(expectedPackageJsonContent); + await updatePackageJsonProperties(packageJsonPath, true); + + expect.assertions(8); + + expect(fsReadSpy).toHaveBeenCalledWith(packageJsonPath, 'utf8'); + }); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index 5df3f3439..0f96019f2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -2,7 +2,7 @@ import {ArgumentConfig, ParseOptions} from 'ts-command-line-args'; import {STRINGS} from './strings'; -import {IFDiffArgs, IEArgs} from '../types/process-args'; +import {IFDiffArgs, IEArgs, IFEnvArgs} from '../types/process-args'; const {DISCLAIMER_MESSAGE} = STRINGS; @@ -83,6 +83,43 @@ export const CONFIG = { SUCCESS_MESSAGE: 'Files match!', FAILURE_MESSAGE: 'Files do not match!', }, + IF_ENV: { + ARGS: { + manifest: { + type: String, + optional: true, + alias: 'm', + description: '[path to the manifest file]', + }, + install: { + type: Boolean, + optional: true, + alias: 'i', + description: '[command to install package.json]', + }, + cwd: { + type: Boolean, + optional: true, + alias: 'c', + description: + '[command to generate the package.json in the command working directory]', + }, + } as ArgumentConfig, + HELP: { + helpArg: 'help', + headerContentSections: [ + {header: 'Impact Framework', content: 'IF-Env Helpful keywords:'}, + ], + footerContentSections: [ + {header: 'Green Software Foundation', content: DISCLAIMER_MESSAGE}, + ], + } as ParseOptions, + SUCCESS_MESSAGE: 'The environment is successfully setup!', + FAILURE_MESSAGE: 'Faied to create the environment!', + FAILURE_MESSAGE_TEMPLATE: + 'Faied to create the environment with the template manifest!', + FAILURE_MESSAGE_DEPENDENCIES: 'Manifest dependencies are not available!', + }, GITHUB_PATH: 'https://github.com', NATIVE_PLUGIN: 'if-plugins', AGGREGATION_ADDITIONAL_PARAMS: ['timestamp', 'duration'], diff --git a/src/config/env-template.yml b/src/config/env-template.yml new file mode 100644 index 000000000..f5fd65a6a --- /dev/null +++ b/src/config/env-template.yml @@ -0,0 +1,24 @@ +name: template manifest # rename me! +description: auto-generated template # update description! +tags: # add any tags that will help you to track your manifests +initialize: + outputs: + - yaml # you can add - csv to export to csv + plugins: # add more plugins for your use-case + memory-energy-from-memory-util: # you can name this any way you like! + method: Coefficient # the name of the function exported from the plugin + path: "builtin" # the import path + global-config: # anmy config required by the plugin + input-parameter: "memory/utilization" + coefficient: 0.0001 #kwH/GB + output-parameter: "memory/energy" +tree: + children: # add a chile for each distinct component you want to measure + child: + pipeline: # the pipeline is an ordered list of plugins you want to execute + - memory-energy-from-memory-util # must match the name in initialize! + config: # any plugin specific, node-level config + inputs: + - timestamp: 2023-12-12T00:00:00.000Z # ISO 8061 string + duration: 3600 # units of seconds + memory/utilization: 10 diff --git a/src/config/strings.ts b/src/config/strings.ts index 82e6d388b..4da6defae 100644 --- a/src/config/strings.ts +++ b/src/config/strings.ts @@ -57,6 +57,9 @@ Note that for the '--output' option you also need to define the output type in y MISSING_GLOBAL_CONFIG: 'Global config is not provided.', MISSING_INPUT_DATA: (param: string) => `${param} is missing from the input array.`, + MANIFEST_NOT_FOUND: 'Manifest file not found.', + INITIALIZING_PACKAGE_JSON: 'Initializing package.json.', + INSTALLING_NPM_PACKAGES: 'Installing npm packages...', NOT_NUMERIC_VALUE: (str: any) => `${str} is not numberic.`, MISSING_FUNCTIONAL_UNIT_CONFIG: '`functional-unit` should be provided in your global config', diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 000000000..48b0b76b9 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/* eslint-disable no-process-exit */ +import {parseIfEnvArgs} from './util/args'; +import {logger} from './util/logger'; + +import {CONFIG} from './config'; + +import {EnvironmentOptions} from './types/if-env'; +import { + addTemplateManifest, + getOptionsFromArgs, + initializeAndInstallLibs, +} from './util/helpers'; + +const {IF_ENV} = CONFIG; +const {SUCCESS_MESSAGE} = IF_ENV; + +const IfEnv = async () => { + const commandArgs = await parseIfEnvArgs(); + const options: EnvironmentOptions = { + folderPath: process.env.CURRENT_DIR || process.cwd(), + install: !!commandArgs.install, + dependencies: {}, + cwd: !!commandArgs.cwd, + }; + + if (commandArgs && commandArgs.manifest) { + const {folderPath, install, dependencies} = + await getOptionsFromArgs(commandArgs); + options.folderPath = commandArgs.cwd ? options.folderPath : folderPath; + options.install = !!install; + options.dependencies = {...dependencies}; + } + + await initializeAndInstallLibs(options); + + if (!commandArgs || !commandArgs.manifest) { + await addTemplateManifest(options.folderPath); + } + + console.log(SUCCESS_MESSAGE); + process.exit(0); +}; + +IfEnv().catch(error => { + if (error instanceof Error) { + logger.error(error); + process.exit(2); + } +}); diff --git a/src/types/if-env.ts b/src/types/if-env.ts new file mode 100644 index 000000000..03ace8ec9 --- /dev/null +++ b/src/types/if-env.ts @@ -0,0 +1,6 @@ +export type EnvironmentOptions = { + folderPath: string; + install: boolean; + cwd: boolean; + dependencies: {[path: string]: string}; +}; diff --git a/src/types/npm.ts b/src/types/npm.ts new file mode 100644 index 000000000..cbb3e0680 --- /dev/null +++ b/src/types/npm.ts @@ -0,0 +1,3 @@ +export type PathWithVersion = {[path: string]: string}; + +export type ManifestPlugin = {[key: string]: {path: string; method: string}}; diff --git a/src/types/process-args.ts b/src/types/process-args.ts index 1a637082f..40dece54d 100644 --- a/src/types/process-args.ts +++ b/src/types/process-args.ts @@ -11,6 +11,12 @@ export interface IFDiffArgs { target: string; } +export interface IFEnvArgs { + manifest?: string; + install?: boolean; + cwd?: boolean; +} + export interface Options { outputPath?: string; stdout?: boolean; diff --git a/src/util/args.ts b/src/util/args.ts index 523791f2a..5d9524390 100644 --- a/src/util/args.ts +++ b/src/util/args.ts @@ -4,19 +4,28 @@ import {parse} from 'ts-command-line-args'; import {ERRORS} from '@grnsft/if-core/utils'; import {checkIfFileIsYaml} from './yaml'; + +import {isFileExists} from './fs'; + import {logger} from './logger'; import {CONFIG, STRINGS} from '../config'; -import {IFDiffArgs, IEArgs, ProcessArgsOutputs} from '../types/process-args'; +import { + IFDiffArgs, + IEArgs, + ProcessArgsOutputs, + IFEnvArgs, +} from '../types/process-args'; import {LoadDiffParams} from '../types/util/args'; const {ParseCliParamsError, CliTargetFileError, CliSourceFileError} = ERRORS; -const {IE, IF_DIFF} = CONFIG; +const {IE, IF_DIFF, IF_ENV} = CONFIG; const { MANIFEST_IS_MISSING, + MANIFEST_NOT_FOUND, NO_OUTPUT, SOURCE_IS_NOT_YAML, TARGET_IS_NOT_YAML, @@ -136,3 +145,44 @@ export const parseIfDiffArgs = () => { throw new ParseCliParamsError(INVALID_TARGET); }; + +/** -- IF Env -- */ + +/** + * Parses `if-env` process arguments. + */ +const validateAndParseIfEnvArgs = () => { + try { + return parse(IF_ENV.ARGS, IF_ENV.HELP); + } catch (error) { + if (error instanceof Error) { + throw new ParseCliParamsError(error.message); + } + + throw error; + } +}; + +/** + * Checks if the `manifest` command is provided and it is valid manifest file. + */ +export const parseIfEnvArgs = async () => { + const {manifest, install, cwd} = validateAndParseIfEnvArgs(); + + if (manifest) { + const response = prependFullFilePath(manifest); + const isManifestFileExists = await isFileExists(response); + + if (!isManifestFileExists) { + throw new ParseCliParamsError(MANIFEST_NOT_FOUND); + } + + if (checkIfFileIsYaml(manifest)) { + return {manifest: response, install, cwd}; + } + + throw new CliSourceFileError(SOURCE_IS_NOT_YAML); + } + + return {install, cwd}; +}; diff --git a/src/util/fs.ts b/src/util/fs.ts new file mode 100644 index 000000000..92ca9684a --- /dev/null +++ b/src/util/fs.ts @@ -0,0 +1,13 @@ +import * as fs from 'fs/promises'; + +/** + * Checks if file exists with the given `filePath`. + */ +export const isFileExists = async (filePath: string) => { + try { + await fs.stat(filePath); + return true; + } catch (error) { + return false; + } +}; diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 0c422c96f..e69b9e0e9 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,16 +1,40 @@ +#!/usr/bin/env node +/* eslint-disable no-process-exit */ import {createInterface} from 'node:readline/promises'; import {exec} from 'node:child_process'; import {promisify} from 'node:util'; -import {ERRORS} from '@grnsft/if-core/utils'; +import * as fs from 'fs/promises'; +import * as path from 'path'; -import {logger} from './logger'; +import {ERRORS} from '@grnsft/if-core/utils'; -import {STRINGS} from '../config'; +import {STRINGS, CONFIG} from '../config'; import {Difference} from '../types/lib/compare'; +import {load} from '../lib/load'; + +import { + installDependencies, + initPackageJsonIfNotExists, + updatePackageJsonDependencies, + extractPathsWithVersion, + updatePackageJsonProperties, +} from './npm'; + +import {logger} from './logger'; +import {EnvironmentOptions} from '../types/if-env'; + +const {IF_ENV} = CONFIG; +const { + FAILURE_MESSAGE, + FAILURE_MESSAGE_TEMPLATE, + FAILURE_MESSAGE_DEPENDENCIES, +} = IF_ENV; + const {UNSUPPORTED_ERROR} = STRINGS; +const {MissingPluginDependenciesError} = ERRORS; /** * Impact engine error handler. Logs errors and appends issue template if error is unknown. @@ -176,3 +200,71 @@ export const parseManifestFromStdin = async () => { return match![1]; }; + +/** + * Gets the folder path of the manifest file, dependencies from manifest file and install argument from the given arguments. + */ +export const getOptionsFromArgs = async (commandArgs: { + manifest: string; + install: boolean | undefined; +}) => { + const {manifest, install} = commandArgs; + const folderPath = path.dirname(manifest); + const loadedManifest = await load(manifest); + const rawManifest = loadedManifest.rawManifest; + const plugins = rawManifest?.initialize?.plugins || {}; + const dependencies = rawManifest?.execution?.environment.dependencies || []; + + if (!dependencies.length) { + throw new MissingPluginDependenciesError(FAILURE_MESSAGE_DEPENDENCIES); + } + + const pathsWithVersion = extractPathsWithVersion(plugins, dependencies); + + return { + folderPath, + dependencies: pathsWithVersion, + install, + }; +}; + +/** + * Creates folder if not exists, installs dependencies if required, update depenedencies. + */ +export const initializeAndInstallLibs = async (options: EnvironmentOptions) => { + try { + const {folderPath, install, cwd, dependencies} = options; + const packageJsonPath = await initPackageJsonIfNotExists(folderPath); + + await updatePackageJsonProperties(packageJsonPath, cwd); + + if (install) { + await installDependencies(folderPath, dependencies); + } else { + await updatePackageJsonDependencies(packageJsonPath, dependencies, cwd); + } + } catch (error) { + console.log(FAILURE_MESSAGE); + process.exit(2); + } +}; + +/** + * Adds a manifest template to the folder where the if-env CLI command runs. + */ +export const addTemplateManifest = async (destinationDir: string) => { + try { + const templateManifest = path.resolve( + __dirname, + '../config/env-template.yml' + ); + + const destinationPath = path.resolve(destinationDir, 'manifest.yml'); + const data = await fs.readFile(templateManifest, 'utf-8'); + + await fs.writeFile(destinationPath, data, 'utf-8'); + } catch (error) { + console.log(FAILURE_MESSAGE_TEMPLATE); + process.exit(1); + } +}; diff --git a/src/util/npm.ts b/src/util/npm.ts new file mode 100644 index 000000000..e453b771b --- /dev/null +++ b/src/util/npm.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/* eslint-disable no-process-exit */ +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import {execPromise} from './helpers'; +import {isFileExists} from './fs'; +import {logger} from './logger'; + +import {STRINGS} from '../config'; +import {ManifestPlugin, PathWithVersion} from '../types/npm'; + +const packageJson = require('../../package.json'); + +const {INITIALIZING_PACKAGE_JSON, INSTALLING_NPM_PACKAGES} = STRINGS; + +/** + * Checks if the package.json is exists, if not, initializes it. + */ +export const initPackageJsonIfNotExists = async (folderPath: string) => { + const packageJsonPath = path.resolve(folderPath, 'package.json'); + const isPackageJsonExists = await isFileExists(packageJsonPath); + + if (!isPackageJsonExists) { + logger.info(INITIALIZING_PACKAGE_JSON); + await execPromise('npm init -y', {cwd: folderPath}); + } + + return packageJsonPath; +}; + +/** + * Installs packages from the specified dependencies in the specified folder. + */ +export const installDependencies = async ( + folderPath: string, + dependencies: {[path: string]: string} +) => { + const packages = Object.entries(dependencies).map( + ([dependency, version]) => `${dependency}@${version.replace('^', '')}` + ); + + logger.info(INSTALLING_NPM_PACKAGES); + await execPromise(`npm install ${packages.join(' ')}`, { + cwd: folderPath, + }); +}; + +/** + * Updates package.json dependencies. + */ +export const updatePackageJsonDependencies = async ( + packageJsonPath: string, + dependencies: PathWithVersion, + cwd: boolean +) => { + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); + + const parsedPackageJson = JSON.parse(packageJsonContent); + + if (cwd) { + parsedPackageJson.dependencies = { + ...parsedPackageJson.dependencies, + ...dependencies, + }; + } else { + parsedPackageJson.dependencies = {...dependencies}; + } + + await fs.writeFile( + packageJsonPath, + JSON.stringify(parsedPackageJson, null, 2) + ); +}; + +/** + * Gets depencecies with versions. + */ +export const extractPathsWithVersion = ( + plugins: ManifestPlugin, + dependencies: string[] +) => { + const paths = Object.keys(plugins).map(plugin => plugins[plugin].path); + const uniquePaths = [...new Set(paths)].filter(path => path !== 'builtin'); + const pathsWithVersion: PathWithVersion = {}; + + uniquePaths.forEach(pluginPath => { + const dependency = dependencies.find((dependency: string) => + dependency.startsWith(pluginPath) + ); + + if (dependency) { + const splittedDependency = dependency.split('@'); + const version = + splittedDependency.length > 2 + ? splittedDependency[2].split(' ')[0] + : splittedDependency[1]; + + pathsWithVersion[pluginPath] = `^${version}`; + } + }); + + return pathsWithVersion; +}; + +/** + * Update the package.json properties. + */ +export const updatePackageJsonProperties = async ( + newPackageJsonPath: string, + appendDependencies: boolean +) => { + const packageJsonContent = await fs.readFile(newPackageJsonPath, 'utf8'); + const parsedPackageJsonContent = JSON.parse(packageJsonContent); + + const properties = { + name: 'if-environment', + description: packageJson.description, + author: packageJson.author, + bugs: packageJson.bugs, + engines: packageJson.engines, + homepage: packageJson.homepage, + dependencies: appendDependencies + ? parsedPackageJsonContent.dependencies + : {}, + }; + + const newPackageJson = Object.assign( + {}, + parsedPackageJsonContent, + properties + ); + + await fs.writeFile( + newPackageJsonPath, + JSON.stringify(newPackageJson, null, 2) + ); +};