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: api cleanup

+330 -513
+22 -20
README.md
··· 1 - # Freeze 1 + # shutter 2 2 3 3 A [birdie](https://github.com/giacomocavalieri/birdie) and [insta](https://github.com/mitsuhiko/insta) inspired snapshot testing library for Go. 4 4 ··· 21 21 22 22 func TestSomething(t *testing.T) { 23 23 result := SomeFunction("foo") 24 - shutter.Snap(t, result) 24 + shutter.Snap(t, "test title", result) 25 25 } 26 26 ``` 27 27 28 28 ### Advanced Usage: Scrubbers and Ignore Patterns 29 29 30 - Freeze supports data scrubbing and field filtering to handle dynamic or sensitive data in snapshots. 30 + shutter supports data scrubbing and field filtering to handle dynamic or sensitive data in snapshots. 31 31 32 32 #### Scrubbers 33 33 ··· 36 36 ```go 37 37 func TestUserAPI(t *testing.T) { 38 38 user := api.GetUser("123") 39 - 39 + 40 40 // Replace UUIDs and timestamps with placeholders 41 - shutter.SnapWithOptions(t, "user", []shutter.SnapshotOption{ 41 + shutter.Snap(t, "user", user, 42 42 shutter.ScrubUUIDs(), 43 43 shutter.ScrubTimestamps(), 44 - }, user) 44 + ) 45 45 } 46 46 ``` 47 47 48 48 **Built-in Scrubbers:** 49 + 49 50 - `ScrubUUIDs()` - Replaces UUIDs with `<UUID>` 50 51 - `ScrubTimestamps()` - Replaces ISO8601 timestamps with `<TIMESTAMP>` 51 52 - `ScrubEmails()` - Replaces email addresses with `<EMAIL>` ··· 78 79 ```go 79 80 func TestAPIResponse(t *testing.T) { 80 81 response := api.GetData() 81 - 82 + 82 83 // Ignore sensitive fields and null values 83 - shutter.SnapJSONWithOptions(t, "response", response, []shutter.SnapshotOption{ 84 + shutter.SnapJSON(t, "response", response, 84 85 shutter.IgnoreSensitiveKeys(), 85 86 shutter.IgnoreNullValues(), 86 87 shutter.IgnoreKeys("created_at", "updated_at"), 87 - }) 88 + ) 88 89 } 89 90 ``` 90 91 91 92 **Built-in Ignore Patterns:** 93 + 92 94 - `IgnoreSensitiveKeys()` - Ignores common sensitive keys (password, token, api_key, etc.) 93 95 - `IgnoreEmptyValues()` - Ignores fields with empty string values 94 96 - `IgnoreNullValues()` - Ignores fields with null values ··· 121 123 ```go 122 124 func TestComplexData(t *testing.T) { 123 125 data := generateTestData() 124 - 125 - shutter.SnapWithOptions(t, "data", []shutter.SnapshotOption{ 126 + 127 + shutter.Snap(t, "data", data, 126 128 // Scrubbers 127 129 shutter.ScrubUUIDs(), 128 130 shutter.ScrubTimestamps(), 129 131 shutter.ScrubEmails(), 130 - 132 + 131 133 // Ignore patterns 132 134 shutter.IgnoreSensitiveKeys(), 133 135 shutter.IgnoreKeys("debug_info"), 134 136 shutter.IgnoreNullValues(), 135 - }, data) 137 + ) 136 138 } 137 139 ``` 138 140 139 141 #### API Reference 140 142 141 - Three snapshot functions support options: 143 + All snapshot functions support options as variadic parameters: 142 144 143 145 ```go 144 146 // For general values (structs, maps, slices, etc.) 145 - shutter.SnapWithOptions(t, "title", []shutter.SnapshotOption{...}, value) 147 + shutter.Snap(t, "title", value, options...) 146 148 147 149 // For JSON strings 148 - shutter.SnapJSONWithOptions(t, "title", jsonString, []shutter.SnapshotOption{...}) 150 + shutter.SnapJSON(t, "title", jsonString, options...) 149 151 150 152 // For plain strings 151 - shutter.SnapStringWithOptions(t, "title", content, []shutter.SnapshotOption{...}) 153 + shutter.SnapString(t, "title", content, options...) 152 154 ``` 153 155 154 156 ### Reviewing Snapshots ··· 159 161 go run github.com/ptdewey/shutter/cmd/shutter review 160 162 ``` 161 163 162 - Freeze can also be used programmatically: 164 + shutter can also be used programmatically: 163 165 164 166 ```go 165 167 // Example: tools/shutter/main.go ··· 179 181 go run tools/shutter/main.go 180 182 ``` 181 183 182 - Freeze also includes (in a separate Go module) a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). 184 + shutter also includes (in a separate Go module) a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). 183 185 (The TUI is shipped in a separate module to make the added dependencies optional) 184 186 185 187 ### TUI Usage ··· 215 217 ## Other Libraries 216 218 217 219 - [go-snaps](https://github.com/gkampitakis/go-snaps) 218 - - Freeze uses the diff implementation from `go-snaps`. 220 + - shutter uses the diff implementation from `go-snaps`. 219 221 - [cupaloy](https://github.com/bradleyjkemp/cupaloy)
-58
api.go
··· 1 - package shutter 2 - 3 - import ( 4 - "github.com/ptdewey/shutter/internal/diff" 5 - "github.com/ptdewey/shutter/internal/files" 6 - "github.com/ptdewey/shutter/internal/pretty" 7 - ) 8 - 9 - // Snapshot represents a captured test snapshot with metadata. 10 - type Snapshot = files.Snapshot 11 - 12 - // DiffLine represents a line in a diff comparison. 13 - type DiffLine = diff.DiffLine 14 - 15 - const ( 16 - // DiffShared indicates a line that is unchanged in both versions. 17 - DiffShared = diff.DiffShared 18 - // DiffOld indicates a line that was removed. 19 - DiffOld = diff.DiffOld 20 - // DiffNew indicates a line that was added. 21 - DiffNew = diff.DiffNew 22 - ) 23 - 24 - // Deserialize parses a raw snapshot file string into a Snapshot struct. 25 - func Deserialize(raw string) (*Snapshot, error) { 26 - return files.Deserialize(raw) 27 - } 28 - 29 - // SaveSnapshot writes a snapshot to disk with the specified state ("new" or "accepted"). 30 - func SaveSnapshot(snap *Snapshot, state string) error { 31 - return files.SaveSnapshot(snap, state) 32 - } 33 - 34 - // ReadSnapshot reads a snapshot from disk for the given test name and state. 35 - func ReadSnapshot(testName string, state string) (*Snapshot, error) { 36 - return files.ReadSnapshot(testName, state) 37 - } 38 - 39 - // SnapshotFileName returns the snapshot file name for a given test name. 40 - func SnapshotFileName(testName string) string { 41 - return files.SnapshotFileName(testName) 42 - } 43 - 44 - // Histogram computes a line-by-line diff between two strings using the histogram algorithm. 45 - func Histogram(old, new string) []DiffLine { 46 - return diff.Histogram(old, new) 47 - } 48 - 49 - // NewSnapshotBox formats a new snapshot as a pretty-printed box for display. 50 - func NewSnapshotBox(snap *Snapshot) string { 51 - return pretty.NewSnapshotBox(snap) 52 - } 53 - 54 - // DiffSnapshotBox formats a diff between old and new snapshots as a pretty-printed box. 55 - func DiffSnapshotBox(oldSnap, newSnap *Snapshot) string { 56 - diffLines := diff.Histogram(oldSnap.Content, newSnap.Content) 57 - return pretty.DiffSnapshotBox(oldSnap, newSnap, diffLines) 58 - }
-36
config.go
··· 1 - package shutter 2 - 3 - // SnapshotOption is a function that configures a SnapshotConfig. 4 - type SnapshotOption func(*SnapshotConfig) 5 - 6 - // SnapshotConfig holds configuration for snapshot scrubbing and filtering. 7 - type SnapshotConfig struct { 8 - Scrubbers []Scrubber 9 - Ignore []IgnorePattern 10 - } 11 - 12 - // newSnapshotConfig creates a new SnapshotConfig with the given options applied. 13 - func newSnapshotConfig(opts []SnapshotOption) *SnapshotConfig { 14 - config := &SnapshotConfig{ 15 - Scrubbers: []Scrubber{}, 16 - Ignore: []IgnorePattern{}, 17 - } 18 - for _, opt := range opts { 19 - opt(config) 20 - } 21 - return config 22 - } 23 - 24 - // WithScrubber adds a custom scrubber to the configuration. 25 - func WithScrubber(scrubber Scrubber) SnapshotOption { 26 - return func(c *SnapshotConfig) { 27 - c.Scrubbers = append(c.Scrubbers, scrubber) 28 - } 29 - } 30 - 31 - // WithIgnorePattern adds a custom ignore pattern to the configuration. 32 - func WithIgnorePattern(pattern IgnorePattern) SnapshotOption { 33 - return func(c *SnapshotConfig) { 34 - c.Ignore = append(c.Ignore, pattern) 35 - } 36 - }
+39 -20
ignore.go
··· 6 6 "strings" 7 7 ) 8 8 9 - // IgnorePattern determines whether a key-value pair should be excluded 10 - // from the snapshot. This is primarily used for JSON and map structures. 11 - type IgnorePattern interface { 12 - ShouldIgnore(key, value string) bool 13 - } 14 - 15 9 // exactKeyValueIgnore ignores exact key-value matches. 16 10 type exactKeyValueIgnore struct { 17 11 key string ··· 22 16 return e.key == key && (e.value == "*" || e.value == value) 23 17 } 24 18 19 + func (e *exactKeyValueIgnore) Apply(content string) string { 20 + // Ignore patterns are applied during JSON transformation, not string scrubbing 21 + return content 22 + } 23 + 25 24 // IgnoreKeyValue creates an ignore pattern that matches exact key-value pairs. 26 25 // Use "*" as the value to ignore any value for the given key. 27 26 func IgnoreKeyValue(key, value string) SnapshotOption { 28 - return WithIgnorePattern(&exactKeyValueIgnore{ 27 + return &exactKeyValueIgnore{ 29 28 key: key, 30 29 value: value, 31 - }) 30 + } 32 31 } 33 32 34 33 // regexKeyValueIgnore ignores key-value pairs matching regex patterns. ··· 43 42 return keyMatch && valueMatch 44 43 } 45 44 45 + func (r *regexKeyValueIgnore) Apply(content string) string { 46 + return content 47 + } 48 + 46 49 // IgnoreKeyPattern creates an ignore pattern using regex patterns for keys and values. 47 50 // Pass empty string for keyPattern or valuePattern to match any key or value. 48 51 func IgnoreKeyPattern(keyPattern, valuePattern string) SnapshotOption { ··· 53 56 if valuePattern != "" { 54 57 valueRe = regexp.MustCompile(valuePattern) 55 58 } 56 - return WithIgnorePattern(&regexKeyValueIgnore{ 59 + return &regexKeyValueIgnore{ 57 60 keyPattern: keyRe, 58 61 valuePattern: valueRe, 59 - }) 62 + } 60 63 } 61 64 62 65 // keyOnlyIgnore ignores any key matching the pattern, regardless of value. ··· 68 71 return slices.Contains(k.keys, key) 69 72 } 70 73 74 + func (k *keyOnlyIgnore) Apply(content string) string { 75 + return content 76 + } 77 + 71 78 // IgnoreKeys creates an ignore pattern that ignores the specified keys 72 79 // regardless of their values. 73 80 func IgnoreKeys(keys ...string) SnapshotOption { 74 - return WithIgnorePattern(&keyOnlyIgnore{ 81 + return &keyOnlyIgnore{ 75 82 keys: keys, 76 - }) 83 + } 77 84 } 78 85 79 86 // regexKeyIgnore ignores keys matching a regex pattern. ··· 85 92 return r.pattern.MatchString(key) 86 93 } 87 94 95 + func (r *regexKeyIgnore) Apply(content string) string { 96 + return content 97 + } 98 + 88 99 // IgnoreKeysMatching creates an ignore pattern that ignores keys matching 89 100 // the given regex pattern. 90 101 func IgnoreKeysMatching(pattern string) SnapshotOption { 91 102 re := regexp.MustCompile(pattern) 92 - return WithIgnorePattern(&regexKeyIgnore{ 103 + return &regexKeyIgnore{ 93 104 pattern: re, 94 - }) 105 + } 95 106 } 96 107 97 108 // Common ignore patterns for sensitive data ··· 103 114 104 115 // IgnoreSensitiveKeys ignores common sensitive key names like password, token, etc. 105 116 func IgnoreSensitiveKeys() SnapshotOption { 106 - return WithIgnorePattern(&keyOnlyIgnore{ 117 + return &keyOnlyIgnore{ 107 118 keys: sensitiveKeys, 108 - }) 119 + } 109 120 } 110 121 111 122 // valueOnlyIgnore ignores any value matching the pattern, regardless of key. ··· 117 128 return slices.Contains(v.values, value) 118 129 } 119 130 131 + func (v *valueOnlyIgnore) Apply(content string) string { 132 + return content 133 + } 134 + 120 135 // IgnoreValues creates an ignore pattern that ignores the specified values 121 136 // regardless of their keys. 122 137 func IgnoreValues(values ...string) SnapshotOption { 123 - return WithIgnorePattern(&valueOnlyIgnore{ 138 + return &valueOnlyIgnore{ 124 139 values: values, 125 - }) 140 + } 126 141 } 127 142 128 143 // customIgnore allows users to provide a custom ignore function. ··· 134 149 return c.ignoreFunc(key, value) 135 150 } 136 151 152 + func (c *customIgnore) Apply(content string) string { 153 + return content 154 + } 155 + 137 156 // CustomIgnore creates an ignore pattern using a custom function. 138 157 func CustomIgnore(ignoreFunc func(key, value string) bool) SnapshotOption { 139 - return WithIgnorePattern(&customIgnore{ 158 + return &customIgnore{ 140 159 ignoreFunc: ignoreFunc, 141 - }) 160 + } 142 161 } 143 162 144 163 // IgnoreEmptyValues ignores fields with empty string values.
+26 -26
ignore_test.go
··· 14 14 "api_key": "sk_live_abc123" 15 15 }` 16 16 17 - shutter.SnapJSONWithOptions(t, "Ignore Password Field", jsonStr, []shutter.SnapshotOption{ 17 + shutter.SnapJSON(t, "Ignore Password Field", jsonStr, 18 18 shutter.IgnoreKeyValue("password", "*"), 19 19 shutter.IgnoreKeyValue("api_key", "*"), 20 - }) 20 + ) 21 21 } 22 22 23 23 func TestIgnoreKeys(t *testing.T) { ··· 30 30 "email": "john@example.com" 31 31 }` 32 32 33 - shutter.SnapJSONWithOptions(t, "Ignore Multiple Keys", jsonStr, []shutter.SnapshotOption{ 33 + shutter.SnapJSON(t, "Ignore Multiple Keys", jsonStr, 34 34 shutter.IgnoreKeys("password", "secret", "token"), 35 - }) 35 + ) 36 36 } 37 37 38 38 func TestIgnoreSensitiveKeys(t *testing.T) { ··· 46 46 "name": "John Doe" 47 47 }` 48 48 49 - shutter.SnapJSONWithOptions(t, "Ignore Sensitive Keys", jsonStr, []shutter.SnapshotOption{ 49 + shutter.SnapJSON(t, "Ignore Sensitive Keys", jsonStr, 50 50 shutter.IgnoreSensitiveKeys(), 51 - }) 51 + ) 52 52 } 53 53 54 54 func TestIgnoreKeysMatching(t *testing.T) { ··· 60 60 "product_name": "Widget" 61 61 }` 62 62 63 - shutter.SnapJSONWithOptions(t, "Ignore Keys Matching Pattern", jsonStr, []shutter.SnapshotOption{ 63 + shutter.SnapJSON(t, "Ignore Keys Matching Pattern", jsonStr, 64 64 shutter.IgnoreKeysMatching(`^user_`), 65 - }) 65 + ) 66 66 } 67 67 68 68 func TestIgnoreKeyPattern(t *testing.T) { ··· 74 74 "email": "john@example.com" 75 75 }` 76 76 77 - shutter.SnapJSONWithOptions(t, "Ignore Key Pattern", jsonStr, []shutter.SnapshotOption{ 77 + shutter.SnapJSON(t, "Ignore Key Pattern", jsonStr, 78 78 shutter.IgnoreKeyPattern(`.*password.*`, ""), 79 79 shutter.IgnoreKeyPattern(`.*token.*`, ""), 80 - }) 80 + ) 81 81 } 82 82 83 83 func TestIgnoreValues(t *testing.T) { ··· 88 88 "state": "pending" 89 89 }` 90 90 91 - shutter.SnapJSONWithOptions(t, "Ignore Specific Values", jsonStr, []shutter.SnapshotOption{ 91 + shutter.SnapJSON(t, "Ignore Specific Values", jsonStr, 92 92 shutter.IgnoreValues("pending"), 93 - }) 93 + ) 94 94 } 95 95 96 96 func TestIgnoreEmptyValues(t *testing.T) { ··· 102 102 "phone": "" 103 103 }` 104 104 105 - shutter.SnapJSONWithOptions(t, "Ignore Empty Values", jsonStr, []shutter.SnapshotOption{ 105 + shutter.SnapJSON(t, "Ignore Empty Values", jsonStr, 106 106 shutter.IgnoreEmptyValues(), 107 - }) 107 + ) 108 108 } 109 109 110 110 func TestIgnoreNullValues(t *testing.T) { ··· 116 116 "age": 30 117 117 }` 118 118 119 - shutter.SnapJSONWithOptions(t, "Ignore Null Values", jsonStr, []shutter.SnapshotOption{ 119 + shutter.SnapJSON(t, "Ignore Null Values", jsonStr, 120 120 shutter.IgnoreNullValues(), 121 - }) 121 + ) 122 122 } 123 123 124 124 func TestCustomIgnore(t *testing.T) { ··· 130 130 "grade": "A" 131 131 }` 132 132 133 - shutter.SnapJSONWithOptions(t, "Custom Ignore Function", jsonStr, []shutter.SnapshotOption{ 133 + shutter.SnapJSON(t, "Custom Ignore Function", jsonStr, 134 134 shutter.CustomIgnore(func(key, value string) bool { 135 135 // Ignore numeric values 136 136 return value == "1" || value == "25" || value == "95" 137 137 }), 138 - }) 138 + ) 139 139 } 140 140 141 141 func TestNestedIgnorePatterns(t *testing.T) { ··· 157 157 } 158 158 }` 159 159 160 - shutter.SnapJSONWithOptions(t, "Nested Ignore Patterns", jsonStr, []shutter.SnapshotOption{ 160 + shutter.SnapJSON(t, "Nested Ignore Patterns", jsonStr, 161 161 shutter.IgnoreSensitiveKeys(), 162 - }) 162 + ) 163 163 } 164 164 165 165 func TestCombinedIgnoreAndScrub(t *testing.T) { ··· 173 173 "ip_address": "192.168.1.1" 174 174 }` 175 175 176 - shutter.SnapJSONWithOptions(t, "Combined Ignore and Scrub", jsonStr, []shutter.SnapshotOption{ 176 + shutter.SnapJSON(t, "Combined Ignore and Scrub", jsonStr, 177 177 // Ignore sensitive keys entirely 178 178 shutter.IgnoreKeys("password", "api_key"), 179 179 // Scrub dynamic/identifiable data ··· 181 181 shutter.ScrubEmails(), 182 182 shutter.ScrubTimestamps(), 183 183 shutter.ScrubIPAddresses(), 184 - }) 184 + ) 185 185 } 186 186 187 187 func TestIgnoreInArrays(t *testing.T) { ··· 202 202 ] 203 203 }` 204 204 205 - shutter.SnapJSONWithOptions(t, "Ignore in Arrays", jsonStr, []shutter.SnapshotOption{ 205 + shutter.SnapJSON(t, "Ignore in Arrays", jsonStr, 206 206 shutter.IgnoreKeys("password"), 207 - }) 207 + ) 208 208 } 209 209 210 210 func TestComplexRealWorldExample(t *testing.T) { ··· 234 234 } 235 235 }` 236 236 237 - shutter.SnapJSONWithOptions(t, "Real World API Response", jsonStr, []shutter.SnapshotOption{ 237 + shutter.SnapJSON(t, "Real World API Response", jsonStr, 238 238 // Ignore sensitive fields 239 239 shutter.IgnoreSensitiveKeys(), 240 240 shutter.IgnoreKeys("card_number"), ··· 244 244 shutter.ScrubTimestamps(), 245 245 shutter.ScrubIPAddresses(), 246 246 shutter.ScrubJWTs(), 247 - }) 247 + ) 248 248 }
+95
internal/snapshots/snapshot.go
··· 1 + package snapshots 2 + 3 + import ( 4 + "fmt" 5 + "path/filepath" 6 + "runtime" 7 + 8 + "github.com/kortschak/utter" 9 + "github.com/ptdewey/shutter/internal/diff" 10 + "github.com/ptdewey/shutter/internal/files" 11 + "github.com/ptdewey/shutter/internal/pretty" 12 + ) 13 + 14 + type T interface { 15 + Helper() 16 + Skip(...any) 17 + Skipf(string, ...any) 18 + SkipNow() 19 + Name() string 20 + Error(...any) 21 + Log(...any) 22 + Cleanup(func()) 23 + } 24 + 25 + func Snap(t T, title, version, content string) { 26 + t.Helper() 27 + testName := t.Name() 28 + 29 + // Capture the caller's filename by walking up the call stack 30 + // to find the first file that's not shutter.go 31 + fileName := "unknown" 32 + for i := 1; i < 10; i++ { 33 + _, file, _, ok := runtime.Caller(i) 34 + if !ok { 35 + break 36 + } 37 + baseName := filepath.Base(file) 38 + // Skip frames within shutter.go to get to the actual test file 39 + if baseName != "shutter.go" { 40 + fileName = baseName 41 + break 42 + } 43 + } 44 + 45 + SnapWithTitle(t, title, testName, fileName, version, content) 46 + } 47 + 48 + func SnapWithTitle(t T, title, testName, fileName, version, content string) { 49 + t.Helper() 50 + 51 + snapshot := &files.Snapshot{ 52 + Title: title, 53 + Test: testName, 54 + FileName: fileName, 55 + Content: content, 56 + Version: version, 57 + } 58 + 59 + accepted, err := files.ReadAccepted(testName) 60 + if err == nil { 61 + if accepted.Content == content { 62 + return 63 + } 64 + 65 + if err := files.SaveSnapshot(snapshot, "new"); err != nil { 66 + t.Error("failed to save snapshot:", err) 67 + return 68 + } 69 + 70 + diffLines := diff.Histogram(accepted.Content, snapshot.Content) 71 + fmt.Println(pretty.DiffSnapshotBox(accepted, snapshot, diffLines)) 72 + t.Error("snapshot mismatch - run 'shutter review' to update") 73 + return 74 + } 75 + 76 + if err := files.SaveSnapshot(snapshot, "new"); err != nil { 77 + t.Error("failed to save snapshot:", err) 78 + return 79 + } 80 + 81 + fmt.Println(pretty.NewSnapshotBox(snapshot)) 82 + t.Error("new snapshot created - run 'shutter review' to accept") 83 + } 84 + 85 + func FormatValues(values ...any) string { 86 + var result string 87 + for _, v := range values { 88 + result += FormatValue(v) 89 + } 90 + return result 91 + } 92 + 93 + func FormatValue(v any) string { 94 + return utter.Sdump(v) 95 + }
+27 -33
scrubbers.go
··· 5 5 "strings" 6 6 ) 7 7 8 - // Scrubber transforms content before snapshotting, typically to remove 9 - // or replace dynamic or sensitive data. 10 - type Scrubber interface { 11 - Scrub(content string) string 12 - } 13 - 14 8 // regexScrubber replaces all matches of a regex pattern with a replacement string. 15 9 type regexScrubber struct { 16 10 pattern *regexp.Regexp 17 11 replacement string 18 12 } 19 13 20 - func (r *regexScrubber) Scrub(content string) string { 14 + func (r *regexScrubber) Apply(content string) string { 21 15 return r.pattern.ReplaceAllString(content, r.replacement) 22 16 } 23 17 ··· 25 19 // regex pattern with the replacement string. 26 20 func RegexScrubber(pattern string, replacement string) SnapshotOption { 27 21 re := regexp.MustCompile(pattern) 28 - return WithScrubber(&regexScrubber{ 22 + return &regexScrubber{ 29 23 pattern: re, 30 24 replacement: replacement, 31 - }) 25 + } 32 26 } 33 27 34 28 // exactMatchScrubber replaces exact string matches with a replacement. ··· 37 31 replacement string 38 32 } 39 33 40 - func (e *exactMatchScrubber) Scrub(content string) string { 34 + func (e *exactMatchScrubber) Apply(content string) string { 41 35 return strings.ReplaceAll(content, e.match, e.replacement) 42 36 } 43 37 44 38 // ExactMatchScrubber creates a scrubber that replaces exact string matches. 45 39 func ExactMatchScrubber(match string, replacement string) SnapshotOption { 46 - return WithScrubber(&exactMatchScrubber{ 40 + return &exactMatchScrubber{ 47 41 match: match, 48 42 replacement: replacement, 49 - }) 43 + } 50 44 } 51 45 52 46 // Common regex patterns for scrubbing ··· 66 60 67 61 // ScrubUUIDs replaces all UUIDs with "<UUID>". 68 62 func ScrubUUIDs() SnapshotOption { 69 - return WithScrubber(&regexScrubber{ 63 + return &regexScrubber{ 70 64 pattern: uuidPattern, 71 65 replacement: "<UUID>", 72 - }) 66 + } 73 67 } 74 68 75 69 // ScrubTimestamps replaces ISO8601 timestamps with "<TIMESTAMP>". 76 70 func ScrubTimestamps() SnapshotOption { 77 - return WithScrubber(&regexScrubber{ 71 + return &regexScrubber{ 78 72 pattern: iso8601Pattern, 79 73 replacement: "<TIMESTAMP>", 80 - }) 74 + } 81 75 } 82 76 83 77 // ScrubEmails replaces email addresses with "<EMAIL>". 84 78 func ScrubEmails() SnapshotOption { 85 - return WithScrubber(&regexScrubber{ 79 + return &regexScrubber{ 86 80 pattern: emailPattern, 87 81 replacement: "<EMAIL>", 88 - }) 82 + } 89 83 } 90 84 91 85 // ScrubUnixTimestamps replaces Unix timestamps (10-13 digits) with "<UNIX_TS>". 92 86 // Note: This is aggressive and may match other long numbers. For more conservative 93 87 // scrubbing with context keywords, use a custom regex. 94 88 func ScrubUnixTimestamps() SnapshotOption { 95 - return WithScrubber(&regexScrubber{ 89 + return &regexScrubber{ 96 90 pattern: unixTsPattern, 97 91 replacement: "<UNIX_TS>", 98 - }) 92 + } 99 93 } 100 94 101 95 // ScrubIPAddresses replaces IPv4 addresses with "<IP>". 102 96 func ScrubIPAddresses() SnapshotOption { 103 - return WithScrubber(&regexScrubber{ 97 + return &regexScrubber{ 104 98 pattern: ipv4Pattern, 105 99 replacement: "<IP>", 106 - }) 100 + } 107 101 } 108 102 109 103 func ScrubCreditCards() SnapshotOption { 110 - return WithScrubber(&regexScrubber{ 104 + return &regexScrubber{ 111 105 pattern: creditCardPattern, 112 106 replacement: "<CREDIT_CARD>", 113 - }) 107 + } 114 108 } 115 109 116 110 func ScrubJWTs() SnapshotOption { 117 - return WithScrubber(&regexScrubber{ 111 + return &regexScrubber{ 118 112 pattern: jwtPattern, 119 113 replacement: "<JWT>", 120 - }) 114 + } 121 115 } 122 116 123 117 func ScrubDates() SnapshotOption { 124 118 datePattern := regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) 125 - return WithScrubber(&regexScrubber{ 119 + return &regexScrubber{ 126 120 pattern: datePattern, 127 121 replacement: "<DATE>", 128 - }) 122 + } 129 123 } 130 124 131 125 // ScrubAPIKeys replaces common API key patterns with "<API_KEY>". 132 126 // Matches patterns like: sk_live_..., pk_test_..., api_key_... 133 127 func ScrubAPIKeys() SnapshotOption { 134 128 apiKeyPattern := regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) 135 - return WithScrubber(&regexScrubber{ 129 + return &regexScrubber{ 136 130 pattern: apiKeyPattern, 137 131 replacement: "<API_KEY>", 138 - }) 132 + } 139 133 } 140 134 141 135 type customScrubber struct { 142 136 scrubFunc func(string) string 143 137 } 144 138 145 - func (c *customScrubber) Scrub(content string) string { 139 + func (c *customScrubber) Apply(content string) string { 146 140 return c.scrubFunc(content) 147 141 } 148 142 149 143 func CustomScrubber(scrubFunc func(string) string) SnapshotOption { 150 - return WithScrubber(&customScrubber{ 144 + return &customScrubber{ 151 145 scrubFunc: scrubFunc, 152 - }) 146 + } 153 147 }
+28 -28
scrubbers_test.go
··· 14 14 "name": "John Doe" 15 15 }` 16 16 17 - shutter.SnapJSONWithOptions(t, "Scrubbed UUIDs", jsonStr, []shutter.SnapshotOption{ 17 + shutter.SnapJSON(t, "Scrubbed UUIDs", jsonStr, 18 18 shutter.ScrubUUIDs(), 19 - }) 19 + ) 20 20 } 21 21 22 22 func TestScrubTimestamps(t *testing.T) { ··· 27 27 "name": "Test Event" 28 28 }` 29 29 30 - shutter.SnapJSONWithOptions(t, "Scrubbed Timestamps", jsonStr, []shutter.SnapshotOption{ 30 + shutter.SnapJSON(t, "Scrubbed Timestamps", jsonStr, 31 31 shutter.ScrubTimestamps(), 32 - }) 32 + ) 33 33 } 34 34 35 35 func TestScrubEmails(t *testing.T) { ··· 39 39 "name": "John Doe" 40 40 }` 41 41 42 - shutter.SnapJSONWithOptions(t, "Scrubbed Emails", jsonStr, []shutter.SnapshotOption{ 42 + shutter.SnapJSON(t, "Scrubbed Emails", jsonStr, 43 43 shutter.ScrubEmails(), 44 - }) 44 + ) 45 45 } 46 46 47 47 func TestScrubIPAddresses(t *testing.T) { ··· 51 51 "message": "Connection from 172.16.0.100" 52 52 }` 53 53 54 - shutter.SnapJSONWithOptions(t, "Scrubbed IPs", jsonStr, []shutter.SnapshotOption{ 54 + shutter.SnapJSON(t, "Scrubbed IPs", jsonStr, 55 55 shutter.ScrubIPAddresses(), 56 - }) 56 + ) 57 57 } 58 58 59 59 func TestScrubJWTs(t *testing.T) { ··· 62 62 "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" 63 63 }` 64 64 65 - shutter.SnapJSONWithOptions(t, "Scrubbed JWTs", jsonStr, []shutter.SnapshotOption{ 65 + shutter.SnapJSON(t, "Scrubbed JWTs", jsonStr, 66 66 shutter.ScrubJWTs(), 67 - }) 67 + ) 68 68 } 69 69 70 70 func TestMultipleScrubbers(t *testing.T) { ··· 76 76 "name": "John Doe" 77 77 }` 78 78 79 - shutter.SnapJSONWithOptions(t, "Multiple Scrubbers", jsonStr, []shutter.SnapshotOption{ 79 + shutter.SnapJSON(t, "Multiple Scrubbers", jsonStr, 80 80 shutter.ScrubUUIDs(), 81 81 shutter.ScrubEmails(), 82 82 shutter.ScrubTimestamps(), 83 83 shutter.ScrubIPAddresses(), 84 - }) 84 + ) 85 85 } 86 86 87 87 func TestRegexScrubber(t *testing.T) { ··· 91 91 "name": "Test User" 92 92 }` 93 93 94 - shutter.SnapJSONWithOptions(t, "Custom Regex Scrubber", jsonStr, []shutter.SnapshotOption{ 94 + shutter.SnapJSON(t, "Custom Regex Scrubber", jsonStr, 95 95 shutter.RegexScrubber(`sk_(live|test)_[a-zA-Z0-9]+`, "<API_KEY>"), 96 - }) 96 + ) 97 97 } 98 98 99 99 func TestExactMatchScrubber(t *testing.T) { 100 100 content := "The secret password is 'p@ssw0rd123' and should be hidden." 101 101 102 - shutter.SnapStringWithOptions(t, "Exact Match Scrubber", content, []shutter.SnapshotOption{ 102 + shutter.SnapString(t, "Exact Match Scrubber", content, 103 103 shutter.ExactMatchScrubber("p@ssw0rd123", "<PASSWORD>"), 104 - }) 104 + ) 105 105 } 106 106 107 107 func TestCustomScrubber(t *testing.T) { 108 108 content := "Hello World! This is a TEST." 109 109 110 - shutter.SnapStringWithOptions(t, "Custom Scrubber", content, []shutter.SnapshotOption{ 110 + shutter.SnapString(t, "Custom Scrubber", content, 111 111 shutter.CustomScrubber(func(s string) string { 112 112 return strings.ToLower(s) 113 113 }), 114 - }) 114 + ) 115 115 } 116 116 117 117 func TestScrubDates(t *testing.T) { ··· 122 122 "name": "John Doe" 123 123 }` 124 124 125 - shutter.SnapJSONWithOptions(t, "Scrubbed Dates", jsonStr, []shutter.SnapshotOption{ 125 + shutter.SnapJSON(t, "Scrubbed Dates", jsonStr, 126 126 shutter.ScrubDates(), 127 - }) 127 + ) 128 128 } 129 129 130 130 func TestScrubAPIKeys(t *testing.T) { ··· 135 135 "name": "Test Config" 136 136 }` 137 137 138 - shutter.SnapJSONWithOptions(t, "Scrubbed API Keys", jsonStr, []shutter.SnapshotOption{ 138 + shutter.SnapJSON(t, "Scrubbed API Keys", jsonStr, 139 139 shutter.ScrubAPIKeys(), 140 - }) 140 + ) 141 141 } 142 142 143 143 func TestScrubWithSnapFunction(t *testing.T) { ··· 148 148 "name": "John Doe", 149 149 } 150 150 151 - shutter.SnapWithOptions(t, "Scrub With Snap", []shutter.SnapshotOption{ 151 + shutter.Snap(t, "Scrub With Snap", data, 152 152 shutter.ScrubUUIDs(), 153 153 shutter.ScrubEmails(), 154 154 shutter.ScrubTimestamps(), 155 - }, data) 155 + ) 156 156 } 157 157 158 158 func TestCreditCardScrubbing(t *testing.T) { ··· 163 163 "name": "John Doe" 164 164 }` 165 165 166 - shutter.SnapJSONWithOptions(t, "Scrubbed Credit Cards", jsonStr, []shutter.SnapshotOption{ 166 + shutter.SnapJSON(t, "Scrubbed Credit Cards", jsonStr, 167 167 shutter.ScrubCreditCards(), 168 - }) 168 + ) 169 169 } 170 170 171 171 func TestUnixTimestampScrubbing(t *testing.T) { ··· 176 176 "name": "Test Event" 177 177 }` 178 178 179 - shutter.SnapJSONWithOptions(t, "Scrubbed Unix Timestamps", jsonStr, []shutter.SnapshotOption{ 179 + shutter.SnapJSON(t, "Scrubbed Unix Timestamps", jsonStr, 180 180 shutter.ScrubUnixTimestamps(), 181 - }) 181 + ) 182 182 }
+93 -138
shutter.go
··· 1 1 package shutter 2 2 3 3 import ( 4 - "fmt" 5 - "path/filepath" 6 - "runtime" 7 - 8 4 "github.com/kortschak/utter" 9 - "github.com/ptdewey/shutter/internal/diff" 10 - "github.com/ptdewey/shutter/internal/files" 11 - "github.com/ptdewey/shutter/internal/pretty" 12 5 "github.com/ptdewey/shutter/internal/review" 6 + "github.com/ptdewey/shutter/internal/snapshots" 13 7 "github.com/ptdewey/shutter/internal/transform" 14 8 ) 15 9 16 10 const version = "0.1.0" 17 11 18 - // TODO: probably make this (and other things) configurable 19 12 func init() { 20 13 utter.Config.ElideType = true 21 14 utter.Config.SortKeys = true 22 15 } 23 16 24 - // SnapString takes a string value and creates a snapshot with the given title. 25 - func SnapString(t testingT, title string, content string) { 17 + // Snap takes any values, formats them, and creates a snapshot with the given title. 18 + // For complex types, values are formatted using a pretty-printer. 19 + // The last parameters can be SnapshotOptions to apply scrubbers before snapshotting. 20 + // 21 + // shutter.Snap(t, "title", any(value1), any(value2), shutter.ScrubUUIDs()) 22 + // 23 + // REFACTOR: should this take in _one_ value, and then allow options as additional inputs? 24 + func Snap(t snapshots.T, title string, values ...any) { 26 25 t.Helper() 27 - SnapStringWithOptions(t, title, content, nil) 26 + 27 + // Separate options from values 28 + var opts []SnapshotOption 29 + var actualValues []any 30 + 31 + for _, v := range values { 32 + if opt, ok := v.(SnapshotOption); ok { 33 + opts = append(opts, opt) 34 + } else { 35 + actualValues = append(actualValues, v) 36 + } 37 + } 38 + 39 + content := snapshots.FormatValues(actualValues...) 40 + 41 + // Apply scrubber options directly to the formatted content 42 + scrubbers, _ := extractOptions(opts) 43 + scrubbedContent := applyOptions(content, scrubbers) 44 + 45 + snapshots.Snap(t, title, version, scrubbedContent) 28 46 } 29 47 30 - // SnapStringWithOptions takes a string and applies scrubbers before snapshotting. 31 - func SnapStringWithOptions(t testingT, title string, content string, opts []SnapshotOption) { 48 + // SnapString takes a string value and creates a snapshot with the given title. 49 + // Options can be provided to apply scrubbers before snapshotting. 50 + func SnapString(t snapshots.T, title string, content string, opts ...SnapshotOption) { 32 51 t.Helper() 33 - config := newSnapshotConfig(opts) 34 52 35 - // Apply scrubbers to the content 36 - scrubbedContent := transform.ApplyScrubbers(content, toTransformScrubbers(config.Scrubbers)) 53 + // Apply scrubber options directly to the content 54 + scrubbers, _ := extractOptions(opts) 55 + scrubbedContent := applyOptions(content, scrubbers) 37 56 38 - snap(t, title, scrubbedContent) 57 + snapshots.Snap(t, title, version, scrubbedContent) 39 58 } 40 59 41 60 // SnapJSON takes a JSON string, validates it, and pretty-prints it with 42 61 // consistent formatting before snapshotting. This preserves the raw JSON 43 62 // format while ensuring valid JSON structure. 44 - func SnapJSON(t testingT, title string, jsonStr string) { 45 - t.Helper() 46 - SnapJSONWithOptions(t, title, jsonStr, nil) 47 - } 48 - 49 - // SnapJSONWithOptions takes a JSON string and applies scrubbers and ignore patterns 50 - // before snapshotting. This allows filtering sensitive data and normalizing dynamic values. 51 - func SnapJSONWithOptions(t testingT, title string, jsonStr string, opts []SnapshotOption) { 63 + // Options can be provided to apply scrubbers and ignore patterns. 64 + func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...SnapshotOption) { 52 65 t.Helper() 53 66 54 - config := newSnapshotConfig(opts) 67 + scrubbers, ignores := extractOptions(opts) 55 68 56 69 // Transform the JSON with ignore patterns and scrubbers 57 70 transformConfig := &transform.Config{ 58 - Scrubbers: toTransformScrubbers(config.Scrubbers), 59 - Ignore: toTransformIgnorePatterns(config.Ignore), 71 + Scrubbers: toTransformScrubbers(scrubbers), 72 + Ignore: toTransformIgnorePatterns(ignores), 60 73 } 61 74 62 75 transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig) ··· 65 78 return 66 79 } 67 80 68 - snap(t, title, transformedJSON) 81 + snapshots.Snap(t, title, version, transformedJSON) 69 82 } 70 83 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. 73 - func Snap(t testingT, title string, values ...any) { 74 - t.Helper() 75 - SnapWithOptions(t, title, nil, values...) 84 + // Review launches an interactive review session to accept or reject snapshot changes. 85 + func Review() error { 86 + return review.Review() 76 87 } 77 88 78 - // SnapWithOptions takes any values, formats them, and applies scrubbers before snapshotting. 79 - // For structured data (maps, slices, structs), scrubbers are applied to the formatted output. 80 - func SnapWithOptions(t testingT, title string, opts []SnapshotOption, values ...any) { 81 - t.Helper() 82 - config := newSnapshotConfig(opts) 83 - 84 - content := formatValues(values...) 85 - 86 - // Apply scrubbers to the formatted content 87 - scrubbedContent := transform.ApplyScrubbers(content, toTransformScrubbers(config.Scrubbers)) 89 + // AcceptAll accepts all pending snapshot changes without review. 90 + func AcceptAll() error { 91 + return review.AcceptAll() 92 + } 88 93 89 - snap(t, title, scrubbedContent) 94 + // RejectAll rejects all pending snapshot changes without review. 95 + func RejectAll() error { 96 + return review.RejectAll() 90 97 } 91 98 92 - func snap(t testingT, title string, content string) { 93 - t.Helper() 94 - testName := t.Name() 99 + // SnapshotOption represents a transformation that can be applied to snapshot content. 100 + // Options are applied in the order they are provided. 101 + type SnapshotOption interface { 102 + Apply(content string) string 103 + } 95 104 96 - // Capture the caller's file name by walking up the call stack 97 - // to find the first file that's not shutter.go TODO: does this actually work for all cases? 98 - fileName := "unknown" 99 - for i := 1; i < 10; i++ { 100 - _, file, _, ok := runtime.Caller(i) 101 - if !ok { 102 - break 103 - } 104 - baseName := filepath.Base(file) 105 - // Skip frames within shutter.go to get to the actual test file 106 - if baseName != "shutter.go" { 107 - fileName = baseName 108 - break 109 - } 110 - } 111 - 112 - snapWithTitle(t, title, testName, fileName, content) 105 + // IgnoreOption represents a pattern for ignoring key-value pairs in JSON structures. 106 + type IgnoreOption interface { 107 + ShouldIgnore(key, value string) bool 113 108 } 114 109 115 - func snapWithTitle(t testingT, title string, testName string, fileName string, content string) { 116 - t.Helper() 117 - 118 - snapshot := &files.Snapshot{ 119 - Title: title, 120 - Test: testName, 121 - FileName: fileName, 122 - Content: content, 123 - Version: version, 124 - } 125 - 126 - accepted, err := files.ReadAccepted(testName) 127 - if err == nil { 128 - if accepted.Content == content { 129 - return 130 - } 131 - 132 - if err := files.SaveSnapshot(snapshot, "new"); err != nil { 133 - t.Error("failed to save snapshot:", err) 134 - return 110 + // extractOptions separates scrubbers and ignore patterns from options. 111 + func extractOptions(opts []SnapshotOption) (scrubbers []SnapshotOption, ignores []IgnoreOption) { 112 + for _, opt := range opts { 113 + if ignore, ok := opt.(IgnoreOption); ok { 114 + ignores = append(ignores, ignore) 115 + } else { 116 + scrubbers = append(scrubbers, opt) 135 117 } 136 - 137 - diffLines := diff.Histogram(accepted.Content, snapshot.Content) 138 - fmt.Println(pretty.DiffSnapshotBox(accepted, snapshot, diffLines)) 139 - t.Error("snapshot mismatch - run 'shutter review' to update") 140 - return 141 118 } 142 - 143 - if err := files.SaveSnapshot(snapshot, "new"); err != nil { 144 - t.Error("failed to save snapshot:", err) 145 - return 146 - } 147 - 148 - fmt.Println(pretty.NewSnapshotBox(snapshot)) 149 - t.Error("new snapshot created - run 'shutter review' to accept") 119 + return scrubbers, ignores 150 120 } 151 121 152 - func formatValues(values ...any) string { 153 - var result string 154 - for _, v := range values { 155 - result += formatValue(v) 122 + // applyOptions applies all scrubber options to content in sequence. 123 + func applyOptions(content string, opts []SnapshotOption) string { 124 + for _, opt := range opts { 125 + content = opt.Apply(content) 156 126 } 157 - return result 127 + return content 158 128 } 159 129 160 - func formatValue(v any) string { 161 - return utter.Sdump(v) 130 + // scrubberAdapter adapts a SnapshotOption to the transform.Scrubber interface. 131 + type scrubberAdapter struct { 132 + opt SnapshotOption 162 133 } 163 134 164 - // Review launches an interactive review session to accept or reject snapshot changes. 165 - func Review() error { 166 - return review.Review() 135 + func (s *scrubberAdapter) Scrub(content string) string { 136 + return s.opt.Apply(content) 167 137 } 168 138 169 - // AcceptAll accepts all pending snapshot changes without review. 170 - func AcceptAll() error { 171 - return review.AcceptAll() 139 + func toTransformScrubbers(opts []SnapshotOption) []transform.Scrubber { 140 + result := make([]transform.Scrubber, len(opts)) 141 + for i, opt := range opts { 142 + result[i] = &scrubberAdapter{opt: opt} 143 + } 144 + return result 172 145 } 173 146 174 - // RejectAll rejects all pending snapshot changes without review. 175 - func RejectAll() error { 176 - return review.RejectAll() 147 + // ignoreAdapter adapts an IgnoreOption to the transform.IgnorePattern interface. 148 + type ignoreAdapter struct { 149 + ignore IgnoreOption 177 150 } 178 151 179 - type testingT interface { 180 - Helper() 181 - Skip(...any) 182 - Skipf(string, ...any) 183 - SkipNow() 184 - Name() string 185 - Error(...any) 186 - Log(...any) 187 - Cleanup(func()) 152 + func (i *ignoreAdapter) ShouldIgnore(key, value string) bool { 153 + return i.ignore.ShouldIgnore(key, value) 188 154 } 189 155 190 - // Type conversion helpers to bridge shutter package types with transform package types. 191 - // These work because the interfaces have identical method signatures (structural typing). 192 - 193 - func toTransformScrubbers(scrubbers []Scrubber) []transform.Scrubber { 194 - result := make([]transform.Scrubber, len(scrubbers)) 195 - for i, s := range scrubbers { 196 - result[i] = s 197 - } 198 - return result 199 - } 200 - 201 - func toTransformIgnorePatterns(patterns []IgnorePattern) []transform.IgnorePattern { 202 - result := make([]transform.IgnorePattern, len(patterns)) 203 - for i, p := range patterns { 204 - result[i] = p 156 + func toTransformIgnorePatterns(ignores []IgnoreOption) []transform.IgnorePattern { 157 + result := make([]transform.IgnorePattern, len(ignores)) 158 + for i, ignore := range ignores { 159 + result[i] = &ignoreAdapter{ignore: ignore} 205 160 } 206 161 return result 207 162 }
-154
shutter_test.go
··· 10 10 "time" 11 11 12 12 "github.com/ptdewey/shutter" 13 - "github.com/ptdewey/shutter/internal/files" 14 13 ) 15 14 16 15 func TestSnapString(t *testing.T) { ··· 43 42 "foo": "bar", 44 43 "wibble": "wobble", 45 44 }) 46 - } 47 - 48 - func TestSerializeDeserialize(t *testing.T) { 49 - snap := &shutter.Snapshot{ 50 - Title: "My Test Title", 51 - Test: "TestExample", 52 - FileName: "test_file.go", 53 - Content: "test content\nmultiline", 54 - } 55 - 56 - serialized := snap.Serialize() 57 - expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_name: test_file.go\nversion: \n---\ntest content\nmultiline" 58 - if serialized != expected { 59 - t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) 60 - } 61 - 62 - deserialized, err := shutter.Deserialize(serialized) 63 - if err != nil { 64 - t.Fatalf("failed to deserialize: %v", err) 65 - } 66 - 67 - if deserialized.Title != snap.Title { 68 - t.Errorf("title mismatch: %s != %s", deserialized.Title, snap.Title) 69 - } 70 - if deserialized.Test != snap.Test { 71 - t.Errorf("test name mismatch: %s != %s", deserialized.Test, snap.Test) 72 - } 73 - if deserialized.FileName != snap.FileName { 74 - t.Errorf("file name mismatch: %s != %s", deserialized.FileName, snap.FileName) 75 - } 76 - if deserialized.Content != snap.Content { 77 - t.Errorf("content mismatch: %s != %s", deserialized.Content, snap.Content) 78 - } 79 - } 80 - 81 - func TestFileOperations(t *testing.T) { 82 - snap := &shutter.Snapshot{ 83 - Title: "File Ops Title", 84 - Test: "TestFileOps", 85 - Content: "file test content", 86 - } 87 - 88 - if err := files.SaveSnapshot(snap, "test"); err != nil { 89 - t.Fatalf("failed to save snapshot: %v", err) 90 - } 91 - 92 - read, err := shutter.ReadSnapshot("TestFileOps", "test") 93 - if err != nil { 94 - t.Fatalf("failed to read snapshot: %v", err) 95 - } 96 - 97 - if read.Content != snap.Content { 98 - t.Errorf("content mismatch: %s != %s", read.Content, snap.Content) 99 - } 100 - 101 - // cleanupTestSnapshots(t) 102 - } 103 - 104 - func TestSnapshotFileName(t *testing.T) { 105 - tests := []struct { 106 - input string 107 - expected string 108 - }{ 109 - {"TestMyFunction", "test_my_function"}, 110 - {"test_another_one", "test_another_one"}, 111 - {"TestCamelCase", "test_camel_case"}, 112 - {"TestWithNumbers123", "test_with_numbers123"}, 113 - } 114 - 115 - for _, tt := range tests { 116 - result := shutter.SnapshotFileName(tt.input) 117 - if result != tt.expected { 118 - t.Errorf("SnapshotFileName(%s) = %s, want %s", tt.input, result, tt.expected) 119 - } 120 - } 121 - } 122 - 123 - func TestHistogramDiff(t *testing.T) { 124 - oldStr := "line1\nline2\nline3" 125 - newStr := "line1\nmodified\nline3" 126 - 127 - diff := shutter.Histogram(oldStr, newStr) 128 - 129 - if len(diff) < 3 { 130 - t.Errorf("expected at least 3 diff lines, got %d", len(diff)) 131 - } 132 - 133 - if diff[0].Kind != shutter.DiffShared || diff[0].Line != "line1" { 134 - t.Errorf("line 0: expected shared 'line1', got %v %s", diff[0].Kind, diff[0].Line) 135 - } 136 - 137 - hasModified := false 138 - for _, d := range diff { 139 - if d.Line == "modified" { 140 - hasModified = true 141 - if d.Kind != shutter.DiffNew { 142 - t.Errorf("'modified' should be marked as new") 143 - } 144 - } 145 - } 146 - if !hasModified { 147 - t.Error("diff missing 'modified' line") 148 - } 149 - 150 - hasLine3 := false 151 - for _, d := range diff { 152 - if d.Line == "line3" && d.Kind == shutter.DiffShared { 153 - hasLine3 = true 154 - } 155 - } 156 - if !hasLine3 { 157 - t.Error("diff should have 'line3' as shared") 158 - } 159 - } 160 - 161 - func TestDiffSnapshotBox(t *testing.T) { 162 - old := &shutter.Snapshot{ 163 - Title: "Diff Test Title", 164 - Test: "TestDiff", 165 - Content: "old content", 166 - } 167 - 168 - new := &shutter.Snapshot{ 169 - Title: "Diff Test Title", 170 - Test: "TestDiff", 171 - Content: "new content", 172 - } 173 - 174 - box := shutter.DiffSnapshotBox(old, new) 175 - if box == "" { 176 - t.Error("DiffSnapshotBox returned empty string") 177 - } 178 - 179 - if !contains(box, "Snapshot Diff") { 180 - t.Error("DiffSnapshotBox missing header") 181 - } 182 - } 183 - 184 - func TestNewSnapshotBox(t *testing.T) { 185 - snap := &shutter.Snapshot{ 186 - Title: "New Test Title", 187 - Test: "TestNew", 188 - Content: "test content", 189 - } 190 - 191 - box := shutter.NewSnapshotBox(snap) 192 - if box == "" { 193 - t.Error("NewSnapshotBox returned empty string") 194 - } 195 - 196 - if !contains(box, "New Snapshot") { 197 - t.Error("NewSnapshotBox missing header") 198 - } 199 45 } 200 46 201 47 func contains(s, substr string) bool {