backspin 0.7.1 → 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"
@@ -40,9 +38,6 @@ module Backspin
40
38
  end
41
39
  end
42
40
 
43
- # Include RSpec mocks methods
44
- extend RSpec::Mocks::ExampleMethods
45
-
46
41
  class << self
47
42
  def configuration
48
43
  return @configuration if @configuration
@@ -72,136 +67,163 @@ module Backspin
72
67
 
73
68
  # Primary API - records on first run, verifies on subsequent runs
74
69
  #
75
- # @param record_name [String] Name for the record file
76
- # @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
77
74
  # @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
75
  # @param filter [Proc] Custom filter for recorded data
84
76
  # @return [RecordResult] Result object with output and status
85
- 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)
86
98
  raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
87
99
  raise ArgumentError, "block is required" unless block_given?
88
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)
89
107
  record_path = Record.build_record_path(record_name)
90
108
  mode = determine_mode(mode, record_path)
109
+ validate_mode!(mode)
91
110
 
92
- # Create or load the record based on mode
93
111
  record = if mode == :record
94
112
  Record.create(record_name)
95
113
  else
96
114
  Record.load_or_create(record_path)
97
115
  end
98
116
 
99
- # Create recorder with all needed context
100
117
  recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
101
118
 
102
- # Execute the appropriate mode
103
119
  result = case mode
104
120
  when :record
105
- recorder.setup_recording_stubs(:capture3, :system)
106
- recorder.perform_recording(&block)
121
+ recorder.perform_capture_recording(&block)
107
122
  when :verify
108
- recorder.perform_verification(&block)
109
- when :playback
110
- recorder.perform_playback(&block)
123
+ recorder.perform_capture_verification(&block)
111
124
  else
112
125
  raise ArgumentError, "Unknown mode: #{mode}"
113
126
  end
114
127
 
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
128
+ raise_on_verification_failure!(result)
147
129
 
148
130
  result
149
131
  end
150
132
 
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)
133
+ def perform_command_run(command, name:, env:, mode:, matcher:, filter:)
134
+ record_path = Record.build_record_path(name)
163
135
  mode = determine_mode(mode, record_path)
136
+ validate_mode!(mode)
164
137
 
165
- # Create or load the record based on mode
166
138
  record = if mode == :record
167
- Record.create(record_name)
139
+ Record.create(name)
168
140
  else
169
141
  Record.load_or_create(record_path)
170
142
  end
171
143
 
172
- # Create recorder with all needed context
173
- recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
144
+ normalized_env = env.nil? ? nil : normalize_env(env)
174
145
 
175
- # Execute the appropriate mode
176
146
  result = case mode
177
147
  when :record
178
- 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)
179
161
  when :verify
180
- recorder.perform_capture_verification(&block)
181
- when :playback
182
- 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
+ )
183
190
  else
184
191
  raise ArgumentError, "Unknown mode: #{mode}"
185
192
  end
186
193
 
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"
194
+ raise_on_verification_failure!(result)
192
195
 
193
- # Include diff if available
194
- if result.diff
195
- error_message += "\n\nDiff:\n#{result.diff}"
196
- end
196
+ result
197
+ end
197
198
 
198
- raise VerificationError.new(error_message, result: result)
199
- end
199
+ def normalize_env(env)
200
+ raise ArgumentError, "env must be a Hash" unless env.is_a?(Hash)
200
201
 
201
- result
202
+ env.empty? ? nil : env
202
203
  end
203
204
 
204
- 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
205
227
 
206
228
  def determine_mode(mode_option, record_path)
207
229
  return mode_option if mode_option && mode_option != :auto
@@ -209,5 +231,13 @@ module Backspin
209
231
  # Auto mode: record if file doesn't exist, verify if it does
210
232
  File.exist?(record_path) ? :verify : :record
211
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
212
242
  end
213
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.1
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.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: []