From a6a8bd16c5fe9160df3fcf989f1c80c696cae7b0 Mon Sep 17 00:00:00 2001 From: Tian Feng Date: Mon, 9 Sep 2024 09:33:14 -0700 Subject: [PATCH] feat: Support global node (#944) * feat: Support global node * refine node runtime selection * cleanup * add comment to function * let chef get nodeVersion to start job * fix unit test * revise comment * set nodeVersion to empty if the runner is not for global node * refine validation msg * move runtime to a separate package * revise * revert unnecessary settings * fmt * fmt * add comment for RuntimeResponse * extract runtime setup * avoid big if block * add ut for runtime validate * fix rebased error * when runtime reaches its EOL, just warn user * refine runtime validation * exitCode is really unnecessary * rename setRunime to setNodeRuntime * add JSON schema * rename namingMap * rename Runtime struct fields * rename SupportGlobalNode to SupportsRuntime * rename SelectNode to Find * lower alias before matching runtime * introduce removal date * Update internal/runtime/runtime.go Co-authored-by: Alex Plischke * track node version usage * update unit test * make SupportsRuntime more generic * update method comment * lower alias inside of findRuntimeByAlias * refine runtime validation msg * hmm.. do not change parameter value * set default runtime if it is supported but not set * update JSON schema * refine * refine validation msg * rename findRuntimeByAlias * revert runtime ut --------- Co-authored-by: Alex Plischke --- api/saucectl.schema.json | 22 +++ api/v1/framework/cypress.schema.json | 3 + api/v1/subschema/common.schema.json | 8 + .../playwright-cucumberjs.schema.json | 3 + api/v1alpha/framework/playwright.schema.json | 3 + api/v1alpha/framework/testcafe.schema.json | 3 + api/v1alpha/subschema/common.schema.json | 8 + internal/cmd/run/cucumber.go | 2 +- internal/cmd/run/cypress.go | 2 +- internal/cmd/run/playwright.go | 2 +- internal/cmd/run/testcafe.go | 2 +- internal/cucumber/config.go | 1 + internal/cypress/config.go | 2 + internal/cypress/v1/config.go | 9 ++ internal/framework/framework.go | 15 ++ internal/framework/search_test.go | 10 ++ internal/http/testcomposer.go | 51 ++++++ internal/http/webdriver.go | 2 + internal/job/starter.go | 2 + internal/mocks/frameworks.go | 7 + internal/playwright/config.go | 1 + internal/runtime/runtime.go | 148 ++++++++++++++++++ internal/runtime/runtime_test.go | 137 ++++++++++++++++ internal/saucecloud/cucumber.go | 55 +++++-- internal/saucecloud/cypress.go | 56 +++++-- internal/saucecloud/playwright.go | 56 +++++-- internal/saucecloud/testcafe.go | 53 ++++++- internal/testcafe/config.go | 1 + internal/usage/tracker.go | 5 + 29 files changed, 628 insertions(+), 41 deletions(-) create mode 100644 internal/runtime/runtime.go create mode 100644 internal/runtime/runtime_test.go diff --git a/api/saucectl.schema.json b/api/saucectl.schema.json index eff62f213..15d0b5f5e 100644 --- a/api/saucectl.schema.json +++ b/api/saucectl.schema.json @@ -343,6 +343,14 @@ "kind": { "const": "cypress" }, + "nodeVersion": { + "description": "Specifies the Node.js version for Sauce Cloud. Supports SemVer notation and aliases.", + "examples": [ + "v20", + "iron", + "lts" + ] + }, "showConsoleLog": { "description": "Shows suites console.log locally. By default console.log is only shown on failures.", "type": "boolean" @@ -1256,6 +1264,14 @@ "kind": { "const": "playwright" }, + "nodeVersion": { + "description": "Specifies the Node.js version for Sauce Cloud. Supports SemVer notation and aliases.", + "examples": [ + "v20", + "iron", + "lts" + ] + }, "showConsoleLog": { "$ref": "#/allOf/1/then/properties/showConsoleLog" }, @@ -1599,6 +1615,9 @@ "kind": { "const": "testcafe" }, + "nodeVersion": { + "$ref": "#/allOf/2/then/properties/nodeVersion" + }, "showConsoleLog": { "$ref": "#/allOf/1/then/properties/showConsoleLog" }, @@ -2349,6 +2368,9 @@ "kind": { "const": "playwright-cucumberjs" }, + "nodeVersion": { + "$ref": "#/allOf/2/then/properties/nodeVersion" + }, "showConsoleLog": { "$ref": "#/allOf/1/then/properties/showConsoleLog" }, diff --git a/api/v1/framework/cypress.schema.json b/api/v1/framework/cypress.schema.json index 7f967a15e..fef20adb7 100644 --- a/api/v1/framework/cypress.schema.json +++ b/api/v1/framework/cypress.schema.json @@ -24,6 +24,9 @@ "kind": { "const": "cypress" }, + "nodeVersion": { + "$ref": "../subschema/common.schema.json#/definitions/nodeVersion" + }, "showConsoleLog": { "$ref": "../subschema/common.schema.json#/definitions/showConsoleLog" }, diff --git a/api/v1/subschema/common.schema.json b/api/v1/subschema/common.schema.json index 311aafff8..02961eb00 100644 --- a/api/v1/subschema/common.schema.json +++ b/api/v1/subschema/common.schema.json @@ -97,6 +97,14 @@ "default": false } } + }, + "nodeVersion": { + "description": "Specifies the Node.js version for Sauce Cloud. Supports SemVer notation and aliases.", + "examples": [ + "v20", + "iron", + "lts" + ] } } } diff --git a/api/v1alpha/framework/playwright-cucumberjs.schema.json b/api/v1alpha/framework/playwright-cucumberjs.schema.json index 83caa9a56..0876301a8 100644 --- a/api/v1alpha/framework/playwright-cucumberjs.schema.json +++ b/api/v1alpha/framework/playwright-cucumberjs.schema.json @@ -24,6 +24,9 @@ "kind": { "const": "playwright-cucumberjs" }, + "nodeVersion": { + "$ref": "../subschema/common.schema.json#/definitions/nodeVersion" + }, "showConsoleLog": { "$ref": "../subschema/common.schema.json#/definitions/showConsoleLog" }, diff --git a/api/v1alpha/framework/playwright.schema.json b/api/v1alpha/framework/playwright.schema.json index 4bcd54f98..579483fdc 100644 --- a/api/v1alpha/framework/playwright.schema.json +++ b/api/v1alpha/framework/playwright.schema.json @@ -24,6 +24,9 @@ "kind": { "const": "playwright" }, + "nodeVersion": { + "$ref": "../subschema/common.schema.json#/definitions/nodeVersion" + }, "showConsoleLog": { "$ref": "../subschema/common.schema.json#/definitions/showConsoleLog" }, diff --git a/api/v1alpha/framework/testcafe.schema.json b/api/v1alpha/framework/testcafe.schema.json index 88c1a17ed..4d0b6dc1c 100644 --- a/api/v1alpha/framework/testcafe.schema.json +++ b/api/v1alpha/framework/testcafe.schema.json @@ -24,6 +24,9 @@ "kind": { "const": "testcafe" }, + "nodeVersion": { + "$ref": "../subschema/common.schema.json#/definitions/nodeVersion" + }, "showConsoleLog": { "$ref": "../subschema/common.schema.json#/definitions/showConsoleLog" }, diff --git a/api/v1alpha/subschema/common.schema.json b/api/v1alpha/subschema/common.schema.json index 311aafff8..02961eb00 100644 --- a/api/v1alpha/subschema/common.schema.json +++ b/api/v1alpha/subschema/common.schema.json @@ -97,6 +97,14 @@ "default": false } } + }, + "nodeVersion": { + "description": "Specifies the Node.js version for Sauce Cloud. Supports SemVer notation and aliases.", + "examples": [ + "v20", + "iron", + "lts" + ] } } } diff --git a/internal/cmd/run/cucumber.go b/internal/cmd/run/cucumber.go index 9e060ad01..2e8404556 100644 --- a/internal/cmd/run/cucumber.go +++ b/internal/cmd/run/cucumber.go @@ -107,7 +107,7 @@ func runCucumber(cmd *cobra.Command, isCLIDriven bool) (int, error) { props.SetFramework("playwright-cucumberjs").SetFVersion(p.Playwright.Version).SetFlags(cmd.Flags()).SetSauceConfig(p.Sauce). SetArtifacts(p.Artifacts).SetNPM(p.Npm).SetNumSuites(len(p.Suites)). SetSlack(p.Notifications.Slack).SetSharding(cucumber.IsSharded(p.Suites)).SetLaunchOrder(p.Sauce.LaunchOrder). - SetSmartRetry(p.IsSmartRetried()).SetReporters(p.Reporters) + SetSmartRetry(p.IsSmartRetried()).SetReporters(p.Reporters).SetNodeVersion(p.NodeVersion) tracker.Collect(cases.Title(language.English).String(cmds.FullName(cmd)), props) _ = tracker.Close() }() diff --git a/internal/cmd/run/cypress.go b/internal/cmd/run/cypress.go index 48ee6dddf..99ef41128 100644 --- a/internal/cmd/run/cypress.go +++ b/internal/cmd/run/cypress.go @@ -141,7 +141,7 @@ func runCypress(cmd *cobra.Command, cflags cypressFlags, isCLIDriven bool) (int, props.SetFramework("cypress").SetFVersion(p.GetVersion()).SetFlags(cmd.Flags()).SetSauceConfig(p.GetSauceCfg()). SetArtifacts(p.GetArtifactsCfg()).SetNPM(p.GetNpm()).SetNumSuites(len(p.GetSuites())). SetSlack(p.GetNotifications().Slack).SetSharding(p.IsSharded()).SetLaunchOrder(p.GetSauceCfg().LaunchOrder). - SetSmartRetry(p.IsSmartRetried()).SetReporters(p.GetReporters()) + SetSmartRetry(p.IsSmartRetried()).SetReporters(p.GetReporters()).SetNodeVersion(p.GetNodeVersion()) tracker.Collect(cases.Title(language.English).String(cmds.FullName(cmd)), props) _ = tracker.Close() diff --git a/internal/cmd/run/playwright.go b/internal/cmd/run/playwright.go index 11ea90a4f..cc5f81d11 100644 --- a/internal/cmd/run/playwright.go +++ b/internal/cmd/run/playwright.go @@ -153,7 +153,7 @@ func runPlaywright(cmd *cobra.Command, pf playwrightFlags, isCLIDriven bool) (in props.SetFramework("playwright").SetFVersion(p.Playwright.Version).SetFlags(cmd.Flags()).SetSauceConfig(p.Sauce). SetArtifacts(p.Artifacts).SetNPM(p.Npm).SetNumSuites(len(p.Suites)). SetSlack(p.Notifications.Slack).SetSharding(playwright.IsSharded(p.Suites)).SetLaunchOrder(p.Sauce.LaunchOrder). - SetSmartRetry(p.IsSmartRetried()).SetReporters(p.Reporters) + SetSmartRetry(p.IsSmartRetried()).SetReporters(p.Reporters).SetNodeVersion(p.NodeVersion) tracker.Collect(cases.Title(language.English).String(cmds.FullName(cmd)), props) _ = tracker.Close() }() diff --git a/internal/cmd/run/testcafe.go b/internal/cmd/run/testcafe.go index 361bfa80c..1e1cc4152 100644 --- a/internal/cmd/run/testcafe.go +++ b/internal/cmd/run/testcafe.go @@ -176,7 +176,7 @@ func runTestcafe(cmd *cobra.Command, tcFlags testcafeFlags, isCLIDriven bool) (i props.SetFramework("testcafe").SetFVersion(p.Testcafe.Version).SetFlags(cmd.Flags()).SetSauceConfig(p.Sauce). SetArtifacts(p.Artifacts).SetNPM(p.Npm).SetNumSuites(len(p.Suites)). SetSlack(p.Notifications.Slack).SetSharding(testcafe.IsSharded(p.Suites)).SetLaunchOrder(p.Sauce.LaunchOrder). - SetSmartRetry(p.IsSmartRetried()).SetReporters(p.Reporters) + SetSmartRetry(p.IsSmartRetried()).SetReporters(p.Reporters).SetNodeVersion(p.NodeVersion) tracker.Collect(cases.Title(language.English).String(cmds.FullName(cmd)), props) _ = tracker.Close() }() diff --git a/internal/cucumber/config.go b/internal/cucumber/config.go index 0f2782279..5a2710522 100644 --- a/internal/cucumber/config.go +++ b/internal/cucumber/config.go @@ -52,6 +52,7 @@ type Project struct { Env map[string]string `yaml:"env,omitempty" json:"env"` EnvFlag map[string]string `yaml:"-" json:"-"` Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"` + NodeVersion string `yaml:"nodeVersion,omitempty" json:"nodeVersion,omitempty"` } // Playwright represents the playwright setting diff --git a/internal/cypress/config.go b/internal/cypress/config.go index 7a56965f5..3eccd0157 100644 --- a/internal/cypress/config.go +++ b/internal/cypress/config.go @@ -50,6 +50,8 @@ type Project interface { GetSmartRetry(suiteName string) config.SmartRetry FilterFailedTests(suiteName string, report saucereport.SauceReport) error IsSmartRetried() bool + GetNodeVersion() string + SetNodeVersion(string) } type project struct { diff --git a/internal/cypress/v1/config.go b/internal/cypress/v1/config.go index a8db0c685..80f555f29 100644 --- a/internal/cypress/v1/config.go +++ b/internal/cypress/v1/config.go @@ -51,6 +51,7 @@ type Project struct { Env map[string]string `yaml:"env,omitempty" json:"env"` EnvFlag map[string]string `yaml:"-" json:"-"` Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"` + NodeVersion string `yaml:"nodeVersion,omitempty" json:"nodeVersion,omitempty"` } // Suite represents the cypress test suite configuration. @@ -595,3 +596,11 @@ func (p *Project) IsSmartRetried() bool { } return false } + +func (p *Project) GetNodeVersion() string { + return p.NodeVersion +} + +func (p *Project) SetNodeVersion(version string) { + p.NodeVersion = version +} diff --git a/internal/framework/framework.go b/internal/framework/framework.go index 49ca568ae..e48949519 100644 --- a/internal/framework/framework.go +++ b/internal/framework/framework.go @@ -4,6 +4,8 @@ import ( "context" "strings" "time" + + "github.com/saucelabs/saucectl/internal/runtime" ) // Framework represents a test framework (e.g. cypress). @@ -16,6 +18,7 @@ type Framework struct { type MetadataService interface { Frameworks(ctx context.Context) ([]string, error) Versions(ctx context.Context, frameworkName string) ([]Metadata, error) + Runtimes(ctx context.Context) ([]runtime.Runtime, error) } // Metadata represents test runner metadata. @@ -29,6 +32,7 @@ type Metadata struct { Platforms []Platform CloudRunnerVersion string BrowserDefaults map[string]string + Runtimes []string } func (m *Metadata) IsDeprecated() bool { @@ -65,3 +69,14 @@ func PlatformNames(platforms []Platform) []string { return pp } + +// SupportsRuntime checks if the current runner supports the specified runtime. +func (m *Metadata) SupportsRuntime(runtimeName string) bool { + for _, r := range m.Runtimes { + if r == runtimeName { + return true + } + } + + return false +} diff --git a/internal/framework/search_test.go b/internal/framework/search_test.go index 9da3b15d6..ab452e6ee 100644 --- a/internal/framework/search_test.go +++ b/internal/framework/search_test.go @@ -8,11 +8,13 @@ import ( "github.com/Masterminds/semver/v3" "github.com/saucelabs/saucectl/internal/node" + "github.com/saucelabs/saucectl/internal/runtime" ) type MockMetadataService struct { MockFrameworks func(ctx context.Context) ([]string, error) MockVersions func(ctx context.Context, frameworkName string) ([]Metadata, error) + MockRuntimes func(ctx context.Context) ([]runtime.Runtime, error) } func (m *MockMetadataService) Frameworks(ctx context.Context) ([]string, error) { @@ -31,6 +33,14 @@ func (m *MockMetadataService) Versions(ctx context.Context, frameworkName string return nil, nil } +func (m *MockMetadataService) Runtimes(ctx context.Context) ([]runtime.Runtime, error) { + if m.MockRuntimes != nil { + return m.MockRuntimes(ctx) + } + + return nil, nil +} + func TestFrameworkFind_ExactStrategy(t *testing.T) { var tests = []struct { testName string diff --git a/internal/http/testcomposer.go b/internal/http/testcomposer.go index 8d25ffec2..e3c7d0faa 100644 --- a/internal/http/testcomposer.go +++ b/internal/http/testcomposer.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/saucelabs/saucectl/internal/framework" "github.com/saucelabs/saucectl/internal/iam" + "github.com/saucelabs/saucectl/internal/runtime" ) // TestComposer service @@ -36,6 +37,7 @@ type FrameworkResponse struct { Browsers []string } `json:"platforms"` BrowserDefaults map[string]string `json:"browserDefaults"` + Runtimes []string `json:"runtimes"` } // TokenResponse represents the response body for slack token. @@ -49,6 +51,22 @@ type runner struct { GitRelease string `json:"gitRelease"` } +// RuntimeResponse represents the response body for getting runtimes. +type RuntimeResponse struct { + Name string `json:"name"` + Releases []Release `json:"releases"` +} + +type Release struct { + Version string `json:"version"` + Aliases []string `json:"aliases"` + EOLDate time.Time `json:"eolDate"` + RemovalDate time.Time `json:"removalDate"` + Default bool `json:"default"` + + Extra map[string]string `json:"extra"` +} + func NewTestComposer(url string, creds iam.Credentials, timeout time.Duration) TestComposer { return TestComposer{ HTTPClient: NewRetryableClient(timeout), @@ -199,6 +217,7 @@ func (c *TestComposer) Versions(ctx context.Context, frameworkName string) ([]fr Platforms: platforms, CloudRunnerVersion: f.Runner.CloudRunnerVersion, BrowserDefaults: f.BrowserDefaults, + Runtimes: f.Runtimes, }) } return frameworks, nil @@ -218,3 +237,35 @@ func uniqFrameworkNameSet(frameworks []framework.Framework) []string { } return fws } + +func (c *TestComposer) Runtimes(ctx context.Context) ([]runtime.Runtime, error) { + url := fmt.Sprintf("%s/v1/testcomposer/runtimes", c.URL) + + req, err := NewRetryableRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.Credentials.Username, c.Credentials.AccessKey) + + var resp []RuntimeResponse + if err = c.doJSONResponse(req, 200, &resp); err != nil { + return nil, err + } + + var runtimes []runtime.Runtime + for _, rt := range resp { + for _, r := range rt.Releases { + runtimes = append(runtimes, runtime.Runtime{ + Name: rt.Name, + Version: r.Version, + Alias: r.Aliases, + Default: r.Default, + EOLDate: r.EOLDate, + RemovalDate: r.RemovalDate, + Extra: r.Extra, + }) + } + } + + return runtimes, nil +} diff --git a/internal/http/webdriver.go b/internal/http/webdriver.go index fb360f821..a1bbc68b6 100644 --- a/internal/http/webdriver.go +++ b/internal/http/webdriver.go @@ -63,6 +63,7 @@ type SauceOpts struct { UserAgent string `json:"user_agent,omitempty"` TimeZone string `json:"timeZone,omitempty"` Visibility string `json:"public,omitempty"` + NodeVersion string `json:"nodeVersion,omitempty"` // VMD specific settings. @@ -154,6 +155,7 @@ func (c *Webdriver) StartJob(ctx context.Context, opts job.StartOptions) (jobID MaxDuration: 10800, TimeZone: opts.TimeZone, Visibility: opts.Visibility, + NodeVersion: opts.NodeVersion, ARMRequired: opts.ARMRequired, }, DeviceName: opts.DeviceName, diff --git a/internal/job/starter.go b/internal/job/starter.go index 65882a492..db80db3e7 100644 --- a/internal/job/starter.go +++ b/internal/job/starter.go @@ -35,6 +35,8 @@ type StartOptions struct { PlatformName string `json:"platformName,omitempty"` PlatformVersion string `json:"platformVersion,omitempty"` + NodeVersion string `json:"nodeVersion,omitempty"` + Tunnel TunnelOptions `json:"tunnel,omitempty"` Experiments map[string]string `json:"experiments,omitempty"` diff --git a/internal/mocks/frameworks.go b/internal/mocks/frameworks.go index ed52cbe42..e6cff8083 100644 --- a/internal/mocks/frameworks.go +++ b/internal/mocks/frameworks.go @@ -4,12 +4,14 @@ import ( "context" "github.com/saucelabs/saucectl/internal/framework" + "github.com/saucelabs/saucectl/internal/runtime" ) // FakeFrameworkInfoReader is a mock for the interface framework.MetadataService. type FakeFrameworkInfoReader struct { FrameworksFn func(ctx context.Context) ([]string, error) VersionsFn func(ctx context.Context, frameworkName string) ([]framework.Metadata, error) + RuntimeFn func(ctx context.Context) ([]runtime.Runtime, error) } // Frameworks is a wrapper around FrameworksFn. @@ -21,3 +23,8 @@ func (fir *FakeFrameworkInfoReader) Frameworks(ctx context.Context) ([]string, e func (fir *FakeFrameworkInfoReader) Versions(ctx context.Context, frameworkName string) ([]framework.Metadata, error) { return fir.VersionsFn(ctx, frameworkName) } + +// Runtimes is a wrapper around RuntimesFn. +func (fir *FakeFrameworkInfoReader) Runtimes(ctx context.Context) ([]runtime.Runtime, error) { + return fir.RuntimeFn(ctx) +} diff --git a/internal/playwright/config.go b/internal/playwright/config.go index 1846fc18c..040dbbdea 100644 --- a/internal/playwright/config.go +++ b/internal/playwright/config.go @@ -53,6 +53,7 @@ type Project struct { Env map[string]string `yaml:"env,omitempty" json:"env"` EnvFlag map[string]string `yaml:"-" json:"-"` Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"` + NodeVersion string `yaml:"nodeVersion,omitempty" json:"nodeVersion,omitempty"` } // Playwright represents crucial playwright configuration that is required for setting up a project. diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 000000000..2be83f6e0 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,148 @@ +package runtime + +import ( + "fmt" + "strings" + "time" + + "github.com/saucelabs/saucectl/internal/msg" + "golang.org/x/mod/semver" +) + +const NodeRuntime = "nodejs" + +// runtimeDisplayNames maps runtime identifiers to their human-readable display names. +var runtimeDisplayNames = map[string]string{ + NodeRuntime: "Node.js", +} + +// Runtime represents runtime details on the VM. +type Runtime struct { + Name string + Alias []string + Version string + EOLDate time.Time + RemovalDate time.Time + Default bool + Extra map[string]string +} + +// Find selects the appropriate runtime from a list of runtimes. +// It supports full SemVer matching, alias resolution, and fuzzy matching for major or major.minor versions. +// `version` is expected to always start with "v". +func Find(runtimes []Runtime, name, version string) (Runtime, error) { + rts := filterByName(runtimes, name) + if !semver.IsValid(version) { + // If version is not a valid SemVer, check if it's using an alias (e.g., "lts" or code name). + res, err := findByAlias(rts, version) + if err == nil { + return res, nil + } + return Runtime{}, fmt.Errorf("invalid %s version %s", runtimeDisplayNames[name], version) + } + + // If the version is a full SemVer (i.e., major.minor.patch), attempt exact match. + if isFullVersion(version) { + for _, r := range rts { + if "v"+r.Version == version { + return r, nil + } + } + return Runtime{}, fmt.Errorf("no matching %s version found for %s", runtimeDisplayNames[name], version) + } + + // Fuzzy matching: + // Try to match on major.minor. + if onlyHasMajorMinor(version) { + majorMinor := semver.MajorMinor(version) + for _, r := range rts { + if strings.HasPrefix("v"+r.Version, majorMinor+".") { + return r, nil + } + } + return Runtime{}, fmt.Errorf("no matching %s version found for %s", runtimeDisplayNames[name], version) + } + + // Try to match on major version only. + if onlyHasMajor(version) { + major := semver.Major(version) + for _, r := range rts { + if strings.HasPrefix("v"+r.Version, major+".") { + return r, nil + } + } + } + + return Runtime{}, fmt.Errorf("no matching %s version found for %s", runtimeDisplayNames[name], version) +} + +// GetDefault returns the default version for the specified runtime. +func GetDefault(runtimes []Runtime, name string) (Runtime, error) { + for _, r := range runtimes { + if r.Name == name && r.Default { + return r, nil + } + } + + return Runtime{}, fmt.Errorf("no default version found for %s", runtimeDisplayNames[name]) +} + +func findByAlias(runtimes []Runtime, alias string) (Runtime, error) { + als := strings.ToLower(alias) + for _, r := range runtimes { + for _, a := range r.Alias { + if als == a { + return r, nil + } + } + } + + return Runtime{}, fmt.Errorf("alias %q not found", alias) +} + +func filterByName(runtimes []Runtime, name string) []Runtime { + var rts []Runtime + for _, r := range runtimes { + if r.Name == name { + rts = append(rts, r) + } + } + return rts +} + +func onlyHasMajor(version string) bool { + return len(strings.Split(version, ".")) == 1 +} + +func onlyHasMajorMinor(version string) bool { + return len(strings.Split(version, ".")) == 2 +} + +// isFullVersion checks if version follows the full semver format of `{major}.{minor}.{patch}`. +func isFullVersion(version string) bool { + return len(strings.Split(version, ".")) == 3 +} + +func (r *Runtime) Validate(runtimes []Runtime) error { + now := time.Now() + if now.After(r.RemovalDate) { + fmt.Print(msg.RemovalNotice(r.Name, r.Version, getAvailableRuntimes(runtimes, r.Name))) + return fmt.Errorf("unsupported runtime %s(%s)", runtimeDisplayNames[r.Name], r.Version) + } + + if now.After(r.EOLDate) { + fmt.Print(msg.EOLNotice(r.Name, r.Version, r.RemovalDate, getAvailableRuntimes(runtimes, r.Name))) + } + return nil +} + +func getAvailableRuntimes(runtimes []Runtime, name string) []string { + now := time.Now() + var versions []string + for _, r := range runtimes { + if r.Name == name && now.Before(r.EOLDate) { + versions = append(versions, r.Version) + } + } + return versions +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 000000000..2db139b25 --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,137 @@ +package runtime + +import ( + "testing" + + "gotest.tools/assert" +) + +func TestFind(t *testing.T) { + runtimes := []Runtime{ + { + Name: "nodejs", + Version: "20.14.0", + Alias: []string{"iron", "lts"}, + }, + { + Name: "nodejs", + Version: "18.20.4", + Alias: []string{"Hydrogen"}, + }, + } + testcases := []struct { + caseName string + runtimes []Runtime + name string + version string + want string + wantErr string + }{ + { + caseName: "version is invalid", + runtimes: runtimes, + name: NodeRuntime, + version: "vfake-version", + want: "", + wantErr: "invalid Node.js version vfake-version", + }, + { + caseName: "version alias is invalid", + runtimes: runtimes, + name: NodeRuntime, + version: "my-alias", + want: "", + wantErr: "invalid Node.js version my-alias", + }, + { + caseName: "version alias is valid", + runtimes: runtimes, + name: NodeRuntime, + version: "iron", + want: "20.14.0", + wantErr: "", + }, + { + caseName: "valid version contains major, minor and patch", + runtimes: runtimes, + name: NodeRuntime, + version: "v20.14.0", + want: "20.14.0", + wantErr: "", + }, + { + caseName: "invalid version not starts with v", + runtimes: runtimes, + name: NodeRuntime, + version: "20.14.0", + want: "", + wantErr: "invalid Node.js version 20.14.0", + }, + { + caseName: "invalid version contains major, minor and patch", + runtimes: runtimes, + name: NodeRuntime, + version: "v20.14.2", + want: "", + wantErr: "no matching Node.js version found for v20.14.2", + }, + { + caseName: "invalid version contains non-numeric major, minor and patch", + runtimes: runtimes, + name: NodeRuntime, + version: "va.b.c", + want: "", + wantErr: "invalid Node.js version va.b.c", + }, + { + caseName: "valid version only contains major and minor", + runtimes: runtimes, + name: NodeRuntime, + version: "v20.14", + want: "20.14.0", + wantErr: "", + }, + { + caseName: "valid version only contains major", + runtimes: runtimes, + name: NodeRuntime, + version: "v18", + want: "18.20.4", + wantErr: "", + }, + { + caseName: "invalid version only contains major", + runtimes: runtimes, + name: NodeRuntime, + version: "v22", + want: "", + wantErr: "no matching Node.js version found for v22", + }, + { + caseName: "should precisely match complete major and minor version", + runtimes: runtimes, + name: NodeRuntime, + version: "v20.1", + want: "", + wantErr: "no matching Node.js version found for v20.1", + }, + { + caseName: "should precisely match complete major version", + runtimes: runtimes, + name: NodeRuntime, + version: "v2", + want: "", + wantErr: "no matching Node.js version found for v2", + }, + } + + for _, tc := range testcases { + t.Run(tc.caseName, func(t *testing.T) { + got, err := Find(tc.runtimes, tc.name, tc.version) + if err != nil { + assert.Equal(t, tc.wantErr, err.Error()) + } + assert.Equal(t, tc.want, got.Version) + }) + } +} diff --git a/internal/saucecloud/cucumber.go b/internal/saucecloud/cucumber.go index 21fde6c48..ec73e569d 100644 --- a/internal/saucecloud/cucumber.go +++ b/internal/saucecloud/cucumber.go @@ -10,6 +10,7 @@ import ( "github.com/saucelabs/saucectl/internal/framework" "github.com/saucelabs/saucectl/internal/msg" "github.com/saucelabs/saucectl/internal/playwright" + "github.com/saucelabs/saucectl/internal/runtime" "github.com/saucelabs/saucectl/internal/job" ) @@ -22,17 +23,19 @@ type CucumberRunner struct { // RunProject runs the defined tests on sauce cloud func (r *CucumberRunner) RunProject() (int, error) { - exitCode := 1 - m, err := r.MetadataSearchStrategy.Find(context.Background(), r.MetadataService, playwright.Kind, r.Project.Playwright.Version) if err != nil { r.logFrameworkError(err) - return exitCode, err + return 1, err } + r.setVersions(m) if err := r.validateFramework(m); err != nil { - return exitCode, err + return 1, err + } + + if err := r.setNodeRuntime(m); err != nil { + return 1, err } - r.setVersions(m) if err := r.validateTunnel( r.Project.Sauce.Tunnel.Name, @@ -45,7 +48,7 @@ func (r *CucumberRunner) RunProject() (int, error) { app, otherApps, err := r.remoteArchiveProject(r.Project, r.Project.RootDir, r.Project.Sauce.Sauceignore, r.Project.DryRun) if err != nil { - return exitCode, err + return 1, err } if r.Project.DryRun { @@ -54,11 +57,11 @@ func (r *CucumberRunner) RunProject() (int, error) { } passed := r.runSuites(app, otherApps) - if passed { - return 0, nil + if !passed { + return 1, nil } - return exitCode, nil + return 0, nil } // setVersions sets the framework and runner versions based on the fetched framework metadata. @@ -85,6 +88,39 @@ func (r *CucumberRunner) validateFramework(m framework.Metadata) error { return nil } +func (r *CucumberRunner) setNodeRuntime(metadata framework.Metadata) error { + if !metadata.SupportsRuntime(runtime.NodeRuntime) { + r.Project.NodeVersion = "" + return nil + } + + runtimes, err := r.MetadataService.Runtimes(context.Background()) + if err != nil { + return err + } + // Set the default version if the runner supports global Node.js + // but no version is specified by user. + if r.Project.NodeVersion == "" { + d, err := runtime.GetDefault(runtimes, runtime.NodeRuntime) + if err != nil { + return err + } + r.Project.NodeVersion = d.Version + return nil + } + + rt, err := runtime.Find(runtimes, runtime.NodeRuntime, r.Project.NodeVersion) + if err != nil { + return err + } + if err := rt.Validate(runtimes); err != nil { + return err + } + r.Project.NodeVersion = rt.Version + + return nil +} + func (r *CucumberRunner) getSuiteNames() []string { var names []string for _, s := range r.Project.Suites { @@ -124,6 +160,7 @@ func (r *CucumberRunner) runSuites(app string, otherApps []string) bool { Suite: s.Name, Framework: "playwright", FrameworkVersion: r.Project.Playwright.Version, + NodeVersion: r.Project.NodeVersion, BrowserName: s.BrowserName, BrowserVersion: s.BrowserVersion, PlatformName: s.PlatformName, diff --git a/internal/saucecloud/cypress.go b/internal/saucecloud/cypress.go index e8edb2dea..b2976a1b2 100644 --- a/internal/saucecloud/cypress.go +++ b/internal/saucecloud/cypress.go @@ -11,6 +11,7 @@ import ( "github.com/saucelabs/saucectl/internal/framework" "github.com/saucelabs/saucectl/internal/job" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/runtime" ) // CypressRunner represents the Sauce Labs cloud implementation for cypress. @@ -21,18 +22,19 @@ type CypressRunner struct { // RunProject runs the tests defined in cypress.Project. func (r *CypressRunner) RunProject() (int, error) { - exitCode := 1 - - cyVersion := r.Project.GetVersion() - m, err := r.MetadataSearchStrategy.Find(context.Background(), r.MetadataService, cypress.Kind, cyVersion) + m, err := r.MetadataSearchStrategy.Find(context.Background(), r.MetadataService, cypress.Kind, r.Project.GetVersion()) if err != nil { r.logFrameworkError(err) - return exitCode, err + return 1, err } + r.setVersions(m) if err := r.validateFramework(m); err != nil { return 1, err } - r.setVersions(m) + + if err := r.setNodeRuntime(m); err != nil { + return 1, err + } if err := r.validateTunnel( r.Project.GetSauceCfg().Tunnel.Name, @@ -45,7 +47,7 @@ func (r *CypressRunner) RunProject() (int, error) { app, otherApps, err := r.remoteArchiveProject(r.Project, r.Project.GetRootDir(), r.Project.GetSauceCfg().Sauceignore, r.Project.IsDryRun()) if err != nil { - return exitCode, err + return 1, err } if r.Project.IsDryRun() { @@ -54,11 +56,44 @@ func (r *CypressRunner) RunProject() (int, error) { } passed := r.runSuites(app, otherApps) - if passed { - exitCode = 0 + if !passed { + return 1, nil + } + + return 0, nil +} + +func (r *CypressRunner) setNodeRuntime(m framework.Metadata) error { + if !m.SupportsRuntime(runtime.NodeRuntime) { + r.Project.SetNodeVersion("") + return nil + } + + runtimes, err := r.MetadataService.Runtimes(context.Background()) + if err != nil { + return err + } + // Set the default version if the runner supports global Node.js + // but no version is specified by user. + if r.Project.GetNodeVersion() == "" { + d, err := runtime.GetDefault(runtimes, runtime.NodeRuntime) + if err != nil { + return err + } + r.Project.SetNodeVersion(d.Version) + return nil + } + + rt, err := runtime.Find(runtimes, runtime.NodeRuntime, r.Project.GetNodeVersion()) + if err != nil { + return err + } + if err := rt.Validate(runtimes); err != nil { + return err } + r.Project.SetNodeVersion(rt.Version) - return exitCode, nil + return nil } // setVersions sets the framework and runner versions based on the fetched framework metadata. @@ -119,6 +154,7 @@ func (r *CypressRunner) runSuites(app string, otherApps []string) bool { Suite: s.Name, Framework: "cypress", FrameworkVersion: r.Project.GetVersion(), + NodeVersion: r.Project.GetNodeVersion(), BrowserName: s.Browser, BrowserVersion: s.BrowserVersion, PlatformName: s.PlatformName, diff --git a/internal/saucecloud/playwright.go b/internal/saucecloud/playwright.go index 137053840..d59690c98 100644 --- a/internal/saucecloud/playwright.go +++ b/internal/saucecloud/playwright.go @@ -7,10 +7,10 @@ import ( "github.com/rs/zerolog/log" "github.com/saucelabs/saucectl/internal/framework" - "github.com/saucelabs/saucectl/internal/msg" - "github.com/saucelabs/saucectl/internal/job" + "github.com/saucelabs/saucectl/internal/msg" "github.com/saucelabs/saucectl/internal/playwright" + "github.com/saucelabs/saucectl/internal/runtime" ) // PlaywrightRunner represents the Sauce Labs cloud implementation for playwright. @@ -27,17 +27,19 @@ var PlaywrightBrowserMap = map[string]string{ // RunProject runs the tests defined in cypress.Project. func (r *PlaywrightRunner) RunProject() (int, error) { - exitCode := 1 - m, err := r.MetadataSearchStrategy.Find(context.Background(), r.MetadataService, playwright.Kind, r.Project.Playwright.Version) if err != nil { r.logFrameworkError(err) - return exitCode, err + return 1, err } + r.setVersions(m) if err := r.validateFramework(m); err != nil { return 1, err } - r.setVersions(m) + + if err := r.setNodeRuntime(m); err != nil { + return 1, err + } if err := r.validateTunnel( r.Project.Sauce.Tunnel.Name, @@ -50,7 +52,7 @@ func (r *PlaywrightRunner) RunProject() (int, error) { app, otherApps, err := r.remoteArchiveProject(r.Project, r.Project.RootDir, r.Project.Sauce.Sauceignore, r.Project.DryRun) if err != nil { - return exitCode, err + return 1, err } if r.Project.DryRun { @@ -59,11 +61,44 @@ func (r *PlaywrightRunner) RunProject() (int, error) { } passed := r.runSuites(app, otherApps) - if passed { - exitCode = 0 + if !passed { + return 1, nil + } + + return 0, nil +} + +func (r *PlaywrightRunner) setNodeRuntime(m framework.Metadata) error { + if !m.SupportsRuntime(runtime.NodeRuntime) { + r.Project.NodeVersion = "" + return nil } - return exitCode, nil + runtimes, err := r.MetadataService.Runtimes(context.Background()) + if err != nil { + return err + } + // Set the default version if the runner supports global Node.js + // but no version is specified by user. + if r.Project.NodeVersion == "" { + d, err := runtime.GetDefault(runtimes, runtime.NodeRuntime) + if err != nil { + return err + } + r.Project.NodeVersion = d.Version + return nil + } + + rt, err := runtime.Find(runtimes, runtime.NodeRuntime, r.Project.NodeVersion) + if err != nil { + return err + } + if err := rt.Validate(runtimes); err != nil { + return err + } + r.Project.NodeVersion = rt.Version + + return nil } // setVersions sets the framework and runner versions based on the fetched framework metadata. @@ -135,6 +170,7 @@ func (r *PlaywrightRunner) runSuites(app string, otherApps []string) bool { Suite: s.Name, Framework: "playwright", FrameworkVersion: s.PlaywrightVersion, + NodeVersion: r.Project.NodeVersion, BrowserName: s.Params.BrowserName, BrowserVersion: s.Params.BrowserVersion, PlatformName: s.PlatformName, diff --git a/internal/saucecloud/testcafe.go b/internal/saucecloud/testcafe.go index 9152022c2..faf87e1b3 100644 --- a/internal/saucecloud/testcafe.go +++ b/internal/saucecloud/testcafe.go @@ -8,6 +8,7 @@ import ( "github.com/rs/zerolog/log" "github.com/saucelabs/saucectl/internal/framework" "github.com/saucelabs/saucectl/internal/msg" + "github.com/saucelabs/saucectl/internal/runtime" "github.com/saucelabs/saucectl/internal/job" "github.com/saucelabs/saucectl/internal/testcafe" @@ -21,17 +22,19 @@ type TestcafeRunner struct { // RunProject runs the defined tests on sauce cloud func (r *TestcafeRunner) RunProject() (int, error) { - exitCode := 1 - m, err := r.MetadataSearchStrategy.Find(context.Background(), r.MetadataService, testcafe.Kind, r.Project.Testcafe.Version) if err != nil { r.logFrameworkError(err) - return exitCode, err + return 1, err } + r.setVersions(m) if err := r.validateFramework(m); err != nil { return 1, err } - r.setVersions(m) + + if err := r.setNodeRuntime(m); err != nil { + return 1, err + } if err := r.validateTunnel( r.Project.Sauce.Tunnel.Name, @@ -44,7 +47,7 @@ func (r *TestcafeRunner) RunProject() (int, error) { app, otherApps, err := r.remoteArchiveProject(r.Project, r.Project.RootDir, r.Project.Sauce.Sauceignore, r.Project.DryRun) if err != nil { - return exitCode, err + return 1, err } if r.Project.DryRun { @@ -53,11 +56,44 @@ func (r *TestcafeRunner) RunProject() (int, error) { } passed := r.runSuites(app, otherApps) - if passed { - return 0, nil + if !passed { + return 1, nil + } + + return 0, nil +} + +func (r *TestcafeRunner) setNodeRuntime(m framework.Metadata) error { + if !m.SupportsRuntime(runtime.NodeRuntime) { + r.Project.NodeVersion = "" + return nil + } + + runtimes, err := r.MetadataService.Runtimes(context.Background()) + if err != nil { + return err + } + // Set the default version if the runner supports global Node.js + // but no version is specified by user. + if r.Project.NodeVersion == "" { + d, err := runtime.GetDefault(runtimes, runtime.NodeRuntime) + if err != nil { + return err + } + r.Project.NodeVersion = d.Version + return nil + } + + rt, err := runtime.Find(runtimes, runtime.NodeRuntime, r.Project.NodeVersion) + if err != nil { + return err + } + if err := rt.Validate(runtimes); err != nil { + return err } + r.Project.NodeVersion = rt.Version - return exitCode, nil + return nil } // setVersions sets the framework and runner versions based on the fetched framework metadata. @@ -153,6 +189,7 @@ func (r *TestcafeRunner) generateStartOpts(s testcafe.Suite) job.StartOptions { Suite: s.Name, Framework: "testcafe", FrameworkVersion: r.Project.Testcafe.Version, + NodeVersion: r.Project.NodeVersion, BrowserName: s.BrowserName, BrowserVersion: s.BrowserVersion, Name: s.Name, diff --git a/internal/testcafe/config.go b/internal/testcafe/config.go index b3b99ff24..79758d641 100644 --- a/internal/testcafe/config.go +++ b/internal/testcafe/config.go @@ -53,6 +53,7 @@ type Project struct { Env map[string]string `yaml:"env,omitempty" json:"env"` EnvFlag map[string]string `yaml:"-" json:"-"` Notifications config.Notifications `yaml:"notifications,omitempty" json:"-"` + NodeVersion string `yaml:"nodeVersion,omitempty" json:"nodeVersion,omitempty"` } // Filter represents the testcafe filters configuration diff --git a/internal/usage/tracker.go b/internal/usage/tracker.go index 60bd84020..68ebb332e 100644 --- a/internal/usage/tracker.go +++ b/internal/usage/tracker.go @@ -104,6 +104,11 @@ func (p Properties) SetReporters(reporters config.Reporters) Properties { return p } +func (p Properties) SetNodeVersion(version string) Properties { + p["node_version"] = version + return p +} + // Tracker is an interface for providing usage tracking. type Tracker interface { io.Closer