diff --git a/golden/config.go b/golden/config.go index a6ab2d73..00d608c0 100644 --- a/golden/config.go +++ b/golden/config.go @@ -11,6 +11,25 @@ import ( const goldenExtension = ".golden" +// NewConfig creates a new Config with default values. +func NewConfig() Config { + return Config{ + GoldenExtension: goldenExtension, + } +} + +// NewScriptConfig creates a new ScriptConfig with default values. +func NewScriptConfig() ScriptConfig { + return ScriptConfig{ + DisplayStdout: true, + DisplayStderr: true, + ScriptExtensions: []ScriptExtension{ + {Extension: ".sh", Command: "bash"}, + }, + GoldenExtension: goldenExtension, + } +} + // Config lets a user configure the golden file tests. type Config struct { // VerifyFunc is used to validate output against input, if provided. @@ -65,32 +84,71 @@ type Config struct { ExecutionConfig *ExecutionConfig } -// BashConfig defines the configuration for a golden bash test. -type BashConfig struct { +// ScriptConfig defines the configuration for a golden script test. +type ScriptConfig struct { // DisplayStdout indicates whether to display or suppress stdout. DisplayStdout bool // DisplayStderr indicates whether to display or suppress stderr. DisplayStderr bool // OutputProcessConfig defines how to process the output before comparison. OutputProcessConfig OutputProcessConfig + // ScriptExtensions is a list of script file extensions to be considered for + // golden file tests alongside their respective command to execute them. + // A typical definition for bash scripts looks like this: + // ScriptExtensions: []ScriptExtension{ + // {Extension: ".sh", Command: "bash"}, + // } + ScriptExtensions []ScriptExtension // GoldenExtension is the file extension to use for the golden file. If not // provided, then the default extension (.golden) is used. GoldenExtension string // Envs specifies the environment variables to set for execution. Envs [][2]string - // PostProcessFunctions defines a list of functions to be executed after the bash - // script has been run. This can be used to make use of the output of the bash script - // and perform additional operations on it. The functions are executed in the order - // they are defined and are not used for comparison. + // PostProcessFunctions defines a list of functions to be executed after the + // script has been run. This can be used to make use of the output of the + // script and perform additional operations on it. The functions are + // executed in the order they are defined and are not used for comparison. PostProcessFunctions []func(goldenFile string) error - // WorkingDir is the directory where the bash script(s) will be - // executed. + // WorkingDir is the directory where the script(s) will be executed. WorkingDir string - // WaitBefore adds a delay before running the bash script. This is useful - // when throttling is needed, e.g., when dealing with rate limiting. + // WaitBefore adds a delay before running the script. This is useful when + // throttling is needed, e.g., when dealing with rate limiting. WaitBefore time.Duration } +// NewLineStyle defines the style of new lines to be used on the output before +// comparison. +type NewLineStyle string + +const ( + // NewLineStyleUntouched indicates that the output should be kept untouched + // and not modified. + NewLineStyleUntouched NewLineStyle = "" + // NewLineStyleLF indicates that the output should be converted to LF (Line + // Feed) before comparison. This is the default style used in Unix-like + // systems. + NewLineStyleLF NewLineStyle = "LF" + // NewLineStyleCRLF indicates that the output should be converted to CRLF + // (Carriage Return + Line Feed) before comparison. This is the default + // style used in Windows systems. + NewLineStyleCRLF NewLineStyle = "CRLF" +) + +// ScriptExtension defines a script file extension and the command to execute +// it. This is used in the ScriptConfig to define which scripts should be +// considered for golden file tests and how to execute them. +type ScriptExtension struct { + // Extension is the file extension of the script, e.g., ".sh". + Extension string + // Command is the command to execute the script, e.g., "bash". + Command string + // PrefixArgs are the arguments to be passed to the command before the + // script file name. This is useful for commands that require additional + // arguments, e.g., "powershell" requires "-File" before the script file + // name. + PrefixArgs []string +} + // TransientField represents a field that is transient, this is, dynamic in // nature. Examples of such fields include durations, times, versions, etc. // Transient fields are replaced in golden file tests to always obtain the same @@ -174,6 +232,10 @@ type OutputProcessConfig struct { // KeepVolatileData indicates whether to keep or replace frequently // changing data. KeepVolatileData bool + // NewLineStyle defines the new line style to be used on the output. I.e., + // the output can be kept untouched, or converted to LF or CRLF before + // comparison. + NewLineStyle NewLineStyle // TransientFields are keys that hold values which are transient (dynamic) // in nature, such as the elapsed time, version, start time, etc. Transient // fields have a special parsing in the .golden file and they are diff --git a/golden/dag.go b/golden/dag.go index ba7dec20..c7f8ef60 100644 --- a/golden/dag.go +++ b/golden/dag.go @@ -2,6 +2,7 @@ package golden import ( "fmt" + "path/filepath" "strings" "sync" "testing" @@ -11,12 +12,12 @@ import ( type DagTestCase struct { Name string Needs []string - Config *BashConfig + Config *ScriptConfig Path string } // DagTest runs a set of test cases in topological order. -// Each test case is a BashTest, and the test cases are connected by their +// Each test case is a ScriptTest, and the test cases are connected by their // dependencies. If a test case has dependencies, it will only be run after all // of its dependencies have been run. // @@ -26,13 +27,13 @@ type DagTestCase struct { // { // name: "app-create", // needs: []string{}, -// config: BashConfig{ /**/ }, +// config: ScriptConfig{ /**/ }, // path: "app-create", // }, // { // name: "app-push", // needs: []string{"app-create"}, -// config: BashConfig{ /**/ }, +// config: ScriptConfig{ /**/ }, // path: "app-push", // }, // } @@ -79,16 +80,26 @@ func DagTest(t *testing.T, cases []DagTestCase) { var wg sync.WaitGroup for _, nextCase := range next { wg.Add(1) - config := BashConfig{} + config := NewScriptConfig() if nextCase.Config != nil { config = *nextCase.Config } + if len(config.ScriptExtensions) == 0 { + // Default script extension if none is provided. + config.ScriptExtensions = []ScriptExtension{{Extension: ".sh", Command: "bash"}} + } + + // Get the script extension for the test case. + ext, err := dagGetScriptExtension(nextCase.Path, config) + if err != nil { + t.Fatal(err) + } - nextCase := nextCase + nextCase := nextCase // Capture the variable for the goroutine. go func() { + defer wg.Done() // Run the test case. - BashTestFile(t, nextCase.Path, config) - wg.Done() + ScriptTestFile(t, ext.Command, nextCase.Path, config) }() } @@ -108,6 +119,18 @@ func DagTest(t *testing.T, cases []DagTestCase) { } } +func dagGetScriptExtension(path string, config ScriptConfig) (ScriptExtension, error) { + // Get extension from the path. + ext := filepath.Ext(path) + // Search for fitting script definition among config.ScriptExtensions. + for _, def := range config.ScriptExtensions { + if def.Extension == ext { + return def, nil + } + } + return ScriptExtension{}, fmt.Errorf("no script definition found for path %s with extension %s", path, ext) +} + func validate(cases []DagTestCase) error { // Ensure that all cases have unique names. names := make(map[string]bool) diff --git a/golden/eol.go b/golden/eol.go new file mode 100644 index 00000000..52d54b13 --- /dev/null +++ b/golden/eol.go @@ -0,0 +1,20 @@ +package golden + +import ( + "bytes" +) + +// convertNewLineStyle converts the new line style of the content based on the +// specified NewLineStyle. +func convertNewLineStyle(content []byte, style NewLineStyle) []byte { + switch style { + case NewLineStyleUntouched: + return content // Keep the original line endings + case NewLineStyleLF: + return bytes.ReplaceAll(content, []byte{'\r', '\n'}, []byte{'\n'}) + case NewLineStyleCRLF: + return bytes.ReplaceAll(content, []byte{'\n'}, []byte{'\r', '\n'}) + default: + return content // Default case, keep as is + } +} diff --git a/golden/file.go b/golden/file.go index 60025400..09500bb6 100644 --- a/golden/file.go +++ b/golden/file.go @@ -169,6 +169,8 @@ func comparison( ) } + actualBytes = convertNewLineStyle(actualBytes, config.OutputProcessConfig.NewLineStyle) + outputWithTransient := map[string]any{} flattenedOutput := map[string]any{} if !config.CompareConfig.TxtParse { diff --git a/golden/bash.go b/golden/script.go similarity index 62% rename from golden/bash.go rename to golden/script.go index e171e3f4..730a84f7 100644 --- a/golden/bash.go +++ b/golden/script.go @@ -7,7 +7,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "time" @@ -15,61 +14,125 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" ) -// BashTest executes a golden file test for a bash command. It walks over -// the goldenDir to gather all .sh scripts present in the dir. It then executes -// each of the scripts and compares expected vs. actual outputs. If -// displayStdout or displayStderr are true, the output of each script will be -// composed of the resulting stderr + stdout. +type scriptTest struct { + // Path is the path to the script file. + Path string + // Command is the command to execute the script. + Command string + // PrefixArgs are the arguments to be passed to the command before the + // script file name. + PrefixArgs []string +} + +// BashTest calls ScriptTest with bash as the command to execute the scripts and +// the file extension .sh. This is a convenience function for bash scripts. func BashTest( t *testing.T, goldenDir string, - bashConfig BashConfig, + scriptConfig ScriptConfig, +) { + scriptConfig.ScriptExtensions = []ScriptExtension{ + {Extension: ".sh", Command: "bash"}, + } + ScriptTest(t, goldenDir, scriptConfig) +} + +// ScriptTest executes a golden file test for scripts defined via +// ScriptExtensions in the config. It walks over the goldenDir to gather all +// scripts present in the dir (based on the extensions given by +// ScriptExtensions). It then executes each of the scripts and compares expected +// vs. actual outputs. If displayStdout or displayStderr are true, the output of +// each script will be composed of the resulting stderr + stdout. +func ScriptTest( + t *testing.T, + goldenDir string, + scriptConfig ScriptConfig, ) { // Fail immediately, if dir does not exist if stat, err := os.Stat(goldenDir); err != nil || !stat.IsDir() { t.Fatalf("dir %s does not exist", goldenDir) } - // Collect bash scripts. - var scripts []string + // If no script extensions are provided, we fail the test. + if len(scriptConfig.ScriptExtensions) == 0 { + t.Fatal("no script extensions provided in script config") + } + + // Collect scripts. + definitions := make(map[string]ScriptExtension) + for _, ext := range scriptConfig.ScriptExtensions { + if ext.Extension == "" || ext.Command == "" { + t.Fatal("script extension has empty extension or command") + } + if _, exists := definitions[ext.Extension]; exists { + t.Fatalf("script extension %s already defined", ext.Extension) + } + definitions[ext.Extension] = ext + } + var scripts []scriptTest fn := func(path string, _ os.FileInfo, _ error) error { - // Only consider .sh files - if strings.HasSuffix(path, ".sh") { - scripts = append(scripts, path) + // Check if the file should be considered as a script to test. + extension := filepath.Ext(path) + if _, exists := definitions[extension]; !exists { + return nil // Skip this file, it's not a script we want to test. } - + // Add to the list of scripts to test. + scripts = append(scripts, scriptTest{ + Path: path, + Command: definitions[extension].Command, + PrefixArgs: definitions[extension].PrefixArgs, + }) return nil } if err := filepath.Walk(goldenDir, fn); err != nil { t.Fatal("error walking over files: ", err) } - cwd, err := os.Getwd() - if err != nil { - t.Fatal("error getting current working directory: ", err) + // If no scripts were found, fail the test. + if len(scripts) == 0 { + t.Fatal("no scripts found in directory: ", goldenDir) } // Execute a golden file test for each script. Make the script path // absolute to avoid issues with custom working directories. + cwd, err := os.Getwd() + if err != nil { + t.Fatal("error getting current working directory: ", err) + } for _, script := range scripts { - BashTestFile(t, filepath.Join(cwd, script), bashConfig) + ScriptTestFile(t, script.Command, filepath.Join(cwd, script.Path), scriptConfig) } // Post-process files containing volatile data. - postProcessVolatileData(t, bashConfig) + postProcessVolatileData(t, scriptConfig) } -// BashTestFile executes a golden file test for a single bash script. The -// script is executed and the expected output is compared with the actual -// output. +// BashTestFile executes a golden file test for a single bash script. The script +// is executed and the expected output is compared with the actual output. func BashTestFile( t *testing.T, script string, - bashConfig BashConfig, + bashConfig ScriptConfig, +) { + ScriptTestFile( + t, + "bash", + script, + bashConfig, + ) +} + +// ScriptTestFile executes a golden file test for a single script. The script is +// executed and the expected output is compared with the actual output. +func ScriptTestFile( + t *testing.T, + command string, + script string, + scriptConfig ScriptConfig, ) { ext := goldenExtension - if bashConfig.GoldenExtension != "" { - ext = bashConfig.GoldenExtension + if scriptConfig.GoldenExtension != "" { + ext = scriptConfig.GoldenExtension } goldenFilePath := script + ext // Function run by the test. @@ -86,34 +149,34 @@ func BashTestFile( t.Fatalf("script %s does not exist", script) } - // Execute a bash command which consists of executing a .sh file. - cmd := exec.Command("bash", script) + // Execute the script using the provided command. + cmd := exec.Command(command, script) // Pass environment and add custom environment variables cmd.Env = os.Environ() - for _, e := range bashConfig.Envs { + for _, e := range scriptConfig.Envs { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", e[0], e[1])) } // Set custom working directory if provided. - if bashConfig.WorkingDir != "" { - cmd.Dir = bashConfig.WorkingDir + if scriptConfig.WorkingDir != "" { + cmd.Dir = scriptConfig.WorkingDir } // Run the command and gather the output bytes. - out, err := runCmd(cmd, bashConfig.DisplayStdout, bashConfig.DisplayStderr) + out, err := runCmd(cmd, scriptConfig.DisplayStdout, scriptConfig.DisplayStderr) if err != nil { t.Fatal(err) } // Process the output data before comparison. - got := processOutput(t, out, goldenFilePath, bashConfig.OutputProcessConfig) + got := processOutput(t, out, goldenFilePath, scriptConfig.OutputProcessConfig) // Write the output bytes to a .golden file, if the test is being // updated - if *update || bashConfig.OutputProcessConfig.AlwaysUpdate { + if *update || scriptConfig.OutputProcessConfig.AlwaysUpdate { if err := os.WriteFile(goldenFilePath, []byte(got), 0o644); err != nil { - t.Fatal("error writing bash output to file: ", err) + t.Fatal("error writing script output to file: ", err) } } @@ -138,16 +201,16 @@ func BashTestFile( } // Delay the execution of the test to adhere for rate limits. - if bashConfig.WaitBefore > 0 { - t.Logf("delaying test execution for %v", bashConfig.WaitBefore) - <-time.After(bashConfig.WaitBefore) + if scriptConfig.WaitBefore > 0 { + t.Logf("delaying test execution for %v", scriptConfig.WaitBefore) + <-time.After(scriptConfig.WaitBefore) } // Test is executed. t.Run(script, f) // Run post-process functions. - for _, f := range bashConfig.PostProcessFunctions { + for _, f := range scriptConfig.PostProcessFunctions { err := f(goldenFilePath) if err != nil { t.Fatalf("error running post-process function: %v", err) @@ -216,6 +279,10 @@ func processOutput( } } + // Apply new line style (if specified). + out = convertNewLineStyle(out, config.NewLineStyle) + + // Work on string from here on. got := string(out) // Apply regex replacements for volatile data. @@ -233,10 +300,10 @@ func processOutput( func postProcessVolatileData( t *testing.T, - bashConfig BashConfig, + scriptConfig ScriptConfig, ) { // Post-process files containing volatile data. - for _, file := range bashConfig.OutputProcessConfig.VolatileDataFiles { + for _, file := range scriptConfig.OutputProcessConfig.VolatileDataFiles { // Read the file. out, err := os.ReadFile(file) if err != nil { @@ -245,11 +312,11 @@ func postProcessVolatileData( got := string(out) // Replace default volatile content with a placeholder. - if !bashConfig.OutputProcessConfig.KeepVolatileData { + if !scriptConfig.OutputProcessConfig.KeepVolatileData { got = regexReplaceAllDefault(got) } // Apply custom volatile regex replacements. - for _, r := range bashConfig.OutputProcessConfig.VolatileRegexReplacements { + for _, r := range scriptConfig.OutputProcessConfig.VolatileRegexReplacements { got = regexReplaceCustom(got, r.Replacement, r.Regex) } diff --git a/run/tests/http/async/main_test.go b/run/tests/http/async/main_test.go index 72f83412..e337f6e5 100644 --- a/run/tests/http/async/main_test.go +++ b/run/tests/http/async/main_test.go @@ -18,7 +18,7 @@ func TestMain(m *testing.M) { // the output is compared against the expected one. func TestGoldenBash(t *testing.T) { // Execute the rest of the bash commands. - golden.BashTest(t, "./bash", golden.BashConfig{ + golden.BashTest(t, "./bash", golden.ScriptConfig{ DisplayStdout: true, DisplayStderr: true, OutputProcessConfig: golden.OutputProcessConfig{ diff --git a/run/tests/http/error_log/main_test.go b/run/tests/http/error_log/main_test.go index 59ae3df7..c39a46ba 100644 --- a/run/tests/http/error_log/main_test.go +++ b/run/tests/http/error_log/main_test.go @@ -18,7 +18,7 @@ func TestMain(m *testing.M) { // the output is compared against the expected one. func TestGoldenBash(t *testing.T) { // Execute the rest of the bash commands. - golden.BashTest(t, "./bash", golden.BashConfig{ + golden.BashTest(t, "./bash", golden.ScriptConfig{ DisplayStdout: true, DisplayStderr: true, OutputProcessConfig: golden.OutputProcessConfig{ diff --git a/run/tests/http/sync/main_test.go b/run/tests/http/sync/main_test.go index ccf9bdc7..da625544 100644 --- a/run/tests/http/sync/main_test.go +++ b/run/tests/http/sync/main_test.go @@ -18,7 +18,7 @@ func TestMain(m *testing.M) { // the output is compared against the expected one. func TestGoldenBash(t *testing.T) { // Execute the rest of the bash commands. - golden.BashTest(t, "./bash", golden.BashConfig{ + golden.BashTest(t, "./bash", golden.ScriptConfig{ DisplayStdout: true, DisplayStderr: true, }) diff --git a/run/tests/http/too_many_requests/main_test.go b/run/tests/http/too_many_requests/main_test.go index ccf9bdc7..da625544 100644 --- a/run/tests/http/too_many_requests/main_test.go +++ b/run/tests/http/too_many_requests/main_test.go @@ -18,7 +18,7 @@ func TestMain(m *testing.M) { // the output is compared against the expected one. func TestGoldenBash(t *testing.T) { // Execute the rest of the bash commands. - golden.BashTest(t, "./bash", golden.BashConfig{ + golden.BashTest(t, "./bash", golden.ScriptConfig{ DisplayStdout: true, DisplayStderr: true, }) diff --git a/run/tests/simple/main_test.go b/run/tests/simple/main_test.go index c013fef3..746c4205 100644 --- a/run/tests/simple/main_test.go +++ b/run/tests/simple/main_test.go @@ -40,7 +40,7 @@ func TestGolden(t *testing.T) { // the output is compared against the expected one. func TestGoldenBash(t *testing.T) { // Execute the rest of the bash commands. - golden.BashTest(t, "./bash", golden.BashConfig{ + golden.BashTest(t, "./bash", golden.ScriptConfig{ DisplayStdout: true, DisplayStderr: true, })