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 +4 -4
- data/CHANGELOG.md +4 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +7 -9
- data/MATCHERS.md +234 -0
- data/README.md +43 -25
- data/backspin.gemspec +2 -4
- data/fixtures/backspin/all_for_logging.yml +1 -1
- data/lib/backspin/command.rb +7 -0
- data/lib/backspin/command_diff.rb +8 -80
- data/lib/backspin/configuration.rb +57 -0
- data/lib/backspin/matcher.rb +110 -0
- data/lib/backspin/recorder.rb +152 -7
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +53 -65
- data/release.rake +6 -5
- metadata +11 -23
- data/MATCH_ON_USAGE.md +0 -110
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e49507f2d196ebd4d7b4d39a6013d840224a357272fb5ba4880702e26d28c8d9
|
4
|
+
data.tar.gz: cfee20f6ff8b65c622bfc9482d85e49c78f78e1e9a2c91b5f09f71e2b1d61663
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
data/Gemfile.lock
CHANGED
@@ -1,20 +1,19 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
backspin (0.
|
5
|
-
ostruct
|
6
|
-
rspec-mocks (~> 3
|
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.
|
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.
|
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
|
-
|
83
|
-
|
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
|
[](https://circleci.com/gh/rsanheim/backspin)
|
6
6
|
[](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
|
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
|
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
|
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
|
-
#
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
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
|
-
|
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"
|
29
|
-
spec.add_dependency "rspec-mocks", "~> 3
|
30
|
-
|
31
|
-
spec.add_development_dependency "gem-release", "~> 2"
|
28
|
+
spec.add_dependency "ostruct"
|
29
|
+
spec.add_dependency "rspec-mocks", "~> 3"
|
32
30
|
end
|
data/lib/backspin/command.rb
CHANGED
@@ -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 =
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|