diff --git a/build/build.go b/build/build.go index 2308efd8..50bbc541 100644 --- a/build/build.go +++ b/build/build.go @@ -98,6 +98,11 @@ func (b *Build) AddNpmModule(srcPath string) (*NpmModule, error) { return newNpmModule(srcPath, b) } +// AddPnpmModule adds a Pnpm module to this Build. Pass srcPath as an empty string if the root of the Pnpm project is the working directory. +func (b *Build) AddPnpmModule(srcPath string) (*PnpmModule, error) { + return newPnpmModule(srcPath, b) +} + // AddPythonModule adds a Python module to this Build. Pass srcPath as an empty string if the root of the python project is the working directory. func (b *Build) AddPythonModule(srcPath string, tool pythonutils.PythonTool) (*PythonModule, error) { return newPythonModule(srcPath, tool, b) diff --git a/build/pnpm.go b/build/pnpm.go new file mode 100644 index 00000000..8dca5dd9 --- /dev/null +++ b/build/pnpm.go @@ -0,0 +1,114 @@ +package build + +import ( + "errors" + "os" + "strings" + + buildutils "github.com/jfrog/build-info-go/build/utils" + "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/build-info-go/utils" +) + +const minSupportedPnpmVersion = "6.0.0" + +type PnpmModule struct { + containingBuild *Build + name string + srcPath string + executablePath string + pnpmArgs []string + collectBuildInfo bool +} + +// Pass an empty string for srcPath to find the pnpm project in the working directory. +func newPnpmModule(srcPath string, containingBuild *Build) (*PnpmModule, error) { + pnpmVersion, executablePath, err := buildutils.GetPnpmVersionAndExecPath(containingBuild.logger) + if err != nil { + return nil, err + } + if pnpmVersion.Compare(minSupportedPnpmVersion) > 0 { + return nil, errors.New("pnpm CLI must have version " + minSupportedPnpmVersion + " or higher. The current version is: " + pnpmVersion.GetVersion()) + } + + if srcPath == "" { + wd, err := os.Getwd() + if err != nil { + return nil, err + } + srcPath, err = utils.FindFileInDirAndParents(wd, "package.json") + if err != nil { + return nil, err + } + } + + // Read module name - pnpm uses the same package.json format as npm + packageInfo, err := buildutils.ReadPackageInfoFromPackageJsonIfExists(srcPath, pnpmVersion) + if err != nil { + return nil, err + } + name := packageInfo.BuildInfoModuleId() + + return &PnpmModule{name: name, srcPath: srcPath, containingBuild: containingBuild, executablePath: executablePath}, nil +} + +func (pm *PnpmModule) Build() error { + if len(pm.pnpmArgs) > 0 { + output, _, err := buildutils.RunPnpmCmd(pm.executablePath, pm.srcPath, pm.pnpmArgs, &utils.NullLog{}) + if len(output) > 0 { + pm.containingBuild.logger.Output(strings.TrimSpace(string(output))) + } + if err != nil { + return err + } + // After executing the user-provided command, cleaning pnpmArgs is needed. + pm.filterPnpmArgsFlags() + } + if !pm.collectBuildInfo { + return nil + } + return pm.CalcDependencies() +} + +func (pm *PnpmModule) CalcDependencies() error { + if !pm.containingBuild.buildNameAndNumberProvided() { + return errors.New("a build name must be provided in order to collect the project's dependencies") + } + buildInfoDependencies, err := buildutils.CalculatePnpmDependenciesList(pm.executablePath, pm.srcPath, pm.name, + buildutils.PnpmTreeDepListParam{Args: pm.pnpmArgs}, true, pm.containingBuild.logger) + if err != nil { + return err + } + buildInfoModule := entities.Module{Id: pm.name, Type: entities.Npm, Dependencies: buildInfoDependencies} + buildInfo := &entities.BuildInfo{Modules: []entities.Module{buildInfoModule}} + return pm.containingBuild.SaveBuildInfo(buildInfo) +} + +func (pm *PnpmModule) SetName(name string) { + pm.name = name +} + +func (pm *PnpmModule) SetPnpmArgs(pnpmArgs []string) { + pm.pnpmArgs = pnpmArgs +} + +func (pm *PnpmModule) SetCollectBuildInfo(collectBuildInfo bool) { + pm.collectBuildInfo = collectBuildInfo +} + +func (pm *PnpmModule) AddArtifacts(artifacts ...entities.Artifact) error { + return pm.containingBuild.AddArtifacts(pm.name, entities.Npm, artifacts...) +} + +// This function discards the pnpm command in pnpmArgs and keeps only the command flags. +// It is necessary for the pnpm command's name to come before the pnpm command's flags in pnpmArgs for the function to work correctly. +func (pm *PnpmModule) filterPnpmArgsFlags() { + if len(pm.pnpmArgs) == 1 && !strings.HasPrefix(pm.pnpmArgs[0], "-") { + pm.pnpmArgs = []string{} + } + for argIndex := 0; argIndex < len(pm.pnpmArgs); argIndex++ { + if strings.HasPrefix(pm.pnpmArgs[argIndex], "-") { + pm.pnpmArgs = pm.pnpmArgs[argIndex:] + } + } +} diff --git a/build/pnpm_test.go b/build/pnpm_test.go new file mode 100644 index 00000000..66819670 --- /dev/null +++ b/build/pnpm_test.go @@ -0,0 +1,210 @@ +package build + +import ( + "path/filepath" + "strconv" + "testing" + "time" + + buildutils "github.com/jfrog/build-info-go/build/utils" + "github.com/jfrog/build-info-go/tests" + "github.com/jfrog/build-info-go/utils" + "github.com/stretchr/testify/assert" +) + +var pnpmLogger = utils.NewDefaultLogger(utils.INFO) + +func TestGenerateBuildInfoForPnpm(t *testing.T) { + pnpmVersion, execPath, err := buildutils.GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + service := NewBuildInfoService() + pnpmBuild, err := service.GetOrCreateBuild("build-info-go-test-pnpm", strconv.FormatInt(time.Now().Unix(), 10)) + assert.NoError(t, err) + defer func() { + assert.NoError(t, pnpmBuild.Clean()) + }() + + // Create pnpm project. + path, err := filepath.Abs(filepath.Join(".", "testdata")) + assert.NoError(t, err) + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + // Install dependencies in the pnpm project. + _, _, err = buildutils.RunPnpmCmd(execPath, projectPath, []string{"install"}, pnpmLogger) + assert.NoError(t, err) + + pnpmModule, err := pnpmBuild.AddPnpmModule(projectPath) + assert.NoError(t, err) + + err = pnpmModule.CalcDependencies() + assert.NoError(t, err) + + buildInfo, err := pnpmBuild.ToBuildInfo() + assert.NoError(t, err) + + // Verify results - should have at least one module with dependencies + assert.Len(t, buildInfo.Modules, 1) + assert.Greater(t, len(buildInfo.Modules[0].Dependencies), 0, "Expected at least one dependency") + + // Verify pnpm version is supported + assert.True(t, pnpmVersion.AtLeast("6.0.0"), "pnpm version should be at least 6.0.0") +} + +func TestFilterPnpmArgsFlags(t *testing.T) { + _, execPath, err := buildutils.GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + service := NewBuildInfoService() + pnpmBuild, err := service.GetOrCreateBuild("build-info-go-test-pnpm", strconv.FormatInt(time.Now().Unix(), 10)) + assert.NoError(t, err) + defer func() { + assert.NoError(t, pnpmBuild.Clean()) + }() + + // Create pnpm project. + path, err := filepath.Abs(filepath.Join(".", "testdata")) + assert.NoError(t, err) + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + // Install dependencies first + _, _, err = buildutils.RunPnpmCmd(execPath, projectPath, []string{"install"}, pnpmLogger) + assert.NoError(t, err) + + pnpmModule, err := pnpmBuild.AddPnpmModule(projectPath) + assert.NoError(t, err) + + // Test filtering with command and flags + pnpmArgs := []string{"ls", "--json", "--long"} + pnpmModule.SetPnpmArgs(pnpmArgs) + pnpmModule.filterPnpmArgsFlags() + expected := []string{"--json", "--long"} + assert.Equal(t, expected, pnpmModule.pnpmArgs) + + // Test filtering with only flags + pnpmArgs = []string{"--prod", "--json"} + pnpmModule.SetPnpmArgs(pnpmArgs) + pnpmModule.filterPnpmArgsFlags() + expected = []string{"--prod", "--json"} + assert.Equal(t, expected, pnpmModule.pnpmArgs) + + // Test filtering with single command (no flags) + pnpmArgs = []string{"install"} + pnpmModule.SetPnpmArgs(pnpmArgs) + pnpmModule.filterPnpmArgsFlags() + expected = []string{} + assert.Equal(t, expected, pnpmModule.pnpmArgs) +} + +func TestPnpmModuleSetters(t *testing.T) { + _, execPath, err := buildutils.GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + service := NewBuildInfoService() + pnpmBuild, err := service.GetOrCreateBuild("build-info-go-test-pnpm", strconv.FormatInt(time.Now().Unix(), 10)) + assert.NoError(t, err) + defer func() { + assert.NoError(t, pnpmBuild.Clean()) + }() + + // Create pnpm project. + path, err := filepath.Abs(filepath.Join(".", "testdata")) + assert.NoError(t, err) + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + // Install dependencies first + _, _, err = buildutils.RunPnpmCmd(execPath, projectPath, []string{"install"}, pnpmLogger) + assert.NoError(t, err) + + pnpmModule, err := pnpmBuild.AddPnpmModule(projectPath) + assert.NoError(t, err) + + // Test SetName + pnpmModule.SetName("custom-module-name") + assert.Equal(t, "custom-module-name", pnpmModule.name) + + // Test SetPnpmArgs + args := []string{"--prod", "--frozen-lockfile"} + pnpmModule.SetPnpmArgs(args) + assert.Equal(t, args, pnpmModule.pnpmArgs) + + // Test SetCollectBuildInfo + pnpmModule.SetCollectBuildInfo(true) + assert.True(t, pnpmModule.collectBuildInfo) + pnpmModule.SetCollectBuildInfo(false) + assert.False(t, pnpmModule.collectBuildInfo) +} + +func TestPnpmModuleBuild(t *testing.T) { + _, _, err := buildutils.GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + service := NewBuildInfoService() + pnpmBuild, err := service.GetOrCreateBuild("build-info-go-test-pnpm", strconv.FormatInt(time.Now().Unix(), 10)) + assert.NoError(t, err) + defer func() { + assert.NoError(t, pnpmBuild.Clean()) + }() + + // Create pnpm project. + path, err := filepath.Abs(filepath.Join(".", "testdata")) + assert.NoError(t, err) + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + pnpmModule, err := pnpmBuild.AddPnpmModule(projectPath) + assert.NoError(t, err) + + // Test Build with install command + pnpmModule.SetPnpmArgs([]string{"install"}) + pnpmModule.SetCollectBuildInfo(false) + err = pnpmModule.Build() + assert.NoError(t, err) + + // Test Build with collectBuildInfo enabled + pnpmModule.SetCollectBuildInfo(true) + err = pnpmModule.Build() + assert.NoError(t, err) + + buildInfo, err := pnpmBuild.ToBuildInfo() + assert.NoError(t, err) + assert.Len(t, buildInfo.Modules, 1) +} + +func TestNewPnpmModule(t *testing.T) { + _, _, err := buildutils.GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + service := NewBuildInfoService() + pnpmBuild, err := service.GetOrCreateBuild("build-info-go-test-pnpm", strconv.FormatInt(time.Now().Unix(), 10)) + assert.NoError(t, err) + defer func() { + assert.NoError(t, pnpmBuild.Clean()) + }() + + // Create pnpm project. + path, err := filepath.Abs(filepath.Join(".", "testdata")) + assert.NoError(t, err) + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + // Test creating module with explicit path + pnpmModule, err := pnpmBuild.AddPnpmModule(projectPath) + assert.NoError(t, err) + assert.NotNil(t, pnpmModule) + assert.Equal(t, projectPath, pnpmModule.srcPath) + assert.NotEmpty(t, pnpmModule.executablePath) +} diff --git a/build/testdata/pnpm/dependenciesList.json b/build/testdata/pnpm/dependenciesList.json new file mode 100644 index 00000000..b29c4650 --- /dev/null +++ b/build/testdata/pnpm/dependenciesList.json @@ -0,0 +1,25 @@ +{ + "underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true + }, + "@jfrog/test-package": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jfrog/test-package/-/test-package-1.0.0.tgz", + "integrity": "sha512-test==", + "dependencies": { + "nested-dep": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nested-dep/-/nested-dep-2.0.0.tgz", + "integrity": "sha512-nested==" + } + } + } +} diff --git a/build/testdata/pnpm/noBuildProject/package.json b/build/testdata/pnpm/noBuildProject/package.json new file mode 100644 index 00000000..803fddc9 --- /dev/null +++ b/build/testdata/pnpm/noBuildProject/package.json @@ -0,0 +1,7 @@ +{ + "name": "no-build-pnpm-project", + "version": "1.0.0", + "dependencies": { + "underscore": "1.13.6" + } +} diff --git a/build/testdata/pnpm/project1/package.json b/build/testdata/pnpm/project1/package.json new file mode 100644 index 00000000..851e43bd --- /dev/null +++ b/build/testdata/pnpm/project1/package.json @@ -0,0 +1,17 @@ +{ + "name": "build-info-go-pnpm-tests", + "version": "1.0.0", + "description": "test package for pnpm", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "underscore": "1.13.6" + }, + "devDependencies": { + "minimist": "1.2.8" + } +} diff --git a/build/utils/pnpm.go b/build/utils/pnpm.go new file mode 100644 index 00000000..278c8246 --- /dev/null +++ b/build/utils/pnpm.go @@ -0,0 +1,514 @@ +package utils + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/jfrog/gofrog/crypto" + + "golang.org/x/exp/slices" + + "github.com/buger/jsonparser" + "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/gofrog/version" +) + +const pnpmInstallCommand = "install" +const packageJsonFile = "package.json" +const pnpmLockFile = "pnpm-lock.yaml" +const minPnpmVersion = "6.0.0" + +// CalculatePnpmDependenciesList gets a pnpm project's dependencies. +func CalculatePnpmDependenciesList(executablePath, srcPath, moduleId string, pnpmParams PnpmTreeDepListParam, calculateChecksums bool, log utils.Log) ([]entities.Dependency, error) { + if log == nil { + log = &utils.NullLog{} + } + // Calculate pnpm dependency tree using 'pnpm ls...'. + dependenciesMap, err := CalculatePnpmDependenciesMap(executablePath, srcPath, moduleId, pnpmParams, log, false) + if err != nil { + return nil, err + } + var cacache *cacache + if calculateChecksums { + // Get local pnpm cache (pnpm uses the same cacache format as npm). + cacheLocation, err := GetPnpmConfigCache(srcPath, executablePath, log) + if err != nil { + return nil, err + } + cacache = NewNpmCacache(cacheLocation) + } + var dependenciesList []entities.Dependency + var missingPeerDeps, missingBundledDeps, missingOptionalDeps, otherMissingDeps []string + for _, dep := range dependenciesMap { + if dep.Integrity == "" && dep.InBundle { + missingBundledDeps = append(missingBundledDeps, dep.Id) + continue + } + if dep.Integrity == "" && dep.PeerMissing != nil { + missingPeerDeps = append(missingPeerDeps, dep.Id) + continue + } + if calculateChecksums { + dep.Md5, dep.Sha1, dep.Sha256, err = calculatePnpmChecksum(cacache, dep.Name, dep.Version, dep.Integrity) + if err != nil { + if dep.Optional { + missingOptionalDeps = append(missingOptionalDeps, dep.Id) + continue + } + otherMissingDeps = append(otherMissingDeps, dep.Id) + log.Debug("couldn't calculate checksum for " + dep.Id + ". Error: '" + err.Error() + "'.") + continue + } + } + + dependenciesList = append(dependenciesList, dep.Dependency) + } + if len(missingPeerDeps) > 0 { + printPnpmMissingDependenciesWarning("peerDependency", missingPeerDeps, log) + } + if len(missingBundledDeps) > 0 { + printPnpmMissingDependenciesWarning("bundleDependencies", missingBundledDeps, log) + } + if len(missingOptionalDeps) > 0 { + printPnpmMissingDependenciesWarning("optionalDependencies", missingOptionalDeps, log) + } + if len(otherMissingDeps) > 0 { + log.Warn("The following dependencies will not be included in the build-info, because they are missing in the pnpm store: '" + strings.Join(otherMissingDeps, ",") + "'.\nHint: Try deleting 'node_modules' and/or '" + pnpmLockFile + "'.") + } + return dependenciesList, nil +} + +type pnpmDependencyInfo struct { + entities.Dependency + *pnpmLsDependency +} + +// Run 'pnpm list ...' command and parse the returned result to create a dependencies map. +// The dependencies map looks like name:version -> entities.Dependency. +func CalculatePnpmDependenciesMap(executablePath, srcPath, moduleId string, pnpmListParams PnpmTreeDepListParam, log utils.Log, skipInstall bool) (map[string]*pnpmDependencyInfo, error) { + dependenciesMap := make(map[string]*pnpmDependencyInfo) + pnpmVersion, err := GetPnpmVersion(executablePath, log) + if err != nil { + return nil, err + } + nodeModulesExist, err := utils.IsDirExists(filepath.Join(srcPath, "node_modules"), false) + if err != nil { + return nil, err + } + var data []byte + if nodeModulesExist && !pnpmListParams.IgnoreNodeModules && !skipInstall { + data = runPnpmLsWithNodeModules(executablePath, srcPath, pnpmListParams.Args, log) + } else { + data, err = runPnpmLsWithoutNodeModules(executablePath, srcPath, pnpmListParams, log, pnpmVersion, skipInstall) + if err != nil { + return nil, err + } + } + // pnpm ls --json returns an array with one object containing dependencies + // We need to extract the first element + data = extractPnpmLsData(data, log) + if data == nil { + return dependenciesMap, nil + } + // Parse the dependencies json object. + return dependenciesMap, jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) (err error) { + if string(key) == "dependencies" || string(key) == "devDependencies" { + err = parsePnpmDependencies(value, []string{moduleId}, dependenciesMap, log) + } + return err + }) +} + +// extractPnpmLsData extracts the dependency data from pnpm ls --json output. +// pnpm ls --json returns an array: [{ "name": "...", "dependencies": {...} }] +func extractPnpmLsData(data []byte, log utils.Log) []byte { + if len(data) == 0 { + return nil + } + // pnpm ls --json returns an array, get the first element + var result []byte + _, err := jsonparser.ArrayEach(data, func(value []byte, dataType jsonparser.ValueType, offset int, err error) { + if result == nil { + result = value + } + }) + if err != nil { + log.Debug("Failed to parse pnpm ls output as array, trying as object: " + err.Error()) + // Maybe it's already an object (older pnpm versions) + return data + } + return result +} + +func runPnpmLsWithNodeModules(executablePath, srcPath string, pnpmArgs []string, log utils.Log) (data []byte) { + pnpmArgs = append(pnpmArgs, "--json", "--long", "--depth", "Infinity") + data, errData, err := RunPnpmCmd(executablePath, srcPath, AppendPnpmCommand(pnpmArgs, "ls"), log) + if err != nil { + log.Warn(err.Error()) + } else if len(errData) > 0 { + log.Warn("Encountered some issues while running 'pnpm ls' command:\n" + strings.TrimSpace(string(errData))) + } + return +} + +func runPnpmLsWithoutNodeModules(executablePath, srcPath string, pnpmListParams PnpmTreeDepListParam, log utils.Log, pnpmVersion *version.Version, skipInstall bool) ([]byte, error) { + installRequired, err := isPnpmInstallRequired(srcPath, pnpmListParams, log, skipInstall) + if err != nil { + return nil, err + } + + if installRequired { + err = installPnpmLockfile(executablePath, srcPath, pnpmListParams.InstallCommandArgs, pnpmListParams.Args, log, pnpmVersion) + if err != nil { + return nil, err + } + } + pnpmListParams.Args = append(pnpmListParams.Args, "--json", "--long", "--depth", "Infinity") + data, errData, err := RunPnpmCmd(executablePath, srcPath, AppendPnpmCommand(pnpmListParams.Args, "ls"), log) + if err != nil { + log.Warn(err.Error()) + } else if len(errData) > 0 { + log.Warn("Encountered some issues while running 'pnpm ls' command:\n" + strings.TrimSpace(string(errData))) + } + return data, nil +} + +// isPnpmInstallRequired determines whether a project installation is required. +// Checks if the "pnpm-lock.yaml" file exists in the project directory. +func isPnpmInstallRequired(srcPath string, pnpmListParams PnpmTreeDepListParam, log utils.Log, skipInstall bool) (bool, error) { + isPnpmLockExist, err := utils.IsFileExists(filepath.Join(srcPath, pnpmLockFile), false) + if err != nil { + return false, err + } + + if len(pnpmListParams.InstallCommandArgs) > 0 { + return true, nil + } + if !isPnpmLockExist || (pnpmListParams.OverwritePackageLock && checkIfPnpmLockFileShouldBeUpdated(srcPath, log)) { + if skipInstall { + return false, &utils.ErrProjectNotInstalled{UninstalledDir: srcPath} + } + return true, nil + } + return false, nil +} + +func installPnpmLockfile(executablePath, srcPath string, pnpmInstallCommandArgs, pnpmArgs []string, log utils.Log, pnpmVersion *version.Version) error { + if pnpmVersion.AtLeast(minPnpmVersion) { + pnpmArgs = append(pnpmArgs, "--lockfile-only") + pnpmArgs = append(pnpmArgs, filterPnpmUniqueArgs(pnpmInstallCommandArgs, pnpmArgs)...) + _, _, err := RunPnpmCmd(executablePath, srcPath, AppendPnpmCommand(pnpmArgs, "install"), log) + if err != nil { + return err + } + return nil + } + return errors.New("it looks like you're using version " + pnpmVersion.GetVersion() + " of the pnpm client. Versions below " + minPnpmVersion + " require running `pnpm install` before running this command") +} + +// filterPnpmUniqueArgs removes any arguments from argsToFilter that are already present in existingArgs. +func filterPnpmUniqueArgs(argsToFilter []string, existingArgs []string) []string { + var filteredArgs []string + for _, arg := range argsToFilter { + if arg == pnpmInstallCommand { + continue + } + if !slices.Contains(existingArgs, arg) { + filteredArgs = append(filteredArgs, arg) + } + } + return filteredArgs +} + +// Check if package.json has been modified compared to pnpm-lock.yaml. +func checkIfPnpmLockFileShouldBeUpdated(srcPath string, log utils.Log) bool { + packageJsonInfo, err := os.Stat(filepath.Join(srcPath, packageJsonFile)) + if err != nil { + log.Warn("Failed to get file info for package.json, err: %v", err) + return false + } + + packageJsonInfoModTime := packageJsonInfo.ModTime() + pnpmLockInfo, err := os.Stat(filepath.Join(srcPath, pnpmLockFile)) + if err != nil { + log.Warn("Failed to get file info for pnpm-lock.yaml, err: %v", err) + return false + } + pnpmLockInfoModTime := pnpmLockInfo.ModTime() + return packageJsonInfoModTime.After(pnpmLockInfoModTime) +} + +func GetPnpmVersion(executablePath string, log utils.Log) (*version.Version, error) { + versionData, _, err := RunPnpmCmd(executablePath, "", []string{"--version"}, log) + if err != nil { + return nil, err + } + return version.NewVersion(strings.TrimSpace(string(versionData))), nil +} + +type PnpmTreeDepListParam struct { + // Required for the 'install' and 'ls' commands + Args []string + // Optional user-supplied arguments for the 'install' command + InstallCommandArgs []string + // Ignore the node_modules folder if exists + IgnoreNodeModules bool + // Rewrite pnpm-lock.yaml, if exists + OverwritePackageLock bool +} + +// pnpm ls --json dependency structure +type pnpmLsDependency struct { + Name string + Version string + Path string + Resolved string + Integrity string + InBundle bool + Dev bool + Optional bool + Missing bool + Problems []string + PeerMissing interface{} +} + +// Return name:version of a dependency +func (pld *pnpmLsDependency) id() string { + return pld.Name + ":" + pld.Version +} + +func (pld *pnpmLsDependency) getScopes() (scopes []string) { + if pld.Dev { + scopes = append(scopes, "dev") + } else { + scopes = append(scopes, "prod") + } + if strings.HasPrefix(pld.Name, "@") { + splitValues := strings.Split(pld.Name, "/") + if len(splitValues) > 2 { + scopes = append(scopes, splitValues[0]) + } + } + return +} + +// Parses pnpm dependencies recursively and adds the collected dependencies to the given dependencies map. +func parsePnpmDependencies(data []byte, pathToRoot []string, dependencies map[string]*pnpmDependencyInfo, log utils.Log) error { + return jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error { + if string(value) == "{}" { + log.Debug(fmt.Sprintf("%s is missing. This may be the result of an optional dependency.", key)) + return nil + } + pnpmLsDep, err := parsePnpmLsDependency(value) + if err != nil { + return err + } + pnpmLsDep.Name = string(key) + + // Handle git dependencies + if isGitDependency(pnpmLsDep.Version) { + if hash := extractVersionFromGitUrl(pnpmLsDep.Version); hash != "" { + pnpmLsDep.Version = hash + } else { + checksums, err := crypto.CalcChecksums(strings.NewReader(pnpmLsDep.Version), crypto.SHA1) + if err != nil { + return err + } + pnpmLsDep.Version = checksums[crypto.SHA1] + } + } + + if pnpmLsDep.Version == "" { + resolvedUrl := pnpmLsDep.Resolved + if resolvedUrl == "" && pnpmLsDep.Path != "" { + resolvedUrl = pnpmLsDep.Path + } + switch { + case resolvedUrl != "": + checksums, err := crypto.CalcChecksums(strings.NewReader(resolvedUrl), crypto.SHA1) + if err != nil { + return err + } + if ver := extractVersionFromGitUrl(resolvedUrl); ver != "" { + pnpmLsDep.Version = ver + } else { + pnpmLsDep.Version = checksums[crypto.SHA1] + } + case pnpmLsDep.Missing || pnpmLsDep.Problems != nil: + log.Debug(fmt.Sprintf("%s is missing, this may be the result of a peer dependency.", key)) + return nil + default: + return errors.New("failed to parse '" + string(value) + "' from pnpm ls output.") + } + } + appendPnpmDependency(dependencies, pnpmLsDep, pathToRoot) + transitive, _, _, err := jsonparser.Get(value, "dependencies") + if err != nil && err.Error() != "Key path not found" { + return err + } + if len(transitive) > 0 { + if err := parsePnpmDependencies(transitive, append([]string{pnpmLsDep.id()}, pathToRoot...), dependencies, log); err != nil { + return err + } + } + return nil + }) +} + +func parsePnpmLsDependency(data []byte) (*pnpmLsDependency, error) { + pnpmLsDep := new(pnpmLsDependency) + return pnpmLsDep, json.Unmarshal(data, pnpmLsDep) +} + +func appendPnpmDependency(dependencies map[string]*pnpmDependencyInfo, dep *pnpmLsDependency, pathToRoot []string) { + depId := dep.id() + scopes := dep.getScopes() + if dependencies[depId] == nil { + dependency := &pnpmDependencyInfo{ + Dependency: entities.Dependency{Id: depId}, + pnpmLsDependency: dep, + } + dependencies[depId] = dependency + } + if dependencies[depId].Integrity == "" { + dependencies[depId].Integrity = dep.Integrity + } + dependencies[depId].Scopes = appendPnpmScopes(dependencies[depId].Scopes, scopes) + dependencies[depId].RequestedBy = append(dependencies[depId].RequestedBy, pathToRoot) +} + +// Lookup for a dependency's tarball in pnpm store, and calculate checksum. +func calculatePnpmChecksum(cacache *cacache, name, version, integrity string) (md5 string, sha1 string, sha256 string, err error) { + if integrity == "" { + var info *cacacheInfo + info, err = cacache.GetInfo(name + "@" + version) + if err != nil { + return + } + integrity = info.Integrity + } + var path string + path, err = cacache.GetTarball(integrity) + if err != nil { + return + } + checksums, err := crypto.GetFileChecksums(path) + if err != nil { + return + } + return checksums[crypto.MD5], checksums[crypto.SHA1], checksums[crypto.SHA256], err +} + +// Merge two scopes and remove duplicates. +func appendPnpmScopes(oldScopes []string, newScopes []string) []string { + contained := make(map[string]bool) + allScopes := []string{} + for _, scope := range append(oldScopes, newScopes...) { + if scope == "" { + continue + } + if !contained[scope] { + allScopes = append(allScopes, scope) + } + contained[scope] = true + } + return allScopes +} + +func RunPnpmCmd(executablePath, srcPath string, pnpmArgs []string, log utils.Log) (stdResult, errResult []byte, err error) { + args := make([]string, 0) + for i := 0; i < len(pnpmArgs); i++ { + if strings.TrimSpace(pnpmArgs[i]) != "" { + args = append(args, pnpmArgs[i]) + } + } + log.Debug("Running 'pnpm " + strings.Join(pnpmArgs, " ") + "' command.") + command := exec.Command(executablePath, args...) + command.Dir = srcPath + outBuffer := bytes.NewBuffer([]byte{}) + command.Stdout = outBuffer + errBuffer := bytes.NewBuffer([]byte{}) + command.Stderr = errBuffer + err = command.Run() + errResult = errBuffer.Bytes() + stdResult = outBuffer.Bytes() + if err != nil { + err = fmt.Errorf("error while running '%s %s': %s\n%s", executablePath, strings.Join(args, " "), err.Error(), strings.TrimSpace(string(errResult))) + return + } + log.Verbose("pnpm '" + strings.Join(args, " ") + "' standard output is:\n" + strings.TrimSpace(string(stdResult))) + return +} + +// AppendPnpmCommand appends the pnpm command as the first element in pnpmArgs strings array. +func AppendPnpmCommand(pnpmArgs []string, command string) []string { + tempArgs := []string{command} + tempArgs = append(tempArgs, pnpmArgs...) + return tempArgs +} + +func GetPnpmVersionAndExecPath(log utils.Log) (*version.Version, string, error) { + if log == nil { + log = &utils.NullLog{} + } + pnpmExecPath, err := exec.LookPath("pnpm") + if err != nil { + return nil, "", err + } + + if pnpmExecPath == "" { + return nil, "", errors.New("could not find the 'pnpm' executable in the system PATH") + } + + log.Debug("Using pnpm executable:", pnpmExecPath) + + versionData, _, err := RunPnpmCmd(pnpmExecPath, "", []string{"--version"}, log) + if err != nil { + return nil, "", err + } + return version.NewVersion(strings.TrimSpace(string(versionData))), pnpmExecPath, nil +} + +// GetPnpmConfigCache returns the pnpm store/cache path. +// pnpm uses a content-addressable store, typically at ~/.local/share/pnpm/store or ~/.pnpm-store +func GetPnpmConfigCache(srcPath, executablePath string, log utils.Log) (string, error) { + // pnpm store path + data, errData, err := RunPnpmCmd(executablePath, srcPath, []string{"store", "path"}, log) + if err != nil { + return "", err + } else if len(errData) > 0 { + log.Warn("Encountered some issues while running 'pnpm store path' command:\n" + string(errData)) + } + storePath := strings.TrimSpace(string(data)) + // pnpm store has a different structure than npm cache, but it contains a similar cache + // The store path returned is like /path/to/store/v3 + // We need to find the cache directory + cachePath := filepath.Join(filepath.Dir(storePath), "cache") + found, err := utils.IsDirExists(cachePath, true) + if err != nil { + return "", err + } + if !found { + // Try the store path itself as some pnpm versions use different layouts + found, err = utils.IsDirExists(storePath, true) + if err != nil { + return "", err + } + if !found { + return "", errors.New("pnpm store is not found in '" + storePath + "'. Hint: Try running 'pnpm install' first.") + } + return storePath, nil + } + return cachePath, nil +} + +func printPnpmMissingDependenciesWarning(dependencyType string, dependencies []string, log utils.Log) { + log.Debug("The following dependencies will not be included in the build-info, because the 'pnpm ls' command did not return their integrity.\nThe reason why the version wasn't returned may be because the package is a '" + dependencyType + "', which was not manually installed.\nIt is therefore okay to skip this dependency: " + strings.Join(dependencies, ",")) +} diff --git a/build/utils/pnpm_test.go b/build/utils/pnpm_test.go new file mode 100644 index 00000000..cfcb03ac --- /dev/null +++ b/build/utils/pnpm_test.go @@ -0,0 +1,509 @@ +package utils + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jfrog/gofrog/crypto" + + "github.com/stretchr/testify/require" + + "github.com/jfrog/build-info-go/tests" + "github.com/jfrog/build-info-go/utils" + "github.com/stretchr/testify/assert" +) + +var pnpmLogger = utils.NewDefaultLogger(utils.INFO) + +func TestGetPnpmVersionAndExecPath(t *testing.T) { + // This test requires pnpm to be installed + version, execPath, err := GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + assert.NotNil(t, version) + assert.NotEmpty(t, execPath) + assert.True(t, version.AtLeast("6.0.0"), "pnpm version should be at least 6.0.0") +} + +func TestGetPnpmVersion(t *testing.T) { + _, execPath, err := GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + version, err := GetPnpmVersion(execPath, pnpmLogger) + assert.NoError(t, err) + assert.NotNil(t, version) +} + +func TestRunPnpmCmd(t *testing.T) { + _, execPath, err := GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + stdout, stderr, err := RunPnpmCmd(execPath, "", []string{"--version"}, pnpmLogger) + assert.NoError(t, err) + assert.Empty(t, stderr) + assert.NotEmpty(t, stdout) +} + +func TestAppendPnpmCommand(t *testing.T) { + testcases := []struct { + args []string + command string + expected []string + }{ + {[]string{"--json", "--long"}, "ls", []string{"ls", "--json", "--long"}}, + {[]string{}, "install", []string{"install"}}, + {[]string{"--prod"}, "install", []string{"install", "--prod"}}, + } + for _, tc := range testcases { + result := AppendPnpmCommand(tc.args, tc.command) + assert.Equal(t, tc.expected, result) + } +} + +func TestAppendPnpmScopes(t *testing.T) { + var scopes = []struct { + a []string + b []string + expected []string + }{ + {[]string{"item"}, []string{}, []string{"item"}}, + {[]string{"item"}, []string{""}, []string{"item"}}, + {[]string{}, []string{"item"}, []string{"item"}}, + {[]string{"item1"}, []string{"item2"}, []string{"item1", "item2"}}, + {[]string{"item"}, []string{"item"}, []string{"item"}}, + {[]string{"item1", "item2"}, []string{"item2"}, []string{"item1", "item2"}}, + {[]string{"item1"}, []string{"item2", "item1"}, []string{"item1", "item2"}}, + {[]string{"item1", "item1"}, []string{"item2"}, []string{"item1", "item2"}}, + {[]string{"item1"}, []string{"item2", "item2"}, []string{"item1", "item2"}}, + } + for _, v := range scopes { + result := appendPnpmScopes(v.a, v.b) + assert.ElementsMatch(t, result, v.expected, "appendPnpmScopes(\"%s\",\"%s\") => '%s', want '%s'", v.a, v.b, result, v.expected) + } +} + +func TestFilterPnpmUniqueArgs(t *testing.T) { + var testcases = []struct { + argsToFilter []string + alreadyExists []string + expectedResult []string + }{ + { + argsToFilter: []string{"install"}, + alreadyExists: []string{}, + expectedResult: nil, + }, + { + argsToFilter: []string{"install", "--flagA"}, + alreadyExists: []string{"--flagA"}, + expectedResult: nil, + }, + { + argsToFilter: []string{"install", "--flagA", "--flagB"}, + alreadyExists: []string{"--flagA"}, + expectedResult: []string{"--flagB"}, + }, + { + argsToFilter: []string{"install", "--flagA", "--flagB"}, + alreadyExists: []string{"--flagA", "--flagC"}, + expectedResult: []string{"--flagB"}, + }, + } + + for _, testcase := range testcases { + assert.Equal(t, testcase.expectedResult, filterPnpmUniqueArgs(testcase.argsToFilter, testcase.alreadyExists)) + } +} + +func TestParsePnpmDependencies(t *testing.T) { + dependenciesJsonList, err := os.ReadFile(filepath.Join("..", "testdata", "pnpm", "dependenciesList.json")) + if err != nil { + t.Error(err) + } + + expectedDependenciesList := []struct { + Key string + pathToRoot [][]string + }{ + {"underscore:1.13.6", [][]string{{"root"}}}, + {"minimist:1.2.8", [][]string{{"root"}}}, + {"@jfrog/test-package:1.0.0", [][]string{{"root"}}}, + {"nested-dep:2.0.0", [][]string{{"@jfrog/test-package:1.0.0", "root"}}}, + } + dependencies := make(map[string]*pnpmDependencyInfo) + err = parsePnpmDependencies(dependenciesJsonList, []string{"root"}, dependencies, utils.NewDefaultLogger(utils.INFO)) + assert.NoError(t, err) + assert.Equal(t, len(expectedDependenciesList), len(dependencies)) + for _, eDependency := range expectedDependenciesList { + found := false + for aDependency, v := range dependencies { + if aDependency == eDependency.Key && assert.ElementsMatch(t, v.RequestedBy, eDependency.pathToRoot) { + found = true + break + } + } + assert.True(t, found, "The expected dependency:", eDependency, "is missing from the actual dependencies list:\n", dependencies) + } +} + +func TestPnpmLsDependencyScopes(t *testing.T) { + testcases := []struct { + name string + dep *pnpmLsDependency + expectedScopes []string + }{ + { + name: "Production dependency", + dep: &pnpmLsDependency{Name: "lodash", Dev: false}, + expectedScopes: []string{"prod"}, + }, + { + name: "Dev dependency", + dep: &pnpmLsDependency{Name: "jest", Dev: true}, + expectedScopes: []string{"dev"}, + }, + { + name: "Scoped package production", + dep: &pnpmLsDependency{Name: "@types/node", Dev: false}, + expectedScopes: []string{"prod"}, + }, + { + name: "Scoped package dev", + dep: &pnpmLsDependency{Name: "@types/jest", Dev: true}, + expectedScopes: []string{"dev"}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + scopes := tc.dep.getScopes() + assert.Equal(t, tc.expectedScopes, scopes) + }) + } +} + +func TestPnpmLsDependencyId(t *testing.T) { + dep := &pnpmLsDependency{Name: "lodash", Version: "4.17.21"} + assert.Equal(t, "lodash:4.17.21", dep.id()) + + scopedDep := &pnpmLsDependency{Name: "@types/node", Version: "18.0.0"} + assert.Equal(t, "@types/node:18.0.0", scopedDep.id()) +} + +func TestParsePnpmDependenciesEdgeCases(t *testing.T) { + testcases := []struct { + name string + inputJson string + expectedId string + shouldBeSkipped bool + expectParseError bool + }{ + { + name: "Git URL with hash in resolved", + inputJson: `{"my-git-pkg":{"resolved": "git+ssh://git@github.com/user/repo.git#abc123def"}}`, + expectedId: "my-git-pkg:abc123def", + shouldBeSkipped: false, + expectParseError: false, + }, + { + name: "Git URL without hash in resolved", + inputJson: `{"my-pkg":{"resolved": "git+https://github.com/user/repo.git"}}`, + expectedId: func() string { + checksums, _ := crypto.CalcChecksums(strings.NewReader("git+https://github.com/user/repo.git"), crypto.SHA1) + return "my-pkg:" + checksums[crypto.SHA1] + }(), + shouldBeSkipped: false, + expectParseError: false, + }, + { + name: "Local file path in path field", + inputJson: `{"my-local-pkg":{"path": "/local/path/to/pkg"}}`, + expectedId: func() string { + checksums, _ := crypto.CalcChecksums(strings.NewReader("/local/path/to/pkg"), crypto.SHA1) + return "my-local-pkg:" + checksums[crypto.SHA1] + }(), + shouldBeSkipped: false, + expectParseError: false, + }, + { + name: "Missing dependency", + inputJson: `{"bad-pkg":{"missing": true}}`, + shouldBeSkipped: true, + expectParseError: false, + }, + { + name: "No version and no resolved, not missing", + inputJson: `{"bad-pkg":{"dev": true}}`, + shouldBeSkipped: false, + expectParseError: true, + }, + { + name: "Regular dependency", + inputJson: `{"react":{"version": "18.2.0", "integrity": "sha512-..."}}`, + expectedId: "react:18.2.0", + shouldBeSkipped: false, + expectParseError: false, + }, + { + name: "Empty object should be skipped", + inputJson: `{"optional-pkg":{}}`, + shouldBeSkipped: true, + expectParseError: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + depsMap := make(map[string]*pnpmDependencyInfo) + err := parsePnpmDependencies([]byte(tc.inputJson), []string{"root"}, depsMap, &utils.NullLog{}) + + if tc.expectParseError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + if tc.shouldBeSkipped { + assert.Empty(t, depsMap, "Expected dependency to be skipped, but it was added") + } else { + assert.Len(t, depsMap, 1, "Expected exactly one dependency") + depInfo, ok := depsMap[tc.expectedId] + assert.True(t, ok, "Expected dependency ID '%s' not found in map", tc.expectedId) + if ok { + assert.Equal(t, tc.expectedId, depInfo.Id, "Dependency ID mismatch") + } + } + }) + } +} + +func TestExtractPnpmLsData(t *testing.T) { + // Test with array format (pnpm ls --json output) + arrayJson := `[{"name": "test", "version": "1.0.0", "dependencies": {"lodash": {"version": "4.17.21"}}}]` + result := extractPnpmLsData([]byte(arrayJson), &utils.NullLog{}) + assert.NotNil(t, result) + assert.Contains(t, string(result), "dependencies") + + // Test with empty array + emptyArrayJson := `[]` + result = extractPnpmLsData([]byte(emptyArrayJson), &utils.NullLog{}) + assert.Nil(t, result) + + // Test with object format (fallback) + objectJson := `{"name": "test", "dependencies": {"lodash": {"version": "4.17.21"}}}` + result = extractPnpmLsData([]byte(objectJson), &utils.NullLog{}) + assert.NotNil(t, result) + + // Test with empty data + result = extractPnpmLsData([]byte{}, &utils.NullLog{}) + assert.Nil(t, result) +} + +func TestCalculatePnpmDependenciesMapWithProhibitedInstallation(t *testing.T) { + path, cleanup := tests.CreateTestProject(t, filepath.Join("..", "testdata", "pnpm", "noBuildProject")) + defer cleanup() + + _, execPath, err := GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + dependencies, err := CalculatePnpmDependenciesMap(execPath, path, "jfrogtest", + PnpmTreeDepListParam{Args: []string{}, IgnoreNodeModules: false, OverwritePackageLock: false}, pnpmLogger, true) + + assert.Nil(t, dependencies) + assert.Error(t, err) + var installForbiddenErr *utils.ErrProjectNotInstalled + assert.True(t, errors.As(err, &installForbiddenErr)) +} + +func TestPnpmDependenciesListIntegration(t *testing.T) { + _, execPath, err := GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + path, err := filepath.Abs(filepath.Join("..", "testdata")) + assert.NoError(t, err) + + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + // Install dependencies + _, _, err = RunPnpmCmd(execPath, projectPath, []string{"install"}, pnpmLogger) + assert.NoError(t, err) + + // Calculate dependencies + dependencies, err := CalculatePnpmDependenciesList(execPath, projectPath, "build-info-go-pnpm-tests", + PnpmTreeDepListParam{Args: []string{}}, false, pnpmLogger) + assert.NoError(t, err) + + // Should have at least the direct dependencies + assert.Greater(t, len(dependencies), 0, "Error: dependencies are not found!") + + // Check for known dependencies + foundUnderscore := false + foundMinimist := false + for _, dep := range dependencies { + if strings.HasPrefix(dep.Id, "underscore:") { + foundUnderscore = true + } + if strings.HasPrefix(dep.Id, "minimist:") { + foundMinimist = true + } + } + assert.True(t, foundUnderscore, "Expected to find underscore dependency") + assert.True(t, foundMinimist, "Expected to find minimist dependency") +} + +func TestPnpmDependenciesWithoutNodeModules(t *testing.T) { + _, execPath, err := GetPnpmVersionAndExecPath(pnpmLogger) + if err != nil { + t.Skip("pnpm is not installed, skipping test") + } + + path, err := filepath.Abs(filepath.Join("..", "testdata")) + assert.NoError(t, err) + + projectPath, cleanup := tests.CreatePnpmTest(t, path, "project1") + defer cleanup() + + // Install to create lock file, then remove node_modules + _, _, err = RunPnpmCmd(execPath, projectPath, []string{"install"}, pnpmLogger) + assert.NoError(t, err) + + // Remove node_modules + require.NoError(t, utils.RemoveTempDir(filepath.Join(projectPath, "node_modules"))) + + // Calculate dependencies should still work using lock file + dependencies, err := CalculatePnpmDependenciesList(execPath, projectPath, "build-info-go-pnpm-tests", + PnpmTreeDepListParam{Args: []string{}}, false, pnpmLogger) + assert.NoError(t, err) + assert.Greater(t, len(dependencies), 0, "Error: dependencies are not found!") +} + +func TestPnpmTreeDepListParam(t *testing.T) { + param := PnpmTreeDepListParam{ + Args: []string{"--prod"}, + InstallCommandArgs: []string{"install", "--frozen-lockfile"}, + IgnoreNodeModules: true, + OverwritePackageLock: false, + } + + assert.Equal(t, []string{"--prod"}, param.Args) + assert.Equal(t, []string{"install", "--frozen-lockfile"}, param.InstallCommandArgs) + assert.True(t, param.IgnoreNodeModules) + assert.False(t, param.OverwritePackageLock) +} + +func TestCheckIfPnpmLockFileShouldBeUpdated(t *testing.T) { + tempDir, cleanup := tests.CreateTempDirWithCallbackAndAssert(t) + defer cleanup() + + // Create package.json + packageJsonPath := filepath.Join(tempDir, "package.json") + assert.NoError(t, os.WriteFile(packageJsonPath, []byte(`{"name": "test"}`), 0644)) + + // Test when lock file doesn't exist + result := checkIfPnpmLockFileShouldBeUpdated(tempDir, &utils.NullLog{}) + assert.False(t, result) + + // Create pnpm-lock.yaml + lockFilePath := filepath.Join(tempDir, "pnpm-lock.yaml") + assert.NoError(t, os.WriteFile(lockFilePath, []byte("lockfileVersion: 5.4"), 0644)) + + // Test when lock file is newer + result = checkIfPnpmLockFileShouldBeUpdated(tempDir, &utils.NullLog{}) + assert.False(t, result) +} + +func TestIsPnpmInstallRequired(t *testing.T) { + tempDir, cleanup := tests.CreateTempDirWithCallbackAndAssert(t) + defer cleanup() + + // Create package.json + packageJsonPath := filepath.Join(tempDir, "package.json") + assert.NoError(t, os.WriteFile(packageJsonPath, []byte(`{"name": "test"}`), 0644)) + + // Test when lock file doesn't exist and skipInstall is false + required, err := isPnpmInstallRequired(tempDir, PnpmTreeDepListParam{}, &utils.NullLog{}, false) + assert.NoError(t, err) + assert.True(t, required) + + // Test when lock file doesn't exist and skipInstall is true + _, err = isPnpmInstallRequired(tempDir, PnpmTreeDepListParam{}, &utils.NullLog{}, true) + assert.Error(t, err) + var installForbiddenErr *utils.ErrProjectNotInstalled + assert.True(t, errors.As(err, &installForbiddenErr)) + + // Create pnpm-lock.yaml + lockFilePath := filepath.Join(tempDir, "pnpm-lock.yaml") + assert.NoError(t, os.WriteFile(lockFilePath, []byte("lockfileVersion: 5.4"), 0644)) + + // Test when lock file exists + required, err = isPnpmInstallRequired(tempDir, PnpmTreeDepListParam{}, &utils.NullLog{}, false) + assert.NoError(t, err) + assert.False(t, required) + + // Test when InstallCommandArgs are provided + required, err = isPnpmInstallRequired(tempDir, PnpmTreeDepListParam{InstallCommandArgs: []string{"install"}}, &utils.NullLog{}, false) + assert.NoError(t, err) + assert.True(t, required) +} + +func TestAppendPnpmDependency(t *testing.T) { + dependencies := make(map[string]*pnpmDependencyInfo) + + dep1 := &pnpmLsDependency{ + Name: "lodash", + Version: "4.17.21", + Integrity: "sha512-test", + Dev: false, + } + + // Add first dependency + appendPnpmDependency(dependencies, dep1, []string{"root"}) + assert.Len(t, dependencies, 1) + assert.Equal(t, "lodash:4.17.21", dependencies["lodash:4.17.21"].Id) + assert.Equal(t, []string{"prod"}, dependencies["lodash:4.17.21"].Scopes) + assert.Equal(t, [][]string{{"root"}}, dependencies["lodash:4.17.21"].RequestedBy) + + // Add same dependency from different path + appendPnpmDependency(dependencies, dep1, []string{"other-pkg", "root"}) + assert.Len(t, dependencies, 1) + assert.Equal(t, [][]string{{"root"}, {"other-pkg", "root"}}, dependencies["lodash:4.17.21"].RequestedBy) + + // Add different dependency + dep2 := &pnpmLsDependency{ + Name: "jest", + Version: "29.0.0", + Dev: true, + } + appendPnpmDependency(dependencies, dep2, []string{"root"}) + assert.Len(t, dependencies, 2) + assert.Equal(t, []string{"dev"}, dependencies["jest:29.0.0"].Scopes) +} + +func TestParsePnpmLsDependency(t *testing.T) { + jsonData := `{ + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": false, + "optional": false + }` + + dep, err := parsePnpmLsDependency([]byte(jsonData)) + assert.NoError(t, err) + assert.Equal(t, "4.17.21", dep.Version) + assert.Equal(t, "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", dep.Resolved) + assert.Equal(t, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", dep.Integrity) + assert.False(t, dep.Dev) + assert.False(t, dep.Optional) +} diff --git a/tests/test_helpers.go b/tests/test_helpers.go index 76a8d1b1..0dec26a2 100644 --- a/tests/test_helpers.go +++ b/tests/test_helpers.go @@ -64,6 +64,14 @@ func CreateNpmTest(t *testing.T, testdataPath, projectDirName string, withOsInPa return CreateTestProject(t, path) } +// Return the project path for pnpm tests based on 'projectDir'. +// testdataPath - abs path to testdata dir. +// projectDirName - name of the project's directory. +func CreatePnpmTest(t *testing.T, testdataPath, projectDirName string) (tmpProjectPath string, cleanup func()) { + path := filepath.Join(testdataPath, "pnpm", projectDirName) + return CreateTestProject(t, path) +} + func PrintBuildInfoMismatch(t *testing.T, expected, actual []entities.Module) { excpectedStr, err := json.MarshalIndent(expected, "", " ") assert.NoError(t, err)