From 531a0b8cfe262ee7b548082c88ffa1b867890b57 Mon Sep 17 00:00:00 2001 From: phgermanov Date: Fri, 3 Jan 2025 12:42:59 +0200 Subject: [PATCH] feat: Introduce new npmExecuteTests step (#5124) --- cmd/getConfig.go | 3 + cmd/metadata_generated.go | 1 + cmd/mtaBuild_test.go | 1 + cmd/npmExecuteTests.go | 115 ++++++ cmd/npmExecuteTests_generated.go | 376 ++++++++++++++++++++ cmd/npmExecuteTests_generated_test.go | 20 ++ cmd/npmExecuteTests_test.go | 88 +++++ cmd/piper.go | 10 +- documentation/docs/steps/npmExecuteTests.md | 114 ++++++ documentation/mkdocs.yml | 1 + resources/metadata/npmExecuteTests.yaml | 159 +++++++++ vars/npmExecuteTests.groovy | 15 + 12 files changed, 897 insertions(+), 6 deletions(-) create mode 100644 cmd/npmExecuteTests.go create mode 100644 cmd/npmExecuteTests_generated.go create mode 100644 cmd/npmExecuteTests_generated_test.go create mode 100644 cmd/npmExecuteTests_test.go create mode 100644 documentation/docs/steps/npmExecuteTests.md create mode 100644 resources/metadata/npmExecuteTests.yaml create mode 100644 vars/npmExecuteTests.groovy diff --git a/cmd/getConfig.go b/cmd/getConfig.go index 0712f22e84..f8f54af0eb 100644 --- a/cmd/getConfig.go +++ b/cmd/getConfig.go @@ -138,6 +138,9 @@ func GetStageConfig() (config.StepConfig, error) { defaultConfig := []io.ReadCloser{} for _, f := range GeneralConfig.DefaultConfig { + if configOptions.OpenFile == nil { + return stepConfig, errors.New("config: open file function not set") + } fc, err := configOptions.OpenFile(f, GeneralConfig.GitHubAccessTokens) // only create error for non-default values if err != nil && f != ".pipeline/defaults.yaml" { diff --git a/cmd/metadata_generated.go b/cmd/metadata_generated.go index 56cebc07d4..50b0d6dadb 100644 --- a/cmd/metadata_generated.go +++ b/cmd/metadata_generated.go @@ -103,6 +103,7 @@ func GetAllStepMetadata() map[string]config.StepData { "nexusUpload": nexusUploadMetadata(), "npmExecuteLint": npmExecuteLintMetadata(), "npmExecuteScripts": npmExecuteScriptsMetadata(), + "npmExecuteTests": npmExecuteTestsMetadata(), "pipelineCreateScanSummary": pipelineCreateScanSummaryMetadata(), "protecodeExecuteScan": protecodeExecuteScanMetadata(), "pythonBuild": pythonBuildMetadata(), diff --git a/cmd/mtaBuild_test.go b/cmd/mtaBuild_test.go index 273ff3af91..e784fc1d5d 100644 --- a/cmd/mtaBuild_test.go +++ b/cmd/mtaBuild_test.go @@ -87,6 +87,7 @@ func TestMtaBuild(t *testing.T) { SetConfigOptions(ConfigCommandOptions{ OpenFile: config.OpenPiperFile, }) + t.Run("Application name not set", func(t *testing.T) { utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{} diff --git a/cmd/npmExecuteTests.go b/cmd/npmExecuteTests.go new file mode 100644 index 0000000000..90dfaad2e5 --- /dev/null +++ b/cmd/npmExecuteTests.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/SAP/jenkins-library/pkg/command" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/telemetry" +) + +type vaultUrl struct { + URL string `json:"url"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +func npmExecuteTests(config npmExecuteTestsOptions, _ *telemetry.CustomData) { + c := command.Command{} + + c.Stdout(log.Writer()) + c.Stderr(log.Writer()) + err := runNpmExecuteTests(&config, &c) + if err != nil { + log.Entry().WithError(err).Fatal("Step execution failed") + } +} + +func runNpmExecuteTests(config *npmExecuteTestsOptions, c command.ExecRunner) error { + if len(config.Envs) > 0 { + c.SetEnv(config.Envs) + } + + if len(config.Paths) > 0 { + path := fmt.Sprintf("PATH=%s:%s", os.Getenv("PATH"), strings.Join(config.Paths, ":")) + c.SetEnv([]string{path}) + } + + if config.WorkingDirectory != "" { + if err := os.Chdir(config.WorkingDirectory); err != nil { + return fmt.Errorf("failed to change directory: %w", err) + } + } + + installCommandTokens := strings.Fields(config.InstallCommand) + if err := c.RunExecutable(installCommandTokens[0], installCommandTokens[1:]...); err != nil { + return fmt.Errorf("failed to execute install command: %w", err) + } + + parsedURLs, err := parseURLs(config.VaultURLs) + if err != nil { + return err + } + + for _, app := range parsedURLs { + if err := runTestForUrl(app.URL, app.Username, app.Password, config, c); err != nil { + return err + } + } + + if err := runTestForUrl(config.BaseURL, config.VaultUsername, config.VaultPassword, config, c); err != nil { + return err + } + return nil +} + +func runTestForUrl(url, username, password string, config *npmExecuteTestsOptions, command command.ExecRunner) error { + credentialsToEnv(username, password, config.UsernameEnvVar, config.PasswordEnvVar, command) + // we need to reset the env vars as the next test might not have any credentials + defer resetCredentials(config.UsernameEnvVar, config.PasswordEnvVar, command) + + runScriptTokens := strings.Fields(config.RunCommand) + if config.UrlOptionPrefix != "" { + runScriptTokens = append(runScriptTokens, config.UrlOptionPrefix+url) + } + if err := command.RunExecutable(runScriptTokens[0], runScriptTokens[1:]...); err != nil { + return fmt.Errorf("failed to execute npm script: %w", err) + } + + return nil +} + +func parseURLs(urls []map[string]interface{}) ([]vaultUrl, error) { + parsedUrls := []vaultUrl{} + + for _, url := range urls { + parsedUrl := vaultUrl{} + urlStr, ok := url["url"].(string) + if !ok { + return nil, fmt.Errorf("url field is not a string") + } + parsedUrl.URL = urlStr + if username, ok := url["username"].(string); ok { + parsedUrl.Username = username + } + + if password, ok := url["password"].(string); ok { + parsedUrl.Password = password + } + parsedUrls = append(parsedUrls, parsedUrl) + } + return parsedUrls, nil +} + +func credentialsToEnv(username, password, usernameEnv, passwordEnv string, c command.ExecRunner) { + if username == "" || password == "" { + return + } + c.SetEnv([]string{usernameEnv + "=" + username, passwordEnv + "=" + password}) +} + +func resetCredentials(usernameEnv, passwordEnv string, c command.ExecRunner) { + c.SetEnv([]string{usernameEnv + "=", passwordEnv + "="}) +} diff --git a/cmd/npmExecuteTests_generated.go b/cmd/npmExecuteTests_generated.go new file mode 100644 index 0000000000..3d794ea322 --- /dev/null +++ b/cmd/npmExecuteTests_generated.go @@ -0,0 +1,376 @@ +// Code generated by piper's step-generator. DO NOT EDIT. + +package cmd + +import ( + "fmt" + "os" + "reflect" + "strings" + "time" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/gcp" + "github.com/SAP/jenkins-library/pkg/gcs" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/splunk" + "github.com/SAP/jenkins-library/pkg/telemetry" + "github.com/SAP/jenkins-library/pkg/validation" + "github.com/bmatcuk/doublestar" + "github.com/spf13/cobra" +) + +type npmExecuteTestsOptions struct { + InstallCommand string `json:"installCommand,omitempty"` + RunCommand string `json:"runCommand,omitempty"` + VaultURLs []map[string]interface{} `json:"vaultURLs,omitempty"` + VaultUsername string `json:"vaultUsername,omitempty"` + VaultPassword string `json:"vaultPassword,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + UsernameEnvVar string `json:"usernameEnvVar,omitempty"` + PasswordEnvVar string `json:"passwordEnvVar,omitempty"` + UrlOptionPrefix string `json:"urlOptionPrefix,omitempty"` + Envs []string `json:"envs,omitempty"` + Paths []string `json:"paths,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` +} + +type npmExecuteTestsReports struct { +} + +func (p *npmExecuteTestsReports) persist(stepConfig npmExecuteTestsOptions, gcpJsonKeyFilePath string, gcsBucketId string, gcsFolderPath string, gcsSubFolder string) { + if gcsBucketId == "" { + log.Entry().Info("persisting reports to GCS is disabled, because gcsBucketId is empty") + return + } + log.Entry().Info("Uploading reports to Google Cloud Storage...") + content := []gcs.ReportOutputParam{ + {FilePattern: "**/e2e-results.xml", ParamRef: "", StepResultType: "end-to-end-test"}, + } + envVars := []gcs.EnvVar{ + {Name: "GOOGLE_APPLICATION_CREDENTIALS", Value: gcpJsonKeyFilePath, Modified: false}, + } + gcsClient, err := gcs.NewClient(gcs.WithEnvVars(envVars)) + if err != nil { + log.Entry().Errorf("creation of GCS client failed: %v", err) + return + } + defer gcsClient.Close() + structVal := reflect.ValueOf(&stepConfig).Elem() + inputParameters := map[string]string{} + for i := 0; i < structVal.NumField(); i++ { + field := structVal.Type().Field(i) + if field.Type.String() == "string" { + paramName := strings.Split(field.Tag.Get("json"), ",") + paramValue, _ := structVal.Field(i).Interface().(string) + inputParameters[paramName[0]] = paramValue + } + } + if err := gcs.PersistReportsToGCS(gcsClient, content, inputParameters, gcsFolderPath, gcsBucketId, gcsSubFolder, doublestar.Glob, os.Stat); err != nil { + log.Entry().Errorf("failed to persist reports: %v", err) + } +} + +// NpmExecuteTestsCommand Executes end-to-end tests using npm +func NpmExecuteTestsCommand() *cobra.Command { + const STEP_NAME = "npmExecuteTests" + + metadata := npmExecuteTestsMetadata() + var stepConfig npmExecuteTestsOptions + var startTime time.Time + var reports npmExecuteTestsReports + var logCollector *log.CollectorHook + var splunkClient *splunk.Splunk + telemetryClient := &telemetry.Telemetry{} + + var createNpmExecuteTestsCmd = &cobra.Command{ + Use: STEP_NAME, + Short: "Executes end-to-end tests using npm", + Long: `This step executes end-to-end tests in a Docker environment using npm. + +The step spins up a Docker container based on the specified ` + "`" + `dockerImage` + "`" + ` and executes the ` + "`" + `installScript` + "`" + ` and ` + "`" + `runScript` + "`" + ` from ` + "`" + `package.json` + "`" + `. + +The application URLs and credentials can be specified in ` + "`" + `appUrls` + "`" + ` and ` + "`" + `credentialsId` + "`" + ` respectively. If ` + "`" + `wdi5` + "`" + ` is set to ` + "`" + `true` + "`" + `, the step uses ` + "`" + `wdi5_username` + "`" + ` and ` + "`" + `wdi5_password` + "`" + ` for authentication. + +The tests can be restricted to run only on the productive branch by setting ` + "`" + `onlyRunInProductiveBranch` + "`" + ` to ` + "`" + `true` + "`" + `.`, + PreRunE: func(cmd *cobra.Command, _ []string) error { + startTime = time.Now() + log.SetStepName(STEP_NAME) + log.SetVerbose(GeneralConfig.Verbose) + + GeneralConfig.GitHubAccessTokens = ResolveAccessTokens(GeneralConfig.GitHubTokens) + + path, err := os.Getwd() + if err != nil { + return err + } + fatalHook := &log.FatalHook{CorrelationID: GeneralConfig.CorrelationID, Path: path} + log.RegisterHook(fatalHook) + + err = PrepareConfig(cmd, &metadata, STEP_NAME, &stepConfig, config.OpenPiperFile) + if err != nil { + log.SetErrorCategory(log.ErrorConfiguration) + return err + } + + if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 { + sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID) + log.RegisterHook(&sentryHook) + } + + if len(GeneralConfig.HookConfig.SplunkConfig.Dsn) > 0 || len(GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint) > 0 { + splunkClient = &splunk.Splunk{} + logCollector = &log.CollectorHook{CorrelationID: GeneralConfig.CorrelationID} + log.RegisterHook(logCollector) + } + + if err = log.RegisterANSHookIfConfigured(GeneralConfig.CorrelationID); err != nil { + log.Entry().WithError(err).Warn("failed to set up SAP Alert Notification Service log hook") + } + + validation, err := validation.New(validation.WithJSONNamesForStructFields(), validation.WithPredefinedErrorMessages()) + if err != nil { + return err + } + if err = validation.ValidateStruct(stepConfig); err != nil { + log.SetErrorCategory(log.ErrorConfiguration) + return err + } + + return nil + }, + Run: func(_ *cobra.Command, _ []string) { + vaultClient := config.GlobalVaultClient() + if vaultClient != nil { + defer vaultClient.MustRevokeToken() + } + + stepTelemetryData := telemetry.CustomData{} + stepTelemetryData.ErrorCode = "1" + handler := func() { + reports.persist(stepConfig, GeneralConfig.GCPJsonKeyFilePath, GeneralConfig.GCSBucketId, GeneralConfig.GCSFolderPath, GeneralConfig.GCSSubFolder) + config.RemoveVaultSecretFiles() + stepTelemetryData.Duration = fmt.Sprintf("%v", time.Since(startTime).Milliseconds()) + stepTelemetryData.ErrorCategory = log.GetErrorCategory().String() + stepTelemetryData.PiperCommitHash = GitCommit + telemetryClient.SetData(&stepTelemetryData) + telemetryClient.Send() + if len(GeneralConfig.HookConfig.SplunkConfig.Dsn) > 0 { + splunkClient.Initialize(GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.SplunkConfig.Dsn, + GeneralConfig.HookConfig.SplunkConfig.Token, + GeneralConfig.HookConfig.SplunkConfig.Index, + GeneralConfig.HookConfig.SplunkConfig.SendLogs) + splunkClient.Send(telemetryClient.GetData(), logCollector) + } + if len(GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint) > 0 { + splunkClient.Initialize(GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblToken, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblIndex, + GeneralConfig.HookConfig.SplunkConfig.SendLogs) + splunkClient.Send(telemetryClient.GetData(), logCollector) + } + if GeneralConfig.HookConfig.GCPPubSubConfig.Enabled { + err := gcp.NewGcpPubsubClient( + vaultClient, + GeneralConfig.HookConfig.GCPPubSubConfig.ProjectNumber, + GeneralConfig.HookConfig.GCPPubSubConfig.IdentityPool, + GeneralConfig.HookConfig.GCPPubSubConfig.IdentityProvider, + GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.OIDCConfig.RoleID, + ).Publish(GeneralConfig.HookConfig.GCPPubSubConfig.Topic, telemetryClient.GetDataBytes()) + if err != nil { + log.Entry().WithError(err).Warn("event publish failed") + } + } + } + log.DeferExitHandler(handler) + defer handler() + telemetryClient.Initialize(GeneralConfig.NoTelemetry, STEP_NAME, GeneralConfig.HookConfig.PendoConfig.Token) + npmExecuteTests(stepConfig, &stepTelemetryData) + stepTelemetryData.ErrorCode = "0" + log.Entry().Info("SUCCESS") + }, + } + + addNpmExecuteTestsFlags(createNpmExecuteTestsCmd, &stepConfig) + return createNpmExecuteTestsCmd +} + +func addNpmExecuteTestsFlags(cmd *cobra.Command, stepConfig *npmExecuteTestsOptions) { + cmd.Flags().StringVar(&stepConfig.InstallCommand, "installCommand", `npm ci`, "Command to be executed for installation`.") + cmd.Flags().StringVar(&stepConfig.RunCommand, "runCommand", `npm run wdi5`, "Command to be executed for running tests`.") + + cmd.Flags().StringVar(&stepConfig.VaultUsername, "vaultUsername", os.Getenv("PIPER_vaultUsername"), "The base URL username.") + cmd.Flags().StringVar(&stepConfig.VaultPassword, "vaultPassword", os.Getenv("PIPER_vaultPassword"), "The base URL password.") + cmd.Flags().StringVar(&stepConfig.BaseURL, "baseUrl", `http://localhost:8080/index.html`, "Base URL of the application to be tested.") + cmd.Flags().StringVar(&stepConfig.UsernameEnvVar, "usernameEnvVar", `wdi5_username`, "Env var for username.") + cmd.Flags().StringVar(&stepConfig.PasswordEnvVar, "passwordEnvVar", `wdi5_password`, "Env var for password.") + cmd.Flags().StringVar(&stepConfig.UrlOptionPrefix, "urlOptionPrefix", os.Getenv("PIPER_urlOptionPrefix"), "If you want to specify an extra option that the tested url it appended to.\nFor example if the test URL is `http://localhost and urlOptionPrefix is `--base-url=`,\nwe'll add `--base-url=http://localhost` to your runScript.\n") + cmd.Flags().StringSliceVar(&stepConfig.Envs, "envs", []string{}, "List of environment variables to be set") + cmd.Flags().StringSliceVar(&stepConfig.Paths, "paths", []string{}, "List of paths to be added to $PATH") + cmd.Flags().StringVar(&stepConfig.WorkingDirectory, "workingDirectory", `.`, "Directory where your tests are located relative to the root of your project") + + cmd.MarkFlagRequired("runCommand") +} + +// retrieve step metadata +func npmExecuteTestsMetadata() config.StepData { + var theMetaData = config.StepData{ + Metadata: config.StepMetadata{ + Name: "npmExecuteTests", + Aliases: []config.Alias{}, + Description: "Executes end-to-end tests using npm", + }, + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Parameters: []config.StepParameters{ + { + Name: "installCommand", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `npm ci`, + }, + { + Name: "runCommand", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + Aliases: []config.Alias{}, + Default: `npm run wdi5`, + }, + { + Name: "vaultURLs", + ResourceRef: []config.ResourceReference{ + { + Name: "appMetadataVaultSecretName", + Type: "vaultSecret", + Default: "appMetadata", + }, + }, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "[]map[string]interface{}", + Mandatory: false, + Aliases: []config.Alias{}, + }, + { + Name: "vaultUsername", + ResourceRef: []config.ResourceReference{ + { + Name: "appMetadataVaultSecretName", + Type: "vaultSecret", + Default: "appMetadata", + }, + }, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultUsername"), + }, + { + Name: "vaultPassword", + ResourceRef: []config.ResourceReference{ + { + Name: "appMetadataVaultSecretName", + Type: "vaultSecret", + Default: "appMetadata", + }, + }, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_vaultPassword"), + }, + { + Name: "baseUrl", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `http://localhost:8080/index.html`, + }, + { + Name: "usernameEnvVar", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `wdi5_username`, + }, + { + Name: "passwordEnvVar", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `wdi5_password`, + }, + { + Name: "urlOptionPrefix", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_urlOptionPrefix"), + }, + { + Name: "envs", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "[]string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: []string{}, + }, + { + Name: "paths", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "[]string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: []string{}, + }, + { + Name: "workingDirectory", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `.`, + }, + }, + }, + Containers: []config.Container{ + {Name: "node", Image: "node:lts-bookworm", EnvVars: []config.EnvVar{{Name: "BASE_URL", Value: "${{params.baseUrl}}"}, {Name: "CREDENTIALS_ID", Value: "${{params.credentialsId}}"}, {Name: "no_proxy", Value: "localhost,selenium,$no_proxy"}, {Name: "NO_PROXY", Value: "localhost,selenium,$NO_PROXY"}}, WorkingDir: "/home/node"}, + }, + Sidecars: []config.Container{ + {Name: "selenium", Image: "selenium/standalone-chrome", EnvVars: []config.EnvVar{{Name: "NO_PROXY", Value: "localhost,selenium,$NO_PROXY"}, {Name: "no_proxy", Value: "localhost,selenium,$no_proxy"}}}, + }, + Outputs: config.StepOutputs{ + Resources: []config.StepResources{ + { + Name: "reports", + Type: "reports", + Parameters: []map[string]interface{}{ + {"filePattern": "**/e2e-results.xml", "type": "end-to-end-test"}, + }, + }, + }, + }, + }, + } + return theMetaData +} diff --git a/cmd/npmExecuteTests_generated_test.go b/cmd/npmExecuteTests_generated_test.go new file mode 100644 index 0000000000..58bab7ed11 --- /dev/null +++ b/cmd/npmExecuteTests_generated_test.go @@ -0,0 +1,20 @@ +//go:build unit +// +build unit + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNpmExecuteTestsCommand(t *testing.T) { + t.Parallel() + + testCmd := NpmExecuteTestsCommand() + + // only high level testing performed - details are tested in step generation procedure + assert.Equal(t, "npmExecuteTests", testCmd.Use, "command name incorrect") + +} diff --git a/cmd/npmExecuteTests_test.go b/cmd/npmExecuteTests_test.go new file mode 100644 index 0000000000..bd044ead68 --- /dev/null +++ b/cmd/npmExecuteTests_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRunNpmExecuteTests(t *testing.T) { + t.Parallel() + + testCmd := NpmExecuteTestsCommand() + + // only high level testing performed - details are tested in step generation procedure + assert.Equal(t, "npmExecuteTests", testCmd.Use, "command name incorrect") +} + +func TestParseURLs(t *testing.T) { + tests := []struct { + name string + input []map[string]interface{} + expected []vaultUrl + wantErr bool + }{ + { + name: "Valid URLs", + input: []map[string]interface{}{ + { + "url": "http://example.com", + "username": "user1", + "password": "pass1", + }, + { + "url": "http://example2.com", + }, + }, + expected: []vaultUrl{ + { + URL: "http://example.com", + Username: "user1", + Password: "pass1", + }, + { + URL: "http://example2.com", + }, + }, + wantErr: false, + }, + { + name: "Invalid URL entry", + input: []map[string]interface{}{ + { + "username": "user1", + }, + }, + expected: nil, + wantErr: true, + }, + { + name: "Invalid URL field type", + input: []map[string]interface{}{ + { + "url": 123, + }, + }, + expected: nil, + wantErr: true, + }, + { + name: "Empty URLs", + input: []map[string]interface{}{}, + expected: []vaultUrl{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseURLs(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/cmd/piper.go b/cmd/piper.go index 1b4972987a..295827f8fa 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -24,14 +24,14 @@ type GeneralConfigOptions struct { CorrelationID string CustomConfig string GitHubTokens []string // list of entries in form of : to allow token authentication for downloading config / defaults - DefaultConfig []string //ordered list of Piper default configurations. Can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + DefaultConfig []string // ordered list of Piper default configurations. Can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' IgnoreCustomDefaults bool ParametersJSON string EnvRootPath string NoTelemetry bool StageName string StepConfigJSON string - StepMetadata string //metadata to be considered, can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' + StepMetadata string // metadata to be considered, can be filePath or ENV containing JSON in format 'ENV:MY_ENV_VAR' StepName string Verbose bool LogFormat string @@ -161,6 +161,7 @@ func Execute() { rootCmd.AddCommand(AbapEnvironmentRunATCCheckCommand()) rootCmd.AddCommand(NpmExecuteScriptsCommand()) rootCmd.AddCommand(NpmExecuteLintCommand()) + rootCmd.AddCommand(NpmExecuteTestsCommand()) rootCmd.AddCommand(GctsCreateRepositoryCommand()) rootCmd.AddCommand(GctsExecuteABAPQualityChecksCommand()) rootCmd.AddCommand(GctsExecuteABAPUnitTestsCommand()) @@ -269,7 +270,6 @@ func addRootFlags(rootCmd *cobra.Command) { rootCmd.PersistentFlags().StringVar(&GeneralConfig.GCSFolderPath, "gcsFolderPath", "", "GCS folder path. One of the components of GCS target folder") rootCmd.PersistentFlags().StringVar(&GeneralConfig.GCSBucketId, "gcsBucketId", "", "Bucket name for Google Cloud Storage") rootCmd.PersistentFlags().StringVar(&GeneralConfig.GCSSubFolder, "gcsSubFolder", "", "Used to logically separate results of the same step result type") - } // ResolveAccessTokens reads a list of tokens in format host:token passed via command line @@ -353,7 +353,6 @@ func initStageName(outputToLog bool) { // PrepareConfig reads step configuration from various sources and merges it (defaults, config file, flags, ...) func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName string, options interface{}, openFile func(s string, t map[string]string) (io.ReadCloser, error)) error { - log.SetFormatter(GeneralConfig.LogFormat) initStageName(true) @@ -398,7 +397,7 @@ func PrepareConfig(cmd *cobra.Command, metadata *config.StepData, stepName strin // use config & defaults var customConfig io.ReadCloser var err error - //accept that config file and defaults cannot be loaded since both are not mandatory here + // accept that config file and defaults cannot be loaded since both are not mandatory here { projectConfigFile := getProjectConfigFile(GeneralConfig.CustomConfig) if exists, err := piperutils.FileExists(projectConfigFile); exists { @@ -625,7 +624,6 @@ func getStepOptionsStructType(stepOptions interface{}) reflect.Type { } func getProjectConfigFile(name string) string { - var altName string if ext := filepath.Ext(name); ext == ".yml" { altName = fmt.Sprintf("%v.yaml", strings.TrimSuffix(name, ext)) diff --git a/documentation/docs/steps/npmExecuteTests.md b/documentation/docs/steps/npmExecuteTests.md new file mode 100644 index 0000000000..26af47289c --- /dev/null +++ b/documentation/docs/steps/npmExecuteTests.md @@ -0,0 +1,114 @@ +# ${docGenStepName} (Beta) + +[!WARNING] +Please note, that the npmExecuteTests step is in beta state, and there could be breaking changes before we remove the beta notice. + +## ${docGenDescription} + +## ${docGenParameters} + +## ${docGenConfiguration} + +## Examples + +### Simple example using wdi5 + +```yaml +stages: + - name: Test + steps: + - name: npmExecuteTests + type: npmExecuteTests + params: + baseUrl: "http://example.com/index.html" +``` + +This will run your wdi5 tests with the given baseUrl. + +### Advanced example using custom test script with credentials using Vault + +```yaml +stages: + - name: Test + steps: + - name: npmExecuteTests + type: npmExecuteTests + params: + installCommand: "npm install" + runCommand: "npm run custom-e2e-test" + usernameEnvVar: "e2e_username" + passwordEnvVar: "e2e_password" + baseUrl: "http://example.com/index.html" + urlOptionPrefix: "--base-url=" +``` + +and Vault configuration in PIPELINE-GROUP-/PIPELINE-/appMetadata + +```json +{ + "vaultURLs": [ + { + "url": "http://one.example.com/index.html", + "username": "some-username1", + "password": "some-password1" + }, + { + "url": "http://two.example.com/index.html", + "username": "some-username2", + "password": "some-password2" + } + ], + "vaultUsername": "base-url-username", + "vaultPassword": "base-url-password" +} +``` + +This will run your custom install and run script for each URL from secrets and use the given URL like so: + +```shell +npm run custom-e2e-test --base-url=http://one.example.com/index.html +``` + +Each test run will have their own environment variables set: + +```shell +e2e_username=some-username1 +e2e_password=some-password1 +``` + +Environment variables are reset before each test run with their corresponding values from the secrets + +### Custom environment variables and $PATH + +```yaml +stages: + - name: Test + steps: + - name: npmExecuteTests + type: npmExecuteTests + params: + envs: + - "MY_ENV_VAR=value" + paths: + - "/path/to/add" +``` + +If you're running uiVeri5 tests, you might need to set additional environment variables or add paths to the $PATH variable. This can be done using the `envs` and `paths` parameters: + +```yaml +stages: + - name: Test + steps: + - name: npmExecuteTests + type: npmExecuteTests + params: + runCommand: "/home/node/.npm-global/bin/uiveri5" + installCommand: "npm install @ui5/uiveri5 --global --quiet" + runOptions: ["--seleniumAddress=http://localhost:4444/wd/hub"] + usernameEnvVar: "PIPER_SELENIUM_HUB_USER" + passwordEnvVar: "PIPER_SELENIUM_HUB_PASSWORD" + envs: + - "NPM_CONFIG_PREFIX=~/.npm-global" + paths: + - "~/.npm-global/bin" +``` diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index c7e3674b75..953bfb2c94 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -155,6 +155,7 @@ nav: - npmExecuteEndToEndTests: steps/npmExecuteEndToEndTests.md - npmExecuteLint: steps/npmExecuteLint.md - npmExecuteScripts: steps/npmExecuteScripts.md + - npmExecuteTests: steps/npmExecuteTests.md - pipelineExecute: steps/pipelineExecute.md - pipelineRestartSteps: steps/pipelineRestartSteps.md - pipelineStashFiles: steps/pipelineStashFiles.md diff --git a/resources/metadata/npmExecuteTests.yaml b/resources/metadata/npmExecuteTests.yaml new file mode 100644 index 0000000000..7eb1530428 --- /dev/null +++ b/resources/metadata/npmExecuteTests.yaml @@ -0,0 +1,159 @@ +metadata: + name: npmExecuteTests + description: Executes end-to-end tests using npm + longDescription: | + This step executes end-to-end tests in a Docker environment using npm. + + The step spins up a Docker container based on the specified `dockerImage` and executes the `installScript` and `runScript` from `package.json`. + + The application URLs and credentials can be specified in `appUrls` and `credentialsId` respectively. If `wdi5` is set to `true`, the step uses `wdi5_username` and `wdi5_password` for authentication. + + The tests can be restricted to run only on the productive branch by setting `onlyRunInProductiveBranch` to `true`. + +spec: + inputs: + params: + - name: installCommand + type: string + description: Command to be executed for installation`. + scope: + - PARAMETERS + - STAGES + - STEPS + default: "npm ci" + - name: runCommand + type: string + description: Command to be executed for running tests`. + scope: + - PARAMETERS + - STAGES + - STEPS + mandatory: true + default: "npm run wdi5" + - name: vaultURLs + type: "[]map[string]interface{}" + description: | + An array of objects, each representing an application URL with associated credentials. + Each object must have the following properties: + - `url`: The URL of the application. + - `username`: The username for accessing the application. + - `password`: The password for accessing the application. + scope: + - PARAMETERS + - STAGES + - STEPS + resourceRef: + - type: vaultSecret + default: appMetadata + name: appMetadataVaultSecretName + - name: vaultUsername + type: "string" + description: The base URL username. + scope: + - PARAMETERS + - STAGES + - STEPS + resourceRef: + - type: vaultSecret + default: appMetadata + name: appMetadataVaultSecretName + - name: vaultPassword + type: "string" + description: The base URL password. + scope: + - PARAMETERS + - STAGES + - STEPS + resourceRef: + - type: vaultSecret + default: appMetadata + name: appMetadataVaultSecretName + - name: baseUrl + type: string + default: "http://localhost:8080/index.html" + description: Base URL of the application to be tested. + scope: + - PARAMETERS + - STAGES + - STEPS + - name: usernameEnvVar + type: string + default: "wdi5_username" + description: Env var for username. + scope: + - PARAMETERS + - STAGES + - STEPS + - name: passwordEnvVar + type: string + default: "wdi5_password" + description: Env var for password. + scope: + - PARAMETERS + - STAGES + - STEPS + - name: urlOptionPrefix + type: string + description: | + If you want to specify an extra option that the tested url it appended to. + For example if the test URL is `http://localhost and urlOptionPrefix is `--base-url=`, + we'll add `--base-url=http://localhost` to your runScript. + scope: + - PARAMETERS + - STAGES + - STEPS + - name: envs + type: "[]string" + description: List of environment variables to be set + scope: + - PARAMETERS + - STAGES + - STEPS + - name: paths + type: "[]string" + description: List of paths to be added to $PATH + scope: + - PARAMETERS + - STAGES + - STEPS + - name: workingDirectory + type: string + default: "." + description: Directory where your tests are located relative to the root of your project + scope: + - PARAMETERS + - STAGES + - STEPS + outputs: + resources: + - name: reports + type: reports + params: + - filePattern: "**/e2e-results.xml" + type: end-to-end-test + containers: + - name: node + image: node:lts-bookworm + env: + - name: BASE_URL + value: ${{params.baseUrl}} + - name: CREDENTIALS_ID + value: ${{params.credentialsId}} + - name: no_proxy + value: localhost,selenium,$no_proxy + - name: NO_PROXY + value: localhost,selenium,$NO_PROXY + workingDir: /home/node + sidecars: + - image: selenium/standalone-chrome + name: selenium + securityContext: + privileged: true + volumeMounts: + - mountPath: /dev/shm + name: dev-shm + env: + - name: "NO_PROXY" + value: "localhost,selenium,$NO_PROXY" + - name: "no_proxy" + value: "localhost,selenium,$no_proxy" diff --git a/vars/npmExecuteTests.groovy b/vars/npmExecuteTests.groovy new file mode 100644 index 0000000000..bb34499c22 --- /dev/null +++ b/vars/npmExecuteTests.groovy @@ -0,0 +1,15 @@ +import groovy.transform.Field + +@Field String STEP_NAME = getClass().getName() +@Field String METADATA_FILE = 'metadata/npmExecuteTests.yaml' + +@Field Set GENERAL_CONFIG_KEYS = [] + +@Field Set STEP_CONFIG_KEYS = [] + +@Field Set PARAMETER_KEYS = [] + +void call(Map parameters = [:]) { + List credentials = [] + piperExecuteBin(parameters, STEP_NAME, METADATA_FILE, credentials) +}