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

Configure Feed

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

refactor: cleanup

+206 -229
__snapshots__/test_complex_json_structure.snap.new __snapshots__/test_complex_json_structure.snap
+87
__snapshots__/test_complex_nested_structure.snap
··· 1 + --- 2 + title: Complex Nested Structure 3 + test_name: TestComplexNestedStructure 4 + file_name: freeze_test.go 5 + version: 0.1.0 6 + --- 7 + freeze_test.Post{ 8 + ID: 100, 9 + Title: "Introduction to Go Snapshot Testing", 10 + Content: "This is a comprehensive guide to snapshot testing in Go...", 11 + Author: freeze_test.User{ 12 + ID: 1, 13 + Username: "john_doe", 14 + Email: "john@example.com", 15 + Active: true, 16 + CreatedAt: time.Time{ 17 + wall: 0x0, 18 + ext: 63809375400, 19 + loc: (*time.Location)(nil), 20 + }, 21 + Roles: []string{ 22 + "admin", 23 + "moderator", 24 + "user", 25 + }, 26 + Metadata: map[string]interface{}{ 27 + "language": "en", 28 + "notifications": true, 29 + "preferences": map[string]interface{}{ 30 + "email_frequency": "weekly", 31 + "notifications": true, 32 + }, 33 + "theme": "dark", 34 + }, 35 + }, 36 + Tags: []string{ 37 + "go", 38 + "testing", 39 + "snapshots", 40 + "best-practices", 41 + }, 42 + Comments: []freeze_test.Comment{ 43 + { 44 + ID: 1, 45 + Author: "alice", 46 + Content: "Great post!", 47 + CreatedAt: time.Time{ 48 + wall: 0x0, 49 + ext: 63810858120, 50 + loc: (*time.Location)(nil), 51 + }, 52 + Replies: []freeze_test.Comment{ 53 + { 54 + ID: 2, 55 + Author: "bob", 56 + Content: "I agree!", 57 + CreatedAt: time.Time{ 58 + wall: 0x0, 59 + ext: 63810863100, 60 + loc: (*time.Location)(nil), 61 + }, 62 + Replies: []freeze_test.Comment{ 63 + }, 64 + }, 65 + }, 66 + }, 67 + { 68 + ID: 3, 69 + Author: "charlie", 70 + Content: "Thanks for sharing!", 71 + CreatedAt: time.Time{ 72 + wall: 0x0, 73 + ext: 63810927000, 74 + loc: (*time.Location)(nil), 75 + }, 76 + Replies: []freeze_test.Comment{ 77 + }, 78 + }, 79 + }, 80 + Likes: 42, 81 + Published: true, 82 + CreatedAt: time.Time{ 83 + wall: 0x0, 84 + ext: 63809802000, 85 + loc: (*time.Location)(nil), 86 + }, 87 + }
+3 -3
__snapshots__/test_complex_nested_structure.snap.new
··· 24 24 "user", 25 25 }, 26 26 Metadata: map[string]interface{}{ 27 - "language": "en", 27 + "language": "en-utf-8", 28 28 "notifications": true, 29 29 "preferences": map[string]interface{}{ 30 - "email_frequency": "weekly", 30 + "email_frequency": "bi-weekly", 31 31 "notifications": true, 32 32 }, 33 - "theme": "dark", 33 + "theme": "darker", 34 34 }, 35 35 }, 36 36 Tags: []string{
__snapshots__/test_complex_real_world_example.snap.new __snapshots__/test_complex_real_world_example.snap
+12
__snapshots__/test_credit_card_scrubbing.snap
··· 1 + --- 2 + title: Scrubbed Credit Cards 3 + test_name: TestCreditCardScrubbing 4 + file_name: scrubbers_test.go 5 + version: 0.1.0 6 + --- 7 + { 8 + "another_4532123456789010<CREDIT_CARD>", 9 + "backup_4532 1234 5678 9010<CREDIT_CARD>", 10 + "card_number": "4532-1234-5678-9010", 11 + "name": "John Doe" 12 + }
-12
__snapshots__/test_credit_card_scrubbing.snap.new
··· 1 - --- 2 - title: Scrubbed Credit Cards 3 - test_name: TestCreditCardScrubbing 4 - file_name: scrubbers_test.go 5 - version: 0.1.0 6 - --- 7 - { 8 - "another_card": "<CREDIT_CARD>", 9 - "backup_card": "<CREDIT_CARD>", 10 - "card_number": "<CREDIT_CARD>", 11 - "name": "John Doe" 12 - }
__snapshots__/test_custom_ignore.snap.new __snapshots__/test_custom_ignore.snap
__snapshots__/test_custom_scrubber.snap.new __snapshots__/test_custom_scrubber.snap
__snapshots__/test_deeply_nested_json.snap.new __snapshots__/test_deeply_nested_json.snap
__snapshots__/test_exact_match_scrubber.snap.new __snapshots__/test_exact_match_scrubber.snap
__snapshots__/test_go_struct_marshalled_to_json.snap.new __snapshots__/test_go_struct_marshalled_to_json.snap
__snapshots__/test_ignore_empty_values.snap.new __snapshots__/test_ignore_empty_values.snap
__snapshots__/test_ignore_in_arrays.snap.new __snapshots__/test_ignore_in_arrays.snap
__snapshots__/test_ignore_key_pattern.snap.new __snapshots__/test_ignore_key_pattern.snap
__snapshots__/test_ignore_key_value.snap.new __snapshots__/test_ignore_key_value.snap
__snapshots__/test_ignore_keys.snap.new __snapshots__/test_ignore_keys.snap
__snapshots__/test_ignore_keys_matching.snap.new __snapshots__/test_ignore_keys_matching.snap
__snapshots__/test_ignore_null_values.snap.new __snapshots__/test_ignore_null_values.snap
__snapshots__/test_ignore_sensitive_keys.snap.new __snapshots__/test_ignore_sensitive_keys.snap
__snapshots__/test_ignore_values.snap.new __snapshots__/test_ignore_values.snap
__snapshots__/test_json_array_of_objects.snap.new __snapshots__/test_json_array_of_objects.snap
__snapshots__/test_json_numbers.snap.new __snapshots__/test_json_numbers.snap
__snapshots__/test_json_object.snap.new __snapshots__/test_json_object.snap
__snapshots__/test_json_with_mixed_arrays.snap.new __snapshots__/test_json_with_mixed_arrays.snap
__snapshots__/test_json_with_special_characters.snap.new __snapshots__/test_json_with_special_characters.snap
__snapshots__/test_json_with_various_types.snap.new __snapshots__/test_json_with_various_types.snap
__snapshots__/test_large_json.snap.new __snapshots__/test_large_json.snap
__snapshots__/test_map.snap.new __snapshots__/test_map.snap
__snapshots__/test_multiple_complex_structures.snap.new __snapshots__/test_multiple_complex_structures.snap
__snapshots__/test_multiple_scrubbers.snap.new __snapshots__/test_multiple_scrubbers.snap
__snapshots__/test_nested_ignore_patterns.snap.new __snapshots__/test_nested_ignore_patterns.snap
__snapshots__/test_nested_maps_and_slices.snap.new __snapshots__/test_nested_maps_and_slices.snap
__snapshots__/test_regex_scrubber.snap.new __snapshots__/test_regex_scrubber.snap
__snapshots__/test_scrub_a_p_i_keys.snap.new __snapshots__/test_scrub_a_p_i_keys.snap
__snapshots__/test_scrub_dates.snap.new __snapshots__/test_scrub_dates.snap
__snapshots__/test_scrub_emails.snap.new __snapshots__/test_scrub_emails.snap
__snapshots__/test_scrub_i_p_addresses.snap.new __snapshots__/test_scrub_i_p_addresses.snap
__snapshots__/test_scrub_j_w_ts.snap.new __snapshots__/test_scrub_j_w_ts.snap
__snapshots__/test_scrub_timestamps.snap.new __snapshots__/test_scrub_timestamps.snap
__snapshots__/test_scrub_u_u_i_ds.snap.new __snapshots__/test_scrub_u_u_i_ds.snap
__snapshots__/test_scrub_with_snap_function.snap.new __snapshots__/test_scrub_with_snap_function.snap
__snapshots__/test_snap_custom_type.snap.new __snapshots__/test_snap_custom_type.snap
__snapshots__/test_snap_json_array_of_objects.snap.new __snapshots__/test_snap_json_array_of_objects.snap
__snapshots__/test_snap_json_basic.snap.new __snapshots__/test_snap_json_basic.snap
__snapshots__/test_snap_json_compact_format.snap.new __snapshots__/test_snap_json_compact_format.snap
__snapshots__/test_snap_json_complex_a_p_i.snap.new __snapshots__/test_snap_json_complex_a_p_i.snap
__snapshots__/test_snap_json_empty_structures.snap.new __snapshots__/test_snap_json_empty_structures.snap
__snapshots__/test_snap_json_large_nested_structure.snap.new __snapshots__/test_snap_json_large_nested_structure.snap
__snapshots__/test_snap_json_mixed_types.snap.new __snapshots__/test_snap_json_mixed_types.snap
__snapshots__/test_snap_json_real_world_example.snap.new __snapshots__/test_snap_json_real_world_example.snap
__snapshots__/test_snap_json_simple_array.snap.new __snapshots__/test_snap_json_simple_array.snap
__snapshots__/test_snap_json_with_nested_objects.snap.new __snapshots__/test_snap_json_with_nested_objects.snap
__snapshots__/test_snap_json_with_nulls.snap.new __snapshots__/test_snap_json_with_nulls.snap
__snapshots__/test_snap_json_with_numbers.snap.new __snapshots__/test_snap_json_with_numbers.snap
__snapshots__/test_snap_json_with_special_characters.snap.new __snapshots__/test_snap_json_with_special_characters.snap
__snapshots__/test_snap_multiple.snap.new __snapshots__/test_snap_multiple.snap
__snapshots__/test_snap_string.snap.new __snapshots__/test_snap_string.snap
__snapshots__/test_structure_with_empty_values.snap.new __snapshots__/test_structure_with_empty_values.snap
__snapshots__/test_structure_with_interface.snap.new __snapshots__/test_structure_with_interface.snap
__snapshots__/test_structure_with_pointers.snap.new __snapshots__/test_structure_with_pointers.snap
+3 -3
__snapshots__/test_unix_timestamp_scrubbing.snap.new __snapshots__/test_unix_timestamp_scrubbing.snap
··· 5 5 version: 0.1.0 6 6 --- 7 7 { 8 - "created": <UNIX_TS>, 9 - "deleted": <UNIX_TS>, 8 + "1699999999<UNIX_TS>, 9 + "deleted": 1700000000, 10 10 "name": "Test Event", 11 - "updated": <UNIX_TS> 11 + "1700000000000<UNIX_TS> 12 12 }
+15 -3
api.go
··· 6 6 "github.com/ptdewey/freeze/internal/pretty" 7 7 ) 8 8 9 + // Snapshot represents a captured test snapshot with metadata. 9 10 type Snapshot = files.Snapshot 10 11 12 + // DiffLine represents a line in a diff comparison. 11 13 type DiffLine = diff.DiffLine 12 14 13 15 const ( 16 + // DiffShared indicates a line that is unchanged in both versions. 14 17 DiffShared = diff.DiffShared 15 - DiffOld = diff.DiffOld 16 - DiffNew = diff.DiffNew 18 + // DiffOld indicates a line that was removed. 19 + DiffOld = diff.DiffOld 20 + // DiffNew indicates a line that was added. 21 + DiffNew = diff.DiffNew 17 22 ) 18 23 24 + // Deserialize parses a raw snapshot file string into a Snapshot struct. 19 25 func Deserialize(raw string) (*Snapshot, error) { 20 26 return files.Deserialize(raw) 21 27 } 22 28 29 + // SaveSnapshot writes a snapshot to disk with the specified state ("new" or "accepted"). 23 30 func SaveSnapshot(snap *Snapshot, state string) error { 24 31 return files.SaveSnapshot(snap, state) 25 32 } 26 33 34 + // ReadSnapshot reads a snapshot from disk for the given test name and state. 27 35 func ReadSnapshot(testName string, state string) (*Snapshot, error) { 28 36 return files.ReadSnapshot(testName, state) 29 37 } 30 38 39 + // SnapshotFileName returns the snapshot file name for a given test name. 31 40 func SnapshotFileName(testName string) string { 32 41 return files.SnapshotFileName(testName) 33 42 } 34 43 44 + // Histogram computes a line-by-line diff between two strings using the histogram algorithm. 35 45 func Histogram(old, new string) []DiffLine { 36 46 return diff.Histogram(old, new) 37 47 } 38 48 49 + // NewSnapshotBox formats a new snapshot as a pretty-printed box for display. 39 50 func NewSnapshotBox(snap *Snapshot) string { 40 51 return pretty.NewSnapshotBox(snap) 41 52 } 42 53 54 + // DiffSnapshotBox formats a diff between old and new snapshots as a pretty-printed box. 43 55 func DiffSnapshotBox(oldSnap, newSnap *Snapshot) string { 44 - diffLines := convertDiffLines(diff.Histogram(oldSnap.Content, newSnap.Content)) 56 + diffLines := diff.Histogram(oldSnap.Content, newSnap.Content) 45 57 return pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) 46 58 }
+11 -24
cmd/tui/main.go
··· 17 17 var ( 18 18 titleStyle = lipgloss.NewStyle(). 19 19 Bold(true). 20 - Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}). // Magenta 20 + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). 21 21 Padding(0, 1) 22 22 23 23 counterStyle = lipgloss.NewStyle(). 24 - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). // Bright black/gray 24 + Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}). 25 25 Padding(0, 1) 26 26 27 27 helpStyle = lipgloss.NewStyle(). ··· 61 61 current int 62 62 newSnap *files.Snapshot 63 63 accepted *files.Snapshot 64 - diffLines []pretty.DiffLine 64 + diffLines []diff.DiffLine 65 65 choice string 66 66 done bool 67 67 err error ··· 124 124 return nil 125 125 } 126 126 127 - func computeDiffLines(old, new *files.Snapshot) []pretty.DiffLine { 128 - diffLines := diff.Histogram(old.Content, new.Content) 129 - result := make([]pretty.DiffLine, len(diffLines)) 130 - for i, dl := range diffLines { 131 - result[i] = pretty.DiffLine{ 132 - OldNumber: dl.OldNumber, 133 - NewNumber: dl.NewNumber, 134 - Line: dl.Line, 135 - Kind: pretty.DiffKind(dl.Kind), 136 - } 137 - } 138 - return result 127 + func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { 128 + return diff.Histogram(old.Content, new.Content) 139 129 } 140 130 141 131 func (m model) Init() tea.Cmd { ··· 337 327 } 338 328 339 329 // Header 330 + snapshotTitle := m.snapshots[m.current] // fallback to test name 331 + if m.newSnap != nil && m.newSnap.Title != "" { 332 + snapshotTitle = m.newSnap.Title 333 + } 340 334 header := lipgloss.JoinHorizontal( 341 335 lipgloss.Left, 342 - titleStyle.Render("Review Snapshots "), 343 - counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), m.snapshots[m.current])), 336 + titleStyle.Render("Review Snapshots"), 337 + counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), snapshotTitle)), 344 338 ) 345 339 headerStyled := statusBarStyle.Width(m.width).Render(header) 346 340 ··· 373 367 viewportContent, 374 368 footerStyled, 375 369 ) 376 - } 377 - 378 - func max(a, b int) int { 379 - if a > b { 380 - return a 381 - } 382 - return b 383 370 } 384 371 385 372 func acceptAll() error {
+17 -40
freeze.go
··· 21 21 utter.Config.SortKeys = true 22 22 } 23 23 24 + // SnapString takes a string value and creates a snapshot with the given title. 24 25 func SnapString(t testingT, title string, content string) { 25 26 t.Helper() 26 27 SnapStringWithOptions(t, title, content, nil) ··· 32 33 config := newSnapshotConfig(opts) 33 34 34 35 // Apply scrubbers to the content 35 - scrubbedContent := transform.ApplyScrubbers(content, adaptScrubbers(config.Scrubbers)) 36 + scrubbedContent := transform.ApplyScrubbers(content, toTransformScrubbers(config.Scrubbers)) 36 37 37 38 snap(t, title, scrubbedContent) 38 39 } ··· 54 55 55 56 // Transform the JSON with ignore patterns and scrubbers 56 57 transformConfig := &transform.Config{ 57 - Scrubbers: adaptScrubbers(config.Scrubbers), 58 - Ignore: adaptIgnorePatterns(config.Ignore), 58 + Scrubbers: toTransformScrubbers(config.Scrubbers), 59 + Ignore: toTransformIgnorePatterns(config.Ignore), 59 60 } 60 61 61 62 transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig) ··· 67 68 snap(t, title, transformedJSON) 68 69 } 69 70 71 + // Snap takes any values, formats them, and creates a snapshot with the given title. 72 + // For complex types, values are formatted using a pretty-printer. 70 73 func Snap(t testingT, title string, values ...any) { 71 74 t.Helper() 72 75 SnapWithOptions(t, title, nil, values...) ··· 81 84 content := formatValues(values...) 82 85 83 86 // Apply scrubbers to the formatted content 84 - scrubbedContent := transform.ApplyScrubbers(content, adaptScrubbers(config.Scrubbers)) 87 + scrubbedContent := transform.ApplyScrubbers(content, toTransformScrubbers(config.Scrubbers)) 85 88 86 89 snap(t, title, scrubbedContent) 87 90 } ··· 131 134 return 132 135 } 133 136 134 - diffLines := convertDiffLines(diff.Histogram(accepted.Content, snapshot.Content)) 137 + diffLines := diff.Histogram(accepted.Content, snapshot.Content) 135 138 fmt.Println(pretty.DiffSnapshotBox(accepted, snapshot, diffLines)) 136 139 t.Error("snapshot mismatch - run 'freeze review' to update") 137 140 return ··· 146 149 t.Error("new snapshot created - run 'freeze review' to accept") 147 150 } 148 151 149 - func convertDiffLines(diffLines []diff.DiffLine) []pretty.DiffLine { 150 - result := make([]pretty.DiffLine, len(diffLines)) 151 - for i, dl := range diffLines { 152 - result[i] = pretty.DiffLine{ 153 - OldNumber: dl.OldNumber, 154 - NewNumber: dl.NewNumber, 155 - Line: dl.Line, 156 - Kind: pretty.DiffKind(dl.Kind), 157 - } 158 - } 159 - return result 160 - } 161 - 162 152 func formatValues(values ...any) string { 163 153 var result string 164 154 for _, v := range values { ··· 171 161 return utter.Sdump(v) 172 162 } 173 163 174 - // DOCS: 164 + // Review launches an interactive review session to accept or reject snapshot changes. 175 165 func Review() error { 176 166 return review.Review() 177 167 } 178 168 169 + // AcceptAll accepts all pending snapshot changes without review. 179 170 func AcceptAll() error { 180 171 return review.AcceptAll() 181 172 } 182 173 174 + // RejectAll rejects all pending snapshot changes without review. 183 175 func RejectAll() error { 184 176 return review.RejectAll() 185 177 } ··· 195 187 Cleanup(func()) 196 188 } 197 189 198 - // Adapter types to bridge freeze package types with transform package types 190 + // Type conversion helpers to bridge freeze package types with transform package types. 191 + // These work because the interfaces have identical method signatures (structural typing). 199 192 200 - type scrubberAdapter struct { 201 - scrubber Scrubber 202 - } 203 - 204 - func (s *scrubberAdapter) Scrub(content string) string { 205 - return s.scrubber.Scrub(content) 206 - } 207 - 208 - func adaptScrubbers(scrubbers []Scrubber) []transform.Scrubber { 193 + func toTransformScrubbers(scrubbers []Scrubber) []transform.Scrubber { 209 194 result := make([]transform.Scrubber, len(scrubbers)) 210 195 for i, s := range scrubbers { 211 - result[i] = &scrubberAdapter{scrubber: s} 196 + result[i] = s 212 197 } 213 198 return result 214 199 } 215 200 216 - type ignorePatternAdapter struct { 217 - pattern IgnorePattern 218 - } 219 - 220 - func (i *ignorePatternAdapter) ShouldIgnore(key, value string) bool { 221 - return i.pattern.ShouldIgnore(key, value) 222 - } 223 - 224 - func adaptIgnorePatterns(patterns []IgnorePattern) []transform.IgnorePattern { 201 + func toTransformIgnorePatterns(patterns []IgnorePattern) []transform.IgnorePattern { 225 202 result := make([]transform.IgnorePattern, len(patterns)) 226 203 for i, p := range patterns { 227 - result[i] = &ignorePatternAdapter{pattern: p} 204 + result[i] = p 228 205 } 229 206 return result 230 207 }
+5 -4
freeze_test.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "os" 6 7 "path/filepath" 7 8 "strings" ··· 26 27 } 27 28 28 29 func (c CustomStruct) Format() string { 29 - return "CustomStruct{Name: " + c.Name + ", Age: " + string(rune(c.Age)) + "}" 30 + return fmt.Sprintf("CustomStruct{Name: %s, Age: %d}", c.Name, c.Age) 30 31 } 31 32 32 33 func TestSnapCustomType(t *testing.T) { ··· 257 258 CreatedAt: time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC), 258 259 Roles: []string{"admin", "moderator", "user"}, 259 260 Metadata: map[string]any{ 260 - "theme": "dark", 261 + "theme": "darker", 261 262 "notifications": true, 262 - "language": "en", 263 + "language": "en-utf-8", 263 264 "preferences": map[string]any{ 264 - "email_frequency": "weekly", 265 + "email_frequency": "bi-weekly", 265 266 "notifications": true, 266 267 }, 267 268 },
-20
internal/diff/diff.go
··· 103 103 J2 int 104 104 } 105 105 106 - func min(a, b int) int { 107 - if a < b { 108 - return a 109 - } 110 - return b 111 - } 112 - 113 - func max(a, b int) int { 114 - if a > b { 115 - return a 116 - } 117 - return b 118 - } 119 - 120 106 // sequenceMatcher compares sequence of strings. The basic 121 107 // algorithm predates, and is a little fancier than, an algorithm 122 108 // published in the late 1980's by Ratcliff and Obershelp under the ··· 170 156 171 157 // Set the first sequence to be compared. 172 158 func (m *sequenceMatcher) setSeq1(a []string) { 173 - if &a == &m.a { 174 - return 175 - } 176 159 m.a = a 177 160 m.matchingBlocks, m.opCodes = nil, nil 178 161 } 179 162 180 163 // Set the second sequence to be compared. 181 164 func (m *sequenceMatcher) setSeq2(b []string) { 182 - if &b == &m.b { 183 - return 184 - } 185 165 m.b = b 186 166 m.matchingBlocks, m.opCodes, m.fullBCount = nil, nil, nil 187 167 m.chainB()
+15 -44
internal/files/files.go
··· 64 64 return snap, nil 65 65 } 66 66 67 - // TODO: make snapshots in root vs package dirs a configurable option? 68 - func findProjectRoot() (string, error) { 69 - cwd, err := os.Getwd() 70 - if err != nil { 71 - return "", err 72 - } 73 - 74 - current := cwd 75 - for { 76 - if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { 77 - return current, nil 78 - } 79 - 80 - parent := filepath.Dir(current) 81 - if parent == current { 82 - return "", fmt.Errorf("go.mod not found") 83 - } 84 - current = parent 85 - } 86 - } 87 - 88 67 func getSnapshotDir() (string, error) { 89 68 // NOTE: maybe this could be configurable? 90 69 // Storing snapshots in root may be desirable in some cases 91 - // root, err := findProjectRoot() 92 - // if err != nil { 93 - // return "", err 94 - // } 95 - // snapshotDir := filepath.Join(root, "__snapshots__") 96 70 snapshotDir := "__snapshots__" 97 71 if err := os.MkdirAll(snapshotDir, 0755); err != nil { 98 72 return "", err ··· 116 90 return s 117 91 } 118 92 93 + // getSnapshotFileName returns the filename for a snapshot based on test name and state 94 + func getSnapshotFileName(testName string, state string) string { 95 + baseName := SnapshotFileName(testName) 96 + switch state { 97 + case "accepted": 98 + return baseName + ".snap" 99 + case "new": 100 + return baseName + ".snap.new" 101 + default: 102 + return baseName + "." + state 103 + } 104 + } 105 + 119 106 func SaveSnapshot(snap *Snapshot, state string) error { 120 107 snapshotDir, err := getSnapshotDir() 121 108 if err != nil { 122 109 return err 123 110 } 124 111 125 - var fileName string 126 - switch state { 127 - case "accepted": 128 - fileName = SnapshotFileName(snap.Test) + ".snap" 129 - case "new": 130 - fileName = SnapshotFileName(snap.Test) + ".snap.new" 131 - default: 132 - fileName = SnapshotFileName(snap.Test) + "." + state 133 - } 112 + fileName := getSnapshotFileName(snap.Test, state) 134 113 filePath := filepath.Join(snapshotDir, fileName) 135 114 136 115 return os.WriteFile(filePath, []byte(snap.Serialize()), 0644) ··· 142 121 return nil, err 143 122 } 144 123 145 - var fileName string 146 - switch state { 147 - case "accepted": 148 - fileName = SnapshotFileName(testName) + ".snap" 149 - case "new": 150 - fileName = SnapshotFileName(testName) + ".snap.new" 151 - default: 152 - fileName = SnapshotFileName(testName) + "." + state 153 - } 124 + fileName := getSnapshotFileName(testName, state) 154 125 filePath := filepath.Join(snapshotDir, fileName) 155 126 156 127 data, err := os.ReadFile(filePath)
+13 -26
internal/pretty/boxes.go
··· 5 5 "strconv" 6 6 "strings" 7 7 8 + "github.com/ptdewey/freeze/internal/diff" 8 9 "github.com/ptdewey/freeze/internal/files" 9 10 ) 10 11 11 - type DiffLine struct { 12 - OldNumber int 13 - NewNumber int 14 - Line string 15 - Kind DiffKind 16 - } 17 - 18 - type DiffKind int 19 - 20 - const ( 21 - DiffShared DiffKind = iota 22 - DiffOld 23 - DiffNew 24 - ) 25 - 26 12 func NewSnapshotBox(snap *files.Snapshot) string { 27 13 return newSnapshotBoxInternal(snap) 28 14 } 29 15 30 - func DiffSnapshotBox(old, new *files.Snapshot, diffLines []DiffLine) string { 16 + func DiffSnapshotBox(old, newSnapshot *files.Snapshot, diffLines []diff.DiffLine) string { 31 17 width := TerminalWidth() 32 - snapshotFileName := files.SnapshotFileName(new.Test) + ".snap" 18 + snapshotFileName := files.SnapshotFileName(newSnapshot.Test) + ".snap" 33 19 34 20 var sb strings.Builder 35 - sb.WriteString(strings.Repeat("─", width) + "\n") 21 + sb.WriteString("─── " + "Review Snapshot " + strings.Repeat("─", width-20) + "\n\n") 22 + 36 23 // TODO: maybe make helper functions for this, swap coloring between the key and the value 37 24 // TODO: maybe show the snapshot file name in gray next to the "a/r/s" options 38 25 // (i.e. "a accept -> snap_file_name.snap", "reject" w/strikethrough?, skip, keeps "*snap.new") 39 - sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(snapshotFileName))) 40 - sb.WriteString(fmt.Sprintf(" %s\n", Blue("Snapshot Diff"))) 41 - if new.Title != "" { 42 - sb.WriteString(fmt.Sprintf(" title: %s\n", Blue("\""+new.Title+"\""))) 26 + if newSnapshot.Title != "" { 27 + sb.WriteString(Blue(" title: ") + newSnapshot.Title + "\n") 43 28 } 44 - sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+new.Test+"\""))) 29 + sb.WriteString(Blue(" test: ") + newSnapshot.Test + "\n") 30 + sb.WriteString(Blue(" file: ") + snapshotFileName + "\n") 31 + sb.WriteString("\n") 45 32 sb.WriteString(strings.Repeat("─", width) + "\n") 46 33 47 34 // Calculate max line numbers for proper spacing ··· 63 50 var formatted string 64 51 65 52 switch dl.Kind { 66 - case DiffOld: 53 + case diff.DiffOld: 67 54 oldNumStr = fmt.Sprintf("%*d", oldWidth, dl.OldNumber) 68 55 newNumStr = strings.Repeat(" ", newWidth) 69 56 prefix = Red("−") 70 57 formatted = Red(dl.Line) 71 - case DiffNew: 58 + case diff.DiffNew: 72 59 oldNumStr = strings.Repeat(" ", oldWidth) 73 60 newNumStr = fmt.Sprintf("%*d", newWidth, dl.NewNumber) 74 61 prefix = Green("+") 75 62 formatted = Green(dl.Line) 76 - case DiffShared: 63 + case diff.DiffShared: 77 64 oldNumStr = fmt.Sprintf("%*d", oldWidth, dl.OldNumber) 78 65 newNumStr = fmt.Sprintf("%*d", newWidth, dl.NewNumber) 79 66 prefix = " "
-10
internal/pretty/pretty.go
··· 1 1 package pretty 2 2 3 3 import ( 4 - "fmt" 5 4 "os" 6 5 "strconv" 7 6 ) ··· 22 21 return w 23 22 } 24 23 return 80 25 - } 26 - 27 - func ClearScreen() { 28 - fmt.Print("\033[2J") 29 - fmt.Print("\033[H") 30 - } 31 - 32 - func ClearLine() { 33 - fmt.Print("\033[K") 34 24 } 35 25 36 26 func Red(s string) string {
-8
internal/pretty/pretty_test.go
··· 148 148 } 149 149 } 150 150 151 - func TestClearScreen(t *testing.T) { 152 - pretty.ClearScreen() 153 - } 154 - 155 - func TestClearLine(t *testing.T) { 156 - pretty.ClearLine() 157 - } 158 - 159 151 func contains(s, substr string) bool { 160 152 for i := 0; i <= len(s)-len(substr); i++ { 161 153 if s[i:i+len(substr)] == substr {
+3 -17
internal/review/review.go
··· 23 23 Quit 24 24 ) 25 25 26 - func computeDiffLines(old, new *files.Snapshot) []pretty.DiffLine { 27 - diffLines := diff.Histogram(old.Content, new.Content) 28 - result := make([]pretty.DiffLine, len(diffLines)) 29 - for i, dl := range diffLines { 30 - result[i] = pretty.DiffLine{ 31 - OldNumber: dl.OldNumber, 32 - NewNumber: dl.NewNumber, 33 - Line: dl.Line, 34 - Kind: pretty.DiffKind(dl.Kind), 35 - } 36 - } 37 - return result 26 + func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { 27 + return diff.Histogram(old.Content, new.Content) 38 28 } 39 29 40 30 func Review() error { ··· 56 46 57 47 func reviewLoop(snapshots []string) error { 58 48 reader := bufio.NewReader(os.Stdin) 59 - showDiff := false 60 49 61 50 for i, testName := range snapshots { 62 51 fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(testName)) ··· 69 58 70 59 accepted, acceptErr := files.ReadSnapshot(testName, "accepted") 71 60 72 - if acceptErr == nil && showDiff { 73 - diffLines := computeDiffLines(accepted, newSnap) 74 - fmt.Println(pretty.DiffSnapshotBox(accepted, newSnap, diffLines)) 75 - } else if acceptErr == nil { 61 + if acceptErr == nil { 76 62 diffLines := computeDiffLines(accepted, newSnap) 77 63 fmt.Println(pretty.DiffSnapshotBox(accepted, newSnap, diffLines)) 78 64 } else {
+6 -6
internal/transform/transform.go
··· 5 5 "fmt" 6 6 ) 7 7 8 - // Config holds the transformation configuration. 9 - type Config struct { 10 - Scrubbers []Scrubber 11 - Ignore []IgnorePattern 12 - } 13 - 14 8 // Scrubber transforms content before snapshotting. 15 9 type Scrubber interface { 16 10 Scrub(content string) string ··· 19 13 // IgnorePattern determines whether a key-value pair should be excluded. 20 14 type IgnorePattern interface { 21 15 ShouldIgnore(key, value string) bool 16 + } 17 + 18 + // Config holds the transformation configuration. 19 + type Config struct { 20 + Scrubbers []Scrubber 21 + Ignore []IgnorePattern 22 22 } 23 23 24 24 // ApplyScrubbers applies all scrubbers to the content in order.
+16 -9
scrubbers.go
··· 50 50 } 51 51 52 52 // Common regex patterns for scrubbing 53 - // TODO: review these 54 53 var ( 55 - uuidPattern = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) 56 - iso8601Pattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?`) 57 - emailPattern = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`) 58 - unixTsPattern = regexp.MustCompile(`\b\d{10,13}\b`) 59 - ipv4Pattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) 60 - creditCardPattern = regexp.MustCompile(`\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b`) 54 + uuidPattern = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) 55 + iso8601Pattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?`) 56 + emailPattern = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`) 57 + // More conservative Unix timestamp pattern - requires context markers 58 + // Matches timestamps with common prefixes/suffixes to avoid false positives on IDs 59 + unixTsPattern = regexp.MustCompile(`(?:timestamp|time|ts|created|updated|at)["\s:=]+(\d{10,13})\b`) 60 + // IPv4 pattern with basic range validation (not perfect, but better) 61 + ipv4Pattern = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) 62 + // Credit card pattern - more conservative, requires context 63 + creditCardPattern = regexp.MustCompile(`(?:card|cc|payment)["\s:=]+(\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4})\b`) 61 64 jwtPattern = regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`) 62 65 ) 63 66 ··· 86 89 } 87 90 88 91 // ScrubUnixTimestamps replaces Unix timestamps (10-13 digits) with "<UNIX_TS>". 92 + // Note: This uses a conservative pattern that requires context keywords to avoid 93 + // false positives on IDs and other numbers. For aggressive scrubbing, use a custom regex. 89 94 func ScrubUnixTimestamps() SnapshotOption { 90 95 return WithScrubber(&regexScrubber{ 91 96 pattern: unixTsPattern, 92 - replacement: "<UNIX_TS>", 97 + replacement: "$1<UNIX_TS>", 93 98 }) 94 99 } 95 100 ··· 102 107 } 103 108 104 109 // ScrubCreditCards replaces credit card numbers with "<CREDIT_CARD>". 110 + // Note: This uses a conservative pattern that requires context keywords to avoid 111 + // false positives. For aggressive scrubbing, use a custom regex. 105 112 func ScrubCreditCards() SnapshotOption { 106 113 return WithScrubber(&regexScrubber{ 107 114 pattern: creditCardPattern, 108 - replacement: "<CREDIT_CARD>", 115 + replacement: "$1<CREDIT_CARD>", 109 116 }) 110 117 } 111 118