From fc8005ed754a7254ef520c901083f5e0cc673506 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Sun, 17 Nov 2024 16:34:23 +0000 Subject: [PATCH] Copy over the initial functionality from FollowTheProcess/test (#2) ## Summary I had a branch with the very basics of this in `FollowTheProcess/test` before I decided it's actually better as it's own package --- Taskfile.yml | 16 +- go.mod | 9 +- go.sum | 6 + internal/colour/colour.go | 36 +++ internal/diff/diff.go | 271 ++++++++++++++++++ internal/diff/diff_test.go | 57 ++++ internal/diff/testdata/allnew.txtar | 13 + internal/diff/testdata/allold.txtar | 13 + internal/diff/testdata/basic.txtar | 35 +++ internal/diff/testdata/dups.txtar | 40 +++ internal/diff/testdata/end.txtar | 38 +++ internal/diff/testdata/eof.txtar | 9 + internal/diff/testdata/eof1.txtar | 18 ++ internal/diff/testdata/eof2.txtar | 18 ++ internal/diff/testdata/long.txtar | 62 ++++ internal/diff/testdata/same.txtar | 5 + internal/diff/testdata/start.txtar | 34 +++ internal/diff/testdata/triv.txtar | 40 +++ snapshot.go | 134 ++++++++- snapshot_test.go | 140 ++++++++- .../string_fail_already_exists.snap.txt | 1 + .../string_pass_already_exists.snap.txt | 1 + .../TestSnap/string_pass_new_snap.snap.txt | 1 + 23 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 go.sum create mode 100644 internal/colour/colour.go create mode 100644 internal/diff/diff.go create mode 100644 internal/diff/diff_test.go create mode 100644 internal/diff/testdata/allnew.txtar create mode 100644 internal/diff/testdata/allold.txtar create mode 100644 internal/diff/testdata/basic.txtar create mode 100644 internal/diff/testdata/dups.txtar create mode 100644 internal/diff/testdata/end.txtar create mode 100644 internal/diff/testdata/eof.txtar create mode 100644 internal/diff/testdata/eof1.txtar create mode 100644 internal/diff/testdata/eof2.txtar create mode 100644 internal/diff/testdata/long.txtar create mode 100644 internal/diff/testdata/same.txtar create mode 100644 internal/diff/testdata/start.txtar create mode 100644 internal/diff/testdata/triv.txtar create mode 100644 testdata/snapshots/TestSnap/string_fail_already_exists.snap.txt create mode 100644 testdata/snapshots/TestSnap/string_pass_already_exists.snap.txt create mode 100644 testdata/snapshots/TestSnap/string_pass_new_snap.snap.txt diff --git a/Taskfile.yml b/Taskfile.yml index 92c3bd1..7bf1c2b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,7 +9,7 @@ tasks: default: desc: List all available tasks silent: true - cmds: + cmds: - task --list tidy: @@ -36,14 +36,20 @@ tasks: desc: Run the test suite sources: - "**/*.go" - cmds: + - testdata/**/* + - go.mod + - go.sum + cmds: - go test -race ./... {{ .CLI_ARGS }} bench: desc: Run all project benchmarks sources: - "**/*.go" - cmds: + - testdata/**/* + - go.mod + - go.sum + cmds: - go test ./... -run None -benchmem -bench . {{ .CLI_ARGS }} lint: @@ -51,7 +57,7 @@ tasks: sources: - "**/*.go" - .golangci.yml - cmds: + cmds: - golangci-lint run --fix preconditions: - sh: command -v golangci-lint @@ -81,7 +87,7 @@ tasks: sloc: desc: Print lines of code - cmds: + cmds: - fd . -e go | xargs wc -l | sort -nr | head clean: diff --git a/go.mod b/go.mod index 7e08d3f..d182ddc 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/FollowTheProcess/snapshot -go 1.23 \ No newline at end of file +go 1.23 + +require ( + github.com/FollowTheProcess/test v0.17.1 + golang.org/x/tools v0.27.0 +) + +require github.com/google/go-cmp v0.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8167952 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/FollowTheProcess/test v0.17.1 h1:j4TkMqzxvYoyAP9alaTNPgKOPUJHOBCs0z4fNJb7Kr0= +github.com/FollowTheProcess/test v0.17.1/go.mod h1:LlRdAk8bwBZ5kP10xHOcOTknNUrHU347IH7RgAm2Dgs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= diff --git a/internal/colour/colour.go b/internal/colour/colour.go new file mode 100644 index 0000000..41ef08c --- /dev/null +++ b/internal/colour/colour.go @@ -0,0 +1,36 @@ +// Package colour implements basic text colouring for showing text diffs. +// +// I'm really putting "a little copying is better than a little dependency" into action here +// this is from FollowTheProcess/test. +package colour + +// ANSI codes for coloured output, they are all the same length so as not to throw off +// alignment of [text/tabwriter]. +const ( + codeRed = "\x1b[0;0031m" // Red, used for diff lines starting with '-' + codeHeader = "\x1b[1;0036m" // Bold cyan, used for diff headers starting with '@@' + codeGreen = "\x1b[0;0032m" // Green, used for diff lines starting with '+' + codeReset = "\x1b[000000m" // Reset all attributes +) + +// Header returns a diff header styled string. +func Header(text string) string { + return sprint(codeHeader, text) +} + +// Green returns a green styled string. +func Green(text string) string { + return sprint(codeGreen, text) +} + +// Red returns a red styled string. +func Red(text string) string { + return sprint(codeRed, text) +} + +// sprint returns a string with a given colour and the reset code. +// +// It handles checking for NO_COLOR and FORCE_COLOR. +func sprint(code, text string) string { + return code + text + codeReset +} diff --git a/internal/diff/diff.go b/internal/diff/diff.go new file mode 100644 index 0000000..1806017 --- /dev/null +++ b/internal/diff/diff.go @@ -0,0 +1,271 @@ +// Taken from Go's internal/diff with only very minor tweaks; those being: +// - Adding an extra space between the diff character (+/-) and the line so we can easily colour it +// - Lint ignores +// - Renaming diff test package to diff_test +// +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package diff + +import ( + "bytes" + "fmt" + "sort" + "strings" +) + +// A pair is a pair of values tracked for both the x and y side of a diff. +// It is typically a pair of line indexes. +type pair struct{ x, y int } + +// Diff returns an anchored diff of the two texts old and new +// in the “unified diff” format. If old and new are identical, +// Diff returns a nil slice (no output). +// +// Unix diff implementations typically look for a diff with +// the smallest number of lines inserted and removed, +// which can in the worst case take time quadratic in the +// number of lines in the texts. As a result, many implementations +// either can be made to run for a long time or cut off the search +// after a predetermined amount of work. +// +// In contrast, this implementation looks for a diff with the +// smallest number of “unique” lines inserted and removed, +// where unique means a line that appears just once in both old and new. +// We call this an “anchored diff” because the unique lines anchor +// the chosen matching regions. An anchored diff is usually clearer +// than a standard diff, because the algorithm does not try to +// reuse unrelated blank lines or closing braces. +// The algorithm also guarantees to run in O(n log n) time +// instead of the standard O(n²) time. +// +// Some systems call this approach a “patience diff,” named for +// the “patience sorting” algorithm, itself named for a solitaire card game. +// We avoid that name for two reasons. First, the name has been used +// for a few different variants of the algorithm, so it is imprecise. +// Second, the name is frequently interpreted as meaning that you have +// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm, +// when in fact the algorithm is faster than the standard one. +func Diff( //nolint: gocyclo + oldName string, + old []byte, + newName string, + new []byte, //nolint: predeclared +) []byte { + if bytes.Equal(old, new) { + return nil + } + x := lines(old) + y := lines(new) + + // Print diff header. + var out bytes.Buffer + fmt.Fprintf(&out, "diff %s %s\n", oldName, newName) + fmt.Fprintf(&out, "--- %s\n", oldName) + fmt.Fprintf(&out, "+++ %s\n", newName) + + // Loop over matches to consider, + // expanding each match to include surrounding lines, + // and then printing diff chunks. + // To avoid setup/teardown cases outside the loop, + // tgs returns a leading {0,0} and trailing {len(x), len(y)} pair + // in the sequence of matches. + var ( + done pair // printed up to x[:done.x] and y[:done.y] + chunk pair // start lines of current chunk + count pair // number of lines from each side in current chunk + ctext []string // lines for current chunk + ) + for _, m := range tgs(x, y) { + if m.x < done.x { + // Already handled scanning forward from earlier match. + continue + } + + // Expand matching lines as far possible, + // establishing that x[start.x:end.x] == y[start.y:end.y]. + // Note that on the first (or last) iteration we may (or definitey do) + // have an empty match: start.x==end.x and start.y==end.y. + start := m + for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] { + start.x-- + start.y-- + } + end := m + for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] { + end.x++ + end.y++ + } + + // Emit the mismatched lines before start into this chunk. + // (No effect on first sentinel iteration, when start = {0,0}.) + for _, s := range x[done.x:start.x] { + ctext = append(ctext, "- "+s) + count.x++ + } + for _, s := range y[done.y:start.y] { + ctext = append(ctext, "+ "+s) + count.y++ + } + + // If we're not at EOF and have too few common lines, + // the chunk includes all the common lines and continues. + const C = 3 // number of context lines + if (end.x < len(x) || end.y < len(y)) && + (end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) { + for _, s := range x[start.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + continue + } + + // End chunk with common lines for context. + if len(ctext) > 0 { + n := end.x - start.x + if n > C { + n = C + } + for _, s := range x[start.x : start.x+n] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = pair{start.x + n, start.y + n} + + // Format and emit chunk. + // Convert line numbers to 1-indexed. + // Special case: empty file shows up as 0,0 not 1,0. + if count.x > 0 { + chunk.x++ + } + if count.y > 0 { + chunk.y++ + } + fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y) + for _, s := range ctext { + out.WriteString(s) + } + count.x = 0 + count.y = 0 + ctext = ctext[:0] + } + + // If we reached EOF, we're done. + if end.x >= len(x) && end.y >= len(y) { + break + } + + // Otherwise start a new chunk. + chunk = pair{end.x - C, end.y - C} + for _, s := range x[chunk.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + } + + return out.Bytes() +} + +// lines returns the lines in the file x, including newlines. +// If the file does not end in a newline, one is supplied +// along with a warning about the missing newline. +func lines(x []byte) []string { + l := strings.SplitAfter(string(x), "\n") + if l[len(l)-1] == "" { + l = l[:len(l)-1] + } else { + // Treat last line as having a message about the missing newline attached, + // using the same text as BSD/GNU diff (including the leading backslash). + l[len(l)-1] += "\n\\ No newline at end of file\n" + } + return l +} + +// tgs returns the pairs of indexes of the longest common subsequence +// of unique lines in x and y, where a unique line is one that appears +// once in x and once in y. +// +// The longest common subsequence algorithm is as described in +// Thomas G. Szymanski, “A Special Case of the Maximal Common +// Subsequence Problem,” Princeton TR #170 (January 1975), +// available at https://research.swtch.com/tgs170.pdf. +func tgs(x, y []string) []pair { + // Count the number of times each string appears in a and b. + // We only care about 0, 1, many, counted as 0, -1, -2 + // for the x side and 0, -4, -8 for the y side. + // Using negative numbers now lets us distinguish positive line numbers later. + m := make(map[string]int) + for _, s := range x { + if c := m[s]; c > -2 { + m[s] = c - 1 + } + } + for _, s := range y { + if c := m[s]; c > -8 { + m[s] = c - 4 //nolint: mnd + } + } + + // Now unique strings can be identified by m[s] = -1+-4. + // + // Gather the indexes of those strings in x and y, building: + // xi[i] = increasing indexes of unique strings in x. + // yi[i] = increasing indexes of unique strings in y. + // inv[i] = index j such that x[xi[i]] = y[yi[j]]. + var xi, yi, inv []int + for i, s := range y { + if m[s] == -1+-4 { + m[s] = len(yi) + yi = append(yi, i) + } + } + for i, s := range x { + if j, ok := m[s]; ok && j >= 0 { + xi = append(xi, i) + inv = append(inv, j) + } + } + + // Apply Algorithm A from Szymanski's paper. + // In those terms, A = J = inv and B = [0, n). + // We add sentinel pairs {0,0}, and {len(x),len(y)} + // to the returned sequence, to help the processing loop. + J := inv + n := len(xi) + T := make([]int, n) + L := make([]int, n) + for i := range T { + T[i] = n + 1 + } + for i := 0; i < n; i++ { + k := sort.Search(n, func(k int) bool { + return T[k] >= J[i] + }) + T[k] = J[i] + L[i] = k + 1 + } + k := 0 + for _, v := range L { + if k < v { + k = v + } + } + seq := make([]pair, 2+k) //nolint:mnd + seq[1+k] = pair{len(x), len(y)} // sentinel at end + lastj := n + for i := n - 1; i >= 0; i-- { + if L[i] == k && J[i] < lastj { + seq[k] = pair{xi[i], yi[J[i]]} + k-- + } + } + seq[0] = pair{0, 0} // sentinel at start + return seq +} diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go new file mode 100644 index 0000000..5c5d568 --- /dev/null +++ b/internal/diff/diff_test.go @@ -0,0 +1,57 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package diff_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/FollowTheProcess/snapshot/internal/diff" + "golang.org/x/tools/txtar" +) + +func clean(text []byte) []byte { + text = bytes.ReplaceAll(text, []byte("$\n"), []byte("\n")) + text = bytes.TrimSuffix(text, []byte("^D\n")) + return text +} + +func Test(t *testing.T) { + files, err := filepath.Glob(filepath.Join("testdata", "*.txtar")) + if err != nil { + t.Fatalf("could not glob txtar files: %v", err) + } + if len(files) == 0 { + t.Fatalf("no testdata") + } + + for _, file := range files { + t.Run(filepath.Base(file), func(t *testing.T) { + contents, err := os.ReadFile(file) + if err != nil { + t.Fatalf("could not read %s: %v", file, err) + } + // Stupid windows + contents = bytes.ReplaceAll(contents, []byte("\r\n"), []byte("\n")) + archive := txtar.Parse(contents) + if len(archive.Files) != 3 || archive.Files[2].Name != "diff" { + t.Fatalf("%s: want three files, third named \"diff\", got: %v", file, archive.Files) + } + diffs := diff.Diff( + archive.Files[0].Name, + clean(archive.Files[0].Data), + archive.Files[1].Name, + clean(archive.Files[1].Data), + ) + want := clean(archive.Files[2].Data) + if !bytes.Equal(diffs, want) { + t.Fatalf("%s: have:\n%s\nwant:\n%s\n%s", file, + diffs, want, diff.Diff("have", diffs, "want", want)) + } + }) + } +} diff --git a/internal/diff/testdata/allnew.txtar b/internal/diff/testdata/allnew.txtar new file mode 100644 index 0000000..0828b55 --- /dev/null +++ b/internal/diff/testdata/allnew.txtar @@ -0,0 +1,13 @@ +-- old -- +-- new -- +a +b +c +-- diff -- +diff old new +--- old ++++ new +@@ -0,0 +1,3 @@ ++ a ++ b ++ c diff --git a/internal/diff/testdata/allold.txtar b/internal/diff/testdata/allold.txtar new file mode 100644 index 0000000..020cedf --- /dev/null +++ b/internal/diff/testdata/allold.txtar @@ -0,0 +1,13 @@ +-- old -- +a +b +c +-- new -- +-- diff -- +diff old new +--- old ++++ new +@@ -1,3 +0,0 @@ +- a +- b +- c diff --git a/internal/diff/testdata/basic.txtar b/internal/diff/testdata/basic.txtar new file mode 100644 index 0000000..f12c77d --- /dev/null +++ b/internal/diff/testdata/basic.txtar @@ -0,0 +1,35 @@ +# Example from Hunt and McIlroy, “An Algorithm for Differential File Comparison.” +# https://www.cs.dartmouth.edu/~doug/diff.pdf + +-- old -- +a +b +c +d +e +f +g +-- new -- +w +a +b +x +y +z +e +-- diff -- +diff old new +--- old ++++ new +@@ -1,7 +1,7 @@ ++ w + a + b +- c +- d ++ x ++ y ++ z + e +- f +- g diff --git a/internal/diff/testdata/dups.txtar b/internal/diff/testdata/dups.txtar new file mode 100644 index 0000000..f69f6ac --- /dev/null +++ b/internal/diff/testdata/dups.txtar @@ -0,0 +1,40 @@ +-- old -- +a + +b + +c + +d + +e + +f +-- new -- +a + +B + +C + +d + +e + +f +-- diff -- +diff old new +--- old ++++ new +@@ -1,8 +1,8 @@ + a + $ +- b +- +- c ++ B ++ ++ C + $ + d + $ diff --git a/internal/diff/testdata/end.txtar b/internal/diff/testdata/end.txtar new file mode 100644 index 0000000..1c1ef5f --- /dev/null +++ b/internal/diff/testdata/end.txtar @@ -0,0 +1,38 @@ +-- old -- +1 +2 +3 +4 +5 +6 +7 +eight +nine +ten +eleven +-- new -- +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +-- diff -- +diff old new +--- old ++++ new +@@ -5,7 +5,6 @@ + 5 + 6 + 7 +- eight +- nine +- ten +- eleven ++ 8 ++ 9 ++ 10 diff --git a/internal/diff/testdata/eof.txtar b/internal/diff/testdata/eof.txtar new file mode 100644 index 0000000..5dc145c --- /dev/null +++ b/internal/diff/testdata/eof.txtar @@ -0,0 +1,9 @@ +-- old -- +a +b +c^D +-- new -- +a +b +c^D +-- diff -- diff --git a/internal/diff/testdata/eof1.txtar b/internal/diff/testdata/eof1.txtar new file mode 100644 index 0000000..fa9e11f --- /dev/null +++ b/internal/diff/testdata/eof1.txtar @@ -0,0 +1,18 @@ +-- old -- +a +b +c +-- new -- +a +b +c^D +-- diff -- +diff old new +--- old ++++ new +@@ -1,3 +1,3 @@ + a + b +- c ++ c +\ No newline at end of file diff --git a/internal/diff/testdata/eof2.txtar b/internal/diff/testdata/eof2.txtar new file mode 100644 index 0000000..2a3e2d6 --- /dev/null +++ b/internal/diff/testdata/eof2.txtar @@ -0,0 +1,18 @@ +-- old -- +a +b +c^D +-- new -- +a +b +c +-- diff -- +diff old new +--- old ++++ new +@@ -1,3 +1,3 @@ + a + b +- c +\ No newline at end of file ++ c diff --git a/internal/diff/testdata/long.txtar b/internal/diff/testdata/long.txtar new file mode 100644 index 0000000..1ab33fb --- /dev/null +++ b/internal/diff/testdata/long.txtar @@ -0,0 +1,62 @@ +-- old -- +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +14½ +15 +16 +17 +18 +19 +20 +-- new -- +1 +2 +3 +4 +5 +6 +8 +9 +10 +11 +12 +13 +14 +17 +18 +19 +20 +-- diff -- +diff old new +--- old ++++ new +@@ -4,7 +4,6 @@ + 4 + 5 + 6 +- 7 + 8 + 9 + 10 +@@ -12,9 +11,6 @@ + 12 + 13 + 14 +- 14½ +- 15 +- 16 + 17 + 18 + 19 diff --git a/internal/diff/testdata/same.txtar b/internal/diff/testdata/same.txtar new file mode 100644 index 0000000..86b1100 --- /dev/null +++ b/internal/diff/testdata/same.txtar @@ -0,0 +1,5 @@ +-- old -- +hello world +-- new -- +hello world +-- diff -- diff --git a/internal/diff/testdata/start.txtar b/internal/diff/testdata/start.txtar new file mode 100644 index 0000000..3842583 --- /dev/null +++ b/internal/diff/testdata/start.txtar @@ -0,0 +1,34 @@ +-- old -- +e +pi +4 +5 +6 +7 +8 +9 +10 +-- new -- +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +-- diff -- +diff old new +--- old ++++ new +@@ -1,5 +1,6 @@ +- e +- pi ++ 1 ++ 2 ++ 3 + 4 + 5 + 6 diff --git a/internal/diff/testdata/triv.txtar b/internal/diff/testdata/triv.txtar new file mode 100644 index 0000000..a1fea60 --- /dev/null +++ b/internal/diff/testdata/triv.txtar @@ -0,0 +1,40 @@ +# Another example from Hunt and McIlroy, +# “An Algorithm for Differential File Comparison.” +# https://www.cs.dartmouth.edu/~doug/diff.pdf + +# Anchored diff gives up on finding anything, +# since there are no unique lines. + +-- old -- +a +b +c +a +b +b +a +-- new -- +c +a +b +a +b +c +-- diff -- +diff old new +--- old ++++ new +@@ -1,7 +1,6 @@ +- a +- b +- c +- a +- b +- b +- a ++ c ++ a ++ b ++ a ++ b ++ c diff --git a/snapshot.go b/snapshot.go index c3d6cf7..126b62b 100644 --- a/snapshot.go +++ b/snapshot.go @@ -1,7 +1,133 @@ -// Package snapshot is a placeholder for something cool. +// Package snapshot provides a mechanism and a simple interface for performing snapshot testing +// in Go tests. +// +// Snapshots are stored under testdata, organised by test case name and may be updated automatically +// by passing configuration in this package. package snapshot -// Hello returns a welcome message for the project. -func Hello() string { - return "Hello snapshot" +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/FollowTheProcess/snapshot/internal/colour" + "github.com/FollowTheProcess/snapshot/internal/diff" +) + +const ( + defaultFilePermissions = 0o644 // Default permissions for writing files, same as unix touch + defaultDirPermissions = 0o755 // Default permissions for creating directories, same as unix mkdir +) + +// Snap takes a snapshot of value and compares it against the previous snapshot stored under +// testdata/snapshots using the name of the test as the filename. +// +// If there is no previous snapshot for this test, the current snapshot is saved and test is passed, +// if there is an existing snapshot and it matches the current snapshot, the test is also passed. +// +// If the current snapshot does not match the existing one, the test will fail with a rich diff +// of the two snapshots for debugging. +func Snap(tb testing.TB, value any) { + tb.Helper() + + // Base directory under testdata where all snapshots are kept + base := filepath.Join("testdata", "snapshots") + + // Name of the file generated from t.Name(), so for subtests and table driven tests + // this will be of the form TestSomething/subtest1 for example + file := fmt.Sprintf("%s.snap.txt", tb.Name()) + + // Join up the base with the generate filepath + path := filepath.Join(base, file) + + // Because subtests insert a '/' i.e. TestSomething/subtest1, we need to make + // all directories along that path so find the last dir along the path + // and use that in the call to MkDirAll + dir := filepath.Dir(path) + + current := &bytes.Buffer{} + + switch val := value.(type) { + // TODO(@FollowTheProcess): A Snapper interface that users can implement + // to control how their types are serialised for a snapshot + case string: + current.WriteString(val) + default: + // TODO(@FollowTheProcess): Every other type, maybe fall back to + // some sort of generic printing thing? + tb.Fatalf("Snap: unhandled type %T", val) + } + + // Check if one exists already + exists, err := fileExists(path) + if err != nil { + tb.Fatalf("Snap: %v", err) + } + + if !exists { + // No previous snapshot, save the current one, potentially creating the + // directory structure for the first time, then pass the test by returning early + if err = os.MkdirAll(dir, defaultDirPermissions); err != nil { + tb.Fatalf("Snap: could not create snapshot dir: %v", err) + } + + if err = os.WriteFile(path, current.Bytes(), defaultFilePermissions); err != nil { + tb.Fatalf("Snap: could not write snapshot: %v", err) + } + // We're done + return + } + + // Previous snapshot already exists + previous, err := os.ReadFile(path) + if err != nil { + tb.Fatalf("Snap: could not read previous snapshot: %v", err) + } + + if diff := diff.Diff("previous", previous, "current", current.Bytes()); diff != nil { + tb.Fatalf("\nMismatch\n--------\n%s\n", prettyDiff(string(diff))) + } +} + +// fileExists returns whether a path exists and is a file. +func fileExists(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("could not determine existence of %s: %w", path, err) + } + + if info.IsDir() { + return false, fmt.Errorf("%s exists but is a directory, not a file", path) + } + + return true, nil +} + +// prettyDiff takes a string diff in unified diff format and colourises it for easier viewing. +func prettyDiff(diff string) string { + lines := strings.Split(diff, "\n") + for i := 0; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "---") || strings.HasPrefix(trimmed, "- ") { + lines[i] = colour.Red(lines[i]) + } + + if strings.HasPrefix(trimmed, "@@") { + lines[i] = colour.Header(lines[i]) + } + + if strings.HasPrefix(trimmed, "+++") || strings.HasPrefix(trimmed, "+ ") { + lines[i] = colour.Green(lines[i]) + } + } + + return strings.Join(lines, "\n") } diff --git a/snapshot_test.go b/snapshot_test.go index 5f37f2e..69aed9c 100644 --- a/snapshot_test.go +++ b/snapshot_test.go @@ -1,16 +1,146 @@ package snapshot_test import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" "testing" "github.com/FollowTheProcess/snapshot" + "github.com/FollowTheProcess/test" ) -func TestHello(t *testing.T) { - got := snapshot.Hello() - want := "Hello snapshot" +const ( + defaultFilePermissions = 0o644 // Default permissions for writing files, same as unix touch + defaultDirPermissions = 0o755 // Default permissions for creating directories, same as unix mkdir +) + +// TB is a fake implementation of [testing.TB] that simply records in internal +// state whether or not it would have failed and what it would have written. +type TB struct { + testing.TB + out io.Writer + name string + failed bool +} + +func (t *TB) Helper() {} + +func (t *TB) Name() string { + return t.name +} + +func (t *TB) Fatal(args ...any) { + t.failed = true + fmt.Fprint(t.out, args...) +} + +func (t *TB) Fatalf(format string, args ...any) { + t.failed = true + fmt.Fprintf(t.out, format, args...) +} + +func TestSnap(t *testing.T) { + tests := []struct { + value any // Value to snap + name string // Name of the test case (and snapshot file) + createWith string // Create the snapshot file ahead of time with this content + clean bool // If a matching snapshot already exists, remove it first to test clean state + wantFail bool // Whether we want the test to fail + }{ + { + name: "string pass new snap", + value: "Hello snap\n", + wantFail: false, + clean: true, // Delete any matching snap that may already exist so we know it's new + }, + { + name: "string pass already exists", + value: "Hello snap\n", + wantFail: false, + createWith: "Hello snap\n", + }, + { + name: "string fail already exists", + value: "Hello snap\n", + wantFail: true, + createWith: "some other content\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + tb := &TB{out: buf, name: t.Name()} + + if tb.failed { + t.Fatalf("%s initial failed state should be false", t.Name()) + } + + if tt.clean { + deleteSnapshot(t) + } + + if tt.createWith != "" { + // Make the snapshot ahead of time with the given content + makeSnapshot(t, tt.createWith) + } + + // Do the snap + snapshot.Snap(tb, tt.value) + + if tb.failed != tt.wantFail { + t.Fatalf( + "tb.failed = %v, tt.wantFail = %v, output: %s\n", + tb.failed, + tt.wantFail, + buf.String(), + ) + } + }) + } +} + +func snapShotPath(t *testing.T) string { + t.Helper() + + // Base directory under testdata where all snapshots are kept + base := filepath.Join(test.Data(t), "snapshots") + + // Name of the file generated from t.Name(), so for subtests and table driven tests + // this will be of the form TestSomething/subtest1 for example + file := fmt.Sprintf("%s.snap.txt", t.Name()) + + // Join up the base with the generate filepath + return filepath.Join(base, file) +} + +func makeSnapshot(t *testing.T, content string) { + t.Helper() + + path := snapShotPath(t) + + // Because subtests insert a '/' i.e. TestSomething/subtest1, we need to make + // all directories along that path so find the last dir along the path + // and use that in the call to MkDirAll + dir := filepath.Dir(path) + + if err := os.MkdirAll(dir, defaultDirPermissions); err != nil { + t.Fatalf("could not create snapshot dir: %v", err) + } + // No previous snapshot, save the current to the file and pass the test by returning early + if err := os.WriteFile(path, []byte(content), defaultFilePermissions); err != nil { + t.Fatalf("could not write snapshot: %v", err) + } +} + +func deleteSnapshot(t *testing.T) { + t.Helper() + path := snapShotPath(t) - if got != want { - t.Errorf("got %s, wanted %s", got, want) + if err := os.RemoveAll(path); err != nil { + t.Fatalf("could noot delete snapshot: %v", err) } } diff --git a/testdata/snapshots/TestSnap/string_fail_already_exists.snap.txt b/testdata/snapshots/TestSnap/string_fail_already_exists.snap.txt new file mode 100644 index 0000000..7280a7a --- /dev/null +++ b/testdata/snapshots/TestSnap/string_fail_already_exists.snap.txt @@ -0,0 +1 @@ +some other content diff --git a/testdata/snapshots/TestSnap/string_pass_already_exists.snap.txt b/testdata/snapshots/TestSnap/string_pass_already_exists.snap.txt new file mode 100644 index 0000000..6f0700a --- /dev/null +++ b/testdata/snapshots/TestSnap/string_pass_already_exists.snap.txt @@ -0,0 +1 @@ +Hello snap diff --git a/testdata/snapshots/TestSnap/string_pass_new_snap.snap.txt b/testdata/snapshots/TestSnap/string_pass_new_snap.snap.txt new file mode 100644 index 0000000..6f0700a --- /dev/null +++ b/testdata/snapshots/TestSnap/string_pass_new_snap.snap.txt @@ -0,0 +1 @@ +Hello snap