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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Gemfile.lock +1 -1
  4. data/MATCHERS.md +18 -0
  5. data/README.md +57 -0
  6. data/docs/backspin-result-api-sketch.md +13 -9
  7. data/fixtures/backspin/.gitkeep +1 -0
  8. data/lib/backspin/command_diff.rb +70 -13
  9. data/lib/backspin/matcher.rb +59 -51
  10. data/lib/backspin/record.rb +60 -6
  11. data/lib/backspin/recorder.rb +6 -3
  12. data/lib/backspin/snapshot.rb +52 -19
  13. data/lib/backspin/version.rb +1 -1
  14. data/lib/backspin.rb +38 -21
  15. metadata +2 -56
  16. data/fixtures/backspin/all_and_fields.yml +0 -15
  17. data/fixtures/backspin/all_bypass_equality.yml +0 -14
  18. data/fixtures/backspin/all_checks_equality.yml +0 -17
  19. data/fixtures/backspin/all_for_logging.yml +0 -14
  20. data/fixtures/backspin/all_matcher_basic.yml +0 -14
  21. data/fixtures/backspin/all_matcher_custom.yml +0 -17
  22. data/fixtures/backspin/all_matcher_demo.yml +0 -14
  23. data/fixtures/backspin/all_matcher_test.yml +0 -14
  24. data/fixtures/backspin/all_mode_filter.yml +0 -14
  25. data/fixtures/backspin/all_no_short_circuit.yml +0 -14
  26. data/fixtures/backspin/all_pass_field_fail.yml +0 -14
  27. data/fixtures/backspin/all_short_circuit.yml +0 -14
  28. data/fixtures/backspin/all_skips_equality.yml +0 -17
  29. data/fixtures/backspin/all_with_equality.yml +0 -17
  30. data/fixtures/backspin/all_with_fields.yml +0 -17
  31. data/fixtures/backspin/combined_fail_demo.yml +0 -14
  32. data/fixtures/backspin/combined_matcher_demo.yml +0 -14
  33. data/fixtures/backspin/credential_filter.yml +0 -18
  34. data/fixtures/backspin/echo_hello.yml +0 -14
  35. data/fixtures/backspin/echo_verify.yml +0 -14
  36. data/fixtures/backspin/episodes_filter.yml +0 -26
  37. data/fixtures/backspin/failure_test.yml +0 -14
  38. data/fixtures/backspin/field_matcher_demo.yml +0 -17
  39. data/fixtures/backspin/field_matcher_values.yml +0 -14
  40. data/fixtures/backspin/full_data_filter.yml +0 -17
  41. data/fixtures/backspin/key_confusion_test.yml +0 -14
  42. data/fixtures/backspin/match_on_any_fail.yml +0 -21
  43. data/fixtures/backspin/match_on_bad_format.yml +0 -14
  44. data/fixtures/backspin/match_on_fail.yml +0 -15
  45. data/fixtures/backspin/match_on_invalid.yml +0 -14
  46. data/fixtures/backspin/match_on_multiple.yml +0 -28
  47. data/fixtures/backspin/match_on_nil.yml +0 -14
  48. data/fixtures/backspin/match_on_other_fields.yml +0 -23
  49. data/fixtures/backspin/match_on_run_bang.yml +0 -16
  50. data/fixtures/backspin/match_on_run_bang_fail.yml +0 -15
  51. data/fixtures/backspin/match_on_single.yml +0 -17
  52. data/fixtures/backspin/mixed_calls.yml +0 -24
  53. data/fixtures/backspin/multi_command.yml +0 -34
  54. data/fixtures/backspin/multi_command_filter.yml +0 -26
  55. data/fixtures/backspin/multi_field_filter.yml +0 -13
  56. data/fixtures/backspin/multi_system.yml +0 -20
  57. data/fixtures/backspin/nil_filter.yml +0 -14
  58. data/fixtures/backspin/none_mode_test.yml +0 -14
  59. data/fixtures/backspin/path_test.yml +0 -17
  60. data/fixtures/backspin/playback_system.yml +0 -12
  61. data/fixtures/backspin/playback_test.yml +0 -14
  62. data/fixtures/backspin/stderr_test.yml +0 -19
  63. data/fixtures/backspin/strict_test.yml +0 -14
  64. data/fixtures/backspin/string_symbol_test.yml +0 -14
  65. data/fixtures/backspin/system_echo.yml +0 -12
  66. data/fixtures/backspin/system_false.yml +0 -18
  67. data/fixtures/backspin/timestamp_test.yml +0 -18
  68. data/fixtures/backspin/verify_system.yml +0 -12
  69. data/fixtures/backspin/verify_system_diff.yml +0 -11
  70. data/fixtures/backspin/version_test.yml +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8a6c6a0ef97c99ace7fb068e7b85bc40c2f45594bc30ce5002920fd62fcd384
4
- data.tar.gz: f62c15526c0a19a4ae876b8c737b172e2557c54a55f4d5045a0dbc499ccfcd51
3
+ metadata.gz: a9533baf54aa3ae09b9d483d41149a6ede985f3f974f038e287034928a0ab1e3
4
+ data.tar.gz: 75b9cb2018b1f1ca5878f6ce75af74bc9fc4822319ac2f12e8c654f30624f859
5
5
  SHA512:
6
- metadata.gz: 117a79e8e448bae03c68b44e8a6de23671d746d2ea664094e7dc82792d8ff323af7c6faf73f85a9aa020bb259127660abc8d7687cf861b627049b6e3b88ff41f
7
- data.tar.gz: dfeabf728ef8448d01889dbe2d62d9d2a8236da296d9f6ee21619f6e3f976218cd0af3da2ac8b7f4ab1ff9cbc19dd4afaef1ca64ac777f3f9215180a1349c1c0
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- backspin (0.9.0)
4
+ backspin (0.11.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
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 only when writing snapshots to disk.
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.0"
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: `66 examples, 0 failures` and Standard lint passes.
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: expected,
14
- actual: 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 false unless command_types_match?
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 expected_hash["stdout"] != actual_hash["stdout"]
38
- parts << stdout_diff(expected_hash["stdout"], actual_hash["stdout"])
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 expected_hash["stderr"] != actual_hash["stderr"]
42
- parts << stderr_diff(expected_hash["stderr"], actual_hash["stderr"])
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 expected_hash["status"] != actual_hash["status"]
46
- parts << "Exit status: expected #{expected_hash["status"]}, got #{actual_hash["status"]}"
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
@@ -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
- if config.nil?
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
- reasons = []
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 verify_with_hash_matcher
79
- expected_hash = expected.to_h
80
- actual_hash = actual.to_h
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
- results = config.map do |field, matcher_proc|
83
- case field
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(expected_hash[field.to_s], actual_hash[field.to_s])
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
- results.all?
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 default_matcher
97
- @default_matcher ||= lambda do |recorded, actual|
98
- recorded["stdout"] == actual["stdout"] &&
99
- recorded["stderr"] == actual["stderr"] &&
100
- recorded["status"] == actual["status"]
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
@@ -4,8 +4,9 @@ module Backspin
4
4
  class RecordFormatError < StandardError; end
5
5
 
6
6
  class Record
7
- FORMAT_VERSION = "4.0"
8
- attr_reader :path, :snapshot, :recorded_at
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
- @recorded_at ||= snapshot.recorded_at
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
- "snapshot" => @snapshot&.to_h(filter: filter)
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"] == 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
- @recorded_at = data["recorded_at"]
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
@@ -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(