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