backspin 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +3 -1
- data/CHANGELOG.md +4 -0
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -3
- data/Gemfile +1 -1
- data/Gemfile.lock +2 -2
- data/README.md +107 -33
- data/backspin.gemspec +2 -2
- data/bin/rake +27 -0
- data/bin/rspec +27 -0
- data/lib/backspin/command.rb +33 -14
- data/lib/backspin/command_diff.rb +88 -0
- data/lib/backspin/command_result.rb +60 -0
- data/lib/backspin/record.rb +2 -2
- data/lib/backspin/record_result.rb +153 -0
- data/lib/backspin/recorder.rb +4 -23
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +167 -285
- metadata +8 -4
data/lib/backspin.rb
CHANGED
@@ -5,9 +5,12 @@ require "pathname"
|
|
5
5
|
require "ostruct"
|
6
6
|
require "rspec/mocks"
|
7
7
|
require "backspin/version"
|
8
|
+
require "backspin/command_result"
|
8
9
|
require "backspin/command"
|
10
|
+
require "backspin/command_diff"
|
9
11
|
require "backspin/record"
|
10
12
|
require "backspin/recorder"
|
13
|
+
require "backspin/record_result"
|
11
14
|
|
12
15
|
module Backspin
|
13
16
|
class RecordNotFoundError < StandardError; end
|
@@ -69,7 +72,9 @@ module Backspin
|
|
69
72
|
|
70
73
|
class << self
|
71
74
|
def configuration
|
72
|
-
@configuration
|
75
|
+
return @configuration if @configuration
|
76
|
+
|
77
|
+
@configuration = Configuration.new
|
73
78
|
end
|
74
79
|
|
75
80
|
def configure
|
@@ -79,86 +84,6 @@ module Backspin
|
|
79
84
|
def reset_configuration!
|
80
85
|
@configuration = Configuration.new
|
81
86
|
end
|
82
|
-
end
|
83
|
-
|
84
|
-
class Result
|
85
|
-
attr_reader :commands, :record_path
|
86
|
-
|
87
|
-
def initialize(commands:, record_path:)
|
88
|
-
@commands = commands
|
89
|
-
@record_path = record_path
|
90
|
-
end
|
91
|
-
end
|
92
|
-
|
93
|
-
class VerifyResult
|
94
|
-
attr_reader :record_path, :expected_output, :actual_output, :diff, :stderr_diff
|
95
|
-
|
96
|
-
def initialize(verified:, record_path:, expected_output: nil, actual_output: nil,
|
97
|
-
expected_stderr: nil, actual_stderr: nil, expected_status: nil, actual_status: nil,
|
98
|
-
command_executed: true)
|
99
|
-
@verified = verified
|
100
|
-
@record_path = record_path
|
101
|
-
@expected_output = expected_output
|
102
|
-
@actual_output = actual_output
|
103
|
-
@expected_stderr = expected_stderr
|
104
|
-
@actual_stderr = actual_stderr
|
105
|
-
@expected_status = expected_status
|
106
|
-
@actual_status = actual_status
|
107
|
-
@command_executed = command_executed
|
108
|
-
|
109
|
-
if !verified && expected_output && actual_output
|
110
|
-
@diff = generate_diff(expected_output, actual_output)
|
111
|
-
end
|
112
|
-
|
113
|
-
if !verified && expected_stderr && actual_stderr && expected_stderr != actual_stderr
|
114
|
-
@stderr_diff = generate_diff(expected_stderr, actual_stderr)
|
115
|
-
end
|
116
|
-
end
|
117
|
-
|
118
|
-
def verified?
|
119
|
-
@verified
|
120
|
-
end
|
121
|
-
|
122
|
-
def output
|
123
|
-
@actual_output
|
124
|
-
end
|
125
|
-
|
126
|
-
def error_message
|
127
|
-
return nil if verified?
|
128
|
-
|
129
|
-
"Output verification failed\nExpected: #{@expected_output.chomp}\nActual: #{@actual_output.chomp}"
|
130
|
-
end
|
131
|
-
|
132
|
-
def command_executed?
|
133
|
-
@command_executed
|
134
|
-
end
|
135
|
-
|
136
|
-
private
|
137
|
-
|
138
|
-
def generate_diff(expected, actual)
|
139
|
-
expected_lines = expected.lines
|
140
|
-
actual_lines = actual.lines
|
141
|
-
diff = []
|
142
|
-
|
143
|
-
# Simple diff for now
|
144
|
-
expected_lines.each do |line|
|
145
|
-
unless actual_lines.include?(line)
|
146
|
-
diff << "-#{line.chomp}"
|
147
|
-
end
|
148
|
-
end
|
149
|
-
|
150
|
-
actual_lines.each do |line|
|
151
|
-
unless expected_lines.include?(line)
|
152
|
-
diff << "+#{line.chomp}"
|
153
|
-
end
|
154
|
-
end
|
155
|
-
|
156
|
-
diff.join("\n")
|
157
|
-
end
|
158
|
-
end
|
159
|
-
|
160
|
-
class << self
|
161
|
-
attr_accessor :last_output
|
162
87
|
|
163
88
|
def scrub_text(text)
|
164
89
|
return text unless configuration.scrub_credentials && text
|
@@ -173,262 +98,219 @@ module Backspin
|
|
173
98
|
scrubbed
|
174
99
|
end
|
175
100
|
|
176
|
-
|
101
|
+
# Primary API - records on first run, verifies on subsequent runs
|
102
|
+
#
|
103
|
+
# @param record_name [String] Name for the record file
|
104
|
+
# @param options [Hash] Options for recording/verification
|
105
|
+
# @option options [Symbol] :mode (:auto) Recording mode - :auto, :record, :verify, :playback
|
106
|
+
# @option options [Proc] :filter Custom filter for recorded data
|
107
|
+
# @option options [Proc] :matcher Custom matcher for verification
|
108
|
+
# @return [RecordResult] Result object with output and status
|
109
|
+
def run(record_name, options = {}, &block)
|
177
110
|
raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
|
111
|
+
raise ArgumentError, "block is required" unless block_given?
|
178
112
|
|
179
113
|
record_path = build_record_path(record_name)
|
114
|
+
mode = determine_mode(options[:mode], record_path)
|
115
|
+
|
116
|
+
case mode
|
117
|
+
when :record
|
118
|
+
perform_recording(record_name, record_path, options, &block)
|
119
|
+
when :verify
|
120
|
+
perform_verification(record_name, record_path, options, &block)
|
121
|
+
when :playback
|
122
|
+
perform_playback(record_name, record_path, options, &block)
|
123
|
+
else
|
124
|
+
raise ArgumentError, "Unknown mode: #{mode}"
|
125
|
+
end
|
126
|
+
end
|
180
127
|
|
181
|
-
|
182
|
-
|
183
|
-
|
128
|
+
# Strict version of run that raises on verification failure
|
129
|
+
#
|
130
|
+
# @param record_name [String] Name for the record file
|
131
|
+
# @param options [Hash] Options for recording/verification
|
132
|
+
# @return [RecordResult] Result object with output and status
|
133
|
+
# @raise [RSpec::Expectations::ExpectationNotMetError] If verification fails
|
134
|
+
def run!(record_name, options = {}, &block)
|
135
|
+
result = run(record_name, options, &block)
|
184
136
|
|
185
|
-
|
137
|
+
if result.verified? == false
|
138
|
+
error_message = "Backspin verification failed!\n"
|
139
|
+
error_message += "Record: #{result.record_path}\n"
|
186
140
|
|
187
|
-
|
188
|
-
|
189
|
-
# Don't load existing data when creating new record
|
190
|
-
record = Record.new(record_path)
|
191
|
-
record.clear # Clear any loaded data
|
192
|
-
recorder.commands.each { |cmd| record.add_command(cmd) }
|
193
|
-
record.save(filter: filter)
|
141
|
+
# Use the error_message from the result which is now properly formatted
|
142
|
+
error_message += "\n#{result.error_message}" if result.error_message
|
194
143
|
|
195
|
-
|
196
|
-
|
144
|
+
raise RSpec::Expectations::ExpectationNotMetError, error_message
|
145
|
+
end
|
197
146
|
|
198
|
-
|
199
|
-
last_output
|
147
|
+
result
|
200
148
|
end
|
201
149
|
|
202
|
-
|
203
|
-
raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
|
150
|
+
private
|
204
151
|
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
when :none
|
211
|
-
# Never record, only replay
|
212
|
-
unless File.exist?(record_path)
|
213
|
-
raise RecordNotFoundError, "Record not found: #{record_path}"
|
214
|
-
end
|
215
|
-
replay_record(record_path, &block)
|
216
|
-
when :all
|
217
|
-
# Always record
|
218
|
-
record_and_save_record(record_path, filter: filter, &block)
|
219
|
-
when :once
|
220
|
-
# Record if doesn't exist, replay if exists
|
221
|
-
if File.exist?(record_path)
|
222
|
-
replay_record(record_path, &block)
|
223
|
-
else
|
224
|
-
record_and_save_record(record_path, filter: filter, &block)
|
225
|
-
end
|
226
|
-
when :new_episodes
|
227
|
-
# Record new commands not in record
|
228
|
-
# For now, simplified: just append new recordings
|
229
|
-
record_new_episode(record_path, filter: filter, &block)
|
230
|
-
else
|
231
|
-
raise ArgumentError, "Unknown record mode: #{record_mode}"
|
232
|
-
end
|
152
|
+
def determine_mode(mode_option, record_path)
|
153
|
+
return mode_option if mode_option && mode_option != :auto
|
154
|
+
|
155
|
+
# Auto mode: record if file doesn't exist, verify if it does
|
156
|
+
File.exist?(record_path) ? :verify : :record
|
233
157
|
end
|
234
158
|
|
235
|
-
def
|
236
|
-
|
159
|
+
def perform_recording(_record_name, record_path, options)
|
160
|
+
recorder = Recorder.new
|
161
|
+
recorder.record_calls(:capture3, :system)
|
237
162
|
|
238
|
-
|
239
|
-
unless record.exists?
|
240
|
-
raise RecordNotFoundError, "Record not found: #{record_path}"
|
241
|
-
end
|
163
|
+
output = yield
|
242
164
|
|
243
|
-
if
|
244
|
-
|
165
|
+
if output.is_a?(Array) && output.size == 3
|
166
|
+
stdout, stderr, status = output
|
167
|
+
status_int = status.respond_to?(:exitstatus) ? status.exitstatus : status
|
168
|
+
output = [stdout, stderr, status_int]
|
245
169
|
end
|
246
170
|
|
247
|
-
#
|
248
|
-
|
249
|
-
|
171
|
+
# Save the recording
|
172
|
+
FileUtils.mkdir_p(File.dirname(record_path))
|
173
|
+
record = Record.new(record_path)
|
174
|
+
record.clear
|
175
|
+
recorder.commands.each { |cmd| record.add_command(cmd) }
|
176
|
+
record.save(filter: options[:filter])
|
177
|
+
|
178
|
+
# Return result
|
179
|
+
RecordResult.new(
|
180
|
+
output: output,
|
181
|
+
mode: :record,
|
182
|
+
record_path: Pathname.new(record_path),
|
183
|
+
commands: recorder.commands
|
184
|
+
)
|
185
|
+
end
|
250
186
|
|
251
|
-
|
252
|
-
|
187
|
+
def perform_verification(_record_name, record_path, options)
|
188
|
+
record = Record.load_or_create(record_path)
|
253
189
|
|
254
|
-
|
255
|
-
|
256
|
-
recorder.setup_playback_stub(command)
|
257
|
-
|
258
|
-
yield
|
259
|
-
|
260
|
-
# In playback mode, always verified
|
261
|
-
VerifyResult.new(
|
262
|
-
verified: true,
|
263
|
-
record_path: Pathname.new(record_path),
|
264
|
-
expected_output: command.stdout,
|
265
|
-
actual_output: command.stdout,
|
266
|
-
expected_stderr: command.stderr,
|
267
|
-
actual_stderr: command.stderr,
|
268
|
-
expected_status: command.status,
|
269
|
-
actual_status: command.status,
|
270
|
-
command_executed: false
|
271
|
-
)
|
272
|
-
elsif matcher
|
273
|
-
# Custom matcher verification
|
274
|
-
recorder.setup_verification_stub(command)
|
275
|
-
|
276
|
-
yield
|
277
|
-
|
278
|
-
# Call custom matcher - convert command back to hash format for matcher
|
279
|
-
recorded_data = command.to_h
|
280
|
-
verified = matcher.call(recorded_data, recorder.verification_data)
|
281
|
-
|
282
|
-
VerifyResult.new(
|
283
|
-
verified: verified,
|
284
|
-
record_path: Pathname.new(record_path),
|
285
|
-
expected_output: command.stdout,
|
286
|
-
actual_output: recorder.verification_data["stdout"],
|
287
|
-
expected_stderr: command.stderr,
|
288
|
-
actual_stderr: recorder.verification_data["stderr"],
|
289
|
-
expected_status: command.status,
|
290
|
-
actual_status: recorder.verification_data["status"]
|
291
|
-
)
|
292
|
-
else
|
293
|
-
# Default strict mode
|
294
|
-
recorder.setup_verification_stub(command)
|
295
|
-
|
296
|
-
yield
|
297
|
-
|
298
|
-
# Compare outputs
|
299
|
-
actual_stdout = recorder.verification_data["stdout"]
|
300
|
-
actual_stderr = recorder.verification_data["stderr"]
|
301
|
-
actual_status = recorder.verification_data["status"]
|
302
|
-
|
303
|
-
verified =
|
304
|
-
command.stdout == actual_stdout &&
|
305
|
-
command.stderr == actual_stderr &&
|
306
|
-
command.status == actual_status
|
307
|
-
|
308
|
-
VerifyResult.new(
|
309
|
-
verified: verified,
|
310
|
-
record_path: Pathname.new(record_path),
|
311
|
-
expected_output: command.stdout,
|
312
|
-
actual_output: actual_stdout,
|
313
|
-
expected_stderr: command.stderr,
|
314
|
-
actual_stderr: actual_stderr,
|
315
|
-
expected_status: command.status,
|
316
|
-
actual_status: actual_status
|
317
|
-
)
|
318
|
-
end
|
319
|
-
end
|
190
|
+
raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
|
191
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
320
192
|
|
321
|
-
|
322
|
-
|
193
|
+
# For verification, we need to track all commands executed
|
194
|
+
recorder = Recorder.new(mode: :verify, record: record)
|
195
|
+
recorder.setup_replay_stubs
|
323
196
|
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
197
|
+
# Track verification results for each command
|
198
|
+
command_diffs = []
|
199
|
+
command_index = 0
|
200
|
+
|
201
|
+
# Override stubs to verify each command as it's executed
|
202
|
+
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
203
|
+
recorded_command = record.commands[command_index]
|
329
204
|
|
330
|
-
if
|
331
|
-
|
205
|
+
if recorded_command.nil?
|
206
|
+
raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
|
332
207
|
end
|
333
208
|
|
334
|
-
if
|
335
|
-
|
209
|
+
if recorded_command.method_class != Open3::Capture3
|
210
|
+
raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got Open3.capture3"
|
336
211
|
end
|
337
212
|
|
338
|
-
#
|
339
|
-
|
340
|
-
end
|
213
|
+
# Execute the actual command
|
214
|
+
stdout, stderr, status = original_method.call(*args)
|
341
215
|
|
342
|
-
|
343
|
-
|
216
|
+
# Create verification result
|
217
|
+
actual_result = CommandResult.new(
|
218
|
+
stdout: stdout,
|
219
|
+
stderr: stderr,
|
220
|
+
status: status.exitstatus
|
221
|
+
)
|
344
222
|
|
345
|
-
|
223
|
+
# Create CommandDiff to track the comparison
|
224
|
+
command_diffs << CommandDiff.new(
|
225
|
+
recorded_command: recorded_command,
|
226
|
+
actual_result: actual_result,
|
227
|
+
matcher: options[:matcher]
|
228
|
+
)
|
346
229
|
|
347
|
-
|
348
|
-
|
349
|
-
unless record.exists?
|
350
|
-
raise RecordNotFoundError, "Record not found: #{record_path}"
|
230
|
+
command_index += 1
|
231
|
+
[stdout, stderr, status]
|
351
232
|
end
|
352
233
|
|
353
|
-
|
354
|
-
|
355
|
-
end
|
234
|
+
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
235
|
+
recorded_command = record.commands[command_index]
|
356
236
|
|
357
|
-
|
358
|
-
|
359
|
-
|
237
|
+
if recorded_command.nil?
|
238
|
+
raise RecordNotFoundError, "No more recorded commands, but tried to execute: system #{args.inspect}"
|
239
|
+
end
|
360
240
|
|
361
|
-
|
241
|
+
if recorded_command.method_class != ::Kernel::System
|
242
|
+
raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got system"
|
243
|
+
end
|
362
244
|
|
363
|
-
|
364
|
-
|
365
|
-
if block_return_value.is_a?(Array) && block_return_value.size == 3 &&
|
366
|
-
block_return_value[0].is_a?(String) && block_return_value[1].is_a?(String)
|
367
|
-
# Convert status to integer for consistency
|
368
|
-
stdout, stderr, status = block_return_value
|
369
|
-
status_int = status.respond_to?(:exitstatus) ? status.exitstatus : status
|
370
|
-
[stdout, stderr, status_int]
|
371
|
-
else
|
372
|
-
block_return_value
|
373
|
-
end
|
374
|
-
end
|
245
|
+
# Execute the actual command
|
246
|
+
result = original_method.call(receiver, *args)
|
375
247
|
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
248
|
+
# Create verification result (system only gives us exit status)
|
249
|
+
actual_result = CommandResult.new(
|
250
|
+
stdout: "",
|
251
|
+
stderr: "",
|
252
|
+
status: result ? 0 : 1
|
253
|
+
)
|
380
254
|
|
381
|
-
|
255
|
+
# Create CommandDiff to track the comparison
|
256
|
+
command_diffs << CommandDiff.new(
|
257
|
+
recorded_command: recorded_command,
|
258
|
+
actual_result: actual_result,
|
259
|
+
matcher: options[:matcher]
|
260
|
+
)
|
382
261
|
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
record = Record.new(record_path)
|
387
|
-
record.clear # Clear any loaded data
|
388
|
-
recorder.commands.each { |cmd| record.add_command(cmd) }
|
389
|
-
record.save(filter: filter)
|
262
|
+
command_index += 1
|
263
|
+
result
|
264
|
+
end
|
390
265
|
|
391
|
-
#
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
block_return_value
|
266
|
+
# Execute block
|
267
|
+
output = yield
|
268
|
+
|
269
|
+
# Check if all commands were executed
|
270
|
+
if command_index < record.commands.size
|
271
|
+
raise RecordNotFoundError, "Expected #{record.commands.size} commands but only #{command_index} were executed"
|
398
272
|
end
|
273
|
+
|
274
|
+
# Overall verification status
|
275
|
+
all_verified = command_diffs.all?(&:verified?)
|
276
|
+
|
277
|
+
RecordResult.new(
|
278
|
+
output: output,
|
279
|
+
mode: :verify,
|
280
|
+
verified: all_verified,
|
281
|
+
record_path: Pathname.new(record_path),
|
282
|
+
commands: record.commands,
|
283
|
+
command_diffs: command_diffs
|
284
|
+
)
|
399
285
|
end
|
400
286
|
|
401
|
-
def
|
402
|
-
# For new_episodes mode, we'd need to track which commands have been seen
|
403
|
-
# For now, simplified implementation that just appends
|
287
|
+
def perform_playback(_record_name, record_path, _options)
|
404
288
|
record = Record.load_or_create(record_path)
|
405
289
|
|
406
|
-
|
407
|
-
|
408
|
-
recorder.record_calls(:capture3, :system)
|
290
|
+
raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
|
291
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
409
292
|
|
410
|
-
|
293
|
+
# Setup replay mode - this will handle returning values for all commands
|
294
|
+
recorder = Recorder.new(mode: :replay, record: record)
|
295
|
+
recorder.setup_replay_stubs
|
411
296
|
|
412
|
-
#
|
413
|
-
|
414
|
-
recorder.commands.each { |cmd| record.add_command(cmd) }
|
415
|
-
record.save(filter: filter)
|
416
|
-
end
|
297
|
+
# Execute block (all commands will be stubbed with recorded values)
|
298
|
+
output = yield
|
417
299
|
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
300
|
+
RecordResult.new(
|
301
|
+
output: output,
|
302
|
+
mode: :playback,
|
303
|
+
verified: true, # Always true for playback
|
304
|
+
record_path: Pathname.new(record_path),
|
305
|
+
commands: record.commands
|
306
|
+
)
|
425
307
|
end
|
426
308
|
|
427
309
|
def build_record_path(name)
|
428
310
|
backspin_dir = configuration.backspin_dir
|
429
311
|
backspin_dir.mkpath
|
430
312
|
|
431
|
-
File.join(backspin_dir, "#{name}.
|
313
|
+
File.join(backspin_dir, "#{name}.yml")
|
432
314
|
end
|
433
315
|
end
|
434
316
|
end
|
metadata
CHANGED
@@ -1,11 +1,11 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: backspin
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rob Sanheim
|
8
|
-
bindir:
|
8
|
+
bindir: exe
|
9
9
|
cert_chain: []
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
@@ -42,8 +42,7 @@ description: Backspin is a Ruby library for characterization testing of command-
|
|
42
42
|
interactions to make testing faster and more deterministic.
|
43
43
|
email:
|
44
44
|
- rsanheim@gmail.com
|
45
|
-
executables:
|
46
|
-
- setup
|
45
|
+
executables: []
|
47
46
|
extensions: []
|
48
47
|
extra_rdoc_files: []
|
49
48
|
files:
|
@@ -60,10 +59,15 @@ files:
|
|
60
59
|
- README.md
|
61
60
|
- Rakefile
|
62
61
|
- backspin.gemspec
|
62
|
+
- bin/rake
|
63
|
+
- bin/rspec
|
63
64
|
- bin/setup
|
64
65
|
- lib/backspin.rb
|
65
66
|
- lib/backspin/command.rb
|
67
|
+
- lib/backspin/command_diff.rb
|
68
|
+
- lib/backspin/command_result.rb
|
66
69
|
- lib/backspin/record.rb
|
70
|
+
- lib/backspin/record_result.rb
|
67
71
|
- lib/backspin/recorder.rb
|
68
72
|
- lib/backspin/version.rb
|
69
73
|
homepage: https://github.com/rsanheim/backspin
|