From b63fcf3ed6405b6ff00e0cc539e3bb09ace9a018 Mon Sep 17 00:00:00 2001 From: Mike Han <56001373+mhan83@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:08:35 -0700 Subject: [PATCH] feat: Add config to use package-lock.json for dependency installation (#964) * feat: Add usePackageLock config option If `true`, during installation of npm packages, the provided package-lock.json will be retained which will speed up the installation process by allowing npm to skip computing the full dependency tree. * Add `usePackageLock` to schema * Enforce an exact version in package.json * whitespace --- api/saucectl.schema.json | 4 ++ api/v1/subschema/npm.schema.json | 4 ++ internal/config/config.go | 44 +++++++++++++-- internal/config/config_test.go | 97 ++++++++++++++++++++++++++++++++ internal/cucumber/config.go | 13 +++++ internal/cypress/v1/config.go | 13 +++++ internal/playwright/config.go | 14 +++++ internal/testcafe/config.go | 14 +++++ 8 files changed, 198 insertions(+), 5 deletions(-) diff --git a/api/saucectl.schema.json b/api/saucectl.schema.json index 3ad5daa27..5b888e219 100644 --- a/api/saucectl.schema.json +++ b/api/saucectl.schema.json @@ -106,6 +106,10 @@ "description": "Settings specific to npm.", "type": "object", "properties": { + "usePackageLock": { + "type": "boolean", + "description": "Specifies whether to use the project's package-lock.json when installing npm dependencies. If true, package-lock.json will be used during installation which will improve the speed of installation." + }, "packages": { "description": "Specifies any npm packages that are required to run tests.", "type": "object" diff --git a/api/v1/subschema/npm.schema.json b/api/v1/subschema/npm.schema.json index 74dffaa33..06d2980c0 100644 --- a/api/v1/subschema/npm.schema.json +++ b/api/v1/subschema/npm.schema.json @@ -8,6 +8,10 @@ "description": "Settings specific to npm.", "type": "object", "properties": { + "usePackageLock": { + "type": "boolean", + "description": "Specifies whether to use the project's package-lock.json when installing npm dependencies. If true, package-lock.json will be used during installation which will improve the speed of installation." + }, "packages": { "description": "Specifies any npm packages that are required to run tests.", "type": "object" diff --git a/internal/config/config.go b/internal/config/config.go index de7030920..391ed2c4d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strconv" "strings" "time" @@ -20,6 +21,7 @@ import ( _ "github.com/santhosh-tekuri/jsonschema/v5/httploader" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/node" "github.com/saucelabs/saucectl/internal/viper" ) @@ -199,11 +201,12 @@ type Registry struct { // Npm represents the npm settings type Npm struct { // Deprecated. Use Registries instead. - Registry string `yaml:"registry,omitempty" json:"registry,omitempty"` - Registries []Registry `yaml:"registries" json:"registries,omitempty"` - Packages map[string]string `yaml:"packages,omitempty" json:"packages"` - Dependencies []string `yaml:"dependencies,omitempty" json:"dependencies"` - StrictSSL *bool `yaml:"strictSSL,omitempty" json:"strictSSL"` + Registry string `yaml:"registry,omitempty" json:"registry,omitempty"` + Registries []Registry `yaml:"registries" json:"registries,omitempty"` + Packages map[string]string `yaml:"packages,omitempty" json:"packages"` + Dependencies []string `yaml:"dependencies,omitempty" json:"dependencies"` + StrictSSL *bool `yaml:"strictSSL,omitempty" json:"strictSSL"` + UsePackageLock bool `yaml:"usePackageLock,omitempty" json:"usePackageLock"` } // Defaults represents default suite settings. @@ -610,3 +613,34 @@ func ValidateArtifacts(artifacts Artifacts) error { } return nil } + +func ValidatePackageLock() error { + _, err := os.Stat("package-lock.json") + if err != nil { + return fmt.Errorf("missing package-lock.json") + } + return nil +} + +var reExactVersion = regexp.MustCompile(`^\d`) + +func ValidatePackage(packages node.Package, frameworkName string, expectedVersion string) error { + var ver string + var ok bool + ver, ok = packages.Dependencies[frameworkName] + if !ok { + ver, ok = packages.DevDependencies[frameworkName] + } + + if !ok { + return fmt.Errorf("missing framework version. The framework version in your config file (%s) must exactly match the framework version in your package.json", expectedVersion) + } + if !reExactVersion.MatchString(ver) { + return fmt.Errorf("invalid framework version. The framework version in your package.json (%s) must be exact", ver) + } + if expectedVersion != "package.json" && expectedVersion != ver { + return fmt.Errorf("framework version mismatch. The framework version in your config file (%s) must exactly match the framework version in your package.json (%s)", expectedVersion, ver) + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9abdc9c46..ac7276ed5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/saucelabs/saucectl/internal/node" "github.com/stretchr/testify/assert" ) @@ -364,3 +365,99 @@ func TestValidateRegistries(t *testing.T) { }) } } + +func TestValidatePackage(t *testing.T) { + type args struct { + parsedPackages node.Package + framework string + expectedVersion string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "passes with all requirements met in Dependencies", + args: args{ + parsedPackages: node.Package{ + Dependencies: map[string]string{ + "framework": "1.2.3", + }, + }, + framework: "framework", + expectedVersion: "1.2.3", + }, + wantErr: false, + }, + { + name: "passes with all requirements met in DevDependencies", + args: args{ + parsedPackages: node.Package{ + Dependencies: map[string]string{ + "framework": "1.2.3", + }, + }, + framework: "framework", + expectedVersion: "1.2.3", + }, + wantErr: false, + }, + { + name: "passes with all requirements met using package.json", + args: args{ + parsedPackages: node.Package{ + Dependencies: map[string]string{ + "framework": "1.2.3", + }, + }, + framework: "framework", + expectedVersion: "package.json", + }, + wantErr: false, + }, + { + name: "fails when framework missing in packages", + args: args{ + parsedPackages: node.Package{}, + framework: "framework", + expectedVersion: "1.2.3", + }, + wantErr: true, + }, + { + name: "fails when using semver range", + args: args{ + parsedPackages: node.Package{ + Dependencies: map[string]string{ + "framework": "~1.2.0", + }, + }, + framework: "framework", + expectedVersion: "1.2.3", + }, + wantErr: true, + }, + { + name: "fails when versions don't match", + args: args{ + parsedPackages: node.Package{ + Dependencies: map[string]string{ + "framework": "1.2.3", + }, + }, + framework: "framework", + expectedVersion: "2.0.0", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePackage(tt.args.parsedPackages, tt.args.framework, tt.args.expectedVersion) + if (err != nil) != tt.wantErr { + t.Errorf("ValidatePackage() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cucumber/config.go b/internal/cucumber/config.go index 2d9572edb..8dba5d5d2 100644 --- a/internal/cucumber/config.go +++ b/internal/cucumber/config.go @@ -17,6 +17,7 @@ import ( "github.com/saucelabs/saucectl/internal/fpath" "github.com/saucelabs/saucectl/internal/insights" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/node" "github.com/saucelabs/saucectl/internal/region" "github.com/saucelabs/saucectl/internal/saucereport" ) @@ -189,6 +190,18 @@ func Validate(p *Project) error { if err := config.ValidateArtifacts(p.Artifacts); err != nil { return err } + if p.Npm.UsePackageLock { + if err := config.ValidatePackageLock(); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + packages, err := node.PackageFromFile("package.json") + if err != nil { + return fmt.Errorf("invalid use of usePackageLock. Failed to read package.json: %w", err) + } + if err := config.ValidatePackage(packages, "@playwright/test", p.Playwright.Version); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + } p.Playwright.Version = config.StandardizeVersionFormat(p.Playwright.Version) if p.Playwright.Version == "" { diff --git a/internal/cypress/v1/config.go b/internal/cypress/v1/config.go index a45abf032..378aa8499 100644 --- a/internal/cypress/v1/config.go +++ b/internal/cypress/v1/config.go @@ -15,6 +15,7 @@ import ( "github.com/saucelabs/saucectl/internal/cypress/suite" "github.com/saucelabs/saucectl/internal/fpath" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/node" "github.com/saucelabs/saucectl/internal/region" "github.com/saucelabs/saucectl/internal/sauceignore" "github.com/saucelabs/saucectl/internal/saucereport" @@ -219,6 +220,18 @@ func (p *Project) Validate() error { if err := config.ValidateArtifacts(p.Artifacts); err != nil { return err } + if p.Npm.UsePackageLock { + if err := config.ValidatePackageLock(); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + packages, err := node.PackageFromFile("package.json") + if err != nil { + return fmt.Errorf("invalid use of usePackageLock. Failed to read package.json: %w", err) + } + if err := config.ValidatePackage(packages, "cypress", p.Cypress.Version); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + } if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate { return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate)) diff --git a/internal/playwright/config.go b/internal/playwright/config.go index bafe184c5..1db9ba9f6 100644 --- a/internal/playwright/config.go +++ b/internal/playwright/config.go @@ -14,6 +14,7 @@ import ( "github.com/saucelabs/saucectl/internal/fpath" "github.com/saucelabs/saucectl/internal/insights" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/node" "github.com/saucelabs/saucectl/internal/playwright/grep" "github.com/saucelabs/saucectl/internal/region" "github.com/saucelabs/saucectl/internal/sauceignore" @@ -328,6 +329,19 @@ func Validate(p *Project) error { return err } + if p.Npm.UsePackageLock { + if err := config.ValidatePackageLock(); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + packages, err := node.PackageFromFile("package.json") + if err != nil { + return fmt.Errorf("invalid use of usePackageLock. Failed to read package.json: %w", err) + } + if err := config.ValidatePackage(packages, "@playwright/test", p.Playwright.Version); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + } + if p.Sauce.LaunchOrder != "" && p.Sauce.LaunchOrder != config.LaunchOrderFailRate { return fmt.Errorf(msg.InvalidLaunchingOption, p.Sauce.LaunchOrder, string(config.LaunchOrderFailRate)) } diff --git a/internal/testcafe/config.go b/internal/testcafe/config.go index 2dec320c4..53b2ea548 100644 --- a/internal/testcafe/config.go +++ b/internal/testcafe/config.go @@ -14,6 +14,7 @@ import ( "github.com/saucelabs/saucectl/internal/fpath" "github.com/saucelabs/saucectl/internal/insights" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/node" "github.com/saucelabs/saucectl/internal/region" "github.com/saucelabs/saucectl/internal/sauceignore" "github.com/saucelabs/saucectl/internal/saucereport" @@ -263,6 +264,19 @@ func Validate(p *Project) error { return err } + if p.Npm.UsePackageLock { + if err := config.ValidatePackageLock(); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + packages, err := node.PackageFromFile("package.json") + if err != nil { + return fmt.Errorf("invalid use of usePackageLock. Failed to read package.json: %w", err) + } + if err := config.ValidatePackage(packages, "testcafe", p.Testcafe.Version); err != nil { + return fmt.Errorf("invalid use of usePackageLock: %w", err) + } + } + p.Testcafe.Version = config.StandardizeVersionFormat(p.Testcafe.Version) if p.Testcafe.Version == "" { return errors.New(msg.MissingFrameworkVersionConfig)