diff --git a/go.mod b/go.mod index 97e74f3..730545a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/microsoftgraph/msgraph-sdk-go v1.48.0 github.com/microsoftgraph/msgraph-sdk-go-core v1.2.1 github.com/otiai10/copy v1.14.0 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 @@ -48,7 +49,6 @@ require ( github.com/microsoft/kiota-serialization-text-go v1.0.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/internal/broker/broker_test.go b/internal/broker/broker_test.go index d44d620..5574afc 100644 --- a/internal/broker/broker_test.go +++ b/internal/broker/broker_test.go @@ -259,8 +259,7 @@ func TestGetAuthenticationModes(t *testing.T) { } require.NoError(t, err, "GetAuthenticationModes should not have returned an error") - want := testutils.LoadWithUpdateFromGoldenYAML(t, got) - require.Equal(t, want, got, "GetAuthenticationModes should have returned the expected value") + testutils.CheckOrUpdateGoldenYAML(t, got) }) } } @@ -375,8 +374,7 @@ func TestSelectAuthenticationMode(t *testing.T) { } require.NoError(t, err, "SelectAuthenticationMode should not have returned an error") - want := testutils.LoadWithUpdateFromGoldenYAML(t, got) - require.Equal(t, want, got, "SelectAuthenticationMode should have returned the expected layout") + testutils.CheckOrUpdateGoldenYAML(t, got) }) } } @@ -668,7 +666,7 @@ func TestIsAuthenticated(t *testing.T) { } } - testutils.CompareTreesWithFiltering(t, outDir, testutils.GoldenPath(t), testutils.UpdateEnabled()) + testutils.CheckOrUpdateGoldenFileTree(t, outDir, testutils.GoldenPath(t)) }) } } @@ -794,7 +792,7 @@ func TestConcurrentIsAuthenticated(t *testing.T) { t.Logf("Failed to rename issuer data directory: %v", err) } } - testutils.CompareTreesWithFiltering(t, outDir, testutils.GoldenPath(t), testutils.UpdateEnabled()) + testutils.CheckOrUpdateGoldenFileTree(t, outDir, testutils.GoldenPath(t)) }) } } @@ -871,8 +869,7 @@ func TestFetchUserInfo(t *testing.T) { } require.NoError(t, err, "FetchUserInfo should not have returned an error") - want := testutils.LoadWithUpdateFromGoldenYAML(t, got) - require.Equal(t, want, got, "FetchUserInfo should have returned the expected value") + testutils.CheckOrUpdateGoldenYAML(t, got) }) } } @@ -976,8 +973,7 @@ func TestUserPreCheck(t *testing.T) { } require.NoError(t, err, "UserPreCheck should not have returned an error") - want := testutils.LoadWithUpdateFromGolden(t, got) - require.Equal(t, want, got, "UserPreCheck should have returned the expected value") + testutils.CheckOrUpdateGolden(t, got) }) } } diff --git a/internal/broker/config_test.go b/internal/broker/config_test.go index aee7521..bd02d62 100644 --- a/internal/broker/config_test.go +++ b/internal/broker/config_test.go @@ -106,7 +106,7 @@ func TestParseConfig(t *testing.T) { err = os.WriteFile(filepath.Join(outDir, "config.txt"), []byte(strings.Join(fields, "\n")), 0600) require.NoError(t, err) - testutils.CompareTreesWithFiltering(t, outDir, testutils.GoldenPath(t), testutils.UpdateEnabled()) + testutils.CheckOrUpdateGoldenFileTree(t, outDir, testutils.GoldenPath(t)) }) } } diff --git a/internal/testutils/golden.go b/internal/testutils/golden.go index 279ce5f..9a693d2 100644 --- a/internal/testutils/golden.go +++ b/internal/testutils/golden.go @@ -3,13 +3,17 @@ package testutils import ( "bytes" "errors" + "fmt" "io/fs" "os" + "os/exec" "path/filepath" + "strconv" "strings" "testing" cp "github.com/otiai10/copy" + "github.com/pmezard/go-difflib/difflib" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) @@ -29,7 +33,7 @@ func init() { } type goldenOptions struct { - goldenPath string + path string } // GoldenOption is a supported option reference to change the golden files comparison. @@ -39,33 +43,66 @@ type GoldenOption func(*goldenOptions) func WithGoldenPath(path string) GoldenOption { return func(o *goldenOptions) { if path != "" { - o.goldenPath = path + o.path = path } } } +func updateGoldenFile(t *testing.T, path string, data []byte) { + t.Logf("updating golden file %s", path) + err := os.MkdirAll(filepath.Dir(path), 0750) + require.NoError(t, err, "Cannot create directory for updating golden files") + err = os.WriteFile(path, data, 0600) + require.NoError(t, err, "Cannot write golden file") +} + +// CheckOrUpdateGolden compares the provided string with the content of the golden file. If the update environment +// variable is set, the golden file is updated with the provided string. +func CheckOrUpdateGolden(t *testing.T, got string, options ...GoldenOption) { + t.Helper() + + opts := goldenOptions{ + path: GoldenPath(t), + } + for _, f := range options { + f(&opts) + } + + if update { + updateGoldenFile(t, opts.path, []byte(got)) + } + + checkGoldenFileEqualsString(t, got, opts.path) +} + +// CheckOrUpdateGoldenYAML compares the provided object with the content of the golden file. If the update environment +// variable is set, the golden file is updated with the provided object serialized as YAML. +func CheckOrUpdateGoldenYAML[E any](t *testing.T, got E, options ...GoldenOption) { + t.Helper() + + data, err := yaml.Marshal(got) + require.NoError(t, err, "Cannot serialize provided object") + + CheckOrUpdateGolden(t, string(data), options...) +} + // LoadWithUpdateFromGolden loads the element from a plaintext golden file. // It will update the file if the update flag is used prior to loading it. -func LoadWithUpdateFromGolden(t *testing.T, data string, opts ...GoldenOption) string { +func LoadWithUpdateFromGolden(t *testing.T, data string, options ...GoldenOption) string { t.Helper() - o := goldenOptions{ - goldenPath: GoldenPath(t), + opts := goldenOptions{ + path: GoldenPath(t), } - - for _, opt := range opts { - opt(&o) + for _, f := range options { + f(&opts) } if update { - t.Logf("updating golden file %s", o.goldenPath) - err := os.MkdirAll(filepath.Dir(o.goldenPath), 0750) - require.NoError(t, err, "Cannot create directory for updating golden files") - err = os.WriteFile(o.goldenPath, []byte(data), 0600) - require.NoError(t, err, "Cannot write golden file") + updateGoldenFile(t, opts.path, []byte(data)) } - want, err := os.ReadFile(o.goldenPath) + want, err := os.ReadFile(opts.path) require.NoError(t, err, "Cannot load golden file") return string(want) @@ -73,13 +110,13 @@ func LoadWithUpdateFromGolden(t *testing.T, data string, opts ...GoldenOption) s // LoadWithUpdateFromGoldenYAML load the generic element from a YAML serialized golden file. // It will update the file if the update flag is used prior to deserializing it. -func LoadWithUpdateFromGoldenYAML[E any](t *testing.T, got E, opts ...GoldenOption) E { +func LoadWithUpdateFromGoldenYAML[E any](t *testing.T, got E, options ...GoldenOption) E { t.Helper() t.Logf("Serializing object for golden file") data, err := yaml.Marshal(got) require.NoError(t, err, "Cannot serialize provided object") - want := LoadWithUpdateFromGolden(t, string(data), opts...) + want := LoadWithUpdateFromGolden(t, string(data), options...) var wantDeserialized E err = yaml.Unmarshal([]byte(want), &wantDeserialized) @@ -121,117 +158,177 @@ func GoldenPath(t *testing.T) string { return path } -// CompareTreesWithFiltering allows comparing a goldPath directory to p. Those can be updated via the dedicated flag. -// It will filter dconf database and not commit it in the new golden directory. -func CompareTreesWithFiltering(t *testing.T, p, goldPath string, update bool) { +// runDelta pipes the unified diff through the `delta` command for word-level diff and coloring. +func runDelta(diff string) (string, error) { + cmd := exec.Command("delta", "--diff-so-fancy", "--hunk-header-style", "omit") + cmd.Stdin = strings.NewReader(diff) + + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("failed to run delta: %w", err) + } + return out.String(), nil +} + +// checkFileContent compares the content of the actual and golden files and reports any differences. +func checkFileContent(t *testing.T, actual, expected, actualPath, expectedPath string) { + if actual == expected { + return + } + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(expected), + B: difflib.SplitLines(actual), + FromFile: "Expected (golden)", + ToFile: "Actual", + Context: 3, + } + diffStr, err := difflib.GetUnifiedDiffString(diff) + require.NoError(t, err, "Cannot get unified diff") + + // Check if the `delta` command is available and use it to colorize the diff. + _, err = exec.LookPath("delta") + if err == nil { + diffStr, err = runDelta(diffStr) + require.NoError(t, err, "Cannot run delta") + } else { + diffStr = "\nDiff:\n" + diffStr + } + + msg := fmt.Sprintf("Golden file: %s", expectedPath) + if actualPath != "Actual" { + msg += fmt.Sprintf("\nFile: %s", actualPath) + } + + require.Failf(t, strings.Join([]string{ + "Golden file content mismatch", + "\nExpected (golden):", + strings.Repeat("-", 50), + strings.TrimSuffix(expected, "\n"), + strings.Repeat("-", 50), + "\nActual: ", + strings.Repeat("-", 50), + strings.TrimSuffix(actual, "\n"), + strings.Repeat("-", 50), + diffStr, + }, "\n"), msg) +} + +func checkGoldenFileEqualsFile(t *testing.T, path, goldenPath string) { + fileContent, err := os.ReadFile(path) + require.NoError(t, err, "Cannot read file %s", path) + goldenContent, err := os.ReadFile(goldenPath) + require.NoError(t, err, "Cannot read golden file %s", goldenPath) + + checkFileContent(t, string(fileContent), string(goldenContent), path, goldenPath) +} + +func checkGoldenFileEqualsString(t *testing.T, got, goldenPath string) { + goldenContent, err := os.ReadFile(goldenPath) + require.NoError(t, err, "Cannot read golden file %s", goldenPath) + + checkFileContent(t, got, string(goldenContent), "Actual", goldenPath) +} + +// CheckOrUpdateGoldenFileTree allows comparing a goldPath directory to p. Those can be updated via the dedicated flag. +func CheckOrUpdateGoldenFileTree(t *testing.T, path, goldenPath string) { t.Helper() - // UpdateEnabled golden file if update { - t.Logf("updating golden file %s", goldPath) - require.NoError(t, os.RemoveAll(goldPath), "Cannot remove target golden directory") + t.Logf("updating golden path %s", goldenPath) + err := os.RemoveAll(goldenPath) + require.NoError(t, err, "Cannot remove golden path %s", goldenPath) // check the source directory exists before trying to copy it - info, err := os.Stat(p) + info, err := os.Stat(path) if errors.Is(err, fs.ErrNotExist) { return } - require.NoErrorf(t, err, "Error on checking %q", p) + require.NoErrorf(t, err, "Error on checking %q", path) if !info.IsDir() { // copy file - data, err := os.ReadFile(p) - require.NoError(t, err, "Cannot read new generated file file %s", p) - require.NoError(t, os.WriteFile(goldPath, data, info.Mode()), "Cannot write golden file") + data, err := os.ReadFile(path) + require.NoError(t, err, "Cannot read file %s", path) + err = os.WriteFile(goldenPath, data, info.Mode()) + require.NoError(t, err, "Cannot write golden file") } else { - err := addEmptyMarker(p) - require.NoError(t, err, "Cannot add empty marker to directory %s", p) + err := addEmptyMarker(path) + require.NoError(t, err, "Cannot add empty marker to directory %s", path) - err = cp.Copy(p, goldPath) + err = cp.Copy(path, goldenPath) require.NoError(t, err, "Can’t update golden directory") } } - var gotContent map[string]treeAttrs - if _, err := os.Stat(p); err == nil { - gotContent, err = treeContentAndAttrs(t, p, nil) + // Compare the content and attributes of the files in the directories. + err := filepath.WalkDir(path, func(p string, de fs.DirEntry, err error) error { if err != nil { - t.Fatalf("No generated content: %v", err) + return err } - } - var goldContent map[string]treeAttrs - if _, err := os.Stat(goldPath); err == nil { - goldContent, err = treeContentAndAttrs(t, goldPath, nil) - if err != nil { - t.Fatalf("No golden directory found: %v", err) - } - } + relPath, err := filepath.Rel(path, p) + require.NoError(t, err, "Cannot get relative path for %s", p) + goldenFilePath := filepath.Join(goldenPath, relPath) - // Maps are not ordered, so we need to compare the content and attributes of each file - for key, value := range goldContent { - require.Equal(t, value, gotContent[key], "Content or attributes are different for %s", key) - delete(gotContent, key) - } - require.Empty(t, gotContent, "Some files are missing in the golden directory") + if de.IsDir() { + return nil + } - // No more verification on p if it doesn’t exists - if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) { - return - } -} + goldenFile, err := os.Stat(goldenFilePath) + if errors.Is(err, fs.ErrNotExist) { + require.Failf(t, "Unexpected file %s", p) + } + require.NoError(t, err, "Cannot get golden file %s", goldenFilePath) -// treeAttrs are the attributes to take into consideration when comparing each file. -type treeAttrs struct { - content string - path string - executable bool -} + file, err := os.Stat(p) + require.NoError(t, err, "Cannot get file %s", p) -const fileForEmptyDir = ".empty" + // Compare executable bit + a := strconv.FormatInt(int64(goldenFile.Mode().Perm()&0o111), 8) + b := strconv.FormatInt(int64(file.Mode().Perm()&0o111), 8) + require.Equal(t, a, b, "Executable bit does not match.\nFile: %s\nGolden file: %s", p, goldenFilePath) -// treeContentAndAttrs builds a recursive file list of dir with their content and other attributes. -// It can ignore files starting with ignoreHeaders. -func treeContentAndAttrs(t *testing.T, dir string, ignoreHeaders []byte) (map[string]treeAttrs, error) { - t.Helper() + // Compare content + checkGoldenFileEqualsFile(t, p, goldenFilePath) - r := make(map[string]treeAttrs) + return nil + }) + require.NoError(t, err, "Cannot walk through directory %s", path) - err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error { + // Check if there are files in the golden directory that are not in the source directory. + err = filepath.WalkDir(goldenPath, func(p string, de fs.DirEntry, err error) error { if err != nil { return err } - // Ignore markers for empty directories - if filepath.Base(path) == fileForEmptyDir { + // Ignore the ".empty" file + if de.Name() == fileForEmptyDir { return nil } - content := "" - info, err := os.Stat(path) - require.NoError(t, err, "Cannot stat %s", path) - if !de.IsDir() { - d, err := os.ReadFile(path) - if err != nil { - return err - } - // ignore given header - if ignoreHeaders != nil && bytes.HasPrefix(d, ignoreHeaders) { - return nil - } - content = string(d) + relPath, err := filepath.Rel(goldenPath, p) + require.NoError(t, err, "Cannot get relative path for %s", p) + filePath := filepath.Join(path, relPath) + + if de.IsDir() { + return nil } - trimmedPath := strings.TrimPrefix(path, dir) - r[trimmedPath] = treeAttrs{content, strings.TrimPrefix(path, dir), info.Mode()&0111 != 0} + + _, err = os.Stat(filePath) + require.NoError(t, err, "Missing expected file %s", filePath) + return nil }) - if err != nil { - return nil, err - } - - return r, nil + require.NoError(t, err, "Cannot walk through directory %s", goldenPath) } +const fileForEmptyDir = ".empty" + // addEmptyMarker adds to any empty directory, fileForEmptyDir to it. // That allows git to commit it. func addEmptyMarker(p string) error {