backspin 0.9.0 → 0.10.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 +6 -0
- data/Gemfile.lock +1 -1
- data/MATCHERS.md +18 -0
- data/README.md +35 -0
- data/docs/backspin-result-api-sketch.md +5 -3
- data/lib/backspin/command_diff.rb +70 -13
- data/lib/backspin/matcher.rb +59 -51
- data/lib/backspin/record.rb +18 -1
- data/lib/backspin/recorder.rb +6 -3
- data/lib/backspin/snapshot.rb +52 -19
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +36 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edf2daa2123a270ca28d593b594188db57913b2ca5905a45b6a9e0d1221c0da4
|
|
4
|
+
data.tar.gz: 3ec4407d3396e3e8390f62b3c0e7d2124bdf026e4b02e74c036ee37fab8430a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a851d194aa4f400c9104643b5521e7b5bd5e159708a0d6114c70cfd39907fb6174041a3bccffdc824edb3e224bab7db453b906c35adc7f0a2908d2b034a9b11a
|
|
7
|
+
data.tar.gz: 01b1c9b4288f978d9946090afda49e17001732ec0b4f5e47d3831767975d731e5c940cc0fbdd7ccfca4a2bdf54bc8d281eba7d3dc4cbbd137e3f4edb576d9378
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 - 2026-02-11
|
|
4
|
+
* Added `filter_on` to `Backspin.run` and `Backspin.capture` (`:both` default, `:record` opt-out).
|
|
5
|
+
* Changed default filter behavior: `filter` now applies during verify comparisons/diffs when `filter_on: :both`.
|
|
6
|
+
* Matcher callbacks now receive mutable copies of comparison data so in-place mutations do not mutate snapshots.
|
|
7
|
+
* Snapshot serialization is now immutable: `Snapshot#to_h` returns a frozen representation built at initialization.
|
|
8
|
+
|
|
3
9
|
## 0.9.0 - 2026-02-11
|
|
4
10
|
* Breaking: `Backspin.run` and `Backspin.capture` now return `Backspin::BackspinResult` with explicit `result.actual` / `result.expected` snapshots.
|
|
5
11
|
* Breaking: result convenience accessors (`result.stdout`, `result.stderr`, `result.status`) were removed in favor of snapshot access.
|
data/Gemfile.lock
CHANGED
data/MATCHERS.md
CHANGED
|
@@ -77,6 +77,24 @@ The `:all` matcher receives full hashes with these keys:
|
|
|
77
77
|
- `"env"` - Optional Hash of env vars (command runs only)
|
|
78
78
|
- `"recorded_at"` - Timestamp string
|
|
79
79
|
|
|
80
|
+
Matcher inputs are copies of comparison data so in-place mutation inside matcher callbacks
|
|
81
|
+
does not mutate Backspin's stored snapshots.
|
|
82
|
+
|
|
83
|
+
Matcher callbacks should return a boolean. Any truthy return value is treated as pass,
|
|
84
|
+
and false/nil is treated as failure.
|
|
85
|
+
|
|
86
|
+
Example with safe in-place mutation:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
matcher = {
|
|
90
|
+
all: ->(recorded, actual) {
|
|
91
|
+
recorded["stdout"].gsub!(/id=\d+/, "id=[ID]")
|
|
92
|
+
actual["stdout"].gsub!(/id=\d+/, "id=[ID]")
|
|
93
|
+
recorded["stdout"] == actual["stdout"]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
80
98
|
## Examples
|
|
81
99
|
|
|
82
100
|
### Matching Version Numbers
|
data/README.md
CHANGED
|
@@ -132,6 +132,41 @@ result = Backspin.run(["date"], name: "timestamp_test", matcher: {stdout: timest
|
|
|
132
132
|
|
|
133
133
|
For more matcher examples and detailed documentation, see [MATCHERS.md](MATCHERS.md).
|
|
134
134
|
|
|
135
|
+
### Filters and Canonicalization
|
|
136
|
+
|
|
137
|
+
Use `filter:` to normalize snapshot data (timestamps, random IDs, absolute paths).
|
|
138
|
+
|
|
139
|
+
By default (`filter_on: :both`), Backspin applies `filter`:
|
|
140
|
+
- when writing record snapshots
|
|
141
|
+
- during verify for both expected and actual, before matcher and diff
|
|
142
|
+
|
|
143
|
+
If you only want record-time filtering, use `filter_on: :record`.
|
|
144
|
+
|
|
145
|
+
Migration note: older behavior applied `filter` only at record write. To preserve that behavior, set `filter_on: :record`.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
normalize_filter = ->(snapshot) do
|
|
149
|
+
snapshot.merge(
|
|
150
|
+
"stdout" => snapshot["stdout"].gsub(/id=\d+/, "id=[ID]")
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# default: filter_on :both
|
|
155
|
+
Backspin.run(["echo", "id=123"], name: "canonicalized", filter: normalize_filter)
|
|
156
|
+
Backspin.run(["echo", "id=999"], name: "canonicalized", filter: normalize_filter) # verifies
|
|
157
|
+
|
|
158
|
+
# capture also supports verify-time canonicalization
|
|
159
|
+
Backspin.capture("capture_canonicalized", filter: normalize_filter) do
|
|
160
|
+
puts "id=123"
|
|
161
|
+
end
|
|
162
|
+
Backspin.capture("capture_canonicalized", filter: normalize_filter) do
|
|
163
|
+
puts "id=999"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# record-only filtering
|
|
167
|
+
Backspin.run(["echo", "id=123"], name: "record_only", filter: normalize_filter, filter_on: :record)
|
|
168
|
+
```
|
|
169
|
+
|
|
135
170
|
### Working with the Result Object
|
|
136
171
|
|
|
137
172
|
The API returns a `Backspin::BackspinResult` object with helpful methods:
|
|
@@ -15,8 +15,8 @@ Branch: `spike-backspin-result-api`
|
|
|
15
15
|
### Entry points
|
|
16
16
|
|
|
17
17
|
```ruby
|
|
18
|
-
Backspin.run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, &block)
|
|
19
|
-
Backspin.capture(name, mode: :auto, matcher: nil, filter: nil, &block)
|
|
18
|
+
Backspin.run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
|
|
19
|
+
Backspin.capture(name, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Both return `BackspinResult`.
|
|
@@ -130,7 +130,9 @@ result.expected.stdout
|
|
|
130
130
|
## Matcher and Filter Semantics
|
|
131
131
|
|
|
132
132
|
- `matcher:` applies only during verify and compares `expected` vs `actual`.
|
|
133
|
-
- `filter:` applies
|
|
133
|
+
- `filter:` applies during record writes, and during verify when `filter_on: :both`.
|
|
134
|
+
- `filter_on:` supports `:both` (default) and `:record`.
|
|
135
|
+
- `Snapshot` serializes once at initialization and returns a frozen hash from `to_h`.
|
|
134
136
|
- Default match still compares stdout/stderr/status only.
|
|
135
137
|
|
|
136
138
|
## Error Semantics
|
|
@@ -5,21 +5,25 @@ module Backspin
|
|
|
5
5
|
class CommandDiff
|
|
6
6
|
attr_reader :expected, :actual, :matcher
|
|
7
7
|
|
|
8
|
-
def initialize(expected:, actual:, matcher: nil)
|
|
8
|
+
def initialize(expected:, actual:, matcher: nil, filter: nil, filter_on: :both)
|
|
9
9
|
@expected = expected
|
|
10
10
|
@actual = actual
|
|
11
|
+
@expected_compare = build_comparison_snapshot(expected, filter: filter, filter_on: filter_on)
|
|
12
|
+
@actual_compare = build_comparison_snapshot(actual, filter: filter, filter_on: filter_on)
|
|
11
13
|
@matcher = Matcher.new(
|
|
12
14
|
config: matcher,
|
|
13
|
-
expected:
|
|
14
|
-
actual:
|
|
15
|
+
expected: @expected_compare,
|
|
16
|
+
actual: @actual_compare
|
|
15
17
|
)
|
|
18
|
+
@verified = nil
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# @return [Boolean] true if the snapshot output matches.
|
|
19
22
|
def verified?
|
|
20
|
-
return
|
|
23
|
+
return @verified unless @verified.nil?
|
|
24
|
+
return @verified = false unless command_types_match?
|
|
21
25
|
|
|
22
|
-
@matcher.match?
|
|
26
|
+
@verified = @matcher.match?
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# @return [String, nil] Human-readable diff if not verified
|
|
@@ -27,23 +31,21 @@ module Backspin
|
|
|
27
31
|
return nil if verified?
|
|
28
32
|
|
|
29
33
|
parts = []
|
|
30
|
-
expected_hash = expected.to_h
|
|
31
|
-
actual_hash = actual.to_h
|
|
32
34
|
|
|
33
35
|
unless command_types_match?
|
|
34
36
|
parts << "Command type mismatch: expected #{expected.command_type.name}, got #{actual.command_type.name}"
|
|
35
37
|
end
|
|
36
38
|
|
|
37
|
-
if
|
|
38
|
-
parts << stdout_diff(
|
|
39
|
+
if expected_compare.stdout != actual_compare.stdout
|
|
40
|
+
parts << stdout_diff(expected_compare.stdout, actual_compare.stdout)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
|
-
if
|
|
42
|
-
parts << stderr_diff(
|
|
43
|
+
if expected_compare.stderr != actual_compare.stderr
|
|
44
|
+
parts << stderr_diff(expected_compare.stderr, actual_compare.stderr)
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
if
|
|
46
|
-
parts << "Exit status: expected #{
|
|
47
|
+
if expected_compare.status != actual_compare.status
|
|
48
|
+
parts << "Exit status: expected #{expected_compare.status}, got #{actual_compare.status}"
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
parts.join("\n\n")
|
|
@@ -99,5 +101,60 @@ module Backspin
|
|
|
99
101
|
|
|
100
102
|
diff_lines.join("\n")
|
|
101
103
|
end
|
|
104
|
+
|
|
105
|
+
attr_reader :expected_compare
|
|
106
|
+
|
|
107
|
+
attr_reader :actual_compare
|
|
108
|
+
|
|
109
|
+
def build_comparison_snapshot(snapshot, filter:, filter_on:)
|
|
110
|
+
data = deep_dup(snapshot.to_h)
|
|
111
|
+
if filter && filter_on == :both
|
|
112
|
+
data = filter.call(data)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
ComparisonSnapshot.new(
|
|
116
|
+
command_type: snapshot.command_type,
|
|
117
|
+
data: deep_freeze(data)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def deep_dup(value)
|
|
122
|
+
case value
|
|
123
|
+
when Hash
|
|
124
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
125
|
+
when Array
|
|
126
|
+
value.map { |entry| deep_dup(entry) }
|
|
127
|
+
when String
|
|
128
|
+
value.dup
|
|
129
|
+
else
|
|
130
|
+
value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def deep_freeze(value)
|
|
135
|
+
case value
|
|
136
|
+
when Hash
|
|
137
|
+
value.each_value { |entry| deep_freeze(entry) }
|
|
138
|
+
when Array
|
|
139
|
+
value.each { |entry| deep_freeze(entry) }
|
|
140
|
+
end
|
|
141
|
+
value.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class ComparisonSnapshot
|
|
145
|
+
attr_reader :command_type, :stdout, :stderr, :status
|
|
146
|
+
|
|
147
|
+
def initialize(command_type:, data:)
|
|
148
|
+
@command_type = command_type
|
|
149
|
+
@data = data
|
|
150
|
+
@stdout = data["stdout"]
|
|
151
|
+
@stderr = data["stderr"]
|
|
152
|
+
@status = data["status"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_h
|
|
156
|
+
@data
|
|
157
|
+
end
|
|
158
|
+
end
|
|
102
159
|
end
|
|
103
160
|
end
|
data/lib/backspin/matcher.rb
CHANGED
|
@@ -13,48 +13,12 @@ module Backspin
|
|
|
13
13
|
|
|
14
14
|
# @return [Boolean] true if snapshots match according to configured matcher
|
|
15
15
|
def match?
|
|
16
|
-
|
|
17
|
-
default_matcher.call(expected.to_h, actual.to_h)
|
|
18
|
-
elsif config.is_a?(Proc)
|
|
19
|
-
config.call(expected.to_h, actual.to_h)
|
|
20
|
-
elsif config.is_a?(Hash)
|
|
21
|
-
verify_with_hash_matcher
|
|
22
|
-
else
|
|
23
|
-
raise ArgumentError, "Invalid matcher type: #{config.class}"
|
|
24
|
-
end
|
|
16
|
+
evaluation[:match]
|
|
25
17
|
end
|
|
26
18
|
|
|
27
19
|
# @return [String] reason why matching failed
|
|
28
20
|
def failure_reason
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if config.nil?
|
|
32
|
-
expected_hash = expected.to_h
|
|
33
|
-
actual_hash = actual.to_h
|
|
34
|
-
|
|
35
|
-
reasons << "stdout differs" if expected_hash["stdout"] != actual_hash["stdout"]
|
|
36
|
-
reasons << "stderr differs" if expected_hash["stderr"] != actual_hash["stderr"]
|
|
37
|
-
reasons << "exit status differs" if expected_hash["status"] != actual_hash["status"]
|
|
38
|
-
elsif config.is_a?(Hash)
|
|
39
|
-
expected_hash = expected.to_h
|
|
40
|
-
actual_hash = actual.to_h
|
|
41
|
-
|
|
42
|
-
config.each do |field, matcher_proc|
|
|
43
|
-
case field
|
|
44
|
-
when :all
|
|
45
|
-
reasons << ":all matcher failed" unless matcher_proc.call(expected_hash, actual_hash)
|
|
46
|
-
when :stdout, :stderr, :status
|
|
47
|
-
unless matcher_proc.call(expected_hash[field.to_s], actual_hash[field.to_s])
|
|
48
|
-
reasons << "#{field} custom matcher failed"
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
else
|
|
53
|
-
# Proc matcher
|
|
54
|
-
reasons << "custom matcher failed"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
reasons.join(", ")
|
|
21
|
+
evaluation[:reason]
|
|
58
22
|
end
|
|
59
23
|
|
|
60
24
|
private
|
|
@@ -75,29 +39,73 @@ module Backspin
|
|
|
75
39
|
config
|
|
76
40
|
end
|
|
77
41
|
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
42
|
+
def evaluation
|
|
43
|
+
@evaluation ||= if config.nil?
|
|
44
|
+
evaluate_default
|
|
45
|
+
elsif config.is_a?(Proc)
|
|
46
|
+
evaluate_proc
|
|
47
|
+
elsif config.is_a?(Hash)
|
|
48
|
+
evaluate_hash
|
|
49
|
+
else
|
|
50
|
+
raise ArgumentError, "Invalid matcher type: #{config.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def evaluate_default
|
|
55
|
+
reasons = []
|
|
56
|
+
reasons << "stdout differs" if expected.stdout != actual.stdout
|
|
57
|
+
reasons << "stderr differs" if expected.stderr != actual.stderr
|
|
58
|
+
reasons << "exit status differs" if expected.status != actual.status
|
|
81
59
|
|
|
82
|
-
|
|
83
|
-
|
|
60
|
+
{match: reasons.empty?, reason: reasons.join(", ")}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def evaluate_proc
|
|
64
|
+
match = !!config.call(deep_dup(expected_hash), deep_dup(actual_hash))
|
|
65
|
+
reason = match ? "" : "custom matcher failed"
|
|
66
|
+
{match: match, reason: reason}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def evaluate_hash
|
|
70
|
+
reasons = []
|
|
71
|
+
|
|
72
|
+
config.each do |field, matcher_proc|
|
|
73
|
+
passed = case field
|
|
84
74
|
when :all
|
|
85
|
-
matcher_proc.call(expected_hash, actual_hash)
|
|
75
|
+
matcher_proc.call(deep_dup(expected_hash), deep_dup(actual_hash))
|
|
86
76
|
when :stdout, :stderr, :status
|
|
87
|
-
matcher_proc.call(
|
|
77
|
+
matcher_proc.call(deep_dup(expected.public_send(field)), deep_dup(actual.public_send(field)))
|
|
88
78
|
else
|
|
89
79
|
raise ArgumentError, "Unknown field: #{field}"
|
|
90
80
|
end
|
|
81
|
+
|
|
82
|
+
next if passed
|
|
83
|
+
|
|
84
|
+
reasons << ":all matcher failed" if field == :all
|
|
85
|
+
reasons << "#{field} custom matcher failed" if %i[stdout stderr status].include?(field)
|
|
91
86
|
end
|
|
92
87
|
|
|
93
|
-
|
|
88
|
+
{match: reasons.empty?, reason: reasons.join(", ")}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def expected_hash
|
|
92
|
+
@expected_hash ||= expected.to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def actual_hash
|
|
96
|
+
@actual_hash ||= actual.to_h
|
|
94
97
|
end
|
|
95
98
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
def deep_dup(value)
|
|
100
|
+
case value
|
|
101
|
+
when Hash
|
|
102
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
103
|
+
when Array
|
|
104
|
+
value.map { |entry| deep_dup(entry) }
|
|
105
|
+
when String
|
|
106
|
+
value.dup
|
|
107
|
+
else
|
|
108
|
+
value
|
|
101
109
|
end
|
|
102
110
|
end
|
|
103
111
|
end
|
data/lib/backspin/record.rb
CHANGED
|
@@ -47,10 +47,12 @@ module Backspin
|
|
|
47
47
|
|
|
48
48
|
def save(filter: nil)
|
|
49
49
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
50
|
+
snapshot_data = @snapshot&.to_h
|
|
51
|
+
snapshot_data = filter.call(deep_dup(snapshot_data)) if snapshot_data && filter
|
|
50
52
|
record_data = {
|
|
51
53
|
"format_version" => FORMAT_VERSION,
|
|
52
54
|
"recorded_at" => @recorded_at,
|
|
53
|
-
"snapshot" =>
|
|
55
|
+
"snapshot" => snapshot_data
|
|
54
56
|
}
|
|
55
57
|
File.write(@path, record_data.to_yaml)
|
|
56
58
|
end
|
|
@@ -91,5 +93,20 @@ module Backspin
|
|
|
91
93
|
rescue Psych::SyntaxError => e
|
|
92
94
|
raise RecordFormatError, "Invalid record format: #{e.message}"
|
|
93
95
|
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def deep_dup(value)
|
|
100
|
+
case value
|
|
101
|
+
when Hash
|
|
102
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
103
|
+
when Array
|
|
104
|
+
value.map { |entry| deep_dup(entry) }
|
|
105
|
+
when String
|
|
106
|
+
value.dup
|
|
107
|
+
else
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
94
111
|
end
|
|
95
112
|
end
|
data/lib/backspin/recorder.rb
CHANGED
|
@@ -6,13 +6,14 @@ require "backspin/command_diff"
|
|
|
6
6
|
module Backspin
|
|
7
7
|
# Handles capture-mode recording and verification
|
|
8
8
|
class Recorder
|
|
9
|
-
attr_reader :mode, :record, :matcher, :filter
|
|
9
|
+
attr_reader :mode, :record, :matcher, :filter, :filter_on
|
|
10
10
|
|
|
11
|
-
def initialize(mode: :record, record: nil, matcher: nil, filter: nil)
|
|
11
|
+
def initialize(mode: :record, record: nil, matcher: nil, filter: nil, filter_on: :both)
|
|
12
12
|
@mode = mode
|
|
13
13
|
@record = record
|
|
14
14
|
@matcher = matcher
|
|
15
15
|
@filter = filter
|
|
16
|
+
@filter_on = filter_on
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Performs capture recording by intercepting all stdout/stderr output
|
|
@@ -62,7 +63,9 @@ module Backspin
|
|
|
62
63
|
command_diff = CommandDiff.new(
|
|
63
64
|
expected: expected_snapshot,
|
|
64
65
|
actual: actual_snapshot,
|
|
65
|
-
matcher: @matcher
|
|
66
|
+
matcher: @matcher,
|
|
67
|
+
filter: @filter,
|
|
68
|
+
filter_on: @filter_on
|
|
66
69
|
)
|
|
67
70
|
|
|
68
71
|
BackspinResult.new(
|
data/lib/backspin/snapshot.rb
CHANGED
|
@@ -7,12 +7,13 @@ module Backspin
|
|
|
7
7
|
|
|
8
8
|
def initialize(command_type:, args:, env: nil, stdout: "", stderr: "", status: 0, recorded_at: nil)
|
|
9
9
|
@command_type = command_type
|
|
10
|
-
@args = args
|
|
11
|
-
@env = env
|
|
12
|
-
@stdout = stdout || ""
|
|
13
|
-
@stderr = stderr || ""
|
|
10
|
+
@args = sanitize_args(args)
|
|
11
|
+
@env = env.nil? ? nil : sanitize_env(env)
|
|
12
|
+
@stdout = Backspin.scrub_text((stdout || "").dup).freeze
|
|
13
|
+
@stderr = Backspin.scrub_text((stderr || "").dup).freeze
|
|
14
14
|
@status = status || 0
|
|
15
|
-
@recorded_at = recorded_at
|
|
15
|
+
@recorded_at = recorded_at.nil? ? nil : recorded_at.dup.freeze
|
|
16
|
+
@serialized_hash = build_serialized_hash
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def success?
|
|
@@ -23,20 +24,8 @@ module Backspin
|
|
|
23
24
|
!success?
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
def to_h
|
|
27
|
-
|
|
28
|
-
"command_type" => command_type.name,
|
|
29
|
-
"args" => scrub_args(args),
|
|
30
|
-
"stdout" => Backspin.scrub_text(stdout),
|
|
31
|
-
"stderr" => Backspin.scrub_text(stderr),
|
|
32
|
-
"status" => status,
|
|
33
|
-
"recorded_at" => recorded_at
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
data["env"] = scrub_env(env) if env
|
|
37
|
-
data = filter.call(data) if filter
|
|
38
|
-
|
|
39
|
-
data
|
|
27
|
+
def to_h
|
|
28
|
+
@serialized_hash
|
|
40
29
|
end
|
|
41
30
|
|
|
42
31
|
def self.from_h(data)
|
|
@@ -62,6 +51,19 @@ module Backspin
|
|
|
62
51
|
|
|
63
52
|
private
|
|
64
53
|
|
|
54
|
+
def build_serialized_hash
|
|
55
|
+
data = {
|
|
56
|
+
"command_type" => command_type.name,
|
|
57
|
+
"args" => args,
|
|
58
|
+
"stdout" => stdout,
|
|
59
|
+
"stderr" => stderr,
|
|
60
|
+
"status" => status,
|
|
61
|
+
"recorded_at" => recorded_at
|
|
62
|
+
}
|
|
63
|
+
data["env"] = env if env
|
|
64
|
+
deep_freeze(data)
|
|
65
|
+
end
|
|
66
|
+
|
|
65
67
|
def scrub_args(value)
|
|
66
68
|
return value unless Backspin.configuration.scrub_credentials && value
|
|
67
69
|
|
|
@@ -82,6 +84,37 @@ module Backspin
|
|
|
82
84
|
|
|
83
85
|
value.transform_values { |entry| entry.is_a?(String) ? Backspin.scrub_text(entry) : entry }
|
|
84
86
|
end
|
|
87
|
+
|
|
88
|
+
def sanitize_args(value)
|
|
89
|
+
deep_freeze(scrub_args(deep_dup(value)))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def sanitize_env(value)
|
|
93
|
+
deep_freeze(scrub_env(deep_dup(value)))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deep_freeze(value)
|
|
97
|
+
case value
|
|
98
|
+
when Hash
|
|
99
|
+
value.each_value { |v| deep_freeze(v) }
|
|
100
|
+
when Array
|
|
101
|
+
value.each { |v| deep_freeze(v) }
|
|
102
|
+
end
|
|
103
|
+
value.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def deep_dup(value)
|
|
107
|
+
case value
|
|
108
|
+
when Hash
|
|
109
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
110
|
+
when Array
|
|
111
|
+
value.map { |entry| deep_dup(entry) }
|
|
112
|
+
when String
|
|
113
|
+
value.dup
|
|
114
|
+
else
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
85
118
|
end
|
|
86
119
|
end
|
|
87
120
|
|
data/lib/backspin/version.rb
CHANGED
data/lib/backspin.rb
CHANGED
|
@@ -71,19 +71,30 @@ module Backspin
|
|
|
71
71
|
# @param env [Hash] Environment variables to pass to Open3.capture3
|
|
72
72
|
# @param mode [Symbol] Recording mode - :auto, :record, :verify
|
|
73
73
|
# @param matcher [Proc, Hash] Custom matcher for verification
|
|
74
|
-
# @param filter [Proc] Custom filter for recorded data
|
|
74
|
+
# @param filter [Proc] Custom filter for recorded data/canonicalization
|
|
75
|
+
# @param filter_on [Symbol] Filter application mode - :both (default), :record
|
|
75
76
|
# @return [BackspinResult] Aggregate result for this run
|
|
76
|
-
def run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, &block)
|
|
77
|
+
def run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
|
|
78
|
+
validate_filter_on!(filter_on)
|
|
79
|
+
|
|
77
80
|
if block_given?
|
|
78
81
|
raise ArgumentError, "command must be omitted when using a block" unless command.nil?
|
|
79
82
|
raise ArgumentError, "env is not supported when using a block" unless env.nil?
|
|
80
83
|
|
|
81
|
-
return perform_capture(name, mode: mode, matcher: matcher, filter: filter, &block)
|
|
84
|
+
return perform_capture(name, mode: mode, matcher: matcher, filter: filter, filter_on: filter_on, &block)
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
raise ArgumentError, "command is required" if command.nil?
|
|
85
88
|
|
|
86
|
-
perform_command_run(
|
|
89
|
+
perform_command_run(
|
|
90
|
+
command,
|
|
91
|
+
name: name,
|
|
92
|
+
env: env,
|
|
93
|
+
mode: mode,
|
|
94
|
+
matcher: matcher,
|
|
95
|
+
filter: filter,
|
|
96
|
+
filter_on: filter_on
|
|
97
|
+
)
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
# Captures all stdout/stderr output from a block
|
|
@@ -91,18 +102,20 @@ module Backspin
|
|
|
91
102
|
# @param record_name [String] Name for the record file
|
|
92
103
|
# @param mode [Symbol] Recording mode - :auto, :record, :verify
|
|
93
104
|
# @param matcher [Proc, Hash] Custom matcher for verification
|
|
94
|
-
# @param filter [Proc] Custom filter for recorded data
|
|
105
|
+
# @param filter [Proc] Custom filter for recorded data/canonicalization
|
|
106
|
+
# @param filter_on [Symbol] Filter application mode - :both (default), :record
|
|
95
107
|
# @return [BackspinResult] Aggregate result for this run
|
|
96
|
-
def capture(record_name, mode: :auto, matcher: nil, filter: nil, &block)
|
|
108
|
+
def capture(record_name, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
|
|
97
109
|
raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
|
|
98
110
|
raise ArgumentError, "block is required" unless block_given?
|
|
111
|
+
validate_filter_on!(filter_on)
|
|
99
112
|
|
|
100
|
-
perform_capture(record_name, mode: mode, matcher: matcher, filter: filter, &block)
|
|
113
|
+
perform_capture(record_name, mode: mode, matcher: matcher, filter: filter, filter_on: filter_on, &block)
|
|
101
114
|
end
|
|
102
115
|
|
|
103
116
|
private
|
|
104
117
|
|
|
105
|
-
def perform_capture(record_name, mode:, matcher:, filter:, &block)
|
|
118
|
+
def perform_capture(record_name, mode:, matcher:, filter:, filter_on:, &block)
|
|
106
119
|
record_path = Record.build_record_path(record_name)
|
|
107
120
|
mode = determine_mode(mode, record_path)
|
|
108
121
|
validate_mode!(mode)
|
|
@@ -113,7 +126,7 @@ module Backspin
|
|
|
113
126
|
Record.load_or_create(record_path)
|
|
114
127
|
end
|
|
115
128
|
|
|
116
|
-
recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
|
|
129
|
+
recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter, filter_on: filter_on)
|
|
117
130
|
|
|
118
131
|
result = case mode
|
|
119
132
|
when :record
|
|
@@ -129,7 +142,7 @@ module Backspin
|
|
|
129
142
|
result
|
|
130
143
|
end
|
|
131
144
|
|
|
132
|
-
def perform_command_run(command, name:, env:, mode:, matcher:, filter:)
|
|
145
|
+
def perform_command_run(command, name:, env:, mode:, matcher:, filter:, filter_on:)
|
|
133
146
|
record_path = Record.build_record_path(name)
|
|
134
147
|
mode = determine_mode(mode, record_path)
|
|
135
148
|
validate_mode!(mode)
|
|
@@ -180,7 +193,13 @@ module Backspin
|
|
|
180
193
|
stderr: stderr,
|
|
181
194
|
status: status.exitstatus
|
|
182
195
|
)
|
|
183
|
-
command_diff = CommandDiff.new(
|
|
196
|
+
command_diff = CommandDiff.new(
|
|
197
|
+
expected: expected_snapshot,
|
|
198
|
+
actual: actual_snapshot,
|
|
199
|
+
matcher: matcher,
|
|
200
|
+
filter: filter,
|
|
201
|
+
filter_on: filter_on
|
|
202
|
+
)
|
|
184
203
|
BackspinResult.new(
|
|
185
204
|
mode: :verify,
|
|
186
205
|
record_path: record.path,
|
|
@@ -242,5 +261,11 @@ module Backspin
|
|
|
242
261
|
|
|
243
262
|
raise ArgumentError, "Unknown mode: #{mode}"
|
|
244
263
|
end
|
|
264
|
+
|
|
265
|
+
def validate_filter_on!(filter_on)
|
|
266
|
+
return if %i[both record].include?(filter_on)
|
|
267
|
+
|
|
268
|
+
raise ArgumentError, "Unknown filter_on: #{filter_on}. Must be :both or :record"
|
|
269
|
+
end
|
|
245
270
|
end
|
|
246
271
|
end
|