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 3f127c4944..572e203c33 100644 --- a/cmd/mtaBuild_test.go +++ b/cmd/mtaBuild_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/SAP/jenkins-library/pkg/config" "github.com/SAP/jenkins-library/pkg/mock" "github.com/ghodss/yaml" "github.com/stretchr/testify/assert" @@ -26,7 +27,7 @@ func (m *mtaBuildTestUtilsBundle) SetNpmRegistries(defaultNpmRegistry string) er } func (m *mtaBuildTestUtilsBundle) InstallAllDependencies(defaultNpmRegistry string) error { - return errors.New("Test should not install dependencies.") //TODO implement test + return errors.New("Test should not install dependencies.") // TODO implement test } func (m *mtaBuildTestUtilsBundle) DownloadAndCopySettingsFiles(globalSettingsFile string, projectSettingsFile string) error { @@ -48,11 +49,12 @@ func newMtaBuildTestUtilsBundle() *mtaBuildTestUtilsBundle { } func TestMtaBuild(t *testing.T) { - cpe := mtaBuildCommonPipelineEnvironment{} + SetConfigOptions(ConfigCommandOptions{ + OpenFile: config.OpenPiperFile, + }) t.Run("Application name not set", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{} @@ -60,11 +62,9 @@ func TestMtaBuild(t *testing.T) { assert.NotNil(t, err) assert.Equal(t, "'mta.yaml' not found in project sources and 'applicationName' not provided as parameter - cannot generate 'mta.yaml' file", err.Error()) - }) t.Run("Provide default npm registry", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{ApplicationName: "myApp", Platform: "CF", DefaultNpmRegistry: "https://example.org/npm", MtarName: "myName", Source: "./", Target: "./"} @@ -78,7 +78,6 @@ func TestMtaBuild(t *testing.T) { }) t.Run("Package json does not exist", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{ApplicationName: "myApp"} @@ -88,11 +87,9 @@ func TestMtaBuild(t *testing.T) { assert.NotNil(t, err) assert.Equal(t, "package.json file does not exist", err.Error()) - }) t.Run("Write yaml file", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{ApplicationName: "myApp", Platform: "CF", MtarName: "myName", Source: "./", Target: "./", EnableSetTimestamp: true} @@ -125,11 +122,9 @@ func TestMtaBuild(t *testing.T) { assert.Equal(t, "myApp", result.Modules[0].Name) assert.Regexp(t, "^1\\.2\\.3-[\\d]{14}$", result.Modules[0].Parameters["version"]) assert.Equal(t, "myApp", result.Modules[0].Parameters["name"]) - }) t.Run("Dont write mta yaml file when already present no timestamp placeholder", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{ApplicationName: "myApp"} @@ -143,7 +138,6 @@ func TestMtaBuild(t *testing.T) { }) t.Run("Write mta yaml file when already present with timestamp placeholder", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() options := mtaBuildOptions{ApplicationName: "myApp", EnableSetTimestamp: true} @@ -157,7 +151,6 @@ func TestMtaBuild(t *testing.T) { }) t.Run("Mta build mbt toolset", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() cpe.mtarFilePath = "" @@ -179,7 +172,6 @@ func TestMtaBuild(t *testing.T) { t.Run("Source and target related tests", func(t *testing.T) { t.Run("Mta build mbt toolset with custom source and target paths", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() cpe.mtarFilePath = "" @@ -194,9 +186,11 @@ func TestMtaBuild(t *testing.T) { if assert.Len(t, utilsMock.Calls, 1) { assert.Equal(t, "mbt", utilsMock.Calls[0].Exec) - assert.Equal(t, []string{"build", "--mtar", "myName.mtar", "--platform", "CF", + assert.Equal(t, []string{ + "build", "--mtar", "myName.mtar", "--platform", "CF", "--source", filepath.FromSlash("mySourcePath/"), - "--target", filepath.Join(_ignoreError(os.Getwd()), filepath.FromSlash("mySourcePath/myTargetPath/"))}, + "--target", filepath.Join(_ignoreError(os.Getwd()), filepath.FromSlash("mySourcePath/myTargetPath/")), + }, utilsMock.Calls[0].Params) } assert.Equal(t, "mySourcePath/myTargetPath/myName.mtar", cpe.mtarFilePath) @@ -206,7 +200,6 @@ func TestMtaBuild(t *testing.T) { t.Run("M2Path related tests", func(t *testing.T) { t.Run("Mta build mbt toolset with m2Path", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() utilsMock.CurrentDir = "root_folder/workspace" cpe.mtarFilePath = "" @@ -223,9 +216,7 @@ func TestMtaBuild(t *testing.T) { }) t.Run("Settings file releatd tests", func(t *testing.T) { - t.Run("Copy global settings file", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() utilsMock.AddFile("mta.yaml", []byte("ID: \"myNameFromMtar\"")) @@ -240,7 +231,6 @@ func TestMtaBuild(t *testing.T) { }) t.Run("Copy project settings file", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() utilsMock.AddFile("mta.yaml", []byte("ID: \"myNameFromMtar\"")) @@ -256,9 +246,7 @@ func TestMtaBuild(t *testing.T) { }) t.Run("publish related tests", func(t *testing.T) { - t.Run("error when no repository url", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() utilsMock.AddFile("mta.yaml", []byte("ID: \"myNameFromMtar\"")) @@ -270,12 +258,13 @@ func TestMtaBuild(t *testing.T) { }) t.Run("error when no mtar group", func(t *testing.T) { - utilsMock := newMtaBuildTestUtilsBundle() utilsMock.AddFile("mta.yaml", []byte("ID: \"myNameFromMtar\"")) - options := mtaBuildOptions{ApplicationName: "myApp", GlobalSettingsFile: "/opt/maven/settings.xml", Platform: "CF", MtarName: "myName", Source: "./", Target: "./", Publish: true, - MtaDeploymentRepositoryURL: "dummy", MtaDeploymentRepositoryPassword: "dummy", MtaDeploymentRepositoryUser: "dummy"} + options := mtaBuildOptions{ + ApplicationName: "myApp", GlobalSettingsFile: "/opt/maven/settings.xml", Platform: "CF", MtarName: "myName", Source: "./", Target: "./", Publish: true, + MtaDeploymentRepositoryURL: "dummy", MtaDeploymentRepositoryPassword: "dummy", MtaDeploymentRepositoryUser: "dummy", + } err := runMtaBuild(options, &cpe, utilsMock) @@ -285,7 +274,6 @@ func TestMtaBuild(t *testing.T) { } func TestMtaBuildSourceDir(t *testing.T) { - cpe := mtaBuildCommonPipelineEnvironment{} t.Run("getSourcePath", func(t *testing.T) { t.Parallel() @@ -364,12 +352,10 @@ func TestMtaBuildSourceDir(t *testing.T) { err := runMtaBuild(options, &cpe, utilsMock) assert.Nil(t, err) assert.Contains(t, utilsMock.Calls[0].Params, "--sbom-file-path") - }) } func TestMtaBuildMtar(t *testing.T) { - t.Run("getMtarName", func(t *testing.T) { t.Parallel() @@ -412,7 +398,6 @@ func TestMtaBuildMtar(t *testing.T) { assert.Equal(t, filepath.FromSlash("source/target/mta.mtar"), getMtarFilePath(mtaBuildOptions{Source: "source", Target: "target"}, "mta.mtar")) }) }) - } func _ignoreError(s string, e error) string { diff --git a/cmd/npmExecuteTests.go b/cmd/npmExecuteTests.go new file mode 100644 index 0000000000..f9d78d04a3 --- /dev/null +++ b/cmd/npmExecuteTests.go @@ -0,0 +1,111 @@ +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}) + } + + 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 { + log.Entry().Infof("Running end to end tests for URL: %s", url) + + credentialsToEnv(username, password, 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) + } + + // we need to reset the env vars as the next test might not have any credentials + resetCredentials(config.UsernameEnvVar, config.PasswordEnvVar, command) + 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 == "" { + log.Entry().Warnf("Missing credentials: username: %s, password: %s", 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..841b774fb2 --- /dev/null +++ b/cmd/npmExecuteTests_generated.go @@ -0,0 +1,359 @@ +// 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"` +} + +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, _ := os.Getwd() + 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.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{}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `wdi5_username`, + }, + { + Name: "passwordEnvVar", + ResourceRef: []config.ResourceReference{}, + Scope: []string{}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: `wdi5_password`, + }, + { + Name: "urlOptionPrefix", + ResourceRef: []config.ResourceReference{}, + Scope: []string{}, + Type: "string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_urlOptionPrefix"), + }, + { + Name: "envs", + ResourceRef: []config.ResourceReference{}, + Scope: []string{}, + Type: "[]string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: []string{}, + }, + { + Name: "paths", + ResourceRef: []config.ResourceReference{}, + Scope: []string{}, + Type: "[]string", + Mandatory: false, + Aliases: []config.Alias{}, + Default: []string{}, + }, + }, + }, + Containers: []config.Container{ + {Name: "node", Image: "node:lts-bookworm", EnvVars: []config.EnvVar{{Name: "BASE_URL", Value: "${{params.baseUrl}}"}, {Name: "CREDENTIALS_ID", Value: "${{params.credentialsId}}"}}}, + }, + 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..8139654d82 --- /dev/null +++ b/documentation/docs/steps/npmExecuteTests.md @@ -0,0 +1,111 @@ +# ${docGenStepName} + +## ${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..5bc4fc946a --- /dev/null +++ b/resources/metadata/npmExecuteTests.yaml @@ -0,0 +1,113 @@ +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. + - name: passwordEnvVar + type: string + default: "wdi5_password" + description: Env var for password. + - 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. + - name: envs + type: "[]string" + description: List of environment variables to be set + - name: paths + type: "[]string" + description: List of paths to be added to $PATH + 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}} diff --git a/test.md b/test.md new file mode 100644 index 0000000000..f653826dd8 --- /dev/null +++ b/test.md @@ -0,0 +1,153 @@ +24: 24 + +25: 25 + +26: 26 + +27: 27 + +28: 28 + +29: 29 + +30: 30 + +31: 31 + +32: 32 + +33: 33 + +34: 34 + +35: 35 + +36: 36 + +37: 37 + +38: 38 + +39: 39 + +40: 40 + +41: 41 + +42: 42 + +43: 43 + +44: 44 + +45: 45 + +46: 46 + +47: 47 + +48: 48 + +49: 49 + +50: 50 + +51: 51 + +52: 52 + +53: 53 + +54: 54 + +55: 55 + +56: 56 + +57: 57 + +58: 58 + +59: 59 + +60: 60 + +61: 61 + +62: 62 + +63: 63 + +64: 64 + +65: 65 + +66: 66 + +67: 67 + +68: 68 + +69: 69 + +70: 70 + +71: 71 + +72: 72 + +73: 73 + +74: 74 + +75: 75 + +76: 76 + +77: 77 + +78: 78 + +79: 79 + +80: 80 + +81: 81 + +82: 82 + +83: 83 + +84: 84 + +85: 85 + +86: 86 + +87: 87 + +88: 88 + +89: 89 + +90: 90 + +91: 91 + +92: 92 + +93: 93 + +94: 94 + +95: 95 + +96: 96 + +97: 97 + +98: 98 + +99: 99 + +100: 100 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) +}