backspin 0.8.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.
data/lib/backspin.rb CHANGED
@@ -6,13 +6,12 @@ require "open3"
6
6
  require "pathname"
7
7
  require "backspin/version"
8
8
  require "backspin/configuration"
9
- require "backspin/command_result"
10
- require "backspin/command"
9
+ require "backspin/snapshot"
11
10
  require "backspin/matcher"
12
11
  require "backspin/command_diff"
13
12
  require "backspin/record"
13
+ require "backspin/backspin_result"
14
14
  require "backspin/recorder"
15
- require "backspin/record_result"
16
15
 
17
16
  module Backspin
18
17
  class RecordNotFoundError < StandardError; end
@@ -29,12 +28,12 @@ module Backspin
29
28
  result.diff
30
29
  end
31
30
 
32
- def recorded_commands
33
- result.command_diffs.map(&:recorded_command)
31
+ def expected_snapshot
32
+ result.expected
34
33
  end
35
34
 
36
- def actual_commands
37
- result.command_diffs.map(&:actual_command)
35
+ def actual_snapshot
36
+ result.actual
38
37
  end
39
38
  end
40
39
 
@@ -72,19 +71,30 @@ module Backspin
72
71
  # @param env [Hash] Environment variables to pass to Open3.capture3
73
72
  # @param mode [Symbol] Recording mode - :auto, :record, :verify
74
73
  # @param matcher [Proc, Hash] Custom matcher for verification
75
- # @param filter [Proc] Custom filter for recorded data
76
- # @return [RecordResult] Result object with output and status
77
- def run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, &block)
74
+ # @param filter [Proc] Custom filter for recorded data/canonicalization
75
+ # @param filter_on [Symbol] Filter application mode - :both (default), :record
76
+ # @return [BackspinResult] Aggregate result for this run
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
+
78
80
  if block_given?
79
81
  raise ArgumentError, "command must be omitted when using a block" unless command.nil?
80
82
  raise ArgumentError, "env is not supported when using a block" unless env.nil?
81
83
 
82
- 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)
83
85
  end
84
86
 
85
87
  raise ArgumentError, "command is required" if command.nil?
86
88
 
87
- perform_command_run(command, name: name, env: env, mode: mode, matcher: matcher, filter: filter)
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
+ )
88
98
  end
89
99
 
90
100
  # Captures all stdout/stderr output from a block
@@ -92,18 +102,20 @@ module Backspin
92
102
  # @param record_name [String] Name for the record file
93
103
  # @param mode [Symbol] Recording mode - :auto, :record, :verify
94
104
  # @param matcher [Proc, Hash] Custom matcher for verification
95
- # @param filter [Proc] Custom filter for recorded data
96
- # @return [RecordResult] Result object with captured output
97
- def capture(record_name, mode: :auto, matcher: nil, filter: nil, &block)
105
+ # @param filter [Proc] Custom filter for recorded data/canonicalization
106
+ # @param filter_on [Symbol] Filter application mode - :both (default), :record
107
+ # @return [BackspinResult] Aggregate result for this run
108
+ def capture(record_name, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
98
109
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
99
110
  raise ArgumentError, "block is required" unless block_given?
111
+ validate_filter_on!(filter_on)
100
112
 
101
- 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)
102
114
  end
103
115
 
104
116
  private
105
117
 
106
- def perform_capture(record_name, mode:, matcher:, filter:, &block)
118
+ def perform_capture(record_name, mode:, matcher:, filter:, filter_on:, &block)
107
119
  record_path = Record.build_record_path(record_name)
108
120
  mode = determine_mode(mode, record_path)
109
121
  validate_mode!(mode)
@@ -114,7 +126,7 @@ module Backspin
114
126
  Record.load_or_create(record_path)
115
127
  end
116
128
 
117
- 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)
118
130
 
119
131
  result = case mode
120
132
  when :record
@@ -130,7 +142,7 @@ module Backspin
130
142
  result
131
143
  end
132
144
 
133
- def perform_command_run(command, name:, env:, mode:, matcher:, filter:)
145
+ def perform_command_run(command, name:, env:, mode:, matcher:, filter:, filter_on:)
134
146
  record_path = Record.build_record_path(name)
135
147
  mode = determine_mode(mode, record_path)
136
148
  validate_mode!(mode)
@@ -146,8 +158,8 @@ module Backspin
146
158
  result = case mode
147
159
  when :record
148
160
  stdout, stderr, status = execute_command(command, normalized_env)
149
- command_result = Command.new(
150
- method_class: Open3::Capture3,
161
+ actual_snapshot = Snapshot.new(
162
+ command_type: Open3::Capture3,
151
163
  args: command,
152
164
  env: normalized_env,
153
165
  stdout: stdout,
@@ -155,37 +167,47 @@ module Backspin
155
167
  status: status.exitstatus,
156
168
  recorded_at: Time.now.iso8601
157
169
  )
158
- record.add_command(command_result)
170
+ record.set_snapshot(actual_snapshot)
159
171
  record.save(filter: filter)
160
- RecordResult.new(output: [stdout, stderr, status], mode: :record, record: record)
172
+ BackspinResult.new(
173
+ mode: :record,
174
+ record_path: record.path,
175
+ actual: actual_snapshot,
176
+ output: [stdout, stderr, status]
177
+ )
161
178
  when :verify
162
179
  raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
163
- raise RecordNotFoundError, "No commands found in record #{record.path}" if record.empty?
164
- if record.commands.size != 1
165
- raise RecordFormatError, "Invalid record format: expected 1 command for run, found #{record.commands.size}"
166
- end
180
+ raise RecordNotFoundError, "No snapshot found in record #{record.path}" if record.empty?
167
181
 
168
- recorded_command = record.commands.first
169
- unless recorded_command.method_class == Open3::Capture3
182
+ expected_snapshot = record.snapshot
183
+ unless expected_snapshot.command_type == Open3::Capture3
170
184
  raise RecordFormatError, "Invalid record format: expected Open3::Capture3 for run"
171
185
  end
172
186
 
173
187
  stdout, stderr, status = execute_command(command, normalized_env)
174
- actual_command = Command.new(
175
- method_class: Open3::Capture3,
188
+ actual_snapshot = Snapshot.new(
189
+ command_type: Open3::Capture3,
176
190
  args: command,
177
191
  env: normalized_env,
178
192
  stdout: stdout,
179
193
  stderr: stderr,
180
194
  status: status.exitstatus
181
195
  )
182
- command_diff = CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: matcher)
183
- RecordResult.new(
184
- output: [stdout, stderr, status],
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
+ )
203
+ BackspinResult.new(
185
204
  mode: :verify,
205
+ record_path: record.path,
206
+ actual: actual_snapshot,
207
+ expected: expected_snapshot,
186
208
  verified: command_diff.verified?,
187
- record: record,
188
- command_diffs: [command_diff]
209
+ command_diff: command_diff,
210
+ output: [stdout, stderr, status]
189
211
  )
190
212
  else
191
213
  raise ArgumentError, "Unknown mode: #{mode}"
@@ -218,7 +240,7 @@ module Backspin
218
240
  return unless configuration.raise_on_verification_failure && result.verified? == false
219
241
 
220
242
  error_message = "Backspin verification failed!\n"
221
- error_message += "Record: #{result.record.path}\n"
243
+ error_message += "Record: #{result.record_path}\n"
222
244
  details = result.error_message || result.diff
223
245
  error_message += "\n#{details}" if details
224
246
 
@@ -239,5 +261,11 @@ module Backspin
239
261
 
240
262
  raise ArgumentError, "Unknown mode: #{mode}"
241
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
242
270
  end
243
271
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: backspin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Sanheim
@@ -37,6 +37,7 @@ files:
37
37
  - bin/rake
38
38
  - bin/rspec
39
39
  - bin/setup
40
+ - docs/backspin-result-api-sketch.md
40
41
  - examples/match_on_example.rb
41
42
  - fixtures/backspin/all_and_fields.yml
42
43
  - fixtures/backspin/all_bypass_equality.yml
@@ -94,14 +95,13 @@ files:
94
95
  - fixtures/backspin/verify_system_diff.yml
95
96
  - fixtures/backspin/version_test.yml
96
97
  - lib/backspin.rb
97
- - lib/backspin/command.rb
98
+ - lib/backspin/backspin_result.rb
98
99
  - lib/backspin/command_diff.rb
99
- - lib/backspin/command_result.rb
100
100
  - lib/backspin/configuration.rb
101
101
  - lib/backspin/matcher.rb
102
102
  - lib/backspin/record.rb
103
- - lib/backspin/record_result.rb
104
103
  - lib/backspin/recorder.rb
104
+ - lib/backspin/snapshot.rb
105
105
  - lib/backspin/version.rb
106
106
  - release.rake
107
107
  - script/lint
@@ -1,117 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "command_result"
4
-
5
- module Backspin
6
- class Command
7
- attr_reader :args, :env, :result, :recorded_at, :method_class
8
-
9
- def initialize(method_class:, args:, env: nil, stdout: nil, stderr: nil, status: nil, result: nil, recorded_at: nil)
10
- @method_class = method_class
11
- @args = args
12
- @env = env
13
- @recorded_at = recorded_at
14
-
15
- # Accept either a CommandResult or individual stdout/stderr/status
16
- @result = result || CommandResult.new(stdout: stdout || "", stderr: stderr || "", status: status || 0)
17
- end
18
-
19
- def stdout
20
- @result.stdout
21
- end
22
-
23
- def stderr
24
- @result.stderr
25
- end
26
-
27
- def status
28
- @result.status
29
- end
30
-
31
- # Convert to hash for YAML serialization
32
- def to_h(filter: nil)
33
- data = {
34
- "command_type" => @method_class.name,
35
- "args" => scrub_args(@args),
36
- "stdout" => Backspin.scrub_text(@result.stdout),
37
- "stderr" => Backspin.scrub_text(@result.stderr),
38
- "status" => @result.status,
39
- "recorded_at" => @recorded_at
40
- }
41
-
42
- data["env"] = scrub_env(@env) if @env
43
-
44
- # Apply filter if provided
45
- data = filter.call(data) if filter
46
-
47
- data
48
- end
49
-
50
- # Create from hash (for loading from YAML)
51
- def self.from_h(data)
52
- # Determine method class from command_type
53
- method_class = case data["command_type"]
54
- when "Open3::Capture3"
55
- Open3::Capture3
56
- when "Backspin::Capturer"
57
- Backspin::Capturer
58
- else
59
- raise RecordFormatError, "Unknown command type: #{data["command_type"]}"
60
- end
61
-
62
- new(
63
- method_class: method_class,
64
- args: data["args"],
65
- env: data["env"],
66
- stdout: data["stdout"],
67
- stderr: data["stderr"],
68
- status: data["status"],
69
- recorded_at: data["recorded_at"]
70
- )
71
- end
72
-
73
- private
74
-
75
- def scrub_args(args)
76
- return args unless Backspin.configuration.scrub_credentials && args
77
-
78
- case args
79
- when String
80
- Backspin.scrub_text(args)
81
- when Array
82
- args.map do |arg|
83
- case arg
84
- when String
85
- Backspin.scrub_text(arg)
86
- when Array
87
- scrub_args(arg)
88
- when Hash
89
- arg.transform_values { |v| v.is_a?(String) ? Backspin.scrub_text(v) : v }
90
- else
91
- arg
92
- end
93
- end
94
- when Hash
95
- args.transform_values { |v| v.is_a?(String) ? Backspin.scrub_text(v) : v }
96
- else
97
- args
98
- end
99
- end
100
-
101
- def scrub_env(env)
102
- return env unless Backspin.configuration.scrub_credentials && env
103
-
104
- env.transform_values { |value| value.is_a?(String) ? Backspin.scrub_text(value) : value }
105
- end
106
- end
107
- end
108
-
109
- # Define the Open3::Capture3 class for identification
110
- module Open3
111
- class Capture3; end
112
- end
113
-
114
- # Define the Backspin::Capturer class for identification
115
- module Backspin
116
- class Capturer; end
117
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Backspin
4
- # Represents the result of executing a command
5
- # Stores stdout, stderr, and exit status
6
- class CommandResult
7
- attr_reader :stdout, :stderr, :status
8
-
9
- def initialize(stdout:, stderr:, status:)
10
- @stdout = stdout
11
- @stderr = stderr
12
- @status = normalize_status(status)
13
- end
14
-
15
- # @return [Boolean] true if the command succeeded (exit status 0)
16
- def success?
17
- status.zero?
18
- end
19
-
20
- # @return [Boolean] true if the command failed (non-zero exit status)
21
- def failure?
22
- !success?
23
- end
24
-
25
- # @return [Hash] Hash representation of the result
26
- def to_h
27
- {
28
- "stdout" => stdout,
29
- "stderr" => stderr,
30
- "status" => status
31
- }
32
- end
33
-
34
- # Compare two results for equality
35
- def ==(other)
36
- return false unless other.is_a?(CommandResult)
37
-
38
- stdout == other.stdout && stderr == other.stderr && status == other.status
39
- end
40
-
41
- def inspect
42
- "#<Backspin::CommandResult status=#{status} stdout=#{stdout} stderr=#{stderr}>"
43
- end
44
-
45
- private
46
-
47
- def normalize_status(status)
48
- case status
49
- when Integer
50
- status
51
- when Process::Status
52
- status.exitstatus
53
- else
54
- status.respond_to?(:exitstatus) ? status.exitstatus : status.to_i
55
- end
56
- end
57
- end
58
- end
@@ -1,153 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Backspin
4
- # Result object for all Backspin record operations
5
- # Provides a consistent interface whether recording, verifying, or playing back
6
- class RecordResult
7
- attr_reader :output, :commands, :mode, :command_diffs
8
- attr_reader :record
9
-
10
- def initialize(output:, mode:, record:, verified: nil, command_diffs: nil)
11
- @output = output
12
- @mode = mode
13
- @record = record
14
- @commands = record.commands
15
- @verified = verified
16
- @command_diffs = command_diffs || []
17
- end
18
-
19
- # @return [Boolean] true if this result is from recording
20
- def recorded?
21
- mode == :record
22
- end
23
-
24
- def record_path
25
- record.path
26
- end
27
-
28
- # @return [Boolean, nil] true/false for verification results, nil for recording
29
- def verified?
30
- return @verified unless mode == :verify
31
-
32
- return false if command_diffs.size < commands.size
33
-
34
- @verified
35
- end
36
-
37
- # @return [String, nil] Human-readable error message if verification failed
38
- def error_message
39
- return nil unless verified? == false
40
-
41
- # Check for command count mismatch first
42
- if command_diffs.size < commands.size
43
- return "Expected #{commands.size} commands but only #{command_diffs.size} were executed"
44
- end
45
-
46
- return "No commands to verify" if command_diffs.empty?
47
-
48
- failed_diffs = command_diffs.reject(&:verified?)
49
- return "All commands verified" if failed_diffs.empty?
50
-
51
- msg = "Output verification failed for #{failed_diffs.size} command(s):\n\n"
52
-
53
- command_diffs.each_with_index do |diff, idx|
54
- next if diff.verified?
55
-
56
- msg += "Command #{idx + 1}: #{diff.summary}\n"
57
- msg += diff.diff
58
- msg += "\n\n" if idx < command_diffs.size - 1
59
- end
60
-
61
- msg
62
- end
63
-
64
- # @return [String, nil] Combined diff from all failed commands
65
- def diff
66
- return nil if command_diffs.empty?
67
-
68
- failed_diffs = command_diffs.reject(&:verified?)
69
- return nil if failed_diffs.empty?
70
-
71
- diff_parts = []
72
- command_diffs.each_with_index do |cmd_diff, idx|
73
- diff_parts << "Command #{idx + 1}:\n#{cmd_diff.diff}" unless cmd_diff.verified?
74
- end
75
-
76
- diff_parts.join("\n\n")
77
- end
78
-
79
- # Convenience accessors for command output
80
- # For single command (common case), these provide direct access
81
- # For multiple commands, use all_stdout, all_stderr, etc.
82
-
83
- # @return [String, nil] stdout from the first command
84
- def stdout
85
- commands.first&.result&.stdout
86
- end
87
-
88
- # @return [String, nil] stderr from the first command
89
- def stderr
90
- commands.first&.result&.stderr
91
- end
92
-
93
- # @return [Integer, nil] exit status from the first command
94
- def status
95
- commands.first&.result&.status
96
- end
97
-
98
- # Multiple command accessors
99
-
100
- # @return [Array<String>] stdout from all commands
101
- def all_stdout
102
- commands.map { |cmd| cmd.result.stdout }
103
- end
104
-
105
- # @return [Array<String>] stderr from all commands
106
- def all_stderr
107
- commands.map { |cmd| cmd.result.stderr }
108
- end
109
-
110
- # @return [Array<Integer>] exit status from all commands
111
- def all_status
112
- commands.map { |cmd| cmd.result.status }
113
- end
114
-
115
- # @return [Boolean] true if this result contains multiple commands
116
- def multiple_commands?
117
- commands.size > 1
118
- end
119
-
120
- # @return [Boolean] true if all commands succeeded (exit status 0)
121
- def success?
122
- if multiple_commands?
123
- # Check all commands - if any command has non-zero status, we're not successful
124
- commands.all? { |cmd| cmd.result.status.zero? }
125
- else
126
- status&.zero? || false
127
- end
128
- end
129
-
130
- # @return [Boolean] true if any command failed (non-zero exit status)
131
- def failure?
132
- !success?
133
- end
134
-
135
- # @return [Hash] Summary of the result for debugging
136
- def to_h
137
- hash = {
138
- mode: mode,
139
- recorded: recorded?,
140
- stdout: stdout,
141
- stderr: stderr,
142
- status: status
143
- }
144
-
145
- hash[:verified] = verified? unless verified?.nil?
146
- hash[:diff] = diff if diff
147
- # Include number of failed commands if in verify mode
148
- hash[:failed_commands] = command_diffs.count { |d| !d.verified? } if mode == :verify && command_diffs.any?
149
-
150
- hash
151
- end
152
- end
153
- end