backspin 0.9.0 → 0.11.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 +14 -0
- data/Gemfile.lock +1 -1
- data/MATCHERS.md +18 -0
- data/README.md +57 -0
- data/docs/backspin-result-api-sketch.md +13 -9
- data/fixtures/backspin/.gitkeep +1 -0
- data/lib/backspin/command_diff.rb +70 -13
- data/lib/backspin/matcher.rb +59 -51
- data/lib/backspin/record.rb +60 -6
- 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 +38 -21
- metadata +2 -56
- data/fixtures/backspin/all_and_fields.yml +0 -15
- data/fixtures/backspin/all_bypass_equality.yml +0 -14
- data/fixtures/backspin/all_checks_equality.yml +0 -17
- data/fixtures/backspin/all_for_logging.yml +0 -14
- data/fixtures/backspin/all_matcher_basic.yml +0 -14
- data/fixtures/backspin/all_matcher_custom.yml +0 -17
- data/fixtures/backspin/all_matcher_demo.yml +0 -14
- data/fixtures/backspin/all_matcher_test.yml +0 -14
- data/fixtures/backspin/all_mode_filter.yml +0 -14
- data/fixtures/backspin/all_no_short_circuit.yml +0 -14
- data/fixtures/backspin/all_pass_field_fail.yml +0 -14
- data/fixtures/backspin/all_short_circuit.yml +0 -14
- data/fixtures/backspin/all_skips_equality.yml +0 -17
- data/fixtures/backspin/all_with_equality.yml +0 -17
- data/fixtures/backspin/all_with_fields.yml +0 -17
- data/fixtures/backspin/combined_fail_demo.yml +0 -14
- data/fixtures/backspin/combined_matcher_demo.yml +0 -14
- data/fixtures/backspin/credential_filter.yml +0 -18
- data/fixtures/backspin/echo_hello.yml +0 -14
- data/fixtures/backspin/echo_verify.yml +0 -14
- data/fixtures/backspin/episodes_filter.yml +0 -26
- data/fixtures/backspin/failure_test.yml +0 -14
- data/fixtures/backspin/field_matcher_demo.yml +0 -17
- data/fixtures/backspin/field_matcher_values.yml +0 -14
- data/fixtures/backspin/full_data_filter.yml +0 -17
- data/fixtures/backspin/key_confusion_test.yml +0 -14
- data/fixtures/backspin/match_on_any_fail.yml +0 -21
- data/fixtures/backspin/match_on_bad_format.yml +0 -14
- data/fixtures/backspin/match_on_fail.yml +0 -15
- data/fixtures/backspin/match_on_invalid.yml +0 -14
- data/fixtures/backspin/match_on_multiple.yml +0 -28
- data/fixtures/backspin/match_on_nil.yml +0 -14
- data/fixtures/backspin/match_on_other_fields.yml +0 -23
- data/fixtures/backspin/match_on_run_bang.yml +0 -16
- data/fixtures/backspin/match_on_run_bang_fail.yml +0 -15
- data/fixtures/backspin/match_on_single.yml +0 -17
- data/fixtures/backspin/mixed_calls.yml +0 -24
- data/fixtures/backspin/multi_command.yml +0 -34
- data/fixtures/backspin/multi_command_filter.yml +0 -26
- data/fixtures/backspin/multi_field_filter.yml +0 -13
- data/fixtures/backspin/multi_system.yml +0 -20
- data/fixtures/backspin/nil_filter.yml +0 -14
- data/fixtures/backspin/none_mode_test.yml +0 -14
- data/fixtures/backspin/path_test.yml +0 -17
- data/fixtures/backspin/playback_system.yml +0 -12
- data/fixtures/backspin/playback_test.yml +0 -14
- data/fixtures/backspin/stderr_test.yml +0 -19
- data/fixtures/backspin/strict_test.yml +0 -14
- data/fixtures/backspin/string_symbol_test.yml +0 -14
- data/fixtures/backspin/system_echo.yml +0 -12
- data/fixtures/backspin/system_false.yml +0 -18
- data/fixtures/backspin/timestamp_test.yml +0 -18
- data/fixtures/backspin/verify_system.yml +0 -12
- data/fixtures/backspin/verify_system_diff.yml +0 -11
- data/fixtures/backspin/version_test.yml +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a9533baf54aa3ae09b9d483d41149a6ede985f3f974f038e287034928a0ab1e3
|
|
4
|
+
data.tar.gz: 75b9cb2018b1f1ca5878f6ce75af74bc9fc4822319ac2f12e8c654f30624f859
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8d851c2836d8cd0be15b341a7f2af332c356aaa9688766a5b51f65110539a2d500cac8238abd4bc0a668a689effbef45a8f009829a3cd28ff4acd249bb506b4
|
|
7
|
+
data.tar.gz: 0151e1de9a47285ca75552e97154b57100de50fe691100869144e1ed17e1b7c421a012599c4462a333f6abd0c4779f110a4fbffd2bbe60d14d600469da459bad
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 - 2026-02-11
|
|
4
|
+
* Added immutable top-level `first_recorded_at` metadata for record files.
|
|
5
|
+
* Added mutable top-level `recorded_at` metadata that updates on each successful re-record.
|
|
6
|
+
* Added top-level `record_count`, incremented on each successful record write.
|
|
7
|
+
* Record format now writes `format_version: 4.1`; loading remains backward-compatible with 4.0 record files.
|
|
8
|
+
* Added acceptance coverage for v4.1 schema and 4.0-to-4.1 upgrade behavior.
|
|
9
|
+
* Removed legacy committed `.yml` record fixtures from old schema versions.
|
|
10
|
+
|
|
11
|
+
## 0.10.0 - 2026-02-11
|
|
12
|
+
* Added `filter_on` to `Backspin.run` and `Backspin.capture` (`:both` default, `:record` opt-out).
|
|
13
|
+
* Changed default filter behavior: `filter` now applies during verify comparisons/diffs when `filter_on: :both`.
|
|
14
|
+
* Matcher callbacks now receive mutable copies of comparison data so in-place mutations do not mutate snapshots.
|
|
15
|
+
* Snapshot serialization is now immutable: `Snapshot#to_h` returns a frozen representation built at initialization.
|
|
16
|
+
|
|
3
17
|
## 0.9.0 - 2026-02-11
|
|
4
18
|
* Breaking: `Backspin.run` and `Backspin.capture` now return `Backspin::BackspinResult` with explicit `result.actual` / `result.expected` snapshots.
|
|
5
19
|
* 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
|
@@ -91,6 +91,28 @@ result = Backspin.run(["echo", "hello"], name: "echo_test", mode: :verify)
|
|
|
91
91
|
expect(result.verified?).to be true
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
### Record Metadata
|
|
95
|
+
|
|
96
|
+
Backspin writes records using `format_version: "4.1"` with top-level metadata:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
---
|
|
100
|
+
format_version: "4.1"
|
|
101
|
+
first_recorded_at: "2026-01-01T10:00:00Z" # immutable
|
|
102
|
+
recorded_at: "2026-02-01T10:00:00Z" # updates on each write
|
|
103
|
+
record_count: 3 # increments on each write
|
|
104
|
+
snapshot:
|
|
105
|
+
command_type: "Open3::Capture3"
|
|
106
|
+
args: ["echo", "hello"]
|
|
107
|
+
stdout: "hello\n"
|
|
108
|
+
stderr: ""
|
|
109
|
+
status: 0
|
|
110
|
+
recorded_at: "2026-02-01T10:00:00Z"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
When re-recording with `mode: :record`, Backspin preserves `first_recorded_at`, updates `recorded_at`, and increments `record_count`.
|
|
114
|
+
Existing `4.0` records still load and are upgraded to `4.1` metadata on the next write.
|
|
115
|
+
|
|
94
116
|
### Environment Variables
|
|
95
117
|
|
|
96
118
|
```ruby
|
|
@@ -132,6 +154,41 @@ result = Backspin.run(["date"], name: "timestamp_test", matcher: {stdout: timest
|
|
|
132
154
|
|
|
133
155
|
For more matcher examples and detailed documentation, see [MATCHERS.md](MATCHERS.md).
|
|
134
156
|
|
|
157
|
+
### Filters and Canonicalization
|
|
158
|
+
|
|
159
|
+
Use `filter:` to normalize snapshot data (timestamps, random IDs, absolute paths).
|
|
160
|
+
|
|
161
|
+
By default (`filter_on: :both`), Backspin applies `filter`:
|
|
162
|
+
- when writing record snapshots
|
|
163
|
+
- during verify for both expected and actual, before matcher and diff
|
|
164
|
+
|
|
165
|
+
If you only want record-time filtering, use `filter_on: :record`.
|
|
166
|
+
|
|
167
|
+
Migration note: older behavior applied `filter` only at record write. To preserve that behavior, set `filter_on: :record`.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
normalize_filter = ->(snapshot) do
|
|
171
|
+
snapshot.merge(
|
|
172
|
+
"stdout" => snapshot["stdout"].gsub(/id=\d+/, "id=[ID]")
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# default: filter_on :both
|
|
177
|
+
Backspin.run(["echo", "id=123"], name: "canonicalized", filter: normalize_filter)
|
|
178
|
+
Backspin.run(["echo", "id=999"], name: "canonicalized", filter: normalize_filter) # verifies
|
|
179
|
+
|
|
180
|
+
# capture also supports verify-time canonicalization
|
|
181
|
+
Backspin.capture("capture_canonicalized", filter: normalize_filter) do
|
|
182
|
+
puts "id=123"
|
|
183
|
+
end
|
|
184
|
+
Backspin.capture("capture_canonicalized", filter: normalize_filter) do
|
|
185
|
+
puts "id=999"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# record-only filtering
|
|
189
|
+
Backspin.run(["echo", "id=123"], name: "record_only", filter: normalize_filter, filter_on: :record)
|
|
190
|
+
```
|
|
191
|
+
|
|
135
192
|
### Working with the Result Object
|
|
136
193
|
|
|
137
194
|
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
|
|
@@ -139,14 +141,16 @@ result.expected.stdout
|
|
|
139
141
|
- Error message is generated from `BackspinResult#error_message`.
|
|
140
142
|
- Do not duplicate `diff` content in exception formatting.
|
|
141
143
|
|
|
142
|
-
## Record Format Sketch (v4)
|
|
144
|
+
## Record Format Sketch (v4.1)
|
|
143
145
|
|
|
144
146
|
Single-snapshot format to match single-snapshot runtime model:
|
|
145
147
|
|
|
146
148
|
```yaml
|
|
147
149
|
---
|
|
148
|
-
format_version: "4.
|
|
150
|
+
format_version: "4.1"
|
|
151
|
+
first_recorded_at: "2026-01-01T00:00:00Z"
|
|
149
152
|
recorded_at: "2026-02-11T00:00:00Z"
|
|
153
|
+
record_count: 3
|
|
150
154
|
snapshot:
|
|
151
155
|
command_type: "Open3::Capture3"
|
|
152
156
|
args: ["echo", "hello"]
|
|
@@ -182,11 +186,11 @@ Status date: 2026-02-11
|
|
|
182
186
|
|
|
183
187
|
1. `Snapshot` and `BackspinResult` classes are implemented and wired into runtime paths.
|
|
184
188
|
2. `Backspin.run` and `Backspin.capture` now return `BackspinResult`.
|
|
185
|
-
3. `Record` persistence moved to v4 single-snapshot format (`snapshot` key, no `commands` array).
|
|
189
|
+
3. `Record` persistence moved to v4 single-snapshot format (`snapshot` key, no `commands` array), with v4.1 top-level metadata.
|
|
186
190
|
4. `Matcher` and `CommandDiff` now operate on expected/actual snapshots.
|
|
187
191
|
5. Legacy result/command layering was removed from `lib/`.
|
|
188
|
-
6. Specs have been migrated to the new result contract and v4 format.
|
|
189
|
-
7. Validation is green: `
|
|
192
|
+
6. Specs have been migrated to the new result contract and v4.1 format.
|
|
193
|
+
7. Validation is green: `89 examples, 0 failures` and Standard lint passes.
|
|
190
194
|
8. Public docs now use `result.actual` / `result.expected` terminology.
|
|
191
195
|
|
|
192
196
|
## Success Criteria
|
|
@@ -196,7 +200,7 @@ Status date: 2026-02-11
|
|
|
196
200
|
3. In `:verify` mode, `result.expected` is present, `result.verified?` is boolean, and mismatch cases populate `result.diff` plus `result.error_message`.
|
|
197
201
|
4. No multi-command result API remains in the public result contract.
|
|
198
202
|
5. Snapshot object exposes a stable single-command shape: `stdout`, `stderr`, `status`, `args`, `env`, `command_type`.
|
|
199
|
-
6. Record format uses one snapshot (v4), not a commands array.
|
|
203
|
+
6. Record format uses one snapshot (v4.x), not a commands array.
|
|
200
204
|
7. Existing strict verification behavior remains: default raises `Backspin::VerificationError`, while `raise_on_verification_failure = false` returns a failed result without raising.
|
|
201
205
|
8. End-to-end Unix command examples are covered in specs: `echo` record/verify, `ls -1` record/verify, `date` mismatch behavior (or matcher override), and captured `grep | wc` pipeline output via `Backspin.capture`.
|
|
202
206
|
9. Matcher behavior is preserved: default matching remains stdout/stderr/status, and custom `matcher:` contract (Proc, hash fields, `:all`) continues to work for both run and capture verification.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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
|
@@ -4,8 +4,9 @@ module Backspin
|
|
|
4
4
|
class RecordFormatError < StandardError; end
|
|
5
5
|
|
|
6
6
|
class Record
|
|
7
|
-
FORMAT_VERSION = "4.
|
|
8
|
-
|
|
7
|
+
FORMAT_VERSION = "4.1"
|
|
8
|
+
SUPPORTED_FORMAT_VERSIONS = ["4.0", FORMAT_VERSION].freeze
|
|
9
|
+
attr_reader :path, :snapshot, :first_recorded_at, :recorded_at, :record_count
|
|
9
10
|
|
|
10
11
|
def self.load_or_create(path)
|
|
11
12
|
record = new(path)
|
|
@@ -36,28 +37,40 @@ module Backspin
|
|
|
36
37
|
def initialize(path)
|
|
37
38
|
@path = path
|
|
38
39
|
@snapshot = nil
|
|
40
|
+
@first_recorded_at = nil
|
|
39
41
|
@recorded_at = nil
|
|
42
|
+
@record_count = nil
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def set_snapshot(snapshot)
|
|
43
46
|
@snapshot = snapshot
|
|
44
|
-
|
|
47
|
+
snapshot_recorded_at = snapshot.recorded_at || Time.now.iso8601
|
|
48
|
+
@first_recorded_at ||= snapshot_recorded_at
|
|
49
|
+
@recorded_at = snapshot_recorded_at
|
|
45
50
|
self
|
|
46
51
|
end
|
|
47
52
|
|
|
48
53
|
def save(filter: nil)
|
|
49
54
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
55
|
+
snapshot_data = @snapshot&.to_h
|
|
56
|
+
snapshot_data = filter.call(deep_dup(snapshot_data)) if snapshot_data && filter
|
|
57
|
+
next_record_count = (@record_count || 0) + 1
|
|
50
58
|
record_data = {
|
|
51
59
|
"format_version" => FORMAT_VERSION,
|
|
60
|
+
"first_recorded_at" => @first_recorded_at,
|
|
52
61
|
"recorded_at" => @recorded_at,
|
|
53
|
-
"
|
|
62
|
+
"record_count" => next_record_count,
|
|
63
|
+
"snapshot" => snapshot_data
|
|
54
64
|
}
|
|
55
65
|
File.write(@path, record_data.to_yaml)
|
|
66
|
+
@record_count = next_record_count
|
|
56
67
|
end
|
|
57
68
|
|
|
58
69
|
def reload
|
|
59
70
|
@snapshot = nil
|
|
71
|
+
@first_recorded_at = nil
|
|
60
72
|
@recorded_at = nil
|
|
73
|
+
@record_count = nil
|
|
61
74
|
load_from_file if File.exist?(@path)
|
|
62
75
|
end
|
|
63
76
|
|
|
@@ -71,13 +84,15 @@ module Backspin
|
|
|
71
84
|
|
|
72
85
|
def clear
|
|
73
86
|
@snapshot = nil
|
|
87
|
+
@first_recorded_at = nil
|
|
74
88
|
@recorded_at = nil
|
|
89
|
+
@record_count = nil
|
|
75
90
|
end
|
|
76
91
|
|
|
77
92
|
def load_from_file
|
|
78
93
|
data = YAML.load_file(@path.to_s)
|
|
79
94
|
|
|
80
|
-
unless data.is_a?(Hash) && data["format_version"]
|
|
95
|
+
unless data.is_a?(Hash) && SUPPORTED_FORMAT_VERSIONS.include?(data["format_version"])
|
|
81
96
|
raise RecordFormatError, "Invalid record format: expected format version #{FORMAT_VERSION}"
|
|
82
97
|
end
|
|
83
98
|
|
|
@@ -86,10 +101,49 @@ module Backspin
|
|
|
86
101
|
raise RecordFormatError, "Invalid record format: missing snapshot"
|
|
87
102
|
end
|
|
88
103
|
|
|
89
|
-
|
|
104
|
+
format_version = data["format_version"]
|
|
105
|
+
@recorded_at = data["recorded_at"] || snapshot_data["recorded_at"]
|
|
106
|
+
if format_version == FORMAT_VERSION
|
|
107
|
+
@first_recorded_at = data["first_recorded_at"]
|
|
108
|
+
@record_count = data["record_count"]
|
|
109
|
+
else
|
|
110
|
+
# Backfill metadata for v4.0 records.
|
|
111
|
+
@first_recorded_at = data["first_recorded_at"] || @recorded_at
|
|
112
|
+
@record_count = data.fetch("record_count", 1)
|
|
113
|
+
end
|
|
114
|
+
validate_metadata!
|
|
90
115
|
@snapshot = Snapshot.from_h(snapshot_data)
|
|
91
116
|
rescue Psych::SyntaxError => e
|
|
92
117
|
raise RecordFormatError, "Invalid record format: #{e.message}"
|
|
93
118
|
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def validate_metadata!
|
|
123
|
+
unless @first_recorded_at.is_a?(String) && !@first_recorded_at.empty?
|
|
124
|
+
raise RecordFormatError, "Invalid record format: missing first_recorded_at"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
unless @recorded_at.is_a?(String) && !@recorded_at.empty?
|
|
128
|
+
raise RecordFormatError, "Invalid record format: missing recorded_at"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless @record_count.is_a?(Integer) && @record_count.positive?
|
|
132
|
+
raise RecordFormatError, "Invalid record format: record_count must be a positive integer"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deep_dup(value)
|
|
137
|
+
case value
|
|
138
|
+
when Hash
|
|
139
|
+
value.transform_values { |entry| deep_dup(entry) }
|
|
140
|
+
when Array
|
|
141
|
+
value.map { |entry| deep_dup(entry) }
|
|
142
|
+
when String
|
|
143
|
+
value.dup
|
|
144
|
+
else
|
|
145
|
+
value
|
|
146
|
+
end
|
|
147
|
+
end
|
|
94
148
|
end
|
|
95
149
|
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(
|