From de9c1d59b01dbda91cf55735b099ad9fe0a06c10 Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Wed, 28 Oct 2020 16:20:31 +0100 Subject: [PATCH 1/7] docs(badge): add cdk-construct badge, add new tags --- README.md | 1 + package.json | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99aea1e..b246d18 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [AWS CDK](https://aws.amazon.com/cdk/) Construct to build Node.js AWS lambdas using [esbuild](https://github.com/evanw/esbuild). +![CDK Construct NodeJS](https://img.shields.io/badge/cdk--construct-node.js-blue?logo=amazon-aws&color=43853d) [![Build Status](https://img.shields.io/github/workflow/status/floydspace/aws-lambda-nodejs-esbuild/release)](https://github.com/floydspace/aws-lambda-nodejs-esbuild/actions) [![Coverage Status](https://coveralls.io/repos/github/floydspace/aws-lambda-nodejs-esbuild/badge.svg?branch=master)](https://coveralls.io/github/floydspace/aws-lambda-nodejs-esbuild?branch=master) [![npm version](https://badge.fury.io/js/aws-lambda-nodejs-esbuild.svg)](https://badge.fury.io/js/aws-lambda-nodejs-esbuild) diff --git a/package.json b/package.json index 3af8dab..127dfff 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,15 @@ "homepage": "https://floydspace.github.io/aws-lambda-nodejs-esbuild", "keywords": [ "aws-cdk", + "cdk-construct", + "construct", "esbuild", - "aws lambda", + "aws-lambda", + "aws-lambda-node", "aws", "lambda", "bundler", + "builder", "typescript" ], "devDependencies": { From f46c7b6f56a91f2b8b07af9e79635ed011d8dfed Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Wed, 28 Oct 2020 19:34:51 +0100 Subject: [PATCH 2/7] docs(badge): add awesome badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b246d18..2bc337f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Coverage Status](https://coveralls.io/repos/github/floydspace/aws-lambda-nodejs-esbuild/badge.svg?branch=master)](https://coveralls.io/github/floydspace/aws-lambda-nodejs-esbuild?branch=master) [![npm version](https://badge.fury.io/js/aws-lambda-nodejs-esbuild.svg)](https://badge.fury.io/js/aws-lambda-nodejs-esbuild) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) +[![Mentioned in Awesome CDK](https://awesome.re/mentioned-badge.svg)](https://github.com/kolomied/awesome-cdk) Table of Contents From f8834f31ef4ca1a3c9f345862b600b66be271f14 Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Wed, 28 Oct 2020 22:32:46 +0100 Subject: [PATCH 3/7] feat(packager): implement a way to determine a package manager Package manager is now determined by project preference or running environment, npm has priority issue #2 --- src/index.ts | 27 ++++++++++- ...ckExternalModules.ts => pack-externals.ts} | 4 +- src/packagers/index.ts | 46 +++++++++++++++---- src/packagers/npm.ts | 36 ++++++++++----- src/packagers/packager.ts | 1 + src/packagers/yarn.ts | 14 +++++- src/utils.ts | 44 +++++++++++++++++- tests/index.test.ts | 2 +- 8 files changed, 146 insertions(+), 28 deletions(-) rename src/{packExternalModules.ts => pack-externals.ts} (98%) diff --git a/src/index.ts b/src/index.ts index e62340c..42659d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import * as es from 'esbuild'; import * as path from 'path'; import { mergeRight, union, without } from 'ramda'; -import { packExternalModules } from './packExternalModules'; +import { packExternalModules } from './pack-externals'; import { extractFileName, findProjectRoot, nodeMajorVersion } from './utils'; /** @@ -46,6 +46,13 @@ export interface NodejsFunctionProps extends lambda.FunctionOptions { */ readonly exclude?: string[]; + /** + * Whether to use package manager to pack external modules or explicit name of a well known packager. + * + * @default = true // Determined based on what preference is set, and whether it's currently running in a yarn/npm script + */ + readonly packager?: Packager | boolean; + /** * The esbuild bundler specific options. * @@ -54,6 +61,14 @@ export interface NodejsFunctionProps extends lambda.FunctionOptions { readonly esbuildOptions?: es.BuildOptions; } +/** + * Package manager to pack external modules. + */ +export enum Packager { + NPM = 'npm', + YARN = 'yarn', +} + const BUILD_FOLDER = '.build'; const DEFAULT_BUILD_OPTIONS: es.BuildOptions = { bundle: true, @@ -77,6 +92,7 @@ export class NodejsFunction extends lambda.Function { const withDefaultOptions = mergeRight(DEFAULT_BUILD_OPTIONS); const buildOptions = withDefaultOptions(props.esbuildOptions ?? {}); const exclude = union(props.exclude || [], ['aws-sdk']); + const packager = props.packager ?? true; const handler = props.handler ?? 'index.handler'; const defaultRunTime = nodeMajorVersion() >= 12 ? lambda.Runtime.NODEJS_12_X @@ -92,7 +108,14 @@ export class NodejsFunction extends lambda.Function { platform: 'node', }); - packExternalModules(without(exclude, buildOptions.external || []), projectRoot, path.join(projectRoot, BUILD_FOLDER)); + if (packager) { + packExternalModules( + without(exclude, buildOptions.external || []), + projectRoot, + path.join(projectRoot, BUILD_FOLDER), + packager !== true ? packager : undefined, + ); + } super(scope, id, { ...props, diff --git a/src/packExternalModules.ts b/src/pack-externals.ts similarity index 98% rename from src/packExternalModules.ts rename to src/pack-externals.ts index 03383a9..3a47c5c 100644 --- a/src/packExternalModules.ts +++ b/src/pack-externals.ts @@ -116,7 +116,7 @@ function getProdModules(externalModules: { external: string }[], packageJsonPath * This will utilize the npm cache at its best and give us the needed results * and performance. */ -export function packExternalModules(externals: string[], cwd: string, compositeModulePath: string) { +export function packExternalModules(externals: string[], cwd: string, compositeModulePath: string, pkger?: 'npm' | 'yarn') { if (!externals || !externals.length) { return; } @@ -125,7 +125,7 @@ export function packExternalModules(externals: string[], cwd: string, compositeM const packageJsonPath = path.join(cwd, 'package.json'); // Determine and create packager - const packager = Packagers.get(Packagers.Installer.NPM); + const packager = Packagers.get(cwd, pkger); // Fetch needed original package.json sections const packageJson = fs.readJsonSync(packageJsonPath); diff --git a/src/packagers/index.ts b/src/packagers/index.ts index ae8e7f2..6efec90 100644 --- a/src/packagers/index.ts +++ b/src/packagers/index.ts @@ -20,26 +20,56 @@ import { Packager } from './packager'; import { NPM } from './npm'; import { Yarn } from './yarn'; +import { getCurrentPackager, getPackagerFromLockfile } from '../utils'; const registeredPackagers = { npm: new NPM(), yarn: new Yarn() }; -export enum Installer { - NPM = 'npm', - YARN = 'yarn', -} - /** * Factory method. * @param {string} packagerId - Well known packager id. */ -export function get(packagerId: Installer): Packager { - if (!(packagerId in registeredPackagers)) { +export function get(cwd: string, packagerId?: keyof typeof registeredPackagers): Packager { + const pkger = findPackager(cwd, packagerId); + + if (!(pkger in registeredPackagers)) { const message = `Could not find packager '${packagerId}'`; console.log(`ERROR: ${message}`); throw new Error(message); } - return registeredPackagers[packagerId]; + + return registeredPackagers[pkger]; +} + +/** + * Determine what package manager to use based on what preference is set, + * and whether it's currently running in a yarn/npm script + * + * @export + * @param {InstallConfig} config + * @returns {SupportedPackageManagers} + */ +function findPackager(cwd: string, prefer?: keyof typeof registeredPackagers): keyof typeof registeredPackagers { + let pkgManager: keyof typeof registeredPackagers | null = prefer || getCurrentPackager(); + + if (!pkgManager) { + pkgManager = getPackagerFromLockfile(cwd); + } + + if (!pkgManager) { + for (const pkg in registeredPackagers) { + if (registeredPackagers[pkg].isManagerInstalled(cwd)) { + pkgManager = pkg as keyof typeof registeredPackagers; + break; + } + } + } + + if (!pkgManager) { + throw new Error('No supported package manager found'); + } + + return pkgManager; } diff --git a/src/packagers/npm.ts b/src/packagers/npm.ts index b21851a..5283e44 100644 --- a/src/packagers/npm.ts +++ b/src/packagers/npm.ts @@ -1,4 +1,4 @@ -import { any, isEmpty, replace, split } from 'ramda'; +import { any, isEmpty, replace } from 'ramda'; import { JSONObject } from '../types'; import { SpawnError, spawnProcess } from '../utils'; @@ -20,6 +20,18 @@ export class NPM implements Packager { return true; } + isManagerInstalled(cwd: string) { + const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; + const args = ['--version']; + + try { + spawnProcess(command, args, { cwd }); + return true; + } catch (_e) { + return false; + } + } + getProdDependencies(cwd: string, depth: number) { // Get first level dependency graph const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; @@ -44,7 +56,7 @@ export class NPM implements Packager { } catch (err) { if (err instanceof SpawnError) { // Only exit with an error if we have critical npm errors for 2nd level inside - const errors = split('\n', err.stderr); + const errors = err.stderr?.split('\n') ?? []; const failed = errors.reduce((f, error) => { if (f) { return true; @@ -64,15 +76,6 @@ export class NPM implements Packager { } } - _rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { - if (/^file:[^/]{2}/.test(moduleVersion)) { - const filePath = replace(/^file:/, '', moduleVersion); - return replace(/\\/g, '/', `file:${pathToPackageRoot}/${filePath}`); - } - - return moduleVersion; - } - /** * We should not be modifying 'package-lock.json' * because this file should be treated as internal to npm. @@ -82,7 +85,7 @@ export class NPM implements Packager { */ rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject) { if (lockfile.version) { - lockfile.version = this._rebaseFileReferences(pathToPackageRoot, lockfile.version); + lockfile.version = this.rebaseFileReferences(pathToPackageRoot, lockfile.version); } if (lockfile.dependencies) { @@ -117,4 +120,13 @@ export class NPM implements Packager { spawnProcess(command, args, { cwd }); }); } + + private rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { + if (/^file:[^/]{2}/.test(moduleVersion)) { + const filePath = replace(/^file:/, '', moduleVersion); + return replace(/\\/g, '/', `file:${pathToPackageRoot}/${filePath}`); + } + + return moduleVersion; + } } diff --git a/src/packagers/packager.ts b/src/packagers/packager.ts index 43a12fd..5706289 100644 --- a/src/packagers/packager.ts +++ b/src/packagers/packager.ts @@ -4,6 +4,7 @@ export interface Packager { lockfileName: string; copyPackageSectionNames: Array; mustCopyModules: boolean; + isManagerInstalled(cwd: string): boolean; getProdDependencies(cwd: string, depth: number): JSONObject; rebaseLockfile(pathToPackageRoot: string, lockfile: JSONObject): void; install(cwd: string): void; diff --git a/src/packagers/yarn.ts b/src/packagers/yarn.ts index 36aacab..7171cf3 100644 --- a/src/packagers/yarn.ts +++ b/src/packagers/yarn.ts @@ -24,6 +24,18 @@ export class Yarn implements Packager { return false; } + isManagerInstalled(cwd: string) { + const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; + const args = ['--version']; + + try { + spawnProcess(command, args, { cwd }); + return true; + } catch (_e) { + return false; + } + } + getProdDependencies(cwd, depth) { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; const args = ['list', `--depth=${depth || 1}`, '--json', '--production']; @@ -37,7 +49,7 @@ export class Yarn implements Packager { } catch (err) { if (err instanceof SpawnError) { // Only exit with an error if we have critical npm errors for 2nd level inside - const errors = err.stderr.split('\n'); + const errors = err.stderr?.split('\n') ?? []; const failed = errors.reduce((f, error) => { if (f) { return true; diff --git a/src/utils.ts b/src/utils.ts index b8338db..105a1c5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,8 +23,8 @@ export class SpawnError extends Error { */ export function spawnProcess(command: string, args: string[], options: childProcess.SpawnOptionsWithoutStdio) { const child = childProcess.spawnSync(command, args, options); - const stdout = child.stdout.toString('utf8'); - const stderr = child.stderr.toString('utf8'); + const stdout = child.stdout?.toString('utf8'); + const stderr = child.stderr?.toString('utf8'); if (child.status !== 0) { throw new SpawnError(`${command} ${join(' ', args)} failed with code ${child.status}`, stdout, stderr); @@ -92,3 +92,43 @@ export function findProjectRoot(rootDir?: string): string | undefined { export function nodeMajorVersion(): number { return parseInt(process.versions.node.split('.')[0], 10); } + +/** + * Returns the package manager currently active if the program is executed + * through an npm or yarn script like: + * ```bash + * yarn run example + * npm run example + * ``` + */ +export function getCurrentPackager() { + const userAgent = process.env.npm_config_user_agent; + if (!userAgent) { + return null; + } + + if (userAgent.startsWith('npm')) { + return 'npm'; + } + + if (userAgent.startsWith('yarn')) { + return 'yarn'; + } + + return null; +} + +/** + * Checks for the presence of package-lock.json or yarn.lock to determine which package manager is being used + */ +export function getPackagerFromLockfile(cwd: string) { + if (fs.existsSync(path.join(cwd, 'package-lock.json'))) { + return 'npm'; + } + + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { + return 'yarn'; + } + + return null; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 3da98f0..ee1def2 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ jest.mock('esbuild'); -jest.mock('../src/packExternalModules'); +jest.mock('../src/pack-externals'); import '@aws-cdk/assert/jest'; From 9dfe24023647fe5e63c9ab5c905449fef0edd17e Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Wed, 28 Oct 2020 22:57:52 +0100 Subject: [PATCH 4/7] refactor: reshuffle files a bit --- src/{index.ts => function.ts} | 0 tests/{index.test.ts => function.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{index.ts => function.ts} (100%) rename tests/{index.test.ts => function.test.ts} (100%) diff --git a/src/index.ts b/src/function.ts similarity index 100% rename from src/index.ts rename to src/function.ts diff --git a/tests/index.test.ts b/tests/function.test.ts similarity index 100% rename from tests/index.test.ts rename to tests/function.test.ts From 30cc609af4bf555a2c5be4ab72532b2c9373615b Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Wed, 28 Oct 2020 23:06:22 +0100 Subject: [PATCH 5/7] refactor: add index.ts --- src/function.ts | 63 +----------------------------------------------- src/index.ts | 2 ++ src/props.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 62 deletions(-) create mode 100644 src/index.ts create mode 100644 src/props.ts diff --git a/src/function.ts b/src/function.ts index 42659d6..4c25e4d 100644 --- a/src/function.ts +++ b/src/function.ts @@ -5,70 +5,9 @@ import * as path from 'path'; import { mergeRight, union, without } from 'ramda'; import { packExternalModules } from './pack-externals'; +import { NodejsFunctionProps } from './props'; import { extractFileName, findProjectRoot, nodeMajorVersion } from './utils'; -/** - * Properties for a NodejsFunction - */ -export interface NodejsFunctionProps extends lambda.FunctionOptions { - /** - * The root of the lambda project. If you specify this prop, ensure that - * this path includes `entry` and any module/dependencies used by your - * function otherwise bundling will not be possible. - * - * @default = the closest path containing a .git folder - */ - readonly rootDir?: string; - - /** - * The name of the method within your code that Lambda calls to execute your function. - * - * The format includes the file name and handler function. - * For more information, see https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html. - * - * @default = 'index.handler' - */ - readonly handler?: string; - - /** - * The runtime environment. Only runtimes of the Node.js family are - * supported. - * - * @default = `NODEJS_12_X` if `process.versions.node` >= '12.0.0', - * `NODEJS_10_X` otherwise. - */ - readonly runtime?: lambda.Runtime; - - /** - * The list of modules that must be excluded from bundle and from externals. - * - * @default = ['aws-sdk'] - */ - readonly exclude?: string[]; - - /** - * Whether to use package manager to pack external modules or explicit name of a well known packager. - * - * @default = true // Determined based on what preference is set, and whether it's currently running in a yarn/npm script - */ - readonly packager?: Packager | boolean; - - /** - * The esbuild bundler specific options. - * - * @default = { bundle: true, target: 'es2017' } - */ - readonly esbuildOptions?: es.BuildOptions; -} - -/** - * Package manager to pack external modules. - */ -export enum Packager { - NPM = 'npm', - YARN = 'yarn', -} - const BUILD_FOLDER = '.build'; const DEFAULT_BUILD_OPTIONS: es.BuildOptions = { bundle: true, diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f38e06a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './function'; +export * from './props'; diff --git a/src/props.ts b/src/props.ts new file mode 100644 index 0000000..1f7c55f --- /dev/null +++ b/src/props.ts @@ -0,0 +1,64 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import { BuildOptions } from 'esbuild'; + +/** + * Properties for a NodejsFunction + */ +export interface NodejsFunctionProps extends lambda.FunctionOptions { + /** + * The root of the lambda project. If you specify this prop, ensure that + * this path includes `entry` and any module/dependencies used by your + * function otherwise bundling will not be possible. + * + * @default = the closest path containing a .git folder + */ + readonly rootDir?: string; + + /** + * The name of the method within your code that Lambda calls to execute your function. + * + * The format includes the file name and handler function. + * For more information, see https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html. + * + * @default = 'index.handler' + */ + readonly handler?: string; + + /** + * The runtime environment. Only runtimes of the Node.js family are + * supported. + * + * @default = `NODEJS_12_X` if `process.versions.node` >= '12.0.0', + * `NODEJS_10_X` otherwise. + */ + readonly runtime?: lambda.Runtime; + + /** + * The list of modules that must be excluded from bundle and from externals. + * + * @default = ['aws-sdk'] + */ + readonly exclude?: string[]; + + /** + * Whether to use package manager to pack external modules or explicit name of a well known packager. + * + * @default = true // Determined based on what preference is set, and whether it's currently running in a yarn/npm script + */ + readonly packager?: Packager | boolean; + + /** + * The esbuild bundler specific options. + * + * @default = { bundle: true, target: 'es2017' } + */ + readonly esbuildOptions?: BuildOptions; +} + +/** + * Package manager to pack external modules. + */ +export enum Packager { + NPM = 'npm', + YARN = 'yarn', +} From 23854b7fd3fda7b51f85e2471394d0d495f04805 Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Wed, 28 Oct 2020 23:42:01 +0100 Subject: [PATCH 6/7] docs(esbuild): update esbuild documentation links now esbuild has a website --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2bc337f..d95ff81 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ λ💨 aws-lambda-nodejs-esbuild ============== -[AWS CDK](https://aws.amazon.com/cdk/) Construct to build Node.js AWS lambdas using [esbuild](https://github.com/evanw/esbuild). +[AWS CDK](https://aws.amazon.com/cdk/) Construct to build Node.js AWS lambdas using [esbuild](https://esbuild.github.io). ![CDK Construct NodeJS](https://img.shields.io/badge/cdk--construct-node.js-blue?logo=amazon-aws&color=43853d) [![Build Status](https://img.shields.io/github/workflow/status/floydspace/aws-lambda-nodejs-esbuild/release)](https://github.com/floydspace/aws-lambda-nodejs-esbuild/actions) @@ -26,7 +26,7 @@ Features * Zero-config: Works out of the box without the need to install any other packages * Supports ESNext and TypeScript syntax with transforming limitations (See *Note*) -*Note*: The default JavaScript syntax target is set to [`ES2017`](https://node.green/#ES2017), so the final bundle will be supported by all [AWS Lambda Node.js runtimes](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). If you still using an old lambda runtime and have to respect it you can play with esbuild `target` option, see [JavaScript syntax support](https://github.com/evanw/esbuild#javascript-syntax-support) for more details about syntax transform limitations. +*Note*: The default JavaScript syntax target is set to [`ES2017`](https://node.green/#ES2017), so the final bundle will be supported by all [AWS Lambda Node.js runtimes](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). If you still using an old lambda runtime and have to respect it you can play with esbuild `target` option, see [JavaScript syntax support](https://esbuild.github.io/content-types/#javascript) for more details about syntax transform limitations. Installation @@ -62,7 +62,7 @@ By default, no configuration required, but you can change esbuild behavior: } ``` -Check [esbuild](https://github.com/evanw/esbuild#command-line-usage) documentation for the full list of available options. Note that some options like `entryPoints` or `outdir` cannot be overwritten. +Check [esbuild](https://esbuild.github.io/api/#simple-options) documentation for the full list of available options. Note that some options like `entryPoints` or `outdir` cannot be overwritten. The package specified in the `exclude` option is passed to esbuild as `external`, but it is not included in the function bundle either. The default value for this option is `['aws-sdk']`. From b33be2f880ca2baa64b3370e9d654bfd8a81f3c4 Mon Sep 17 00:00:00 2001 From: Victor Korzunin Date: Sun, 1 Nov 2020 01:57:40 +0100 Subject: [PATCH 7/7] fix(packager): introduce unit tests for packagers, fix bugs found on testing --- examples/complete/src/index.ts | 2 +- src/packagers/index.ts | 2 +- src/packagers/npm.ts | 58 ++++--- src/packagers/yarn.ts | 67 +++++---- src/utils.ts | 12 ++ tests/packagers/index.test.ts | 21 +++ tests/packagers/npm.test.ts | 233 +++++++++++++++++++++++++++++ tests/packagers/yarn.test.ts | 266 +++++++++++++++++++++++++++++++++ 8 files changed, 600 insertions(+), 61 deletions(-) create mode 100644 tests/packagers/index.test.ts create mode 100644 tests/packagers/npm.test.ts create mode 100644 tests/packagers/yarn.test.ts diff --git a/examples/complete/src/index.ts b/examples/complete/src/index.ts index 5ed67cb..0367034 100644 --- a/examples/complete/src/index.ts +++ b/examples/complete/src/index.ts @@ -1,6 +1,6 @@ import validateIsin from 'isin-validator'; -export function handler(event: any) { +export function handler(event: string) { const isInvalid = validateIsin(event); return { diff --git a/src/packagers/index.ts b/src/packagers/index.ts index 6efec90..dd71d8f 100644 --- a/src/packagers/index.ts +++ b/src/packagers/index.ts @@ -35,7 +35,7 @@ export function get(cwd: string, packagerId?: keyof typeof registeredPackagers): const pkger = findPackager(cwd, packagerId); if (!(pkger in registeredPackagers)) { - const message = `Could not find packager '${packagerId}'`; + const message = `Could not find packager '${pkger}'`; console.log(`ERROR: ${message}`); throw new Error(message); } diff --git a/src/packagers/npm.ts b/src/packagers/npm.ts index 5283e44..11d8239 100644 --- a/src/packagers/npm.ts +++ b/src/packagers/npm.ts @@ -1,4 +1,4 @@ -import { any, isEmpty, replace } from 'ramda'; +import { any, isEmpty } from 'ramda'; import { JSONObject } from '../types'; import { SpawnError, spawnProcess } from '../utils'; @@ -32,7 +32,7 @@ export class NPM implements Packager { } } - getProdDependencies(cwd: string, depth: number) { + getProdDependencies(cwd: string, depth?: number) { // Get first level dependency graph const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; const args = [ @@ -48,32 +48,34 @@ export class NPM implements Packager { { npmError: 'peer dep missing', log: true } ]; + let processOutput; try { - const processOutput = spawnProcess(command, args, { cwd }); - const depJson = processOutput.stdout; - - return JSON.parse(depJson); + processOutput = spawnProcess(command, args, { cwd }); } catch (err) { - if (err instanceof SpawnError) { - // Only exit with an error if we have critical npm errors for 2nd level inside - const errors = err.stderr?.split('\n') ?? []; - const failed = errors.reduce((f, error) => { - if (f) { - return true; - } - return ( - !isEmpty(error) && - !any(ignoredError => error.startsWith(`npm ERR! ${ignoredError.npmError}`), ignoredNpmErrors) - ); - }, false); - - if (!failed && !isEmpty(err.stdout)) { - return { stdout: err.stdout }; + if (!(err instanceof SpawnError)) { + throw err; + } + + // Only exit with an error if we have critical npm errors for 2nd level inside + const errors = err.stderr?.split('\n') ?? []; + const failed = errors.reduce((f, error) => { + if (f) { + return true; } + return ( + !isEmpty(error) && + !any(ignoredError => error.startsWith(`npm ERR! ${ignoredError.npmError}`), ignoredNpmErrors) + ); + }, false); + + if (failed || isEmpty(err.stdout)) { + throw err; } - throw err; + processOutput = { stdout: err.stdout }; } + + return JSON.parse(processOutput.stdout); } /** @@ -90,7 +92,7 @@ export class NPM implements Packager { if (lockfile.dependencies) { for (const lockedDependency in lockfile.dependencies) { - this.rebaseLockfile(pathToPackageRoot, lockedDependency); + this.rebaseLockfile(pathToPackageRoot, lockfile.dependencies[lockedDependency]); } } @@ -114,17 +116,13 @@ export class NPM implements Packager { runScripts(cwd, scriptNames) { const command = /^win/.test(process.platform) ? 'npm.cmd' : 'npm'; - scriptNames.forEach(scriptName => { - const args = ['run', scriptName]; - - spawnProcess(command, args, { cwd }); - }); + scriptNames.forEach(scriptName => spawnProcess(command, ['run', scriptName], { cwd })); } private rebaseFileReferences(pathToPackageRoot: string, moduleVersion: string) { if (/^file:[^/]{2}/.test(moduleVersion)) { - const filePath = replace(/^file:/, '', moduleVersion); - return replace(/\\/g, '/', `file:${pathToPackageRoot}/${filePath}`); + const filePath = moduleVersion.replace(/^file:/, ''); + return `file:${pathToPackageRoot}/${filePath}`.replace(/\\/g, '/'); } return moduleVersion; diff --git a/src/packagers/yarn.ts b/src/packagers/yarn.ts index 7171cf3..befb7ca 100644 --- a/src/packagers/yarn.ts +++ b/src/packagers/yarn.ts @@ -1,7 +1,7 @@ -import { any, head, isEmpty, join, pathOr, reduce, replace, split, startsWith, tail } from 'ramda'; +import { any, head, isEmpty, join, pathOr, split, tail } from 'ramda'; import { JSONObject } from '../types'; -import { SpawnError, spawnProcess } from '../utils'; +import { safeJsonParse, SpawnError, spawnProcess, splitLines } from '../utils'; import { Packager } from './packager'; /** @@ -36,7 +36,7 @@ export class Yarn implements Packager { } } - getProdDependencies(cwd, depth) { + getProdDependencies(cwd: string, depth?: number) { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; const args = ['list', `--depth=${depth || 1}`, '--json', '--production']; @@ -47,33 +47,36 @@ export class Yarn implements Packager { try { processOutput = spawnProcess(command, args, { cwd }); } catch (err) { - if (err instanceof SpawnError) { - // Only exit with an error if we have critical npm errors for 2nd level inside - const errors = err.stderr?.split('\n') ?? []; - const failed = errors.reduce((f, error) => { - if (f) { - return true; - } - return ( - !isEmpty(error) && - !any(ignoredError => error.startsWith(`npm ERR! ${ignoredError.npmError}`), ignoredYarnErrors) - ); - }, false); - - if (!failed && !isEmpty(err.stdout)) { - return { stdout: err.stdout }; + if (!(err instanceof SpawnError)) { + throw err; + } + + // Only exit with an error if we have critical npm errors for 2nd level inside + const errors = err.stderr?.split('\n') ?? []; + const failed = errors.reduce((f, error) => { + if (f) { + return true; } + return ( + !isEmpty(error) && + !any(ignoredError => error.startsWith(`npm ERR! ${ignoredError.npmError}`), ignoredYarnErrors) + ); + }, false); + + if (failed || isEmpty(err.stdout)) { + throw err; } - throw err; + processOutput = { stdout: err.stdout }; } - const depJson = processOutput.stdout; - const parsedTree = JSON.parse(depJson); - const convertTrees = reduce((__, tree: JSONObject) => { + const lines = splitLines(processOutput.stdout); + const parsedLines = lines.map(safeJsonParse); + const parsedTree = parsedLines.find(line => line && line.type === 'tree'); + const convertTrees = ts => ts.reduce((__, tree: JSONObject) => { const splitModule = split('@', tree.name); // If we have a scoped module we have to re-add the @ - if (startsWith('@', tree.name)) { + if (tree.name.startsWith('@')) { splitModule.splice(0, 1); splitModule[0] = '@' + splitModule[0]; } @@ -101,28 +104,34 @@ export class Yarn implements Packager { while ((match = fileVersionMatcher.exec(lockfile)) !== null) { replacements.push({ oldRef: match[1], - newRef: replace(/\\/g, '/', `${pathToPackageRoot}/${match[1]}`) + newRef: `${pathToPackageRoot}/${match[1]}`.replace(/\\/g, '/') }); } // Replace all lines in lockfile - return reduce((__, replacement) => replace(__, replacement.oldRef, replacement.newRef), lockfile, replacements); + return replacements.reduce((__, replacement) => __.replace(replacement.oldRef, replacement.newRef), lockfile); } - install(cwd) { + install(cwd: string, packagerOptions?) { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; - const args = ['install', '--frozen-lockfile', '--non-interactive']; + const args = [ 'install', '--frozen-lockfile', '--non-interactive' ]; + + // Convert supported packagerOptions + if (packagerOptions.ignoreScripts) { + args.push('--ignore-scripts'); + } spawnProcess(command, args, { cwd }); } // "Yarn install" prunes automatically - prune(cwd) { - return this.install(cwd); + prune(cwd: string, packagerOptions?) { + return this.install(cwd, packagerOptions); } runScripts(cwd, scriptNames: string[]) { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; + scriptNames.forEach(scriptName => spawnProcess(command, ['run', scriptName], { cwd })); } } diff --git a/src/utils.ts b/src/utils.ts index 105a1c5..c31f07c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -33,6 +33,18 @@ export function spawnProcess(command: string, args: string[], options: childProc return { stdout, stderr }; } +export function safeJsonParse(str: string) { + try { + return JSON.parse(str); + } catch (e) { + return null; + } +} + +export function splitLines(str: string) { + return str.split(/\r?\n/); +} + /** * Extracts the file name from handler string. */ diff --git a/tests/packagers/index.test.ts b/tests/packagers/index.test.ts new file mode 100644 index 0000000..019a5ff --- /dev/null +++ b/tests/packagers/index.test.ts @@ -0,0 +1,21 @@ +/** + * Unit tests for packagers/index + */ + +import { get } from '../../src/packagers'; +import { NPM } from '../../src/packagers/npm'; +import * as Utils from '../../src/utils'; + +const getCurrentPackager = jest.spyOn(Utils, 'getCurrentPackager'); + +describe('packagers factory', () => { + it('should throw on unknown packagers', () => { + getCurrentPackager.mockReset().mockReturnValue('unknown' as never); + expect(() => get('.')).toThrowError(/Could not find packager 'unknown'/); + }); + + it('should return npm packager', () => { + const npm = get('.', 'npm'); + expect(npm).toBeInstanceOf(NPM); + }); +}); diff --git a/tests/packagers/npm.test.ts b/tests/packagers/npm.test.ts new file mode 100644 index 0000000..49e5a91 --- /dev/null +++ b/tests/packagers/npm.test.ts @@ -0,0 +1,233 @@ +/** + * Unit tests for packagers/npm + */ + +import { join } from 'ramda'; +import { NPM } from '../../src/packagers/npm'; +import * as Utils from '../../src/utils'; + +const spawnProcess = jest.spyOn(Utils, 'spawnProcess'); + +describe('npm', () => { + let npmModule: NPM; + + beforeAll(() => { + npmModule = new NPM(); + }); + + it('should return "package-lock.json" as lockfile name', () => { + expect(npmModule.lockfileName).toEqual('package-lock.json'); + }); + + it('should return no packager sections', () => { + expect(npmModule.copyPackageSectionNames).toEqual([]); + }); + + it('requires to copy modules', () => { + expect(npmModule.mustCopyModules).toBe(true); + }); + + describe('install', () => { + it('should use npm install', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: 'installed successfully', stderr: '' }); + + const result = npmModule.install('myPath'); + + expect(result).toBeUndefined(); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), ['install'], { + cwd: 'myPath' + }); + }); + }); + + describe('prune', () => { + it('should use npm prune', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: 'success', stderr: '' }); + + const result = npmModule.prune('myPath'); + + expect(result).toBeUndefined(); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), ['prune'], { + cwd: 'myPath' + }); + }); + }); + + describe('runScripts', () => { + it('should use npm run for the given scripts', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: 'success', stderr: '' }); + + const result = npmModule.runScripts('myPath', ['s1', 's2']); + + expect(result).toBeUndefined(); + expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(spawnProcess).toHaveBeenNthCalledWith(1, expect.stringMatching(/^npm/), ['run', 's1'], { + cwd: 'myPath' + }); + expect(spawnProcess).toHaveBeenNthCalledWith(2, expect.stringMatching(/^npm/), ['run', 's2'], { + cwd: 'myPath' + }); + }); + }); + + describe('getProdDependencies', () => { + it('should use npm ls', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: '{}', stderr: '' }); + + const result = npmModule.getProdDependencies('myPath', 10); + + expect(result).toEqual({}); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ + 'ls', + '-prod', + '-json', + '-depth=10' + ], { + cwd: 'myPath' + }); + }); + + it('should default to depth 1', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: '{}', stderr: '' }); + + const result = npmModule.getProdDependencies('myPath'); + + expect(result).toEqual({}); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ + 'ls', + '-prod', + '-json', + '-depth=1' + ], { + cwd: 'myPath' + }); + }); + }); + + it('should reject if npm returns critical and minor errors', () => { + const stderr = + 'ENOENT: No such file\nnpm ERR! extraneous: sinon@2.3.8 ./babel-dynamically-entries/node_modules/serverless-webpack/node_modules/sinon\n\n'; + spawnProcess.mockReset().mockImplementation(() => { + throw new Utils.SpawnError('Command execution failed', '{}', stderr); + }); + + const func = () => npmModule.getProdDependencies('myPath', 1); + + expect(func).toThrowError('Command execution failed'); + // npm ls and npm prune should have been called + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ + 'ls', + '-prod', + '-json', + '-depth=1' + ], { + cwd: 'myPath' + }); + }); + + it('should reject if an error happens without any information in stdout', () => { + spawnProcess.mockReset().mockImplementation(() => { + throw new Utils.SpawnError('Command execution failed', '', ''); + }); + + const func = () => npmModule.getProdDependencies('myPath', 1); + + expect(func).toThrowError('Command execution failed'); + // npm ls and npm prune should have been called + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ + 'ls', + '-prod', + '-json', + '-depth=1' + ], { + cwd: 'myPath' + }); + }); + + it('should ignore minor local NPM errors and log them', () => { + const stderr = join('\n', [ + 'npm ERR! extraneous: sinon@2.3.8 ./babel-dynamically-entries/node_modules/serverless-webpack/node_modules/sinon', + 'npm ERR! missing: internalpackage-1@1.0.0, required by internalpackage-2@1.0.0', + 'npm ERR! peer dep missing: sinon@2.3.8' + ]); + const lsResult = { + version: '1.0.0', + problems: [ + 'npm ERR! extraneous: sinon@2.3.8 ./babel-dynamically-entries/node_modules/serverless-webpack/node_modules/sinon', + 'npm ERR! missing: internalpackage-1@1.0.0, required by internalpackage-2@1.0.0', + 'npm ERR! peer dep missing: sinon@2.3.8' + ], + dependencies: { + '@scoped/vendor': '1.0.0', + uuid: '^5.4.1', + bluebird: '^3.4.0' + } + }; + + spawnProcess.mockReset().mockImplementation(() => { + throw new Utils.SpawnError('Command execution failed', JSON.stringify(lsResult), stderr); + }); + + const dependencies = npmModule.getProdDependencies('myPath', 1); + + // npm ls and npm prune should have been called + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith(expect.stringMatching(/^npm/), [ + 'ls', + '-prod', + '-json', + '-depth=1' + ], { + cwd: 'myPath' + }); + expect(dependencies).toEqual(lsResult); + }); + + it('should rebase lock file references', () => { + const expectedLocalModule = 'file:../../locals/../../mymodule'; + const fakePackageLockJSON = { + name: 'test-service', + version: '1.0.0', + description: 'Packaged externals for test-service', + private: true, + dependencies: { + '@scoped/vendor': '1.0.0', + uuid: { + version: '^5.4.1' + }, + bluebird: { + version: '^3.4.0' + }, + localmodule: { + version: 'file:../../mymodule' + } + } + }; + const expectedPackageLockJSON = { + name: 'test-service', + version: '1.0.0', + description: 'Packaged externals for test-service', + private: true, + dependencies: { + '@scoped/vendor': '1.0.0', + uuid: { + version: '^5.4.1' + }, + bluebird: { + version: '^3.4.0' + }, + localmodule: { + version: expectedLocalModule + } + } + }; + + expect(npmModule.rebaseLockfile('../../locals', fakePackageLockJSON)).toEqual(expectedPackageLockJSON); + }); +}); diff --git a/tests/packagers/yarn.test.ts b/tests/packagers/yarn.test.ts new file mode 100644 index 0000000..ea6e399 --- /dev/null +++ b/tests/packagers/yarn.test.ts @@ -0,0 +1,266 @@ +/** + * Unit tests for packagers/yarn + */ + +import { Yarn } from '../../src/packagers/yarn'; +import * as Utils from '../../src/utils'; + +const spawnProcess = jest.spyOn(Utils, 'spawnProcess'); + +describe('yarn', () => { + let yarnModule: Yarn; + + beforeAll(() => { + yarnModule = new Yarn(); + }); + + it('should return "yarn.lock" as lockfile name', () => { + expect(yarnModule.lockfileName).toEqual('yarn.lock'); + }); + + it('should return packager sections', () => { + expect(yarnModule.copyPackageSectionNames).toEqual(['resolutions']); + }); + + it('does not require to copy modules', () => { + expect(yarnModule.mustCopyModules).toBe(false); + }); + + describe('getProdDependencies', () => { + it('should use yarn list', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: '{}', stderr: '' }); + + const result = yarnModule.getProdDependencies('myPath', 1); + + expect(result).toBeTruthy(); + expect(spawnProcess).toHaveBeenCalledTimes(1), + expect(spawnProcess).toHaveBeenCalledWith( + expect.stringMatching(/^yarn/), + [ 'list', '--depth=1', '--json', '--production' ], + { cwd: 'myPath' } + ); + }); + + it('should transform yarn trees to npm dependencies', () => { + const testYarnResult = + '{"type":"activityStart","data":{"id":0}}\n' + + '{"type":"activityTick","data":{"id":0,"name":"archiver@^2.1.1"}}\n' + + '{"type":"activityTick","data":{"id":0,"name":"bluebird@^3.5.1"}}\n' + + '{"type":"activityTick","data":{"id":0,"name":"fs-extra@^4.0.3"}}\n' + + '{"type":"activityTick","data":{"id":0,"name":"mkdirp@^0.5.1"}}\n' + + '{"type":"activityTick","data":{"id":0,"name":"minimist@^0.0.8"}}\n' + + '{"type":"activityTick","data":{"id":0,"name":"@sls/webpack@^1.0.0"}}\n' + + '{"type":"tree","data":{"type":"list","trees":[' + + '{"name":"archiver@2.1.1","children":[],"hint":null,"color":"bold",' + + '"depth":0},{"name":"bluebird@3.5.1","children":[],"hint":null,"color":' + + '"bold","depth":0},{"name":"fs-extra@4.0.3","children":[],"hint":null,' + + '"color":"bold","depth":0},{"name":"mkdirp@0.5.1","children":[{"name":' + + '"minimist@0.0.8","children":[],"hint":null,"color":"bold","depth":0}],' + + '"hint":null,"color":null,"depth":0},{"name":"@sls/webpack@1.0.0",' + + '"children":[],"hint":null,"color":"bold","depth":0}]}}\n'; + const expectedResult = { + problems: [], + dependencies: { + archiver: { + version: '2.1.1', + dependencies: {} + }, + bluebird: { + version: '3.5.1', + dependencies: {} + }, + 'fs-extra': { + version: '4.0.3', + dependencies: {} + }, + mkdirp: { + version: '0.5.1', + dependencies: { + minimist: { + version: '0.0.8', + dependencies: {} + } + } + }, + '@sls/webpack': { + version: '1.0.0', + dependencies: {} + } + } + }; + spawnProcess.mockReset().mockReturnValue({ stdout: testYarnResult, stderr: '' }); + + const result = yarnModule.getProdDependencies('myPath', 1); + + expect(result).toEqual(expectedResult); + }); + + it('should reject on critical yarn errors', () => { + spawnProcess.mockReset().mockImplementation(() => { + throw new Utils.SpawnError('Exited with code 1', '', 'Yarn failed.\nerror Could not find module.'); + }); + + const func = () => yarnModule.getProdDependencies('myPath', 1); + + expect(func).toThrowError('Exited with code 1'); + }); + }); + + describe('rebaseLockfile', () => { + it('should return the original lockfile', () => { + const testContent = 'eugfogfoigqwoeifgoqwhhacvaisvciuviwefvc'; + const testContent2 = 'eugfogfoigqwoeifgoqwhhacvaisvciuviwefvc'; + expect(yarnModule.rebaseLockfile('.', testContent)).toEqual(testContent2); + }); + + it('should rebase file references', () => { + const testContent = ` + acorn@^2.1.0, acorn@^2.4.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" + + acorn@^3.0.4: + version "3.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" + + otherModule@file:../../otherModule/the-new-version: + version "1.2.0" + + acorn@^2.1.0, acorn@^2.4.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" + + "@myCompany/myModule@../../myModule/the-new-version": + version "6.1.0" + dependencies: + aws-xray-sdk "^1.1.6" + aws4 "^1.6.0" + base-x "^3.0.3" + bluebird "^3.5.1" + chalk "^1.1.3" + cls-bluebird "^2.1.0" + continuation-local-storage "^3.2.1" + lodash "^4.17.4" + moment "^2.20.0" + redis "^2.8.0" + request "^2.83.0" + ulid "^0.1.0" + uuid "^3.1.0" + + acorn@^5.0.0, acorn@^5.5.0: + version "5.5.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" + `; + + const expectedContent = ` + acorn@^2.1.0, acorn@^2.4.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" + + acorn@^3.0.4: + version "3.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" + + otherModule@file:../../project/../../otherModule/the-new-version: + version "1.2.0" + + acorn@^2.1.0, acorn@^2.4.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-2.7.0.tgz#ab6e7d9d886aaca8b085bc3312b79a198433f0e7" + + "@myCompany/myModule@../../project/../../myModule/the-new-version": + version "6.1.0" + dependencies: + aws-xray-sdk "^1.1.6" + aws4 "^1.6.0" + base-x "^3.0.3" + bluebird "^3.5.1" + chalk "^1.1.3" + cls-bluebird "^2.1.0" + continuation-local-storage "^3.2.1" + lodash "^4.17.4" + moment "^2.20.0" + redis "^2.8.0" + request "^2.83.0" + ulid "^0.1.0" + uuid "^3.1.0" + + acorn@^5.0.0, acorn@^5.5.0: + version "5.5.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" + `; + + expect(yarnModule.rebaseLockfile('../../project', testContent)).toEqual(expectedContent); + }); + }); + + describe('install', () => { + it('should use yarn install', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: 'installed successfully', stderr: '' }); + + const result = yarnModule.install('myPath', {}); + + expect(result).toBeUndefined(); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith( + expect.stringMatching(/^yarn/), + [ 'install', '--frozen-lockfile', '--non-interactive' ], + { + cwd: 'myPath' + } + ); + }); + + it('should use ignoreScripts option', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: 'installed successfully', stderr: '' }); + + const result = yarnModule.install('myPath', { ignoreScripts: true }); + + expect(result).toBeUndefined(); + expect(spawnProcess).toHaveBeenCalledTimes(1); + expect(spawnProcess).toHaveBeenCalledWith( + expect.stringMatching(/^yarn/), + [ 'install', '--frozen-lockfile', '--non-interactive', '--ignore-scripts' ], + { + cwd: 'myPath' + } + ); + }); + }); + + describe('prune', () => { + let installStub: jest.SpyInstance; + + beforeAll(() => { + installStub = jest.spyOn(yarnModule, 'install').mockReturnValue(); + }); + + afterAll(() => { + installStub.mockRestore(); + }); + + it('should call install', () => { + yarnModule.prune('myPath', {}); + + expect(installStub).toHaveBeenCalledTimes(1); + expect(installStub).toHaveBeenCalledWith('myPath', {}); + }); + }); + + describe('runScripts', () => { + it('should use yarn run for the given scripts', () => { + spawnProcess.mockReset().mockReturnValue({ stdout: 'success', stderr: '' }); + + const result = yarnModule.runScripts('myPath', [ 's1', 's2' ]); + + expect(result).toBeUndefined(); + expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(spawnProcess).toHaveBeenNthCalledWith(1, expect.stringMatching(/^yarn/), [ 'run', 's1' ], { + cwd: 'myPath' + }); + expect(spawnProcess).toHaveBeenNthCalledWith(2, expect.stringMatching(/^yarn/), [ 'run', 's2' ], { + cwd: 'myPath' + }); + }); + }); +});