backspin 0.7.0 → 0.8.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,8 +4,6 @@ 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
9
  require "backspin/command_result"
@@ -19,10 +17,26 @@ require "backspin/record_result"
19
17
  module Backspin
20
18
  class RecordNotFoundError < StandardError; end
21
19
 
22
- class VerificationError < StandardError; end
20
+ class VerificationError < StandardError
21
+ attr_reader :result
23
22
 
24
- # Include RSpec mocks methods
25
- extend RSpec::Mocks::ExampleMethods
23
+ def initialize(message, result: nil)
24
+ super(message)
25
+ @result = result
26
+ end
27
+
28
+ def diff
29
+ result.diff
30
+ end
31
+
32
+ def recorded_commands
33
+ result.command_diffs.map(&:recorded_command)
34
+ end
35
+
36
+ def actual_commands
37
+ result.command_diffs.map(&:actual_command)
38
+ end
39
+ end
26
40
 
27
41
  class << self
28
42
  def configuration
@@ -53,136 +67,163 @@ module Backspin
53
67
 
54
68
  # Primary API - records on first run, verifies on subsequent runs
55
69
  #
56
- # @param record_name [String] Name for the record file
57
- # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
70
+ # @param command [String, Array] Command to execute via Open3.capture3
71
+ # @param name [String] Name for the record file
72
+ # @param env [Hash] Environment variables to pass to Open3.capture3
73
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify
58
74
  # @param matcher [Proc, Hash] Custom matcher for verification
59
- # - Proc: ->(recorded, actual) { ... } for full command matching
60
- # - Hash: { stdout: ->(recorded, actual) { ... }, stderr: ->(recorded, actual) { ... } } for field-specific matching
61
- # Only specified fields are checked - fields without matchers are ignored
62
- # - Hash with :all key: { all: ->(recorded, actual) { ... } } receives full command hashes
63
- # Can be combined with field matchers - all specified matchers must pass
64
75
  # @param filter [Proc] Custom filter for recorded data
65
76
  # @return [RecordResult] Result object with output and status
66
- def run(record_name, mode: :auto, matcher: nil, filter: nil, &block)
77
+ def run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, &block)
78
+ if block_given?
79
+ raise ArgumentError, "command must be omitted when using a block" unless command.nil?
80
+ raise ArgumentError, "env is not supported when using a block" unless env.nil?
81
+
82
+ return perform_capture(name, mode: mode, matcher: matcher, filter: filter, &block)
83
+ end
84
+
85
+ raise ArgumentError, "command is required" if command.nil?
86
+
87
+ perform_command_run(command, name: name, env: env, mode: mode, matcher: matcher, filter: filter)
88
+ end
89
+
90
+ # Captures all stdout/stderr output from a block
91
+ #
92
+ # @param record_name [String] Name for the record file
93
+ # @param mode [Symbol] Recording mode - :auto, :record, :verify
94
+ # @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)
67
98
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
68
99
  raise ArgumentError, "block is required" unless block_given?
69
100
 
101
+ perform_capture(record_name, mode: mode, matcher: matcher, filter: filter, &block)
102
+ end
103
+
104
+ private
105
+
106
+ def perform_capture(record_name, mode:, matcher:, filter:, &block)
70
107
  record_path = Record.build_record_path(record_name)
71
108
  mode = determine_mode(mode, record_path)
109
+ validate_mode!(mode)
72
110
 
73
- # Create or load the record based on mode
74
111
  record = if mode == :record
75
112
  Record.create(record_name)
76
113
  else
77
114
  Record.load_or_create(record_path)
78
115
  end
79
116
 
80
- # Create recorder with all needed context
81
117
  recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
82
118
 
83
- # Execute the appropriate mode
84
119
  result = case mode
85
120
  when :record
86
- recorder.setup_recording_stubs(:capture3, :system)
87
- recorder.perform_recording(&block)
121
+ recorder.perform_capture_recording(&block)
88
122
  when :verify
89
- recorder.perform_verification(&block)
90
- when :playback
91
- recorder.perform_playback(&block)
123
+ recorder.perform_capture_verification(&block)
92
124
  else
93
125
  raise ArgumentError, "Unknown mode: #{mode}"
94
126
  end
95
127
 
96
- # Check if we should raise on verification failure
97
- if configuration.raise_on_verification_failure && result.verified? == false
98
- error_message = "Backspin verification failed!\n"
99
- error_message += "Record: #{result.record.path}\n"
100
- error_message += "\n#{result.error_message}" if result.error_message
101
-
102
- raise RSpec::Expectations::ExpectationNotMetError, error_message
103
- end
128
+ raise_on_verification_failure!(result)
104
129
 
105
130
  result
106
131
  end
107
132
 
108
- # Strict version of run that raises on verification failure
109
- #
110
- # @param record_name [String] Name for the record file
111
- # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
112
- # @param matcher [Proc, Hash] Custom matcher for verification
113
- # @param filter [Proc] Custom filter for recorded data
114
- # @return [RecordResult] Result object with output and status
115
- # @raise [RSpec::Expectations::ExpectationNotMetError] If verification fails
116
- def run!(record_name, mode: :auto, matcher: nil, filter: nil, &block)
117
- result = run(record_name, mode: mode, matcher: matcher, filter: filter, &block)
118
-
119
- if result.verified? == false
120
- error_message = "Backspin verification failed!\n"
121
- error_message += "Record: #{result.record.path}\n"
122
-
123
- # Use the error_message from the result which is now properly formatted
124
- error_message += "\n#{result.error_message}" if result.error_message
125
-
126
- raise RSpec::Expectations::ExpectationNotMetError, error_message
127
- end
128
-
129
- result
130
- end
131
-
132
- # Captures all stdout/stderr output from a block
133
- #
134
- # @param record_name [String] Name for the record file
135
- # @param mode [Symbol] Recording mode - :auto, :record, :verify, :playback
136
- # @param matcher [Proc, Hash] Custom matcher for verification
137
- # @param filter [Proc] Custom filter for recorded data
138
- # @return [RecordResult] Result object with captured output
139
- def capture(record_name, mode: :auto, matcher: nil, filter: nil, &block)
140
- raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
141
- raise ArgumentError, "block is required" unless block_given?
142
-
143
- record_path = Record.build_record_path(record_name)
133
+ def perform_command_run(command, name:, env:, mode:, matcher:, filter:)
134
+ record_path = Record.build_record_path(name)
144
135
  mode = determine_mode(mode, record_path)
136
+ validate_mode!(mode)
145
137
 
146
- # Create or load the record based on mode
147
138
  record = if mode == :record
148
- Record.create(record_name)
139
+ Record.create(name)
149
140
  else
150
141
  Record.load_or_create(record_path)
151
142
  end
152
143
 
153
- # Create recorder with all needed context
154
- recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
144
+ normalized_env = env.nil? ? nil : normalize_env(env)
155
145
 
156
- # Execute the appropriate mode
157
146
  result = case mode
158
147
  when :record
159
- recorder.perform_capture_recording(&block)
148
+ stdout, stderr, status = execute_command(command, normalized_env)
149
+ command_result = Command.new(
150
+ method_class: Open3::Capture3,
151
+ args: command,
152
+ env: normalized_env,
153
+ stdout: stdout,
154
+ stderr: stderr,
155
+ status: status.exitstatus,
156
+ recorded_at: Time.now.iso8601
157
+ )
158
+ record.add_command(command_result)
159
+ record.save(filter: filter)
160
+ RecordResult.new(output: [stdout, stderr, status], mode: :record, record: record)
160
161
  when :verify
161
- recorder.perform_capture_verification(&block)
162
- when :playback
163
- recorder.perform_capture_playback(&block)
162
+ 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
167
+
168
+ recorded_command = record.commands.first
169
+ unless recorded_command.method_class == Open3::Capture3
170
+ raise RecordFormatError, "Invalid record format: expected Open3::Capture3 for run"
171
+ end
172
+
173
+ stdout, stderr, status = execute_command(command, normalized_env)
174
+ actual_command = Command.new(
175
+ method_class: Open3::Capture3,
176
+ args: command,
177
+ env: normalized_env,
178
+ stdout: stdout,
179
+ stderr: stderr,
180
+ status: status.exitstatus
181
+ )
182
+ command_diff = CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: matcher)
183
+ RecordResult.new(
184
+ output: [stdout, stderr, status],
185
+ mode: :verify,
186
+ verified: command_diff.verified?,
187
+ record: record,
188
+ command_diffs: [command_diff]
189
+ )
164
190
  else
165
191
  raise ArgumentError, "Unknown mode: #{mode}"
166
192
  end
167
193
 
168
- # Check if we should raise on verification failure
169
- if configuration.raise_on_verification_failure && result.verified? == false
170
- error_message = "Backspin verification failed!\n"
171
- error_message += "Record: #{result.record.path}\n"
172
- error_message += result.error_message || "Output verification failed"
194
+ raise_on_verification_failure!(result)
173
195
 
174
- # Include diff if available
175
- if result.diff
176
- error_message += "\n\nDiff:\n#{result.diff}"
177
- end
196
+ result
197
+ end
178
198
 
179
- raise VerificationError, error_message
180
- end
199
+ def normalize_env(env)
200
+ raise ArgumentError, "env must be a Hash" unless env.is_a?(Hash)
181
201
 
182
- result
202
+ env.empty? ? nil : env
183
203
  end
184
204
 
185
- private
205
+ def execute_command(command, env)
206
+ case command
207
+ when String
208
+ env ? Open3.capture3(env, command) : Open3.capture3(command)
209
+ when Array
210
+ raise ArgumentError, "command array cannot be empty" if command.empty?
211
+ env ? Open3.capture3(env, *command) : Open3.capture3(*command)
212
+ else
213
+ raise ArgumentError, "command must be a String or Array"
214
+ end
215
+ end
216
+
217
+ def raise_on_verification_failure!(result)
218
+ return unless configuration.raise_on_verification_failure && result.verified? == false
219
+
220
+ error_message = "Backspin verification failed!\n"
221
+ error_message += "Record: #{result.record.path}\n"
222
+ details = result.error_message || result.diff
223
+ error_message += "\n#{details}" if details
224
+
225
+ raise VerificationError.new(error_message, result: result)
226
+ end
186
227
 
187
228
  def determine_mode(mode_option, record_path)
188
229
  return mode_option if mode_option && mode_option != :auto
@@ -190,5 +231,13 @@ module Backspin
190
231
  # Auto mode: record if file doesn't exist, verify if it does
191
232
  File.exist?(record_path) ? :verify : :record
192
233
  end
234
+
235
+ def validate_mode!(mode)
236
+ return if %i[record verify].include?(mode)
237
+
238
+ raise ArgumentError, "Playback mode is not supported" if mode == :playback
239
+
240
+ raise ArgumentError, "Unknown mode: #{mode}"
241
+ end
193
242
  end
194
243
  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.0
4
+ version: 0.8.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
@@ -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.6.9
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: []