diff --git a/e2e/Makefile b/e2e/Makefile index 6e7a7c7..cdc1037 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -12,10 +12,11 @@ test-e2e-minimal: build test-e2e-individually: build rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/individually && rsync -r ./examples/individually/ ./.test-artifacts/individually/ - cd ./.test-artifacts/individually && yarn install && npx sls package + cd ./.test-artifacts/individually && touch yarn.lock && yarn set version classic && yarn install && npx sls package cd ./.test-artifacts/individually/.serverless && unzip hello1.zip && unzip hello2.zip npx jest -c jest.config.e2e.js --ci ./e2e/individually.test.ts rm -fr ./.test-artifacts + node -e "const f='./package.json',p=require(f);delete p.packageManager;require('fs').writeFileSync(f,JSON.stringify(p,null,2))" test-e2e-complete: build rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/complete && rsync -r ./examples/complete/ ./.test-artifacts/complete/ @@ -27,3 +28,12 @@ test-e2e-complete: build test-e2e-config: build rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/config && rsync -r ./examples/config/ ./.test-artifacts/config/ cd ./.test-artifacts/config && pnpm install && npx sls package + +test-e2e-berry: build + rm -fr ./.test-artifacts && mkdir -p ./.test-artifacts/berry && rsync -r ./examples/berry/ ./.test-artifacts/berry/ + node -e "const fs=require('fs'),f='./package.json',p=require(f);p.workspaces=['.test-artifacts/berry','.test-artifacts/berry/.esbuild/.build'];fs.writeFileSync(f,JSON.stringify(p,null,2))" + cd ./.test-artifacts/berry && yarn set version berry && yarn install && npx sls package + cd ./.test-artifacts/berry/.serverless && unzip hello1.zip && unzip hello2.zip + npx jest -c jest.config.e2e.js --ci ./e2e/berry.test.ts + rm -fr ./.test-artifacts && rm -fr ./.yarn yarn.lock + node -e "const f='./package.json',p=require(f);delete p.packageManager;delete p.workspaces;require('fs').writeFileSync(f,JSON.stringify(p,null,2))" diff --git a/e2e/__snapshots__/berry.test.ts.snap b/e2e/__snapshots__/berry.test.ts.snap new file mode 100644 index 0000000..fd9d1c4 --- /dev/null +++ b/e2e/__snapshots__/berry.test.ts.snap @@ -0,0 +1,536 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`berry 1`] = ` +""use strict";var l=Object.create;var n=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var f=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,d=Object.prototype.hasOwnProperty;var u=(e,s)=>n(e,"name",{value:s,configurable:!0});var p=(e,s)=>{for(var o in s)n(e,o,{get:s[o],enumerable:!0})},c=(e,s,o,r)=>{if(s&&typeof s=="object"||typeof s=="function")for(let t of f(s))!d.call(e,t)&&t!==o&&n(e,t,{get:()=>s[t],enumerable:!(r=a(s,t))||r.enumerable});return e};var y=(e,s,o)=>(o=e!=null?l(m(e)):{},c(s||!e||!e.__esModule?n(o,"default",{value:e,enumerable:!0}):o,e)),g=e=>c(n({},"__esModule",{value:!0}),e);var S={};p(S,{handler:()=>x});module.exports=g(S);var i=y(require("lodash"));async function x(e,s,o){console.log(i.VERSION),await new Promise(t=>setTimeout(t,500));let r={statusCode:200,body:JSON.stringify({message:"Go Serverless v1.0! Your function executed successfully!",input:e})};o(null,r)}u(x,"handler");0&&(module.exports={handler}); +" +`; + +exports[`berry 2`] = ` +""use strict";var u=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var r=Object.getOwnPropertyNames;var a=Object.prototype.hasOwnProperty;var c=(s,e)=>u(s,"name",{value:e,configurable:!0});var l=(s,e)=>{for(var n in e)u(s,n,{get:e[n],enumerable:!0})},d=(s,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of r(e))!a.call(s,t)&&t!==n&&u(s,t,{get:()=>e[t],enumerable:!(o=i(e,t))||o.enumerable});return s};var f=s=>d(u({},"__esModule",{value:!0}),s);var m={};l(m,{handler:()=>y});module.exports=f(m);async function y(s,e,n){await new Promise(t=>setTimeout(t,500));let o={statusCode:200,body:JSON.stringify({message:"Go Serverless v1.0! Your function executed successfully!",input:s})};n(null,o)}c(y,"handler");0&&(module.exports={handler}); +" +`; + +exports[`berry 3`] = `"2010-09-09"`; + +exports[`berry 4`] = `"The AWS CloudFormation template for this Serverless application"`; + +exports[`berry 5`] = ` +{ + "Hello1LambdaFunctionQualifiedArn": { + "Description": "Current Lambda function version", + "Export": { + "Name": "sls-serverless-example-dev-Hello1LambdaFunctionQualifiedArn", + }, + "Value": { + "Ref": Any, + }, + }, + "Hello2LambdaFunctionQualifiedArn": { + "Description": "Current Lambda function version", + "Export": { + "Name": "sls-serverless-example-dev-Hello2LambdaFunctionQualifiedArn", + }, + "Value": { + "Ref": Any, + }, + }, + "ServerlessDeploymentBucketName": { + "Export": { + "Name": "sls-serverless-example-dev-ServerlessDeploymentBucketName", + }, + "Value": { + "Ref": "ServerlessDeploymentBucket", + }, + }, + "ServiceEndpoint": { + "Description": "URL of the service endpoint", + "Export": { + "Name": "sls-serverless-example-dev-ServiceEndpoint", + }, + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "ApiGatewayRestApi", + }, + ".execute-api.", + { + "Ref": "AWS::Region", + }, + ".", + { + "Ref": "AWS::URLSuffix", + }, + "/dev", + ], + ], + }, + }, +} +`; + +exports[`berry 6`] = ` +{ + "ApiGatewayMethodHello1Get": { + "DependsOn": [ + "Hello1LambdaPermissionApiGateway", + ], + "Properties": { + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":apigateway:", + { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "Hello1LambdaFunction", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "MethodResponses": [], + "RequestParameters": {}, + "ResourceId": { + "Ref": "ApiGatewayResourceHello1", + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "ApiGatewayMethodHello2Get": { + "DependsOn": [ + "Hello2LambdaPermissionApiGateway", + ], + "Properties": { + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":apigateway:", + { + "Ref": "AWS::Region", + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "Hello2LambdaFunction", + "Arn", + ], + }, + "/invocations", + ], + ], + }, + }, + "MethodResponses": [], + "RequestParameters": {}, + "ResourceId": { + "Ref": "ApiGatewayResourceHello2", + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi", + }, + }, + "Type": "AWS::ApiGateway::Method", + }, + "ApiGatewayResourceHello1": { + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId", + ], + }, + "PathPart": "hello1", + "RestApiId": { + "Ref": "ApiGatewayRestApi", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "ApiGatewayResourceHello2": { + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId", + ], + }, + "PathPart": "hello2", + "RestApiId": { + "Ref": "ApiGatewayRestApi", + }, + }, + "Type": "AWS::ApiGateway::Resource", + }, + "ApiGatewayRestApi": { + "Properties": { + "EndpointConfiguration": { + "Types": [ + "EDGE", + ], + }, + "Name": "dev-serverless-example", + "Policy": "", + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "Hello1LambdaFunction": { + "DependsOn": [ + "Hello1LogGroup", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "ServerlessDeploymentBucket", + }, + "S3Key": StringContaining "hello1.zip", + }, + "FunctionName": "serverless-example-dev-hello1", + "Handler": "hello1.handler", + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 6, + }, + "Type": "AWS::Lambda::Function", + }, + "Hello1LambdaPermissionApiGateway": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Hello1LambdaFunction", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":execute-api:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":", + { + "Ref": "ApiGatewayRestApi", + }, + "/*/*", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "Hello1LogGroup": { + "Properties": { + "LogGroupName": "/aws/lambda/serverless-example-dev-hello1", + }, + "Type": "AWS::Logs::LogGroup", + }, + "Hello2LambdaFunction": { + "DependsOn": [ + "Hello2LogGroup", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "ServerlessDeploymentBucket", + }, + "S3Key": StringContaining "hello2.zip", + }, + "FunctionName": "serverless-example-dev-hello2", + "Handler": "hello2.handler", + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 6, + }, + "Type": "AWS::Lambda::Function", + }, + "Hello2LambdaPermissionApiGateway": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Hello2LambdaFunction", + "Arn", + ], + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":execute-api:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":", + { + "Ref": "ApiGatewayRestApi", + }, + "/*/*", + ], + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "Hello2LogGroup": { + "Properties": { + "LogGroupName": "/aws/lambda/serverless-example-dev-hello2", + }, + "Type": "AWS::Logs::LogGroup", + }, + "IamRoleLambdaExecution": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "Path": "/", + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:TagResource", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Sub": "arn:\${AWS::Partition}:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/lambda/serverless-example-dev*:*", + }, + ], + }, + { + "Action": [ + "logs:PutLogEvents", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Sub": "arn:\${AWS::Partition}:logs:\${AWS::Region}:\${AWS::AccountId}:log-group:/aws/lambda/serverless-example-dev*:*:*", + }, + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": { + "Fn::Join": [ + "-", + [ + "serverless-example", + "dev", + "lambda", + ], + ], + }, + }, + ], + "RoleName": { + "Fn::Join": [ + "-", + [ + "serverless-example", + "dev", + { + "Ref": "AWS::Region", + }, + "lambdaRole", + ], + ], + }, + }, + "Type": "AWS::IAM::Role", + }, + "ServerlessDeploymentBucket": { + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256", + }, + }, + ], + }, + }, + "Type": "AWS::S3::Bucket", + }, + "ServerlessDeploymentBucketPolicy": { + "Properties": { + "Bucket": { + "Ref": "ServerlessDeploymentBucket", + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:*", + "Condition": { + "Bool": { + "aws:SecureTransport": false, + }, + }, + "Effect": "Deny", + "Principal": "*", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket", + }, + "/*", + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket", + }, + ], + ], + }, + ], + }, + ], + }, + }, + "Type": "AWS::S3::BucketPolicy", + }, +} +`; + +exports[`berry 7`] = ` +{ + "DependsOn": [ + "ApiGatewayMethodHello1Get", + "ApiGatewayMethodHello2Get", + ], + "Properties": { + "RestApiId": { + "Ref": "ApiGatewayRestApi", + }, + "StageName": "dev", + }, + "Type": "AWS::ApiGateway::Deployment", +} +`; + +exports[`berry 8`] = ` +{ + "DeletionPolicy": "Retain", + "Properties": { + "CodeSha256": Any, + "FunctionName": { + "Ref": "Hello1LambdaFunction", + }, + }, + "Type": "AWS::Lambda::Version", +} +`; + +exports[`berry 9`] = ` +{ + "DeletionPolicy": "Retain", + "Properties": { + "CodeSha256": Any, + "FunctionName": { + "Ref": "Hello2LambdaFunction", + }, + }, + "Type": "AWS::Lambda::Version", +} +`; diff --git a/e2e/berry.test.ts b/e2e/berry.test.ts new file mode 100644 index 0000000..e7f87ca --- /dev/null +++ b/e2e/berry.test.ts @@ -0,0 +1,70 @@ +import fs from 'fs'; +import path from 'path'; + +test('berry', () => { + const testArtifactPath = path.resolve(__dirname, '../.test-artifacts/berry/.serverless'); + + const cloudformation = require(path.join(testArtifactPath, 'cloudformation-template-update-stack.json')); + + const hello1indexContents = fs.readFileSync(path.join(testArtifactPath, 'hello1.js')).toString(); + const hello2indexContents = fs.readFileSync(path.join(testArtifactPath, 'hello2.js')).toString(); + + expect(hello1indexContents).toMatchSnapshot(); + + expect(hello2indexContents).toMatchSnapshot(); + + expect(cloudformation.AWSTemplateFormatVersion).toMatchSnapshot(); + + expect(cloudformation.Description).toMatchSnapshot(); + + expect(cloudformation.Outputs).toMatchSnapshot({ + Hello1LambdaFunctionQualifiedArn: { + Value: { Ref: expect.any(String) }, + }, + Hello2LambdaFunctionQualifiedArn: { + Value: { Ref: expect.any(String) }, + }, + }); + + expect(cloudformation.Outputs.Hello1LambdaFunctionQualifiedArn.Value.Ref).toMatch(/^Hello1LambdaVersion/); + + expect(cloudformation.Outputs.Hello2LambdaFunctionQualifiedArn.Value.Ref).toMatch(/^Hello2LambdaVersion/); + + const apiGatewayDeploymentPropertyKey = Object.keys(cloudformation.Resources).find((s) => + s.startsWith('ApiGatewayDeployment') + ) as keyof typeof cloudformation.Resources; + + const hello1LambdaVersionPropertyKey = cloudformation.Outputs.Hello1LambdaFunctionQualifiedArn.Value + .Ref as keyof typeof cloudformation.Resources; + + const hello2LambdaVersionPropertyKey = cloudformation.Outputs.Hello2LambdaFunctionQualifiedArn.Value + .Ref as keyof typeof cloudformation.Resources; + + const { + [apiGatewayDeploymentPropertyKey]: apiGatewayDeployment, + [hello1LambdaVersionPropertyKey]: hello1LambdaVersion, + [hello2LambdaVersionPropertyKey]: hello2LambdaVersion, + ...deterministicResources + } = cloudformation.Resources; + + expect(deterministicResources).toMatchSnapshot({ + Hello1LambdaFunction: { + Properties: { + Code: { S3Key: expect.stringContaining('hello1.zip') }, + }, + }, + Hello2LambdaFunction: { + Properties: { + Code: { S3Key: expect.stringContaining('hello2.zip') }, + }, + }, + }); + + expect(apiGatewayDeployment).toMatchSnapshot(); + expect(hello1LambdaVersion).toMatchSnapshot({ + Properties: { CodeSha256: expect.any(String) }, + }); + expect(hello2LambdaVersion).toMatchSnapshot({ + Properties: { CodeSha256: expect.any(String) }, + }); +}); diff --git a/examples/berry/.gitignore b/examples/berry/.gitignore new file mode 100644 index 0000000..de2471d --- /dev/null +++ b/examples/berry/.gitignore @@ -0,0 +1,2 @@ +.build +.serverless diff --git a/examples/berry/.yarnrc.yml b/examples/berry/.yarnrc.yml new file mode 100644 index 0000000..c6e883c --- /dev/null +++ b/examples/berry/.yarnrc.yml @@ -0,0 +1,2 @@ +nodeLinker: node-modules + diff --git a/examples/berry/hello1.ts b/examples/berry/hello1.ts new file mode 100644 index 0000000..7c4fda1 --- /dev/null +++ b/examples/berry/hello1.ts @@ -0,0 +1,20 @@ +import * as _ from 'lodash'; + +// modern module syntax +export async function handler(event, context, callback) { + // dependencies work as expected + console.log(_.VERSION); + + // async/await also works out of the box + await new Promise((resolve) => setTimeout(resolve, 500)); + + const response = { + statusCode: 200, + body: JSON.stringify({ + message: 'Go Serverless v1.0! Your function executed successfully!', + input: event, + }), + }; + + callback(null, response); +} diff --git a/examples/berry/hello2.ts b/examples/berry/hello2.ts new file mode 100644 index 0000000..308402f --- /dev/null +++ b/examples/berry/hello2.ts @@ -0,0 +1,15 @@ +// modern module syntax +export async function handler(event, context, callback) { + // async/await also works out of the box + await new Promise((resolve) => setTimeout(resolve, 500)); + + const response = { + statusCode: 200, + body: JSON.stringify({ + message: 'Go Serverless v1.0! Your function executed successfully!', + input: event, + }), + }; + + callback(null, response); +} diff --git a/examples/berry/package.json b/examples/berry/package.json new file mode 100644 index 0000000..476326f --- /dev/null +++ b/examples/berry/package.json @@ -0,0 +1,17 @@ +{ + "main": "handler.js", + "scripts": { + "start": "sls offline" + }, + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "@types/lodash": "4.14.185", + "@types/node": "^18.7.21", + "esbuild": "^0.24.0", + "serverless": "^3.22.0", + "serverless-esbuild": "workspace:*", + "serverless-offline": "^10.2.1" + } +} diff --git a/examples/berry/plugins.js b/examples/berry/plugins.js new file mode 100644 index 0000000..bf23f92 --- /dev/null +++ b/examples/berry/plugins.js @@ -0,0 +1,12 @@ +const envPlugin = { + name: 'log-lodash', + setup(build) { + // test interception : log all lodash imports + build.onResolve({ filter: /^lodash$/ }, (args) => { + console.log(args); + }); + }, +}; + +// default export should be an array of plugins +module.exports = [envPlugin]; diff --git a/examples/berry/serverless.yml b/examples/berry/serverless.yml new file mode 100644 index 0000000..3fa45f2 --- /dev/null +++ b/examples/berry/serverless.yml @@ -0,0 +1,38 @@ +service: serverless-example + +plugins: + - serverless-esbuild + - serverless-offline + +package: + individually: true + +provider: + name: aws + runtime: nodejs18.x + +custom: + esbuild: + plugins: ./plugins.js + packager: yarn + bundle: true + minify: true + sourcemap: false + keepNames: true + external: + - lodash + +functions: + hello1: + handler: hello1.handler + events: + - http: + path: hello1 + method: get + + hello2: + handler: hello2.handler + events: + - http: + path: hello2 + method: get diff --git a/src/packagers/yarn.ts b/src/packagers/yarn.ts index f7e7fc4..8763bb9 100644 --- a/src/packagers/yarn.ts +++ b/src/packagers/yarn.ts @@ -22,6 +22,17 @@ export interface YarnDeps { }; } +export interface YarnBerryDep { + value: string; + children: { + Version: string; + Dependencies?: Array<{ + descriptor: string; + locator: string; + }>; + }; +} + const getNameAndVersion = (name: string): { name: string; version: string } => { const atIndex = name.lastIndexOf('@'); @@ -70,6 +81,146 @@ export class Yarn implements Packager { } async getProdDependencies(cwd: string, depth?: number): Promise { + const version = await this.getVersion(cwd); + + if (version.isBerry) { + return this.getBerryProdDependencies(cwd, depth); + } + + return this.getClassicProdDependencies(cwd, depth); + } + + private parsePackageInfo(line: string): { name: string; version: string; depInfo: YarnBerryDep } | null { + try { + const depInfo = JSON.parse(line) as YarnBerryDep; + const valueMatch = depInfo.value.match(/^(.+)@npm:(.+)$/); + + if (valueMatch && valueMatch[1] && valueMatch[2]) { + return { + depInfo, + name: valueMatch[1], + version: valueMatch[2], + }; + } + } catch (e) {} + + return null; + } + + private collectRootDependencies(lines: string[]): DependencyMap { + const rootDependencies: DependencyMap = {}; + + for (const line of lines) { + const packageInfo = this.parsePackageInfo(line); + + if (packageInfo && packageInfo.version !== 'workspace:.') { + rootDependencies[packageInfo.name] = { + version: packageInfo.depInfo.children.Version, + }; + } + } + + return rootDependencies; + } + + private processDependency( + depInfo: YarnBerryDep, + name: string, + rootDependencies: DependencyMap, + dependencies: DependencyMap + ): DependencyMap { + const result = { ...dependencies }; + + if (depInfo.value.includes('workspace:.')) { + return result; + } + + if (depInfo.children.Dependencies) { + const depMap = this.buildDependencyMap(depInfo.children.Dependencies, rootDependencies); + + const rootDep = rootDependencies[name]; + if (rootDep) { + if (Object.keys(depMap).length > 0) { + result[name] = { + ...rootDep, + dependencies: depMap, + }; + } else { + result[name] = rootDep; + } + } + } else { + const rootDep = rootDependencies[name]; + if (!result[name] && rootDep) { + result[name] = rootDep; + } + } + + return result; + } + + /** + * 의존성 맵을 구성합니다. + */ + private buildDependencyMap( + deps: Array<{ descriptor: string; locator: string }>, + rootDependencies: DependencyMap + ): DependencyMap { + const depMap: DependencyMap = {}; + + for (const dep of deps) { + const descriptorMatch = dep.descriptor.match(/^(.+)@npm:(.+)$/); + + if (descriptorMatch && descriptorMatch[1] && descriptorMatch[2]) { + const depName = descriptorMatch[1]; + const depVersionRange = descriptorMatch[2]; + + if (rootDependencies[depName]) { + depMap[depName] = { + version: depVersionRange, + isRootDep: true, + }; + } else { + depMap[depName] = { + version: depVersionRange, + }; + } + } + } + + return depMap; + } + + private async getBerryProdDependencies(cwd: string, _depth?: number): Promise { + const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; + const args = ['info', '-AR', '--json'].filter(Predicate.isString); + + try { + const processOutput = await spawnProcess(command, args, { cwd }); + + const lines = processOutput.stdout.split('\n').filter((line) => line.trim() !== ''); + let dependencies: DependencyMap = {}; + + const rootDependencies = this.collectRootDependencies(lines); + + for (const line of lines) { + const packageInfo = this.parsePackageInfo(line); + + if (packageInfo) { + dependencies = this.processDependency(packageInfo.depInfo, packageInfo.name, rootDependencies, dependencies); + } + } + + return { dependencies }; + } catch (err) { + if (err instanceof SpawnError && !isEmpty(err.stdout)) { + return { stdout: err.stdout }; + } + throw err; + } + } + + private async getClassicProdDependencies(cwd: string, depth?: number): Promise { const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; const args = ['list', depth ? `--depth=${depth}` : null, '--json', '--production'].filter(Predicate.isString); @@ -213,6 +364,9 @@ export class Yarn implements Packager { } rebaseLockfile(pathToPackageRoot: string, lockfile: string) { + console.log('[DEBUG] Yarn: Rebasing lockfile, pathToPackageRoot:', pathToPackageRoot); + console.log('[DEBUG] Yarn: Lockfile size:', lockfile.length); + const fileVersionMatcher = /[^"/]@(?:file:)?((?:\.\/|\.\.\/).*?)[":,]/gm; const replacements: Array<{ oldRef: string; @@ -223,26 +377,39 @@ export class Yarn implements Packager { // Detect all references and create replacement line strings // eslint-disable-next-line no-cond-assign while ((match = fileVersionMatcher.exec(lockfile)) !== null) { + const oldRef = typeof match[1] === 'string' ? match[1] : ''; + const newRef = replace(/\\/g, '/', `${pathToPackageRoot}/${match[1]}`); + + console.log('[DEBUG] Yarn: Found file reference:', oldRef, '-> rebasing to:', newRef); + replacements.push({ - oldRef: typeof match[1] === 'string' ? match[1] : '', - newRef: replace(/\\/g, '/', `${pathToPackageRoot}/${match[1]}`), + oldRef, + newRef, }); } + console.log('[DEBUG] Yarn: Total replacements found:', replacements.length); + // Replace all lines in lockfile - return reduce( + const result = reduce( (__, replacement) => replace(replacement.oldRef, replacement.newRef, __), lockfile, replacements.filter((item) => item.oldRef !== '') ); + + console.log('[DEBUG] Yarn: Rebased lockfile size:', result.length); + return result; } async install(cwd: string, extraArgs: Array, hasLockfile = true) { if (this.packagerOptions.noInstall) { + console.log('[DEBUG] Yarn: noInstall option is set, skipping install'); return; } const version = await this.getVersion(cwd); + console.log('[DEBUG] Yarn: Version detected:', version.version, 'isBerry:', version.isBerry); + const command = /^win/.test(process.platform) ? 'yarn.cmd' : 'yarn'; const args = @@ -250,7 +417,16 @@ export class Yarn implements Packager { ? ['install', ...(version.isBerry ? ['--immutable'] : ['--frozen-lockfile', '--non-interactive']), ...extraArgs] : ['install', ...(version.isBerry ? [] : ['--non-interactive']), ...extraArgs]; - await spawnProcess(command, args, { cwd }); + console.log('[DEBUG] Yarn: Installing with command:', command, args.join(' ')); + console.log('[DEBUG] Yarn: Working directory:', cwd); + + try { + await spawnProcess(command, args, { cwd }); + console.log('[DEBUG] Yarn: Install completed successfully'); + } catch (err) { + console.error('[DEBUG] Yarn: Install failed with error:', err); + throw err; + } } // "Yarn install" prunes automatically diff --git a/src/tests/packagers/yarn.test.ts b/src/tests/packagers/yarn.test.ts index 6375c4f..fdec34c 100644 --- a/src/tests/packagers/yarn.test.ts +++ b/src/tests/packagers/yarn.test.ts @@ -9,235 +9,420 @@ describe('Yarn Packager', () => { const yarn = new Yarn({}); const path = './'; - let spawnSpy: jest.SpyInstance; + describe('Yarn Classic', () => { + let spawnSpy: jest.SpyInstance; - beforeEach(() => { - spawnSpy = jest.spyOn(utils, 'spawnProcess'); - }); + beforeEach(() => { + spawnSpy = jest.spyOn(utils, 'spawnProcess'); + }); - afterEach(() => { - jest.resetAllMocks(); - jest.restoreAllMocks(); - }); + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + beforeEach(() => { + spawnSpy = jest.spyOn(utils, 'spawnProcess'); + }); - it('should call spawnProcess with the correct arguments for listing yarn dependencies', async () => { - spawnSpy.mockResolvedValueOnce({ - stderr: '', - stdout: '{"type":"tree","data":{"type":"list","trees":[]}}', + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); }); - await yarn.getProdDependencies(path); + it('should call spawnProcess with the correct arguments for listing yarn dependencies', async () => { + spawnSpy.mockImplementation((_, args) => { + if (args[0] === '-v') { + return Promise.resolve({ + stderr: '', + stdout: '1.0.1', + }); + } - expect(spawnSpy).toHaveBeenCalledTimes(1); - expect(spawnSpy).toHaveBeenCalledWith('yarn', ['list', '--json', '--production'], { cwd: './' }); - }); + if (args[0] === 'list') { + return Promise.resolve({ + stderr: '', + stdout: '{"type":"tree","data":{"type":"list","trees":[]}}', + }); + } + + return Promise.resolve({ + stderr: '', + stdout: '', + }); + }); - it('should call spawnProcess with the correct arguments for listing yarn dependencies when depth is provided', async () => { - spawnSpy.mockResolvedValueOnce({ - stderr: '', - stdout: '{"type":"tree","data":{"type":"list","trees":[]}}', + await yarn.getProdDependencies(path); + + expect(spawnSpy).toHaveBeenCalledWith('yarn', ['list', '--json', '--production'], { cwd: './' }); }); - await yarn.getProdDependencies(path, 2); + it('should call spawnProcess with the correct arguments for listing yarn dependencies when depth is provided', async () => { + spawnSpy.mockImplementation((_, args) => { + if (args[0] === '-v') { + return Promise.resolve({ + stderr: '', + stdout: '1.0.1', + }); + } + + if (args[0] === 'list') { + return Promise.resolve({ + stderr: '', + stdout: '{"type":"tree","data":{"type":"list","trees":[]}}', + }); + } - expect(spawnSpy).toHaveBeenCalledTimes(1); - expect(spawnSpy).toHaveBeenCalledWith('yarn', ['list', '--depth=2', '--json', '--production'], { - cwd: './', + return Promise.resolve({ + stderr: '', + stdout: '', + }); + }); + + await yarn.getProdDependencies(path, 2); + + expect(spawnSpy).toHaveBeenCalledWith('yarn', ['list', '--depth=2', '--json', '--production'], { + cwd: './', + }); }); - }); - it('should create a dependency tree from yarn output', async () => { - const yarnOutput: YarnDeps = { - type: 'tree', - data: { - type: 'list', - trees: [ - { - name: 'samchungy-a@2.0.0', - children: [ - { - name: 'samchungy-dep-a@1.0.0', - color: 'dim', - shadow: true, + it('should create a dependency tree from yarn output', async () => { + const yarnOutput: YarnDeps = { + type: 'tree', + data: { + type: 'list', + trees: [ + { + name: 'samchungy-a@2.0.0', + children: [ + { + name: 'samchungy-dep-a@1.0.0', + color: 'dim', + shadow: true, + }, + ], + hint: null, + color: 'bold', + depth: 0, + }, + { + name: 'samchungy-b@2.0.0', + children: [ + { + name: 'samchungy-dep-a@2.0.0', + color: 'dim', + shadow: true, + }, + { + name: 'samchungy-dep-a@2.0.0', + children: [], + hint: null, + color: 'bold', + depth: 0, + }, + ], + hint: null, + color: 'bold', + depth: 0, + }, + { + name: 'samchungy-dep-a@1.0.0', + children: [], + hint: null, + color: null, + depth: 0, + }, + ], + }, + }; + const expectedResult: DependenciesResult = { + dependencies: { + 'samchungy-a': { + dependencies: { + 'samchungy-dep-a': { + isRootDep: true, + version: '1.0.0', }, - ], - hint: null, - color: 'bold', - depth: 0, + }, + version: '2.0.0', }, - { - name: 'samchungy-b@2.0.0', - children: [ - { - name: 'samchungy-dep-a@2.0.0', - color: 'dim', - shadow: true, + 'samchungy-b': { + dependencies: { + 'samchungy-dep-a': { + version: '2.0.0', }, - { - name: 'samchungy-dep-a@2.0.0', - children: [], - hint: null, - color: 'bold', - depth: 0, - }, - ], - hint: null, - color: 'bold', - depth: 0, - }, - { - name: 'samchungy-dep-a@1.0.0', - children: [], - hint: null, - color: null, - depth: 0, - }, - ], - }, - }; - const expectedResult: DependenciesResult = { - dependencies: { - 'samchungy-a': { - dependencies: { - 'samchungy-dep-a': { - isRootDep: true, - version: '1.0.0', }, + version: '2.0.0', }, - version: '2.0.0', - }, - 'samchungy-b': { - dependencies: { - 'samchungy-dep-a': { - version: '2.0.0', - }, + 'samchungy-dep-a': { + version: '1.0.0', }, - version: '2.0.0', }, - 'samchungy-dep-a': { - version: '1.0.0', - }, - }, - }; + }; - spawnSpy.mockResolvedValueOnce({ - stderr: '', - stdout: JSON.stringify(yarnOutput), - }); + spawnSpy.mockImplementation((_, args) => { + if (args[0] === '-v') { + return Promise.resolve({ + stderr: '', + stdout: '1.0.1', + }); + } - const result = await yarn.getProdDependencies(path, 2); + if (args[0] === 'list') { + return Promise.resolve({ + stderr: '', + stdout: JSON.stringify(yarnOutput), + }); + } - expect(result).toStrictEqual(expectedResult); - }); + return Promise.resolve({ + stderr: '', + stdout: '', + }); + }); - it('should create a dependency tree which handles deduping from yarn output', async () => { - const yarnOutput: YarnDeps = { - type: 'tree', - data: { - type: 'list', - trees: [ - { - name: 'samchungy-a@3.0.0', - children: [{ name: 'samchungy-dep-b@3.0.0', color: 'dim', shadow: true }], - hint: null, - color: 'bold', - depth: 0, - }, - { - name: 'samchungy-b@5.0.0', - children: [{ name: 'samchungy-dep-b@3.0.0', color: 'dim', shadow: true }], - hint: null, - color: 'bold', - depth: 0, - }, - { - name: 'samchungy-dep-b@3.0.0', - children: [ - { name: 'samchungy-dep-c@^1.0.0', color: 'dim', shadow: true }, - { name: 'samchungy-dep-d@^1.0.0', color: 'dim', shadow: true }, - ], - hint: null, - color: null, - depth: 0, + const result = await yarn.getProdDependencies(path, 2); + + expect(result).toStrictEqual(expectedResult); + }); + + it('should create a dependency tree which handles deduping from yarn output', async () => { + const yarnOutput: YarnDeps = { + type: 'tree', + data: { + type: 'list', + trees: [ + { + name: 'samchungy-a@3.0.0', + children: [{ name: 'samchungy-dep-b@3.0.0', color: 'dim', shadow: true }], + hint: null, + color: 'bold', + depth: 0, + }, + { + name: 'samchungy-b@5.0.0', + children: [{ name: 'samchungy-dep-b@3.0.0', color: 'dim', shadow: true }], + hint: null, + color: 'bold', + depth: 0, + }, + { + name: 'samchungy-dep-b@3.0.0', + children: [ + { name: 'samchungy-dep-c@^1.0.0', color: 'dim', shadow: true }, + { name: 'samchungy-dep-d@^1.0.0', color: 'dim', shadow: true }, + ], + hint: null, + color: null, + depth: 0, + }, + { + name: 'samchungy-dep-c@1.0.0', + children: [{ name: 'samchungy-dep-e@^1.0.0', color: 'dim', shadow: true }], + hint: null, + color: null, + depth: 0, + }, + { + name: 'samchungy-dep-d@1.0.0', + children: [{ name: 'samchungy-dep-e@^1.0.0', color: 'dim', shadow: true }], + hint: null, + color: null, + depth: 0, + }, + { + name: 'samchungy-dep-e@1.0.0', + children: [], + hint: null, + color: null, + depth: 0, + }, + ], + }, + }; + + const expectedResult: DependenciesResult = { + dependencies: { + 'samchungy-a': { + version: '3.0.0', + dependencies: { + 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, + }, }, - { - name: 'samchungy-dep-c@1.0.0', - children: [{ name: 'samchungy-dep-e@^1.0.0', color: 'dim', shadow: true }], - hint: null, - color: null, - depth: 0, + 'samchungy-b': { + version: '5.0.0', + dependencies: { + 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, + }, }, - { - name: 'samchungy-dep-d@1.0.0', - children: [{ name: 'samchungy-dep-e@^1.0.0', color: 'dim', shadow: true }], - hint: null, - color: null, - depth: 0, + 'samchungy-dep-b': { + version: '3.0.0', + dependencies: { + 'samchungy-dep-c': { version: '^1.0.0', isRootDep: true }, + 'samchungy-dep-d': { version: '^1.0.0', isRootDep: true }, + }, }, - { - name: 'samchungy-dep-e@1.0.0', - children: [], - hint: null, - color: null, - depth: 0, + 'samchungy-dep-c': { + version: '1.0.0', + dependencies: { + 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, + }, }, - ], - }, - }; - - const expectedResult: DependenciesResult = { - dependencies: { - 'samchungy-a': { - version: '3.0.0', - dependencies: { - 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, + 'samchungy-dep-d': { + version: '1.0.0', + dependencies: { + 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, + }, }, + 'samchungy-dep-e': { version: '1.0.0' }, }, - 'samchungy-b': { - version: '5.0.0', - dependencies: { - 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, + }; + + spawnSpy.mockImplementation((_, args) => { + if (args[0] === '-v') { + return Promise.resolve({ + stderr: '', + stdout: '1.0.1', + }); + } + + if (args[0] === 'list') { + return Promise.resolve({ + stderr: '', + stdout: JSON.stringify(yarnOutput), + }); + } + + return Promise.resolve({ + stderr: '', + stdout: '', + }); + }); + + const result = await yarn.getProdDependencies(path, 2); + + expect(result).toStrictEqual(expectedResult); + }); + + it('should skip install if the noInstall option is true', async () => { + const yarnWithoutInstall = new Yarn({ + noInstall: true, + }); + + await expect(yarnWithoutInstall.install(path, [], false)).resolves.toBeUndefined(); + expect(spawnSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('Yarn Berry', () => { + let spawnSpy: jest.SpyInstance; + + beforeEach(() => { + spawnSpy = jest.spyOn(utils, 'spawnProcess'); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('should call spawnProcess with the correct arguments for yarn berry', async () => { + spawnSpy.mockImplementation((_, args) => { + if (args[0] === '-v') { + return Promise.resolve({ + stderr: '', + stdout: '2.4.3', + }); + } + + if (args[0] === 'info' && args[1] === '-AR') { + return Promise.resolve({ + stderr: '', + stdout: '{"value":"lodash@npm:4.17.21","children":{"Version":"4.17.21"}}', + }); + } + + return Promise.resolve({ + stderr: '', + stdout: '', + }); + }); + + await yarn.getProdDependencies(path); + + expect(spawnSpy).toHaveBeenCalledTimes(2); + expect(spawnSpy).toHaveBeenNthCalledWith(1, 'yarn', ['-v'], { cwd: './' }); + expect(spawnSpy).toHaveBeenNthCalledWith(2, 'yarn', ['info', '-AR', '--json'], { cwd: './' }); + }); + + it('should create a dependency tree from yarn berry output', async () => { + const expectedResult: DependenciesResult = { + dependencies: { + 'samchungy-a': { + version: '3.0.0', + dependencies: { + 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, + }, }, - }, - 'samchungy-dep-b': { - version: '3.0.0', - dependencies: { - 'samchungy-dep-c': { version: '^1.0.0', isRootDep: true }, - 'samchungy-dep-d': { version: '^1.0.0', isRootDep: true }, + 'samchungy-b': { + version: '5.0.0', + dependencies: { + 'samchungy-dep-b': { version: '3.0.0', isRootDep: true }, + }, }, - }, - 'samchungy-dep-c': { - version: '1.0.0', - dependencies: { - 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, + 'samchungy-dep-b': { + version: '3.0.0', + dependencies: { + 'samchungy-dep-c': { version: '^1.0.0', isRootDep: true }, + 'samchungy-dep-d': { version: '^1.0.0', isRootDep: true }, + }, }, - }, - 'samchungy-dep-d': { - version: '1.0.0', - dependencies: { - 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, + 'samchungy-dep-c': { + version: '1.0.0', + dependencies: { + 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, + }, + }, + 'samchungy-dep-d': { + version: '1.0.0', + dependencies: { + 'samchungy-dep-e': { version: '^1.0.0', isRootDep: true }, + }, }, + 'samchungy-dep-e': { version: '1.0.0' }, }, - 'samchungy-dep-e': { version: '1.0.0' }, - }, - }; + }; - spawnSpy.mockResolvedValueOnce({ - stderr: '', - stdout: JSON.stringify(yarnOutput), - }); + // 버전 조회 시 Yarn Berry 버전 반환 + spawnSpy.mockImplementation((_, args) => { + if (args[0] === '-v') { + return Promise.resolve({ + stderr: '', + stdout: '2.4.3', + }); + } - const result = await yarn.getProdDependencies(path, 2); + if (args[0] === 'info' && args[1] === '-AR') { + return Promise.resolve({ + stderr: '', + stdout: `{"value":"samchungy-a@npm:3.0.0","children":{"Version":"3.0.0","Dependencies":[{"descriptor":"samchungy-dep-b@npm:3.0.0","locator":"samchungy-dep-b@npm:3.0.0"}]}} +{"value":"samchungy-b@npm:5.0.0","children":{"Version":"5.0.0","Dependencies":[{"descriptor":"samchungy-dep-b@npm:3.0.0","locator":"samchungy-dep-b@npm:3.0.0"}]}} +{"value":"samchungy-dep-b@npm:3.0.0","children":{"Version":"3.0.0","Dependencies":[{"descriptor":"samchungy-dep-c@npm:^1.0.0","locator":"samchungy-dep-c@npm:1.0.0"},{"descriptor":"samchungy-dep-d@npm:^1.0.0","locator":"samchungy-dep-d@npm:1.0.0"}]}} +{"value":"samchungy-dep-c@npm:1.0.0","children":{"Version":"1.0.0","Dependencies":[{"descriptor":"samchungy-dep-e@npm:^1.0.0","locator":"samchungy-dep-e@npm:1.0.0"}]}} +{"value":"samchungy-dep-d@npm:1.0.0","children":{"Version":"1.0.0","Dependencies":[{"descriptor":"samchungy-dep-e@npm:^1.0.0","locator":"samchungy-dep-e@npm:1.0.0"}]}} +{"value":"samchungy-dep-e@npm:1.0.0","children":{"Version":"1.0.0"}}`, + }); + } - expect(result).toStrictEqual(expectedResult); - }); + return Promise.resolve({ + stderr: '', + stdout: '', + }); + }); - it('should skip install if the noInstall option is true', async () => { - const yarnWithoutInstall = new Yarn({ - noInstall: true, - }); + const result = await yarn.getProdDependencies(path); - await expect(yarnWithoutInstall.install(path, [], false)).resolves.toBeUndefined(); - expect(spawnSpy).toHaveBeenCalledTimes(0); + expect(result).toStrictEqual(expectedResult); + }); }); });