From 1b88b6e42ed5790f8cddbef76411a956467a2c90 Mon Sep 17 00:00:00 2001 From: jlandowner Date: Sun, 12 Nov 2023 10:50:31 +0900 Subject: [PATCH] use difflib.Diff instead of cmp.Diff --- go.mod | 1 + go.sum | 2 + main.go | 32 ++++-- pkg/charts/snap.go | 27 +++-- pkg/snap/diff.go | 100 +++++++++++++++++ pkg/snap/diff_test.go | 204 ++++++++++++++++++++++++++++++++++ pkg/snap/snapshot.go | 28 ++++- pkg/snap/unstructured.go | 119 ++++++++++++++++++++ pkg/snap/unstructured_test.go | 3 + 9 files changed, 490 insertions(+), 26 deletions(-) create mode 100644 pkg/snap/diff.go create mode 100644 pkg/snap/diff_test.go create mode 100644 pkg/snap/unstructured.go create mode 100644 pkg/snap/unstructured_test.go diff --git a/go.mod b/go.mod index 1ad71c9..6b68ad1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/jlandowner/helm-chartsnap go 1.21 require ( + github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b github.com/evanphx/json-patch/v5 v5.7.0 github.com/fatih/color v1.15.0 github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum index cd69f25..c969759 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= diff --git a/main.go b/main.go index 58c779b..12ade11 100644 --- a/main.go +++ b/main.go @@ -26,11 +26,12 @@ var ( ) type option struct { - ReleaseName string - Chart string - ValuesFile string - UpdateSnapshot bool - OutputDir string + ReleaseName string + Chart string + ValuesFile string + UpdateSnapshot bool + OutputDir string + DiffContextLineN int // Below properties are the same as helm global options // They are passed to the plugin as environment variables @@ -64,7 +65,7 @@ func (o *option) HelmBin() string { func main() { rootCmd := &cobra.Command{ - Use: "chartsnap", + Use: "chartsnap -c CHART", Short: "Snapshot testing tool for Helm charts", Long: ` Snapshot testing tool like Jest for Helm charts. @@ -113,7 +114,10 @@ MIT 2023 jlandowner/helm-chartsnap chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILES_DIRECTOY # Set addtional args or flags for 'helm template' command: - chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILE -- --skip-tests`, + chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILE -- --skip-tests + + # Output with no colors: + NO_COLOR=1 chartsnap -c YOUR_CHART`, Version: fmt.Sprintf("version=%s commit=%s date=%s", version, commit, date), RunE: run, PreRunE: prerun, @@ -136,6 +140,7 @@ MIT 2023 jlandowner/helm-chartsnap if err := rootCmd.MarkPersistentFlagDirname("output-dir"); err != nil { panic(err) } + rootCmd.PersistentFlags().IntVarP(&o.DiffContextLineN, "ctx-lines", "N", 3, "number of lines to show in diff output. 0 for full output") if err := rootCmd.Execute(); err != nil { slog.New(slogHandler()).Error(err.Error()) @@ -235,14 +240,21 @@ func run(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to replace snapshot file: %w", err) } } - matched, failureMessage, err := charts.Snap(ctx, snapshotFilePath, ht) + + opts := charts.ChartSnapOptions{ + HelmTemplateCmdOptions: ht, + SnapshotFile: snapshotFilePath, + DiffContextLineN: o.DiffContextLineN, + } + matched, failureMessage, err := charts.Snap(ctx, opts) if err != nil { bannerPrintln("FAIL", fmt.Sprintf("chart=%s values=%s err=%v", ht.Chart, ht.ValuesFile, err), color.FgRed, color.BgRed) return fmt.Errorf("failed to get snapshot chart=%s values=%s: %w", ht.Chart, ht.ValuesFile, err) } if !matched { - bannerPrintln("FAIL", failureMessage, color.FgRed, color.BgRed) - return fmt.Errorf("not match snapshot chart=%s values=%s", ht.Chart, ht.ValuesFile) + bannerPrintln("FAIL", "Snapshot does not match", color.FgRed, color.BgRed) + fmt.Println(failureMessage) + return fmt.Errorf("snapshot does not match chart=%s values=%s", ht.Chart, ht.ValuesFile) } return nil }) diff --git a/pkg/charts/snap.go b/pkg/charts/snap.go index c4b3625..3de70c0 100644 --- a/pkg/charts/snap.go +++ b/pkg/charts/snap.go @@ -26,10 +26,16 @@ func Log() *slog.Logger { return log } -func Snap(ctx context.Context, snapFile string, o HelmTemplateCmdOptions) (match bool, failureMessage string, err error) { +type ChartSnapOptions struct { + HelmTemplateCmdOptions HelmTemplateCmdOptions + SnapshotFile string + DiffContextLineN int +} + +func Snap(ctx context.Context, o ChartSnapOptions) (match bool, failureMessage string, err error) { sv := SnapshotValues{} - if o.ValuesFile != "" { - f, err := os.Open(o.ValuesFile) + if o.HelmTemplateCmdOptions.ValuesFile != "" { + f, err := os.Open(o.HelmTemplateCmdOptions.ValuesFile) if err != nil { return match, "", fmt.Errorf("failed to open values file: %w", err) } @@ -42,7 +48,7 @@ func Snap(ctx context.Context, snapFile string, o HelmTemplateCmdOptions) (match } Log().Debug("test spec from values file", "spec", sv.TestSpec) - out, err := o.Execute(ctx) + out, err := o.HelmTemplateCmdOptions.Execute(ctx) if err != nil { return match, "", fmt.Errorf("'helm template' command failed: %w: %s", err, out) } @@ -68,13 +74,12 @@ func Snap(ctx context.Context, snapFile string, o HelmTemplateCmdOptions) (match } } } - res, err := unstructured.Encode(manifests) - if err != nil { - return match, "", fmt.Errorf("failed to encode manifests: %w", err) - } - - s := snap.SnapShotMatcher(snapFile, SnapshotID(o.ValuesFile)) - match, err = s.Match(string(res)) + snap.SetLogger(Log()) + s := snap.UnstructuredSnapShotMatcher( + o.SnapshotFile, + SnapshotID(o.HelmTemplateCmdOptions.ValuesFile), + snap.WithDiffContextLineN(o.DiffContextLineN)) + match, err = snap.UnstructuredMatch(s, manifests) if err != nil { return match, "", fmt.Errorf("failed to get snapshot: %w", err) diff --git a/pkg/snap/diff.go b/pkg/snap/diff.go new file mode 100644 index 0000000..58f8636 --- /dev/null +++ b/pkg/snap/diff.go @@ -0,0 +1,100 @@ +package snap + +import ( + "fmt" + "strings" + + "github.com/aryann/difflib" + "github.com/fatih/color" +) + +type DiffOptions struct { + DiffContextLineN int +} + +func (o *DiffOptions) ContextLineN() int { + if o.DiffContextLineN < 0 { + return 0 + } + return o.DiffContextLineN +} + +func WithDiffContextLineN(n int) DiffOptions { + return DiffOptions{DiffContextLineN: n} +} + +func mergeDiffOpts(opts []DiffOptions) DiffOptions { + var merged DiffOptions + for _, v := range opts { + if v.DiffContextLineN > merged.DiffContextLineN { + merged.DiffContextLineN = v.DiffContextLineN + } + } + return merged +} + +func Diff(x, y string, o DiffOptions) string { + diffs := difflib.Diff(strings.Split(x, "\n"), strings.Split(y, "\n")) + + var ( + sb strings.Builder + isDiffSequence bool + ) + + for i, v := range diffs { + if o.ContextLineN() < 1 { + // all records + sb.WriteString(diffString(v)) + continue + } + + if v.Delta != difflib.Common { + isDiffSequence = true + + // if first diff, add a header and previous lines + if i > 0 && diffs[i-1].Delta == difflib.Common { + // header + sb.WriteString(color.New(color.FgCyan).Sprintf("--- line=%d\n", i)) + + // previous lines + for j := intInRange(0, len(diffs), i-o.DiffContextLineN); j < i; j++ { + sb.WriteString(fmt.Sprintf("%s\n", diffs[j])) + } + } + sb.WriteString(diffString(v)) + } else { + if isDiffSequence { + isDiffSequence = false + + // subsequent lines + for j := i; j < intInRange(0, len(diffs), i+o.DiffContextLineN); j++ { + sb.WriteString(fmt.Sprintf("%s\n", diffs[j])) + } + // divider + sb.WriteString("\n") + } + } + } + return sb.String() +} + +func intInRange(min, max, v int) int { + if v >= min && v <= max { + return v + } else if v < min { + return min + } else { + return max + } +} + +func diffString(d difflib.DiffRecord) string { + switch d.Delta { + case difflib.LeftOnly: + return color.New(color.FgRed).Sprintf("%s\n", d) + case difflib.RightOnly: + return color.New(color.FgGreen).Sprintf("%s\n", d) + default: + return fmt.Sprintf("%s\n", d) + } +} diff --git a/pkg/snap/diff_test.go b/pkg/snap/diff_test.go new file mode 100644 index 0000000..76d1227 --- /dev/null +++ b/pkg/snap/diff_test.go @@ -0,0 +1,204 @@ +package snap + +import ( + "reflect" + "testing" + + "github.com/aryann/difflib" +) + +func TestDiffOptions_ContextLineN(t *testing.T) { + type fields struct { + DiffContextLineN int + } + tests := []struct { + name string + fields fields + want int + }{ + { + name: "zero", + fields: fields{DiffContextLineN: -1}, + want: 0, + }, + { + name: "default", + fields: fields{DiffContextLineN: 3}, + want: 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &DiffOptions{ + DiffContextLineN: tt.fields.DiffContextLineN, + } + if got := o.ContextLineN(); got != tt.want { + t.Errorf("DiffOptions.ContextLineN() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_mergeDiffOpts(t *testing.T) { + type args struct { + opts []DiffOptions + } + tests := []struct { + name string + args args + want DiffOptions + }{ + { + name: "single: 0", + args: args{opts: []DiffOptions{{DiffContextLineN: 0}}}, + want: DiffOptions{DiffContextLineN: 0}, + }, + { + name: "single: 3", + args: args{opts: []DiffOptions{{DiffContextLineN: 3}}}, + want: DiffOptions{DiffContextLineN: 3}, + }, + { + name: "multiple: max 1", + args: args{ + opts: []DiffOptions{ + {DiffContextLineN: 3}, + {DiffContextLineN: 2}, + }, + }, + want: DiffOptions{DiffContextLineN: 3}, + }, + { + name: "multiple: max 2", + args: args{ + opts: []DiffOptions{ + {DiffContextLineN: 2}, + {DiffContextLineN: 3}, + }, + }, + want: DiffOptions{DiffContextLineN: 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mergeDiffOpts(tt.args.opts); !reflect.DeepEqual(got, tt.want) { + t.Errorf("mergeDiffOpts() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDiff(t *testing.T) { + type args struct { + x string + y string + o DiffOptions + } + tests := []struct { + name string + args args + want string + }{ + { + name: "single: 0", + args: args{ + x: "a", + y: "b", + o: DiffOptions{DiffContextLineN: 0}, + }, + want: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Diff(tt.args.x, tt.args.y, tt.args.o); got != tt.want { + t.Errorf("Diff() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_intInRange(t *testing.T) { + type args struct { + min int + max int + v int + } + tests := []struct { + name string + args args + want int + }{ + { + name: "min", + args: args{min: 0, max: 10, v: -1}, + want: 0, + }, + { + name: "max", + args: args{min: 0, max: 10, v: 11}, + want: 10, + }, + { + name: "in range", + args: args{min: 0, max: 10, v: 5}, + want: 5, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := intInRange(tt.args.min, tt.args.max, tt.args.v); got != tt.want { + t.Errorf("intInRange() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_diffString(t *testing.T) { + type args struct { + d difflib.DiffRecord + } + tests := []struct { + name string + args args + want string + }{ + { + name: "+", + args: args{ + difflib.DiffRecord{ + Payload: "###", + Delta: difflib.RightOnly, + }, + }, + want: "+ ###\n", + }, + { + name: "-", + args: args{ + difflib.DiffRecord{ + Payload: "###", + Delta: difflib.LeftOnly, + }, + }, + want: "- ###\n", + }, + { + name: "eq", + args: args{ + difflib.DiffRecord{ + Payload: "###", + Delta: difflib.Common, + }, + }, + want: " ###\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := diffString(tt.args.d); got != tt.want { + t.Errorf("diffString() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/snap/snapshot.go b/pkg/snap/snapshot.go index 3af04b5..19c4ff0 100644 --- a/pkg/snap/snapshot.go +++ b/pkg/snap/snapshot.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "errors" + "fmt" + "log/slog" "os" "path/filepath" "regexp" @@ -15,10 +17,21 @@ import ( "github.com/onsi/gomega/types" "github.com/pelletier/go-toml/v2" "github.com/spf13/afero" - - "fmt" ) +var log *slog.Logger + +func SetLogger(slogr *slog.Logger) { + log = slogr +} + +func Log() *slog.Logger { + if log == nil { + log = slog.Default() + } + return log +} + var ( shotCountMap = map[string]int{} cacheFs = afero.NewCacheOnReadFs( @@ -47,11 +60,15 @@ func MatchSnapShot(options ...Option) types.GomegaMatcher { return SnapShotMatcher(snapFile, snapId) } -func SnapShotMatcher(snapFile string, snapId string) *snapShotMatcher { +func SnapShotMatcher(snapFile string, snapId string, diffOpts ...DiffOptions) *snapShotMatcher { + o := mergeDiffOpts(diffOpts) + return &snapShotMatcher{ snapFilePath: snapFile, snapId: snapId, fs: cacheFs, + diffFunc: Diff, + diffOptions: o, } } @@ -63,6 +80,8 @@ type snapShotMatcher struct { fs afero.Fs expectedJson string actualJson string + diffFunc func(x, y string, opts DiffOptions) string + diffOptions DiffOptions } func (m *snapShotMatcher) Match(actual interface{}) (success bool, err error) { @@ -97,8 +116,7 @@ func (m *snapShotMatcher) Match(actual interface{}) (success bool, err error) { } func (m *snapShotMatcher) FailureMessage(actual interface{}) (message string) { - - return "Expected to match\n" + cmp.Diff(m.expectedJson, m.actualJson) + "\n" + return "Expected to match\n" + m.diffFunc(m.expectedJson, m.actualJson, m.diffOptions) + "\n" } func (m *snapShotMatcher) NegatedFailureMessage(actual interface{}) (message string) { diff --git a/pkg/snap/unstructured.go b/pkg/snap/unstructured.go new file mode 100644 index 0000000..af2293b --- /dev/null +++ b/pkg/snap/unstructured.go @@ -0,0 +1,119 @@ +package snap + +import ( + "fmt" + "regexp" + "strings" + + "github.com/aryann/difflib" + "github.com/fatih/color" + gomegatypes "github.com/onsi/gomega/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + unstructutils "github.com/jlandowner/helm-chartsnap/pkg/unstructured" +) + +func UnstructuredSnapShotMatcher(snapFile string, snapId string, diffOpts ...DiffOptions) *snapShotMatcher { + o := mergeDiffOpts(diffOpts) + + return &snapShotMatcher{ + snapFilePath: snapFile, + snapId: snapId, + fs: cacheFs, + diffFunc: UnstructuredSnapshotDiff, + diffOptions: o, + } +} + +func UnstructuredMatch(matcher gomegatypes.GomegaMatcher, manifests []unstructured.Unstructured) (success bool, err error) { + res, err := unstructutils.Encode(manifests) + if err != nil { + return false, fmt.Errorf("failed to encode manifests: %w", err) + } + return matcher.Match(string(res)) +} + +// extract kind value +func findKind(diffs []difflib.DiffRecord) string { + kindExp := regexp.MustCompile(`^ kind: (.+)$`) + for i := 0; i < len(diffs); i++ { + kindMatch := kindExp.FindStringSubmatch(diffs[i].String()) + if len(kindMatch) > 0 { + return kindMatch[1] + } + } + return "" +} + +// extract name value +func findName(diffs []difflib.DiffRecord) string { + metaExp := regexp.MustCompile(`^ metadata:$`) + nameExp := regexp.MustCompile(`^ name: (.+)$`) + for i := 0; i < len(diffs); i++ { + if metaExp.Match([]byte(diffs[i].String())) { + for j := i + 1; j < len(diffs)-i; j++ { + nameMatch := nameExp.FindStringSubmatch(diffs[j].String()) + if len(nameMatch) > 0 { + return nameMatch[1] + } + } + return "" + } + } + return "" +} + +func UnstructuredSnapshotDiff(x, y string, o DiffOptions) string { + divExp := regexp.MustCompile(`^ - object:$`) + diffs := difflib.Diff(strings.Split(x, "\n"), strings.Split(y, "\n")) + + var ( + sb strings.Builder + isDiffSequence bool + currentKind string + currentName string + ) + + for i, v := range diffs { + if o.ContextLineN() < 1 { + // all records + sb.WriteString(diffString(v)) + continue + } + + if divExp.Match([]byte(v.String())) { + isDiffSequence = false + currentKind, currentName = findKind(diffs[i:]), findName(diffs[i:]) + Log().Debug("div match", "kind", currentKind, "name", currentName, "index", i) + } + + if v.Delta != difflib.Common { + isDiffSequence = true + + // if first diff, add a header and previous lines + if i > 0 && diffs[i-1].Delta == difflib.Common { + // header + sb.WriteString(color.New(color.FgCyan).Sprintf("--- kind=%s name=%s line=%d\n", currentKind, currentName, i)) + + // previous lines + for j := intInRange(0, len(diffs), i-o.DiffContextLineN); j < i; j++ { + sb.WriteString(fmt.Sprintf("%s\n", diffs[j])) + } + } + sb.WriteString(diffString(v)) + + } else { + if isDiffSequence { + isDiffSequence = false + + // subsequent lines + for j := i; j < intInRange(0, len(diffs), i+o.DiffContextLineN); j++ { + sb.WriteString(fmt.Sprintf("%s\n", diffs[j])) + } + // divider + sb.WriteString("\n") + } + } + } + return sb.String() +} diff --git a/pkg/snap/unstructured_test.go b/pkg/snap/unstructured_test.go new file mode 100644 index 0000000..b5e98be --- /dev/null +++ b/pkg/snap/unstructured_test.go @@ -0,0 +1,3 @@ +package snap + +// TODO