backspin 0.4.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dced94847519f3f0f72a83bf4faf924d1a87271ba04891082f4e403124eda48c
4
- data.tar.gz: d92e797ff52c989b6e1fd4910397d38770fe91b8e5b14aef4924412c11e8a804
3
+ metadata.gz: e49507f2d196ebd4d7b4d39a6013d840224a357272fb5ba4880702e26d28c8d9
4
+ data.tar.gz: cfee20f6ff8b65c622bfc9482d85e49c78f78e1e9a2c91b5f09f71e2b1d61663
5
5
  SHA512:
6
- metadata.gz: d1dc52cdfbc6835548b8cdac5e0ef7ea26b4714a168870fb480f2e67c9d54b1305f5ef93a0942909a685c74b53a40323beb81bc2b79a2c46ad274ed1054f98a1
7
- data.tar.gz: d3fc481d4e52eb5871c1deace41707a654981336c6570ffc07ae847e3d10a94f3ff602c09b764d0e3591841e334f8c8eed53028db217a3c5f1082e50b5493d9a
6
+ metadata.gz: 660d3af239a57512fbd74eecf2f7fc81c91a6fc42c0a95957952f01931bcd2b5f5387af2004557af985c8c3ef6f37c6b284e41b757af2919a241d5408060b25e
7
+ data.tar.gz: 6a0b7f7e2bc4533e80e8011fb17c1f851c31ddd6e831300efec0589423f541640e1ace953c75f066e8a9efc26c6e3fef886cc87941d204e09d1a2302d58adcbc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2025-06-11
4
+ * Simplify matcher API so user provided matchers override defaults - [#14](https://github.com/rsanheim/backspin/pull/14)
5
+ * Also extract a proper `Matcher` object
6
+
3
7
  ## 0.4.2 - 2025-06-10
4
8
  Unified `:match` API for customizing how actual commands are matched against recorded commands. - [#11](https://github.com/rsanheim/backspin/pull/11)
5
9
 
data/Gemfile CHANGED
@@ -6,8 +6,8 @@ source "https://rubygems.org"
6
6
  gemspec
7
7
 
8
8
  group :development do
9
- gem "rake", "~> 13.0"
10
- gem "rspec", "~> 3.0"
9
+ gem "rake", "~> 13"
10
+ gem "rspec", "~> 3"
11
11
  gem "standard"
12
12
  gem "timecop", "~> 0.9"
13
13
  end
data/Gemfile.lock CHANGED
@@ -1,20 +1,19 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- backspin (0.4.5)
5
- ostruct (~> 0.5.0)
6
- rspec-mocks (~> 3.0)
4
+ backspin (0.6.0)
5
+ ostruct
6
+ rspec-mocks (~> 3)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
11
  ast (2.4.3)
12
12
  diff-lcs (1.6.2)
13
- gem-release (2.2.4)
14
13
  json (2.12.2)
15
14
  language_server-protocol (3.17.0.5)
16
15
  lint_roller (1.1.0)
17
- ostruct (0.5.5)
16
+ ostruct (0.6.1)
18
17
  parallel (1.27.0)
19
18
  parser (3.3.8.0)
20
19
  ast (~> 2.4.1)
@@ -48,7 +47,7 @@ GEM
48
47
  rubocop-ast (>= 1.44.0, < 2.0)
49
48
  ruby-progressbar (~> 1.7)
50
49
  unicode-display_width (>= 2.4.0, < 4.0)
51
- rubocop-ast (1.44.1)
50
+ rubocop-ast (1.45.1)
52
51
  parser (>= 3.3.7.2)
53
52
  prism (~> 1.4)
54
53
  rubocop-performance (1.25.0)
@@ -79,9 +78,8 @@ PLATFORMS
79
78
 
80
79
  DEPENDENCIES
81
80
  backspin!
82
- gem-release (~> 2)
83
- rake (~> 13.0)
84
- rspec (~> 3.0)
81
+ rake (~> 13)
82
+ rspec (~> 3)
85
83
  standard
86
84
  timecop (~> 0.9)
87
85
 
data/MATCHERS.md ADDED
@@ -0,0 +1,234 @@
1
+ # Custom Matcher Usage Guide
2
+
3
+ Backspin supports custom matchers for flexible verification of command outputs. This is useful when dealing with dynamic content like timestamps, process IDs, or version numbers.
4
+
5
+ ## Matcher Behavior
6
+
7
+ **Important**: Matchers work as overrides - only the fields you specify will be checked. Fields without matchers are ignored completely.
8
+
9
+ ## Matcher Formats
10
+
11
+ ### 1. Simple Proc Matcher
12
+
13
+ A proc matcher receives full command hashes and can check any combination of fields:
14
+
15
+ ```ruby
16
+ # Check that output starts with expected prefix
17
+ result = Backspin.run("version_test",
18
+ matcher: ->(recorded, actual) {
19
+ recorded["stdout"].start_with?("v") &&
20
+ actual["stdout"].start_with?("v")
21
+ }) do
22
+ Open3.capture3("node --version")
23
+ end
24
+ ```
25
+
26
+ ### 2. Field-Specific Hash Matchers
27
+
28
+ Use a hash to specify matchers for individual fields. Only specified fields are checked:
29
+
30
+ ```ruby
31
+ # Only check stdout - stderr and status are ignored
32
+ result = Backspin.run("timestamp_test",
33
+ matcher: {
34
+ stdout: ->(recorded, actual) {
35
+ # Both should contain a timestamp
36
+ recorded.match?(/\d{4}-\d{2}-\d{2}/) &&
37
+ actual.match?(/\d{4}-\d{2}-\d{2}/)
38
+ }
39
+ }) do
40
+ Open3.capture3("date")
41
+ end
42
+
43
+ # Check multiple specific fields
44
+ result = Backspin.run("api_test",
45
+ matcher: {
46
+ stdout: ->(recorded, actual) {
47
+ # Check JSON structure exists
48
+ recorded.include?("\"data\":") &&
49
+ actual.include?("\"data\":")
50
+ },
51
+ status: ->(recorded, actual) {
52
+ # Both should succeed
53
+ recorded == 0 && actual == 0
54
+ }
55
+ # Note: stderr is NOT checked
56
+ }) do
57
+ Open3.capture3("curl", "-s", "https://api.example.com")
58
+ end
59
+ ```
60
+
61
+ ### 3. The :all Matcher
62
+
63
+ The `:all` matcher receives complete command hashes with all fields:
64
+
65
+ ```ruby
66
+ # Cross-field validation
67
+ result = Backspin.run("build_test",
68
+ matcher: {
69
+ all: ->(recorded, actual) {
70
+ # Check overall success: stdout has "BUILD SUCCESSFUL" AND status is 0
71
+ actual["stdout"].include?("BUILD SUCCESSFUL") &&
72
+ actual["status"] == 0
73
+ }
74
+ }) do
75
+ Open3.capture3("./build.sh")
76
+ end
77
+ ```
78
+
79
+ ### 4. Combining :all with Field Matchers
80
+
81
+ When both `:all` and field matchers are present, all must pass:
82
+
83
+ ```ruby
84
+ result = Backspin.run("complex_test",
85
+ matcher: {
86
+ all: ->(recorded, actual) {
87
+ # Overall check: command succeeded
88
+ actual["status"] == 0
89
+ },
90
+ stdout: ->(recorded, actual) {
91
+ # Specific check: output contains result
92
+ actual.include?("Result:")
93
+ },
94
+ stderr: ->(recorded, actual) {
95
+ # No errors expected
96
+ actual.empty?
97
+ }
98
+ }) do
99
+ Open3.capture3("./process_data.sh")
100
+ end
101
+ ```
102
+
103
+ ## Matcher Proc Arguments
104
+
105
+ Field-specific matchers receive field values:
106
+ - For `:stdout`, `:stderr` - String values
107
+ - For `:status` - Integer exit code
108
+
109
+ The `:all` matcher receives full hashes with these keys:
110
+ - `"stdout"` - String output
111
+ - `"stderr"` - String error output
112
+ - `"status"` - Integer exit code
113
+ - `"command_type"` - String like "Open3::Capture3"
114
+ - `"args"` - Array of command arguments
115
+ - `"recorded_at"` - Timestamp string
116
+
117
+ ## Examples
118
+
119
+ ### Matching Version Numbers
120
+
121
+ ```ruby
122
+ # Match major version only
123
+ matcher: {
124
+ stdout: ->(recorded, actual) {
125
+ recorded_major = recorded[/(\d+)\./, 1]
126
+ actual_major = actual[/(\d+)\./, 1]
127
+ recorded_major == actual_major
128
+ }
129
+ }
130
+ ```
131
+
132
+ ### Ignoring Timestamps
133
+
134
+ ```ruby
135
+ # Strip timestamps before comparing
136
+ matcher: {
137
+ stdout: ->(recorded, actual) {
138
+ pattern = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/
139
+ recorded.gsub(pattern, '[TIME]') == actual.gsub(pattern, '[TIME]')
140
+ }
141
+ }
142
+ ```
143
+
144
+ ### JSON Response Validation
145
+
146
+ ```ruby
147
+ # Validate JSON structure, ignore values
148
+ matcher: {
149
+ stdout: ->(recorded, actual) {
150
+ begin
151
+ recorded_data = JSON.parse(recorded)
152
+ actual_data = JSON.parse(actual)
153
+
154
+ # Same keys at top level
155
+ recorded_data.keys.sort == actual_data.keys.sort
156
+ rescue JSON::ParserError
157
+ false
158
+ end
159
+ }
160
+ }
161
+ ```
162
+
163
+ ### Logging/Debugging with :all
164
+
165
+ ```ruby
166
+ # Use :all for side effects while other matchers do validation
167
+ logged_commands = []
168
+
169
+ result = Backspin.run("debug_test",
170
+ matcher: {
171
+ all: ->(recorded, actual) {
172
+ # Log for debugging
173
+ logged_commands << {
174
+ args: actual["args"],
175
+ exit: actual["status"],
176
+ output_size: actual["stdout"].size
177
+ }
178
+ true # Always pass
179
+ },
180
+ stdout: ->(recorded, actual) {
181
+ # Actual validation
182
+ actual.include?("SUCCESS")
183
+ }
184
+ }) do
185
+ Open3.capture3("./test.sh")
186
+ end
187
+
188
+ # Can inspect logged_commands after run
189
+ ```
190
+
191
+ ## Using with run!
192
+
193
+ The `run!` method automatically fails tests when matchers return false:
194
+
195
+ ```ruby
196
+ # This will raise an error if the matcher fails
197
+ Backspin.run!("critical_test",
198
+ matcher: {
199
+ stdout: ->(r, a) { a.include?("OK") },
200
+ status: ->(r, a) { a == 0 }
201
+ }) do
202
+ Open3.capture3("./health_check.sh")
203
+ end
204
+ ```
205
+
206
+ ## Migration from match_on
207
+
208
+ The `match_on` option is deprecated. To migrate:
209
+
210
+ ```ruby
211
+ # Old match_on style:
212
+ Backspin.run("test",
213
+ match_on: [:stdout, ->(r, a) { ... }])
214
+
215
+ # New matcher style:
216
+ Backspin.run("test",
217
+ matcher: { stdout: ->(r, a) { ... } })
218
+
219
+ # Old match_on with multiple fields:
220
+ Backspin.run("test",
221
+ match_on: [
222
+ [:stdout, ->(r, a) { ... }],
223
+ [:stderr, ->(r, a) { ... }]
224
+ ])
225
+
226
+ # New matcher style:
227
+ Backspin.run("test",
228
+ matcher: {
229
+ stdout: ->(r, a) { ... },
230
+ stderr: ->(r, a) { ... }
231
+ })
232
+ ```
233
+
234
+ Key difference: `match_on` required other fields to match exactly, while the new `matcher` hash only checks specified fields.
data/README.md CHANGED
@@ -5,11 +5,11 @@
5
5
  [![CircleCI](https://img.shields.io/circleci/build/github/rsanheim/backspin/main)](https://circleci.com/gh/rsanheim/backspin)
6
6
  [![Last Commit](https://img.shields.io/github/last-commit/rsanheim/backspin/main)](https://github.com/rsanheim/backspin/commits/main)
7
7
 
8
- Backspin records and replays CLI interactions in Ruby for easy snapshot testing of command-line interfaces. Currently supports `Open3.capture3` and `system` and requires `rspec-mocks`. More system calls and test integrations are welcome - send a PR!
8
+ Backspin records and replays CLI interactions in Ruby for easy snapshot testing of command-line interfaces. Currently supports `Open3.capture3` and `system` and requires `rspec`, as it uses `rspec-mocks` under the hood.
9
9
 
10
- **NOTE:** Backspin should be considered alpha quality software while pre v1.0. It is in heavy development, and you can expect the API to change. It is being developed in conjunction with production CLI apps, so the API will be refined and improved as we get to 1.0.
10
+ **NOTE:** Backspin should be considered alpha while pre version 1.0. It is in heavy development along-side some real-world CLI apps, so expect things to change and mature.
11
11
 
12
- Inspired by [VCR](https://github.com/vcr/vcr) and other [golden master](https://en.wikipedia.org/wiki/Golden_master_(software_development)) libraries.
12
+ Inspired by [VCR](https://github.com/vcr/vcr) and other [golden master](https://en.wikipedia.org/wiki/Golden_master_(software_development)) testing libraries.
13
13
 
14
14
  ## Overview
15
15
 
@@ -17,6 +17,8 @@ Backspin is a Ruby library for snapshot testing (or characterization testing) of
17
17
 
18
18
  ## Installation
19
19
 
20
+ Requires Ruby 3+ and will use rspec-mocks under the hood...Backspin has not been tested in other test frameworks.
21
+
20
22
  Add this line to your application's Gemfile in the `:test` group:
21
23
 
22
24
  ```ruby
@@ -95,20 +97,47 @@ end
95
97
 
96
98
  ### Custom matchers
97
99
 
98
- For cases where exact matching isn't suitable, you can provide custom verification logic:
100
+ For cases where full matching isn't suitable, you can override via `matcher:`. **NOTE**: If you provide
101
+ custom matchers, that is the only matching that will be done. Default matching is skipped if user-provided
102
+ matchers are present.
103
+
104
+ You can override the full match logic with a proc:
99
105
 
100
106
  ```ruby
101
- # Use custom logic to verify output
102
- result = Backspin.run("version_check",
103
- matcher: ->(recorded, actual) {
104
- # Just check that both start with "ruby"
105
- recorded["stdout"].start_with?("ruby") &&
106
- actual["stdout"].start_with?("ruby")
107
- }) do
108
- Open3.capture3("ruby --version")
107
+ # Match stdout and status, ignore stderr
108
+ my_matcher = ->(recorded, actual) {
109
+ recorded["stdout"] == actual["stdout"] && recorded["status"] != actual["status"]
110
+ }
111
+
112
+ result = Backspin.run("my_test", matcher: { all: my_matcher }) do
113
+ Open3.capture3("echo hello")
109
114
  end
110
115
  ```
111
116
 
117
+ Or you can override specific fields:
118
+
119
+ ```ruby
120
+ # Match dynamic timestamps in stdout
121
+ timestamp_matcher = ->(recorded, actual) {
122
+ recorded.match?(/\d{4}-\d{2}-\d{2}/) && actual.match?(/\d{4}-\d{2}-\d{2}/)
123
+ }
124
+
125
+ result = Backspin.run("timestamp_test", matcher: { stdout: timestamp_matcher }) do
126
+ Open3.capture3("date")
127
+ end
128
+
129
+ # Match version numbers in stderr
130
+ version_matcher = ->(recorded, actual) {
131
+ recorded[/v(\d+)\./, 1] == actual[/v(\d+)\./, 1]
132
+ }
133
+
134
+ result = Backspin.run("version_check", matcher: { stderr: version_matcher }) do
135
+ Open3.capture3("node --version")
136
+ end
137
+ ```
138
+
139
+ For more matcher examples and detailed documentation, see [MATCHERS.md](MATCHERS.md).
140
+
112
141
  ### Working with the Result Object
113
142
 
114
143
  The API returns a `RecordResult` object with helpful methods:
@@ -174,10 +203,10 @@ When verifying multiple commands, Backspin ensures all commands match in the exa
174
203
 
175
204
  If the CLI interaction you are recording contains sensitive data in stdout or stderr, you should be careful to make sure it is not recorded to yaml!
176
205
 
177
- By default, Backspin automatically scrubs [common credential patterns](https://github.com/rsanheim/backspin/blob/f8661f084aad0ae759cd971c4af31ccf9bdc6bba/lib/backspin.rb#L46-L65) from records, but this will only handle some common cases.
206
+ By default, Backspin automatically tries to scrub [common credential patterns](https://github.com/rsanheim/backspin/blob/f8661f084aad0ae759cd971c4af31ccf9bdc6bba/lib/backspin.rb#L46-L65) from records, but this will only handle some common cases.
178
207
  Always review your record files before commiting them to source control.
179
208
 
180
- A tool like [trufflehog](https://github.com/trufflesecurity/trufflehog) or [gitleaks](https://github.com/gitleaks/gitleaks) run via a pre-commit to catch any sensitive data before commit.
209
+ Use a tool like [trufflehog](https://github.com/trufflesecurity/trufflehog) or [gitleaks](https://github.com/gitleaks/gitleaks) run via a pre-commit to catch any sensitive data before commit.
181
210
 
182
211
  ```ruby
183
212
  # This will automatically scrub AWS keys, API tokens, passwords, etc.
@@ -194,7 +223,6 @@ end
194
223
  Backspin.configure do |config|
195
224
  config.scrub_credentials = false
196
225
  end
197
-
198
226
  ```
199
227
 
200
228
  Automatic scrubbing includes:
@@ -203,16 +231,6 @@ Automatic scrubbing includes:
203
231
  - Generic API keys, auth tokens, and passwords
204
232
  - Private keys (RSA, etc.)
205
233
 
206
- ## Features
207
-
208
- - **Simple recording**: Capture stdout, stderr, and exit status
209
- - **Flexible verification**: Strict matching, playback mode, or custom matchers
210
- - **Auto-naming**: Automatically generate record names from RSpec examples
211
- - **Multiple commands**: Record sequences of commands in a single record
212
- - **RSpec integration**: Works seamlessly with RSpec's mocking framework
213
- - **Human-readable**: YAML records are easy to read and edit
214
- - **Credential scrubbing**: Automatically removes sensitive data like API keys and passwords
215
-
216
234
  ## Development
217
235
 
218
236
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
data/backspin.gemspec CHANGED
@@ -25,8 +25,6 @@ Gem::Specification.new do |spec|
25
25
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
26
26
  spec.require_paths = ["lib"]
27
27
 
28
- spec.add_dependency "ostruct", "~> 0.5.0"
29
- spec.add_dependency "rspec-mocks", "~> 3.0"
30
-
31
- spec.add_development_dependency "gem-release", "~> 2"
28
+ spec.add_dependency "ostruct"
29
+ spec.add_dependency "rspec-mocks", "~> 3"
32
30
  end
@@ -5,7 +5,7 @@ commands:
5
5
  - command_type: Open3::Capture3
6
6
  args:
7
7
  - date
8
- stdout: 'Tue Jun 10 22:50:14 CDT 2025
8
+ stdout: 'Wed Jun 11 02:48:13 CDT 2025
9
9
 
10
10
  '
11
11
  stderr: ''
@@ -52,6 +52,8 @@ module Backspin
52
52
  Open3::Capture3
53
53
  when "Kernel::System"
54
54
  ::Kernel::System
55
+ when "Backspin::Capturer"
56
+ Backspin::Capturer
55
57
  else
56
58
  # Default to capture3 for backwards compatibility
57
59
  Open3::Capture3
@@ -97,3 +99,8 @@ end
97
99
  module ::Kernel
98
100
  class System; end
99
101
  end
102
+
103
+ # Define the Backspin::Capturer class for identification
104
+ module Backspin
105
+ class Capturer; end
106
+ end
@@ -9,23 +9,18 @@ module Backspin
9
9
  def initialize(recorded_command:, actual_command:, matcher: nil)
10
10
  @recorded_command = recorded_command
11
11
  @actual_command = actual_command
12
- @matcher = normalize_matcher(matcher)
12
+ @matcher = Matcher.new(
13
+ config: matcher,
14
+ recorded_command: recorded_command,
15
+ actual_command: actual_command
16
+ )
13
17
  end
14
18
 
15
19
  # @return [Boolean] true if the command output matches
16
20
  def verified?
17
- # First check if method classes match
18
21
  return false unless method_classes_match?
19
22
 
20
- if matcher.nil?
21
- recorded_command.result == actual_command.result
22
- elsif matcher.is_a?(Proc) # basic all matcher: lambda { |recorded, actual| ...}
23
- matcher.call(recorded_command.to_h, actual_command.to_h)
24
- elsif matcher.is_a?(Hash) # matcher: {all: lambda { |recorded, actual| ...}, stdout: lambda { |recorded, actual| ...}}
25
- verify_with_hash_matcher
26
- else
27
- raise ArgumentError, "Invalid matcher type: #{matcher.class}"
28
- end
23
+ @matcher.match?
29
24
  end
30
25
 
31
26
  # @return [String, nil] Human-readable diff if not verified
@@ -34,7 +29,6 @@ module Backspin
34
29
 
35
30
  parts = []
36
31
 
37
- # Check method class mismatch first
38
32
  unless method_classes_match?
39
33
  parts << "Command type mismatch: expected #{recorded_command.method_class.name}, got #{actual_command.method_class.name}"
40
34
  end
@@ -65,78 +59,12 @@ module Backspin
65
59
  recorded_command.method_class == actual_command.method_class
66
60
  end
67
61
 
68
- def normalize_matcher(matcher)
69
- return nil if matcher.nil?
70
- return matcher if matcher.is_a?(Proc)
71
-
72
- raise ArgumentError, "Matcher must be a Proc or Hash, got #{matcher.class}" unless matcher.is_a?(Hash)
73
-
74
- # Validate hash keys and values
75
- matcher.each do |key, value|
76
- unless %i[all stdout stderr status].include?(key)
77
- raise ArgumentError, "Invalid matcher key: #{key}. Must be one of: :all, :stdout, :stderr, :status"
78
- end
79
- raise ArgumentError, "Matcher for #{key} must be callable (Proc/Lambda)" unless value.respond_to?(:call)
80
- end
81
- matcher
82
- end
83
-
84
- def verify_with_hash_matcher
85
- recorded_hash = recorded_command.to_h
86
- actual_hash = actual_command.to_h
87
-
88
- all_passed = matcher[:all].nil? || matcher[:all].call(recorded_hash, actual_hash)
89
-
90
- fields_passed = %w[stdout stderr status].all? do |field|
91
- field_sym = field.to_sym
92
- if matcher[field_sym]
93
- matcher[field_sym].call(recorded_hash[field], actual_hash[field])
94
- else
95
- recorded_hash[field] == actual_hash[field]
96
- end
97
- end
98
-
99
- all_passed && fields_passed
100
- end
101
-
102
62
  def failure_reason
103
- reasons = []
104
-
105
- # Check method class first
106
63
  unless method_classes_match?
107
- reasons << "command type mismatch"
108
- return reasons.join(", ")
109
- end
110
-
111
- if matcher.nil?
112
- reasons << "stdout differs" if recorded_command.stdout != actual_command.stdout
113
- reasons << "stderr differs" if recorded_command.stderr != actual_command.stderr
114
- reasons << "exit status differs" if recorded_command.status != actual_command.status
115
- elsif matcher.is_a?(Hash)
116
- recorded_hash = recorded_command.to_h
117
- actual_hash = actual_command.to_h
118
-
119
- # Check :all matcher first
120
- reasons << ":all matcher failed" if matcher[:all] && !matcher[:all].call(recorded_hash, actual_hash)
121
-
122
- # Check field-specific matchers
123
- %w[stdout stderr status].each do |field|
124
- field_sym = field.to_sym
125
- if matcher[field_sym]
126
- unless matcher[field_sym].call(recorded_hash[field], actual_hash[field])
127
- reasons << "#{field} custom matcher failed"
128
- end
129
- elsif recorded_hash[field] != actual_hash[field]
130
- # Always check exact equality for fields without matchers
131
- reasons << "#{field} differs"
132
- end
133
- end
134
- else
135
- # Proc matcher
136
- reasons << "custom matcher failed"
64
+ return "command type mismatch"
137
65
  end
138
66
 
139
- reasons.join(", ")
67
+ @matcher.failure_reason
140
68
  end
141
69
 
142
70
  def stdout_diff
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Backspin
6
+ # Configuration for Backspin
7
+ class Configuration
8
+ attr_accessor :scrub_credentials
9
+ # The directory where backspin will store its files - defaults to fixtures/backspin
10
+ attr_accessor :backspin_dir
11
+ # Regex patterns to scrub from saved output
12
+ attr_reader :credential_patterns
13
+
14
+ def initialize
15
+ @scrub_credentials = true
16
+ @credential_patterns = default_credential_patterns
17
+ @backspin_dir = Pathname(Dir.pwd).join("fixtures", "backspin")
18
+ end
19
+
20
+ def add_credential_pattern(pattern)
21
+ @credential_patterns << pattern
22
+ end
23
+
24
+ def clear_credential_patterns
25
+ @credential_patterns = []
26
+ end
27
+
28
+ def reset_credential_patterns
29
+ @credential_patterns = default_credential_patterns
30
+ end
31
+
32
+ private
33
+
34
+ # Some default patterns for common credential types
35
+ def default_credential_patterns
36
+ [
37
+ # AWS credentials
38
+ /AKIA[0-9A-Z]{16}/, # AWS Access Key ID
39
+ %r{aws_secret_access_key\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})["']?}i, # AWS Secret Key
40
+ %r{aws_session_token\s*[:=]\s*["']?([A-Za-z0-9/+=]+)["']?}i, # AWS Session Token
41
+
42
+ # Google Cloud credentials
43
+ /AIza[0-9A-Za-z\-_]{35}/, # Google API Key
44
+ /[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com/, # Google OAuth2 client ID
45
+ /-----BEGIN (RSA )?PRIVATE KEY-----/, # Private keys
46
+
47
+ # Generic patterns
48
+ /api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Generic API keys
49
+ /auth[_-]?token\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Auth tokens
50
+ /Bearer\s+([A-Za-z0-9\-_]+)/, # Bearer tokens
51
+ /password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i, # Passwords
52
+ /-p([^"'\s]{8,})/, # MySQL-style password args
53
+ /secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i # Generic secrets
54
+ ]
55
+ end
56
+ end
57
+ end