backspin 0.7.1 → 0.9.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
@@ -4,17 +4,14 @@ require "yaml"
4
4
  require "fileutils"
5
5
  require "open3"
6
6
  require "pathname"
7
- require "ostruct"
8
- require "rspec/mocks"
9
7
  require "backspin/version"
10
8
  require "backspin/configuration"
11
- require "backspin/command_result"
12
- require "backspin/command"
9
+ require "backspin/snapshot"
13
10
  require "backspin/matcher"
14
11
  require "backspin/command_diff"
15
12
  require "backspin/record"
13
+ require "backspin/backspin_result"
16
14
  require "backspin/recorder"
17
- require "backspin/record_result"
18
15
 
19
16
  module Backspin
20
17
  class RecordNotFoundError < StandardError; end
@@ -31,18 +28,15 @@ module Backspin
31
28
  result.diff
32
29
  end
33
30
 
34
- def recorded_commands
35
- result.command_diffs.map(&:recorded_command)
31
+ def expected_snapshot
32
+ result.expected
36
33
  end
37
34
 
38
- def actual_commands
39
- result.command_diffs.map(&:actual_command)
35
+ def actual_snapshot
36
+ result.actual
40
37
  end
41
38
  end
42
39
 
43
- # Include RSpec mocks methods
44
- extend RSpec::Mocks::ExampleMethods
45
-
46
40
  class << self
47
41
  def configuration
48
42
  return @configuration if @configuration
@@ -72,136 +66,167 @@ module Backspin
72
66
 
73
67
  # Primary API - records on first run, verifies on subsequent runs
74
68
  #
69
+ # @param command [String, Array] Command to execute via Open3.capture3
70
+ # @param name [String] Name for the record file
71
+ # @param env [Hash] Environment variables to pass to Open3.capture3
72
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify
73
+ # @param matcher [Proc, Hash] Custom matcher for verification
74
+ # @param filter [Proc] Custom filter for recorded data
75
+ # @return [BackspinResult] Aggregate result for this run
76
+ def run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, &block)
77
+ if block_given?
78
+ raise ArgumentError, "command must be omitted when using a block" unless command.nil?
79
+ raise ArgumentError, "env is not supported when using a block" unless env.nil?
80
+
81
+ return perform_capture(name, mode: mode, matcher: matcher, filter: filter, &block)
82
+ end
83
+
84
+ raise ArgumentError, "command is required" if command.nil?
85
+
86
+ perform_command_run(command, name: name, env: env, mode: mode, matcher: matcher, filter: filter)
87
+ end
88
+
89
+ # Captures all stdout/stderr output from a block
90
+ #
75
91
  # @param record_name [String] Name for the record file
76
- # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
92
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify
77
93
  # @param matcher [Proc, Hash] Custom matcher for verification
78
- # - Proc: ->(recorded, actual) { ... } for full command matching
79
- # - Hash: { stdout: ->(recorded, actual) { ... }, stderr: ->(recorded, actual) { ... } } for field-specific matching
80
- # Only specified fields are checked - fields without matchers are ignored
81
- # - Hash with :all key: { all: ->(recorded, actual) { ... } } receives full command hashes
82
- # Can be combined with field matchers - all specified matchers must pass
83
94
  # @param filter [Proc] Custom filter for recorded data
84
- # @return [RecordResult] Result object with output and status
85
- def run(record_name, mode: :auto, matcher: nil, filter: nil, &block)
95
+ # @return [BackspinResult] Aggregate result for this run
96
+ def capture(record_name, mode: :auto, matcher: nil, filter: nil, &block)
86
97
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
87
98
  raise ArgumentError, "block is required" unless block_given?
88
99
 
100
+ perform_capture(record_name, mode: mode, matcher: matcher, filter: filter, &block)
101
+ end
102
+
103
+ private
104
+
105
+ def perform_capture(record_name, mode:, matcher:, filter:, &block)
89
106
  record_path = Record.build_record_path(record_name)
90
107
  mode = determine_mode(mode, record_path)
108
+ validate_mode!(mode)
91
109
 
92
- # Create or load the record based on mode
93
110
  record = if mode == :record
94
111
  Record.create(record_name)
95
112
  else
96
113
  Record.load_or_create(record_path)
97
114
  end
98
115
 
99
- # Create recorder with all needed context
100
116
  recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
101
117
 
102
- # Execute the appropriate mode
103
118
  result = case mode
104
119
  when :record
105
- recorder.setup_recording_stubs(:capture3, :system)
106
- recorder.perform_recording(&block)
120
+ recorder.perform_capture_recording(&block)
107
121
  when :verify
108
- recorder.perform_verification(&block)
109
- when :playback
110
- recorder.perform_playback(&block)
122
+ recorder.perform_capture_verification(&block)
111
123
  else
112
124
  raise ArgumentError, "Unknown mode: #{mode}"
113
125
  end
114
126
 
115
- # Check if we should raise on verification failure
116
- if configuration.raise_on_verification_failure && result.verified? == false
117
- error_message = "Backspin verification failed!\n"
118
- error_message += "Record: #{result.record.path}\n"
119
- error_message += "\n#{result.error_message}" if result.error_message
120
-
121
- raise RSpec::Expectations::ExpectationNotMetError, error_message
122
- end
123
-
124
- result
125
- end
126
-
127
- # Strict version of run that raises on verification failure
128
- #
129
- # @param record_name [String] Name for the record file
130
- # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
131
- # @param matcher [Proc, Hash] Custom matcher for verification
132
- # @param filter [Proc] Custom filter for recorded data
133
- # @return [RecordResult] Result object with output and status
134
- # @raise [RSpec::Expectations::ExpectationNotMetError] If verification fails
135
- def run!(record_name, mode: :auto, matcher: nil, filter: nil, &block)
136
- result = run(record_name, mode: mode, matcher: matcher, filter: filter, &block)
137
-
138
- if result.verified? == false
139
- error_message = "Backspin verification failed!\n"
140
- error_message += "Record: #{result.record.path}\n"
141
-
142
- # Use the error_message from the result which is now properly formatted
143
- error_message += "\n#{result.error_message}" if result.error_message
144
-
145
- raise RSpec::Expectations::ExpectationNotMetError, error_message
146
- end
127
+ raise_on_verification_failure!(result)
147
128
 
148
129
  result
149
130
  end
150
131
 
151
- # Captures all stdout/stderr output from a block
152
- #
153
- # @param record_name [String] Name for the record file
154
- # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
155
- # @param matcher [Proc, Hash] Custom matcher for verification
156
- # @param filter [Proc] Custom filter for recorded data
157
- # @return [RecordResult] Result object with captured output
158
- def capture(record_name, mode: :auto, matcher: nil, filter: nil, &block)
159
- raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
160
- raise ArgumentError, "block is required" unless block_given?
161
-
162
- record_path = Record.build_record_path(record_name)
132
+ def perform_command_run(command, name:, env:, mode:, matcher:, filter:)
133
+ record_path = Record.build_record_path(name)
163
134
  mode = determine_mode(mode, record_path)
135
+ validate_mode!(mode)
164
136
 
165
- # Create or load the record based on mode
166
137
  record = if mode == :record
167
- Record.create(record_name)
138
+ Record.create(name)
168
139
  else
169
140
  Record.load_or_create(record_path)
170
141
  end
171
142
 
172
- # Create recorder with all needed context
173
- recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
143
+ normalized_env = env.nil? ? nil : normalize_env(env)
174
144
 
175
- # Execute the appropriate mode
176
145
  result = case mode
177
146
  when :record
178
- recorder.perform_capture_recording(&block)
147
+ stdout, stderr, status = execute_command(command, normalized_env)
148
+ actual_snapshot = Snapshot.new(
149
+ command_type: Open3::Capture3,
150
+ args: command,
151
+ env: normalized_env,
152
+ stdout: stdout,
153
+ stderr: stderr,
154
+ status: status.exitstatus,
155
+ recorded_at: Time.now.iso8601
156
+ )
157
+ record.set_snapshot(actual_snapshot)
158
+ record.save(filter: filter)
159
+ BackspinResult.new(
160
+ mode: :record,
161
+ record_path: record.path,
162
+ actual: actual_snapshot,
163
+ output: [stdout, stderr, status]
164
+ )
179
165
  when :verify
180
- recorder.perform_capture_verification(&block)
181
- when :playback
182
- recorder.perform_capture_playback(&block)
166
+ raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
167
+ raise RecordNotFoundError, "No snapshot found in record #{record.path}" if record.empty?
168
+
169
+ expected_snapshot = record.snapshot
170
+ unless expected_snapshot.command_type == Open3::Capture3
171
+ raise RecordFormatError, "Invalid record format: expected Open3::Capture3 for run"
172
+ end
173
+
174
+ stdout, stderr, status = execute_command(command, normalized_env)
175
+ actual_snapshot = Snapshot.new(
176
+ command_type: Open3::Capture3,
177
+ args: command,
178
+ env: normalized_env,
179
+ stdout: stdout,
180
+ stderr: stderr,
181
+ status: status.exitstatus
182
+ )
183
+ command_diff = CommandDiff.new(expected: expected_snapshot, actual: actual_snapshot, matcher: matcher)
184
+ BackspinResult.new(
185
+ mode: :verify,
186
+ record_path: record.path,
187
+ actual: actual_snapshot,
188
+ expected: expected_snapshot,
189
+ verified: command_diff.verified?,
190
+ command_diff: command_diff,
191
+ output: [stdout, stderr, status]
192
+ )
183
193
  else
184
194
  raise ArgumentError, "Unknown mode: #{mode}"
185
195
  end
186
196
 
187
- # Check if we should raise on verification failure
188
- if configuration.raise_on_verification_failure && result.verified? == false
189
- error_message = "Backspin verification failed!\n"
190
- error_message += "Record: #{result.record.path}\n"
191
- error_message += result.error_message || "Output verification failed"
197
+ raise_on_verification_failure!(result)
192
198
 
193
- # Include diff if available
194
- if result.diff
195
- error_message += "\n\nDiff:\n#{result.diff}"
196
- end
199
+ result
200
+ end
197
201
 
198
- raise VerificationError.new(error_message, result: result)
199
- end
202
+ def normalize_env(env)
203
+ raise ArgumentError, "env must be a Hash" unless env.is_a?(Hash)
200
204
 
201
- result
205
+ env.empty? ? nil : env
202
206
  end
203
207
 
204
- private
208
+ def execute_command(command, env)
209
+ case command
210
+ when String
211
+ env ? Open3.capture3(env, command) : Open3.capture3(command)
212
+ when Array
213
+ raise ArgumentError, "command array cannot be empty" if command.empty?
214
+ env ? Open3.capture3(env, *command) : Open3.capture3(*command)
215
+ else
216
+ raise ArgumentError, "command must be a String or Array"
217
+ end
218
+ end
219
+
220
+ def raise_on_verification_failure!(result)
221
+ return unless configuration.raise_on_verification_failure && result.verified? == false
222
+
223
+ error_message = "Backspin verification failed!\n"
224
+ error_message += "Record: #{result.record_path}\n"
225
+ details = result.error_message || result.diff
226
+ error_message += "\n#{details}" if details
227
+
228
+ raise VerificationError.new(error_message, result: result)
229
+ end
205
230
 
206
231
  def determine_mode(mode_option, record_path)
207
232
  return mode_option if mode_option && mode_option != :auto
@@ -209,5 +234,13 @@ module Backspin
209
234
  # Auto mode: record if file doesn't exist, verify if it does
210
235
  File.exist?(record_path) ? :verify : :record
211
236
  end
237
+
238
+ def validate_mode!(mode)
239
+ return if %i[record verify].include?(mode)
240
+
241
+ raise ArgumentError, "Playback mode is not supported" if mode == :playback
242
+
243
+ raise ArgumentError, "Unknown mode: #{mode}"
244
+ end
212
245
  end
213
246
  end
metadata CHANGED
@@ -1,42 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: backspin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Sanheim
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: ostruct
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: rspec-mocks
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '3'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '3'
11
+ dependencies: []
40
12
  description: Backspin is a Ruby library for characterization testing of command-line
41
13
  interfaces. Inspired by VCR's cassette-based approach, it records and replays CLI
42
14
  interactions to make testing faster and more deterministic.
@@ -50,6 +22,7 @@ files:
50
22
  - ".gem_release.yml"
51
23
  - ".gitignore"
52
24
  - ".rspec"
25
+ - ".ruby-version"
53
26
  - ".standard.yml"
54
27
  - CHANGELOG.md
55
28
  - CLAUDE.md
@@ -64,6 +37,7 @@ files:
64
37
  - bin/rake
65
38
  - bin/rspec
66
39
  - bin/setup
40
+ - docs/backspin-result-api-sketch.md
67
41
  - examples/match_on_example.rb
68
42
  - fixtures/backspin/all_and_fields.yml
69
43
  - fixtures/backspin/all_bypass_equality.yml
@@ -121,14 +95,13 @@ files:
121
95
  - fixtures/backspin/verify_system_diff.yml
122
96
  - fixtures/backspin/version_test.yml
123
97
  - lib/backspin.rb
124
- - lib/backspin/command.rb
98
+ - lib/backspin/backspin_result.rb
125
99
  - lib/backspin/command_diff.rb
126
- - lib/backspin/command_result.rb
127
100
  - lib/backspin/configuration.rb
128
101
  - lib/backspin/matcher.rb
129
102
  - lib/backspin/record.rb
130
- - lib/backspin/record_result.rb
131
103
  - lib/backspin/recorder.rb
104
+ - lib/backspin/snapshot.rb
132
105
  - lib/backspin/version.rb
133
106
  - release.rake
134
107
  - script/lint
@@ -154,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
154
127
  - !ruby/object:Gem::Version
155
128
  version: '0'
156
129
  requirements: []
157
- rubygems_version: 3.7.2
130
+ rubygems_version: 4.0.3
158
131
  specification_version: 4
159
132
  summary: Record and replay CLI interactions for testing
160
133
  test_files: []
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "command_result"
4
-
5
- module Backspin
6
- class Command
7
- attr_reader :args, :result, :recorded_at, :method_class
8
-
9
- def initialize(method_class:, args:, stdout: nil, stderr: nil, status: nil, result: nil, recorded_at: nil)
10
- @method_class = method_class
11
- @args = args
12
- @recorded_at = recorded_at
13
-
14
- # Accept either a CommandResult or individual stdout/stderr/status
15
- @result = result || CommandResult.new(stdout: stdout || "", stderr: stderr || "", status: status || 0)
16
- end
17
-
18
- def stdout
19
- @result.stdout
20
- end
21
-
22
- def stderr
23
- @result.stderr
24
- end
25
-
26
- def status
27
- @result.status
28
- end
29
-
30
- # Convert to hash for YAML serialization
31
- def to_h(filter: nil)
32
- data = {
33
- "command_type" => @method_class.name,
34
- "args" => scrub_args(@args),
35
- "stdout" => Backspin.scrub_text(@result.stdout),
36
- "stderr" => Backspin.scrub_text(@result.stderr),
37
- "status" => @result.status,
38
- "recorded_at" => @recorded_at
39
- }
40
-
41
- # Apply filter if provided
42
- data = filter.call(data) if filter
43
-
44
- data
45
- end
46
-
47
- # Create from hash (for loading from YAML)
48
- def self.from_h(data)
49
- # Determine method class from command_type
50
- method_class = case data["command_type"]
51
- when "Open3::Capture3"
52
- Open3::Capture3
53
- when "Kernel::System"
54
- ::Kernel::System
55
- when "Backspin::Capturer"
56
- Backspin::Capturer
57
- else
58
- # Default to capture3 for backwards compatibility
59
- Open3::Capture3
60
- end
61
-
62
- new(
63
- method_class: method_class,
64
- args: data["args"],
65
- stdout: data["stdout"],
66
- stderr: data["stderr"],
67
- status: data["status"],
68
- recorded_at: data["recorded_at"]
69
- )
70
- end
71
-
72
- private
73
-
74
- def scrub_args(args)
75
- return args unless Backspin.configuration.scrub_credentials && args
76
-
77
- args.map do |arg|
78
- case arg
79
- when String
80
- Backspin.scrub_text(arg)
81
- when Array
82
- scrub_args(arg)
83
- when Hash
84
- arg.transform_values { |v| v.is_a?(String) ? Backspin.scrub_text(v) : v }
85
- else
86
- arg
87
- end
88
- end
89
- end
90
- end
91
- end
92
-
93
- # Define the Open3::Capture3 class for identification
94
- module Open3
95
- class Capture3; end
96
- end
97
-
98
- # Define the Kernel::System class for identification
99
- module ::Kernel
100
- class System; end
101
- end
102
-
103
- # Define the Backspin::Capturer class for identification
104
- module Backspin
105
- class Capturer; end
106
- 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