Approval-based snapshot testing library for Go (mirror)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implementation

+615 -3
+5
__snapshots__/test_snap_custom_type.new
··· 1 + --- 2 + version: 0.1.0 3 + test_name: TestSnapCustomType 4 + --- 5 + CustomStruct{Name: Alice, Age: }
+7
__snapshots__/test_snap_multiple.new
··· 1 + --- 2 + version: 0.1.0 3 + test_name: TestSnapMultiple 4 + --- 5 + value1 6 + value2 7 + 42
+5
__snapshots__/test_snap_string.new
··· 1 + --- 2 + version: 0.1.0 3 + test_name: TestSnapString 4 + --- 5 + hello world
+57
cmd/freeze/main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/ptdewey/freeze" 9 + ) 10 + 11 + func main() { 12 + flag.Usage = func() { 13 + fmt.Fprintf(os.Stderr, `Usage: freeze [COMMAND] 14 + 15 + Commands: 16 + review Review and accept/reject new snapshots (default) 17 + accept-all Accept all new snapshots 18 + reject-all Reject all new snapshots 19 + help Show this help message 20 + 21 + Examples: 22 + freeze # Start interactive review 23 + freeze review # Same as above 24 + freeze accept-all # Accept all new snapshots 25 + freeze reject-all # Reject all new snapshots 26 + `) 27 + } 28 + 29 + flag.Parse() 30 + 31 + var cmd string 32 + if flag.NArg() > 0 { 33 + cmd = flag.Arg(0) 34 + } 35 + 36 + var err error 37 + switch cmd { 38 + case "", "review": 39 + err = freeze.Review() 40 + case "accept-all": 41 + err = freeze.AcceptAll() 42 + case "reject-all": 43 + err = freeze.RejectAll() 44 + case "help", "-h", "--help": 45 + flag.Usage() 46 + return 47 + default: 48 + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) 49 + flag.Usage() 50 + os.Exit(1) 51 + } 52 + 53 + if err != nil { 54 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 55 + os.Exit(1) 56 + } 57 + }
+102
diff.go
··· 1 + package freeze 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + type DiffKind int 8 + 9 + const ( 10 + DiffShared DiffKind = iota 11 + DiffOld 12 + DiffNew 13 + ) 14 + 15 + type DiffLine struct { 16 + Number int 17 + Line string 18 + Kind DiffKind 19 + } 20 + 21 + func Histogram(old, new string) []DiffLine { 22 + oldLines := strings.Split(old, "\n") 23 + newLines := strings.Split(new, "\n") 24 + 25 + if len(oldLines) == 1 && oldLines[0] == "" { 26 + oldLines = []string{} 27 + } 28 + if len(newLines) == 1 && newLines[0] == "" { 29 + newLines = []string{} 30 + } 31 + 32 + matrix := computeEditDistance(oldLines, newLines) 33 + return traceback(oldLines, newLines, matrix) 34 + } 35 + 36 + func computeEditDistance(old, new []string) [][]int { 37 + m, n := len(old), len(new) 38 + matrix := make([][]int, m+1) 39 + for i := range matrix { 40 + matrix[i] = make([]int, n+1) 41 + } 42 + 43 + for i := 0; i <= m; i++ { 44 + matrix[i][0] = i 45 + } 46 + for j := 0; j <= n; j++ { 47 + matrix[0][j] = j 48 + } 49 + 50 + for i := 1; i <= m; i++ { 51 + for j := 1; j <= n; j++ { 52 + if old[i-1] == new[j-1] { 53 + matrix[i][j] = matrix[i-1][j-1] 54 + } else { 55 + matrix[i][j] = 1 + minThree(matrix[i-1][j], matrix[i][j-1], matrix[i-1][j-1]) 56 + } 57 + } 58 + } 59 + 60 + return matrix 61 + } 62 + 63 + func traceback(old, new []string, matrix [][]int) []DiffLine { 64 + var result []DiffLine 65 + i, j := len(old), len(new) 66 + 67 + for i > 0 || j > 0 { 68 + if i > 0 && j > 0 && old[i-1] == new[j-1] { 69 + result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared}}, result...) 70 + i-- 71 + j-- 72 + } else if j > 0 && (i == 0 || matrix[i][j-1] < matrix[i-1][j]) { 73 + result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) 74 + j-- 75 + } else if i > 0 { 76 + result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld}}, result...) 77 + i-- 78 + } else { 79 + result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) 80 + j-- 81 + } 82 + } 83 + 84 + for idx := range result { 85 + result[idx].Number = idx + 1 86 + } 87 + 88 + return result 89 + } 90 + 91 + func minThree(a, b, c int) int { 92 + if a < b { 93 + if a < c { 94 + return a 95 + } 96 + return c 97 + } 98 + if b < c { 99 + return b 100 + } 101 + return c 102 + }
+159
format.go
··· 1 + package freeze 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strconv" 7 + "strings" 8 + ) 9 + 10 + const ( 11 + colorRed = "\033[31m" 12 + colorGreen = "\033[32m" 13 + colorYellow = "\033[33m" 14 + colorBlue = "\033[34m" 15 + colorGray = "\033[90m" 16 + colorReset = "\033[0m" 17 + colorBold = "\033[1m" 18 + ) 19 + 20 + func TerminalWidth() int { 21 + width := os.Getenv("COLUMNS") 22 + if w, err := strconv.Atoi(width); err == nil && w > 0 { 23 + return w 24 + } 25 + return 80 26 + } 27 + 28 + func ClearScreen() { 29 + fmt.Print("\033[2J") 30 + fmt.Print("\033[H") 31 + } 32 + 33 + func ClearLine() { 34 + fmt.Print("\033[K") 35 + } 36 + 37 + func Red(s string) string { 38 + if !hasColor() { 39 + return s 40 + } 41 + return colorRed + s + colorReset 42 + } 43 + 44 + func Green(s string) string { 45 + if !hasColor() { 46 + return s 47 + } 48 + return colorGreen + s + colorReset 49 + } 50 + 51 + func Yellow(s string) string { 52 + if !hasColor() { 53 + return s 54 + } 55 + return colorYellow + s + colorReset 56 + } 57 + 58 + func Blue(s string) string { 59 + if !hasColor() { 60 + return s 61 + } 62 + return colorBlue + s + colorReset 63 + } 64 + 65 + func Gray(s string) string { 66 + if !hasColor() { 67 + return s 68 + } 69 + return colorGray + s + colorReset 70 + } 71 + 72 + func Bold(s string) string { 73 + if !hasColor() { 74 + return s 75 + } 76 + return colorBold + s + colorReset 77 + } 78 + 79 + func hasColor() bool { 80 + return os.Getenv("NO_COLOR") == "" 81 + } 82 + 83 + func NewSnapshotBox(snap *Snapshot) string { 84 + width := TerminalWidth() 85 + separator := strings.Repeat("─", width) 86 + 87 + var sb strings.Builder 88 + sb.WriteString("╭" + strings.Repeat("─", width) + "╮\n") 89 + // FIX: this line is missing the '│' symbol at the end 90 + sb.WriteString(fmt.Sprintf("│ %s\n", Blue("New Snapshot"))) 91 + sb.WriteString("├" + separator + "┤\n") 92 + 93 + lines := strings.Split(snap.Content, "\n") 94 + for _, line := range lines { 95 + if len(line) > width-4 { 96 + line = line[:width-7] + "..." 97 + } 98 + // TODO: added code lines in snapshots should be in green with "<line number> +" next to them 99 + // - line numbers should be left aligned with space padding 100 + // FIX: each of these lines is missing the '│' symbol at the end 101 + sb.WriteString(fmt.Sprintf("│ %s\n", line)) 102 + } 103 + 104 + sb.WriteString("╰" + strings.Repeat("─", width) + "╯\n") 105 + return sb.String() 106 + } 107 + 108 + func DiffSnapshotBox(old, new *Snapshot) string { 109 + width := TerminalWidth() 110 + 111 + diffLines := Histogram(old.Content, new.Content) 112 + 113 + var sb strings.Builder 114 + sb.WriteString("╭" + strings.Repeat("─", width-2) + "╮\n") 115 + sb.WriteString(fmt.Sprintf("│ %s\n", Blue("Snapshot Diff"))) 116 + sb.WriteString("├" + strings.Repeat("─", width-2) + "┤\n") 117 + 118 + for _, dl := range diffLines { 119 + var prefix string 120 + var formatted string 121 + 122 + switch dl.Kind { 123 + case DiffOld: 124 + prefix = Red("−") 125 + formatted = Red(dl.Line) 126 + case DiffNew: 127 + prefix = Green("+") 128 + formatted = Green(dl.Line) 129 + case DiffShared: 130 + prefix = " " 131 + formatted = dl.Line 132 + } 133 + 134 + display := fmt.Sprintf("%s %s", prefix, formatted) 135 + if len(display) > width-4 { 136 + display = display[:width-7] + "..." 137 + } 138 + sb.WriteString(fmt.Sprintf("│ %s\n", display)) 139 + } 140 + 141 + sb.WriteString("╰" + strings.Repeat("─", width-2) + "╯\n") 142 + return sb.String() 143 + } 144 + 145 + func FormatHeader(text string) string { 146 + return Bold(Blue(text)) 147 + } 148 + 149 + func FormatSuccess(text string) string { 150 + return Green(text) 151 + } 152 + 153 + func FormatError(text string) string { 154 + return Red(text) 155 + } 156 + 157 + func FormatWarning(text string) string { 158 + return Yellow(text) 159 + }
+11 -1
freeze.go
··· 44 44 if accepted.Content == content { 45 45 return 46 46 } 47 + 48 + if err := SaveSnapshot(snapshot, "new"); err != nil { 49 + t.Error("failed to save snapshot:", err) 50 + return 51 + } 52 + 53 + fmt.Println(DiffSnapshotBox(accepted, snapshot)) 54 + t.Error("snapshot mismatch - run 'freeze review' to update") 55 + return 47 56 } 48 57 49 58 if err := SaveSnapshot(snapshot, "new"); err != nil { ··· 51 60 return 52 61 } 53 62 54 - t.Error("snapshot mismatch - run 'freeze review' to update") 63 + fmt.Println(NewSnapshotBox(snapshot)) 64 + t.Error("new snapshot created - run 'freeze review' to accept") 55 65 } 56 66 57 67 func formatValues(values ...any) string {
+108 -2
freeze_test.go
··· 3 3 import ( 4 4 "os" 5 5 "path/filepath" 6 + "strings" 6 7 "testing" 7 8 8 9 "github.com/ptdewey/freeze" ··· 26 27 } 27 28 28 29 func TestSnapCustomType(t *testing.T) { 29 - cs := CustomStruct{Name: "Alice", Age: 30} 30 + cs := CustomStruct{ 31 + Name: "Alice", 32 + Age: 30, 33 + } 30 34 freeze.Snap(t, cs) 31 35 } 32 36 ··· 101 105 } 102 106 } 103 107 108 + func TestHistogramDiff(t *testing.T) { 109 + old := "line1\nline2\nline3" 110 + new := "line1\nmodified\nline3" 111 + 112 + diff := freeze.Histogram(old, new) 113 + 114 + if len(diff) < 3 { 115 + t.Errorf("expected at least 3 diff lines, got %d", len(diff)) 116 + } 117 + 118 + if diff[0].Kind != freeze.DiffShared || diff[0].Line != "line1" { 119 + t.Errorf("line 0: expected shared 'line1', got %v %s", diff[0].Kind, diff[0].Line) 120 + } 121 + 122 + hasModified := false 123 + for _, d := range diff { 124 + if d.Line == "modified" { 125 + hasModified = true 126 + if d.Kind != freeze.DiffNew { 127 + t.Errorf("'modified' should be marked as new") 128 + } 129 + } 130 + } 131 + if !hasModified { 132 + t.Error("diff missing 'modified' line") 133 + } 134 + 135 + hasLine3 := false 136 + for _, d := range diff { 137 + if d.Line == "line3" && d.Kind == freeze.DiffShared { 138 + hasLine3 = true 139 + } 140 + } 141 + if !hasLine3 { 142 + t.Error("diff should have 'line3' as shared") 143 + } 144 + } 145 + 146 + func TestDiffSnapshotBox(t *testing.T) { 147 + old := &freeze.Snapshot{ 148 + Version: "0.1.0", 149 + TestName: "TestDiff", 150 + Content: "old content", 151 + } 152 + 153 + new := &freeze.Snapshot{ 154 + Version: "0.1.0", 155 + TestName: "TestDiff", 156 + Content: "new content", 157 + } 158 + 159 + box := freeze.DiffSnapshotBox(old, new) 160 + if box == "" { 161 + t.Error("DiffSnapshotBox returned empty string") 162 + } 163 + 164 + if !contains(box, "Snapshot Diff") { 165 + t.Error("DiffSnapshotBox missing header") 166 + } 167 + } 168 + 169 + func TestNewSnapshotBox(t *testing.T) { 170 + snap := &freeze.Snapshot{ 171 + Version: "0.1.0", 172 + TestName: "TestNew", 173 + Content: "test content", 174 + } 175 + 176 + box := freeze.NewSnapshotBox(snap) 177 + if box == "" { 178 + t.Error("NewSnapshotBox returned empty string") 179 + } 180 + 181 + if !contains(box, "New Snapshot") { 182 + t.Error("NewSnapshotBox missing header") 183 + } 184 + } 185 + 186 + func TestFormatFunctions(t *testing.T) { 187 + tests := []struct { 188 + name string 189 + fn func(string) string 190 + text string 191 + }{ 192 + {"Red", freeze.Red, "error"}, 193 + {"Green", freeze.Green, "success"}, 194 + {"Yellow", freeze.Yellow, "warning"}, 195 + {"Blue", freeze.Blue, "info"}, 196 + } 197 + 198 + for _, tt := range tests { 199 + result := tt.fn(tt.text) 200 + if result == "" { 201 + t.Errorf("%s returned empty string", tt.name) 202 + } 203 + } 204 + } 205 + 206 + func contains(s, substr string) bool { 207 + return strings.Contains(s, substr) 208 + } 209 + 104 210 func cleanupTestSnapshots(t *testing.T) { 105 211 t.Helper() 106 212 ··· 110 216 return 111 217 } 112 218 113 - snapshotDir := filepath.Join(cwd, "__snapshots__") 219 + snapshotDir := filepath.Join(cwd, "freeze_snapshots") 114 220 _ = os.RemoveAll(snapshotDir) 115 221 }
+161
review.go
··· 1 + package freeze 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "os" 7 + "strings" 8 + ) 9 + 10 + type ReviewChoice int 11 + 12 + const ( 13 + Accept ReviewChoice = iota 14 + Reject 15 + Skip 16 + ToggleDiff 17 + Quit 18 + ) 19 + 20 + func Review() error { 21 + snapshots, err := ListNewSnapshots() 22 + if err != nil { 23 + return err 24 + } 25 + 26 + if len(snapshots) == 0 { 27 + fmt.Println(FormatSuccess("✓ No new snapshots to review")) 28 + return nil 29 + } 30 + 31 + fmt.Println(FormatHeader("🐦 Freeze - Snapshot Review")) 32 + fmt.Printf("Found %d new snapshot(s) to review\n\n", len(snapshots)) 33 + 34 + return reviewLoop(snapshots) 35 + } 36 + 37 + func reviewLoop(snapshots []string) error { 38 + reader := bufio.NewReader(os.Stdin) 39 + showDiff := false 40 + 41 + for i, testName := range snapshots { 42 + fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), FormatHeader(testName)) 43 + 44 + newSnap, err := ReadSnapshot(testName, "new") 45 + if err != nil { 46 + fmt.Println(FormatError("✗ Failed to read new snapshot: " + err.Error())) 47 + continue 48 + } 49 + 50 + accepted, acceptErr := ReadSnapshot(testName, "accepted") 51 + 52 + if acceptErr == nil && showDiff { 53 + fmt.Println(DiffSnapshotBox(accepted, newSnap)) 54 + } else if acceptErr == nil { 55 + fmt.Println(DiffSnapshotBox(accepted, newSnap)) 56 + } else { 57 + fmt.Println(NewSnapshotBox(newSnap)) 58 + } 59 + 60 + for { 61 + choice, err := askChoice(reader, i+1, len(snapshots)) 62 + if err != nil { 63 + return err 64 + } 65 + 66 + switch choice { 67 + case Accept: 68 + if err := AcceptSnapshot(testName); err != nil { 69 + fmt.Println(FormatError("✗ Failed to accept snapshot: " + err.Error())) 70 + } else { 71 + fmt.Println(FormatSuccess("✓ Snapshot accepted")) 72 + } 73 + break 74 + case Reject: 75 + if err := RejectSnapshot(testName); err != nil { 76 + fmt.Println(FormatError("✗ Failed to reject snapshot: " + err.Error())) 77 + } else { 78 + fmt.Println(FormatWarning("⊘ Snapshot rejected")) 79 + } 80 + break 81 + case Skip: 82 + fmt.Println(FormatWarning("⊘ Snapshot skipped")) 83 + break 84 + case ToggleDiff: 85 + showDiff = !showDiff 86 + if acceptErr == nil { 87 + fmt.Println(DiffSnapshotBox(accepted, newSnap)) 88 + } else { 89 + fmt.Println(NewSnapshotBox(newSnap)) 90 + } 91 + continue 92 + case Quit: 93 + fmt.Println("\nReview interrupted") 94 + return nil 95 + } 96 + break 97 + } 98 + } 99 + 100 + fmt.Println("\n" + FormatSuccess("✓ Review complete")) 101 + return nil 102 + } 103 + 104 + func askChoice(reader *bufio.Reader, current, total int) (ReviewChoice, error) { 105 + fmt.Printf("\nOptions: [a]ccept [r]eject [s]kip [d]iff [q]uit: ") 106 + 107 + input, err := reader.ReadString('\n') 108 + if err != nil { 109 + return Quit, err 110 + } 111 + 112 + input = strings.ToLower(strings.TrimSpace(input)) 113 + 114 + switch input { 115 + case "a", "accept": 116 + return Accept, nil 117 + case "r", "reject": 118 + return Reject, nil 119 + case "s", "skip": 120 + return Skip, nil 121 + case "d", "diff": 122 + return ToggleDiff, nil 123 + case "q", "quit": 124 + return Quit, nil 125 + default: 126 + fmt.Println(FormatWarning("Invalid option, please try again")) 127 + return askChoice(reader, current, total) 128 + } 129 + } 130 + 131 + func AcceptAll() error { 132 + snapshots, err := ListNewSnapshots() 133 + if err != nil { 134 + return err 135 + } 136 + 137 + for _, testName := range snapshots { 138 + if err := AcceptSnapshot(testName); err != nil { 139 + return err 140 + } 141 + } 142 + 143 + fmt.Printf(FormatSuccess("✓ Accepted %d snapshot(s)\n"), len(snapshots)) 144 + return nil 145 + } 146 + 147 + func RejectAll() error { 148 + snapshots, err := ListNewSnapshots() 149 + if err != nil { 150 + return err 151 + } 152 + 153 + for _, testName := range snapshots { 154 + if err := RejectSnapshot(testName); err != nil { 155 + return err 156 + } 157 + } 158 + 159 + fmt.Printf(FormatWarning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) 160 + return nil 161 + }