diff --git a/build/utils/yarn.go b/build/utils/yarn.go index f461c771..4068f54b 100644 --- a/build/utils/yarn.go +++ b/build/utils/yarn.go @@ -94,11 +94,17 @@ func GetYarnExecutable() (string, error) { return yarnExecPath, nil } -// GetYarnDependencies returns a map of the dependencies of a Yarn project and the root package of the project. -// The keys are the packages' values (Yarn's full identifiers of the packages), for example: '@scope/package-name@1.0.0' -// (for yarn v < 2.0.0) or @scope/package-name@npm:1.0.0 (for yarn v >= 2.0.0). -// Pay attention that a package's value won't necessarily contain its version. Use the version in package's details instead. -func GetYarnDependencies(executablePath, srcPath string, packageInfo *PackageInfo, log utils.Log) (dependenciesMap map[string]*YarnDependency, root *YarnDependency, err error) { +// Returns a map of the dependencies of a Yarn project along with the root package of the project. +// The map's keys are the full identifiers of the packages as used by Yarn; for example: +// '@scope/package-name@1.0.0' (for Yarn versions < 2.0.0) or '@scope/package-name@npm:1.0.0' (for Yarn versions >= 2.0.0). +// Note that a package's value may not necessarily contain its version; instead, use the version in the package's details. +// Arguments: +// executablePath - The path to the Yarn executable. +// srcPath - The path to the project's source that we wish to work with. +// packageInfo - The project's package information. +// log - The logger. +// allowPartialResults - If true, the function will allow some errors to occur without failing the flow and will generate partial results. +func GetYarnDependencies(executablePath, srcPath string, packageInfo *PackageInfo, log utils.Log, allowPartialResults bool) (dependenciesMap map[string]*YarnDependency, root *YarnDependency, err error) { executableVersionStr, err := GetVersion(executablePath, srcPath) if err != nil { return @@ -124,7 +130,7 @@ func GetYarnDependencies(executablePath, srcPath string, packageInfo *PackageInf if isV2AndAbove { dependenciesMap, root, err = buildYarnV2DependencyMap(packageInfo, responseStr) } else { - dependenciesMap, root, err = buildYarnV1DependencyMap(packageInfo, responseStr) + dependenciesMap, root, err = buildYarnV1DependencyMap(packageInfo, responseStr, allowPartialResults, log) } return } @@ -144,10 +150,15 @@ func GetVersion(executablePath, srcPath string) (string, error) { return strings.TrimSpace(outBuffer.String()), err } -// buildYarnV1DependencyMap builds a map of dependencies for Yarn versions < 2.0.0 -// Pay attention that in Yarn < 2.0.0 the project itself with its direct dependencies is not presented when running the -// command 'yarn list' therefore the root is built manually. -func buildYarnV1DependencyMap(packageInfo *PackageInfo, responseStr string) (dependenciesMap map[string]*YarnDependency, root *YarnDependency, err error) { +// Builds a map of dependencies for Yarn versions < 2.0.0. +// Note that in Yarn < 2.0.0, the project itself, along with its direct dependencies, is not present when running the +// command 'yarn list'; therefore, the root is built manually. +// Arguments: +// packageInfo - The project's package information. +// responseStr - The response string from the 'yarn list' command. +// allowPartialResults - If true, the function will allow some errors to occur without failing the flow and will generate partial results. +// log - The logger. +func buildYarnV1DependencyMap(packageInfo *PackageInfo, responseStr string, allowPartialResults bool, log utils.Log) (dependenciesMap map[string]*YarnDependency, root *YarnDependency, err error) { dependenciesMap = make(map[string]*YarnDependency) var depTree Yarn1Data err = json.Unmarshal([]byte(responseStr), &depTree) @@ -164,23 +175,26 @@ func buildYarnV1DependencyMap(packageInfo *PackageInfo, responseStr string) (dep packNameToFullName := make(map[string]string) // Initializing dependencies map without child dependencies for each dependency + creating a map that maps: package-name -> package-name@version + // The two phases mapping is performed since the responseStr from 'yarn list' contains the resolved versions of the dependencies at the map's first level, but may contain the caret version range (^) at the children level. + // Therefore, a manual matching must be made in order to output a map with parent-child relation containing only resolved versions. for _, curDependency := range depTree.Data.DepTree { var packageCleanName, packageVersion string packageCleanName, packageVersion, err = splitNameAndVersion(curDependency.Name) if err != nil { return } - + // We insert to dependenciesMap dependencies with the resolved versions only. All dependencies at the responseStr first level contain resolved versions only (their children may contain caret version ranges). dependenciesMap[curDependency.Name] = &YarnDependency{ Value: curDependency.Name, Details: YarnDepDetails{Version: packageVersion}, } packNameToFullName[packageCleanName] = curDependency.Name } + log.Debug(fmt.Sprintf("'yarn list' output string: %s\n\nPackage name to full name map content: %v", responseStr, packNameToFullName)) // Adding child dependencies for each dependency for _, curDependency := range depTree.Data.DepTree { - dependency := dependenciesMap[curDependency.Name] + dependencyToUpdateInMap := dependenciesMap[curDependency.Name] for _, subDep := range curDependency.Dependencies { var subDepName string @@ -191,12 +205,17 @@ func buildYarnV1DependencyMap(packageInfo *PackageInfo, responseStr string) (dep packageWithResolvedVersion := packNameToFullName[subDepName] if packageWithResolvedVersion == "" { + if allowPartialResults { + log.Warn(fmt.Sprintf("error occurred during Yarn dependencies map calculation: couldn't find resolved version for '%s' in 'yarn list' output\nFinal rasults may be partial", subDep.DependencyName)) + continue + } + err = fmt.Errorf("couldn't find resolved version for '%s' in 'yarn list' output", subDep.DependencyName) return } - dependency.Details.Dependencies = append(dependency.Details.Dependencies, YarnDependencyPointer{subDep.DependencyName, packageWithResolvedVersion}) + dependencyToUpdateInMap.Details.Dependencies = append(dependencyToUpdateInMap.Details.Dependencies, YarnDependencyPointer{subDep.DependencyName, packageWithResolvedVersion}) } - dependenciesMap[curDependency.Name] = dependency + dependenciesMap[curDependency.Name] = dependencyToUpdateInMap } rootDependency := buildYarn1Root(packageInfo, packNameToFullName) @@ -205,7 +224,7 @@ func buildYarnV1DependencyMap(packageInfo *PackageInfo, responseStr string) (dep return } -// buildYarnV2DependencyMap builds a map of dependencies for Yarn version >= 2.0.0 +// Builds a map of dependencies for Yarn version >= 2.0.0 // Note that in some versions of Yarn, the version of the root package is '0.0.0-use.local', instead of the version in the package.json file. func buildYarnV2DependencyMap(packageInfo *PackageInfo, responseStr string) (dependenciesMap map[string]*YarnDependency, root *YarnDependency, err error) { dependenciesMap = make(map[string]*YarnDependency) @@ -233,7 +252,7 @@ func buildYarnV2DependencyMap(packageInfo *PackageInfo, responseStr string) (dep return } -// runYarnInfoOrList depends on the yarn version currently operating on the project, runs the command that gets the dependencies of the project +// Depending on the Yarn version currently in use for the project, this function runs the command that retrieves the project's dependencies func runYarnInfoOrList(executablePath string, srcPath string, v2AndAbove bool) (outResult, errResult string, err error) { var command *exec.Cmd if v2AndAbove { diff --git a/build/utils/yarn_test.go b/build/utils/yarn_test.go index 7685d553..e65d9732 100644 --- a/build/utils/yarn_test.go +++ b/build/utils/yarn_test.go @@ -4,6 +4,7 @@ import ( "github.com/jfrog/build-info-go/utils" "github.com/stretchr/testify/assert" "path/filepath" + "sort" "strings" "testing" ) @@ -53,7 +54,7 @@ func checkGetYarnDependencies(t *testing.T, versionDir string, expectedLocators Dependencies: map[string]string{"react": "18.2.0", "xml": "1.0.1"}, DevDependencies: map[string]string{"json": "9.0.6"}, } - dependenciesMap, root, err := GetYarnDependencies(executablePath, projectSrcPath, &pacInfo, &utils.NullLog{}) + dependenciesMap, root, err := GetYarnDependencies(executablePath, projectSrcPath, &pacInfo, &utils.NullLog{}, false) assert.NoError(t, err) assert.NotNil(t, root) @@ -104,6 +105,97 @@ func checkGetYarnDependencies(t *testing.T, versionDir string, expectedLocators } } +// This test checks the error handling of buildYarnV1DependencyMap with a response string that is missing a dependency, when allow-partial-results is set to true. +// responseStr, which is an output of 'yarn list' should contain every dependency (direct and indirect) of the project at the first level, with the direct children of each dependency. +// Sometimes the first level is lacking a dependency that appears as a child, or the child dependency is not found at the first level of the map, hence an error should be thrown. +// When apply-partial-results is set to true we expect to provide a partial map instead of dropping the entire flow and return an error in such a case. +func TestBuildYarnV1DependencyMapWithLackingDependencyInResponseString(t *testing.T) { + packageInfo := &PackageInfo{ + Name: "test-project", + Version: "1.0.0", + Dependencies: map[string]string{"minimist": "1.2.5", "yarn-inner": "file:./yarn-inner"}, + } + + // This responseStr simulates should trigger an error since it is missing 'tough-cookie' at the "trees" first level, but this dependency appears as a child for another dependency (and hence should have been in the "trees" level as well) + responseStr := "{\"type\":\"tree\",\"data\":{\"type\":\"list\",\"trees\":[{\"name\":\"minimist@1.2.5\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"yarn-inner@1.0.0\",\"children\":[{\"name\":\"tough-cookie@2.5.0\",\"color\":\"dim\",\"shadow\":true}],\"hint\":null,\"color\":\"bold\",\"depth\":0}]}}" + + expectedRoot := YarnDependency{ + Value: "test-project", + Details: YarnDepDetails{ + Version: "1.0.0", + Dependencies: []YarnDependencyPointer{ + { + Descriptor: "", + Locator: "minimist@1.2.5", + }, + { + Descriptor: "", + Locator: "yarn-inner@1.0.0", + }, + }, + }, + } + + expectedDependenciesMap := map[string]*YarnDependency{ + "minimist@1.2.5": { + Value: "minimist@1.2.5", + Details: YarnDepDetails{ + Version: "1.2.5", + Dependencies: nil, + }, + }, + "yarn-inner@1.0.0": { + Value: "yarn-inner@1.0.0", + Details: YarnDepDetails{ + Version: "1.0.0", + Dependencies: nil, + }, + }, + "test-project": { + Value: "test-project", + Details: YarnDepDetails{ + Version: "1.0.0", + Dependencies: []YarnDependencyPointer{ + { + Descriptor: "", + Locator: "minimist@1.2.5", + }, + { + Descriptor: "", + Locator: "yarn-inner@1.0.0", + }, + }, + }, + }, + } + + dependenciesMap, root, err := buildYarnV1DependencyMap(packageInfo, responseStr, true, &utils.NullLog{}) + assert.NoError(t, err) + // Verifying root + assert.NotNil(t, root) + assert.Equal(t, expectedRoot.Value, root.Value) + assert.Len(t, root.Details.Dependencies, len(expectedRoot.Details.Dependencies)) + sort.Slice(root.Details.Dependencies, func(i, j int) bool { + return root.Details.Dependencies[i].Locator < root.Details.Dependencies[j].Locator + }) + assert.EqualValues(t, expectedRoot.Details.Dependencies, root.Details.Dependencies) + + // Verifying dependencies map + assert.Equal(t, len(expectedDependenciesMap), len(dependenciesMap)) + for expectedKey, expectedValue := range expectedDependenciesMap { + value := dependenciesMap[expectedKey] + assert.NotNil(t, value) + assert.EqualValues(t, expectedValue.Value, value.Value) + assert.EqualValues(t, expectedValue.Details.Version, value.Details.Version) + if expectedValue.Details.Dependencies != nil { + sort.Slice(value.Details.Dependencies, func(i, j int) bool { + return value.Details.Dependencies[i].Locator < value.Details.Dependencies[j].Locator + }) + assert.EqualValues(t, expectedValue.Details.Dependencies, value.Details.Dependencies) + } + } +} + func TestYarnDependency_Name(t *testing.T) { testCases := []struct { packageFullName string diff --git a/build/yarn.go b/build/yarn.go index bf3bd1be..1585087f 100644 --- a/build/yarn.go +++ b/build/yarn.go @@ -82,7 +82,7 @@ func (ym *YarnModule) Build() error { } func (ym *YarnModule) getDependenciesMap() (map[string]*entities.Dependency, error) { - dependenciesMap, root, err := buildutils.GetYarnDependencies(ym.executablePath, ym.srcPath, ym.packageInfo, ym.containingBuild.logger) + dependenciesMap, root, err := buildutils.GetYarnDependencies(ym.executablePath, ym.srcPath, ym.packageInfo, ym.containingBuild.logger, false) if err != nil { return nil, err }