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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +18 -6
- data/.ruby-version +1 -0
- data/CHANGELOG.md +14 -1
- data/CLAUDE.md +8 -9
- data/CONTRIBUTING.md +5 -12
- data/Gemfile.lock +14 -17
- data/MATCHERS.md +28 -136
- data/README.md +56 -120
- data/backspin.gemspec +0 -3
- data/examples/match_on_example.rb +42 -71
- data/lib/backspin/command.rb +32 -21
- data/lib/backspin/command_diff.rb +14 -8
- data/lib/backspin/configuration.rb +1 -1
- data/lib/backspin/record.rb +9 -18
- data/lib/backspin/record_result.rb +0 -6
- data/lib/backspin/recorder.rb +46 -301
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +136 -87
- metadata +4 -31
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
|
|
20
|
+
class VerificationError < StandardError
|
|
21
|
+
attr_reader :result
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
57
|
-
# @param
|
|
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(
|
|
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.
|
|
87
|
-
recorder.perform_recording(&block)
|
|
121
|
+
recorder.perform_capture_recording(&block)
|
|
88
122
|
when :verify
|
|
89
|
-
recorder.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
139
|
+
Record.create(name)
|
|
149
140
|
else
|
|
150
141
|
Record.load_or_create(record_path)
|
|
151
142
|
end
|
|
152
143
|
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
error_message += "\n\nDiff:\n#{result.diff}"
|
|
177
|
-
end
|
|
196
|
+
result
|
|
197
|
+
end
|
|
178
198
|
|
|
179
|
-
|
|
180
|
-
|
|
199
|
+
def normalize_env(env)
|
|
200
|
+
raise ArgumentError, "env must be a Hash" unless env.is_a?(Hash)
|
|
181
201
|
|
|
182
|
-
|
|
202
|
+
env.empty? ? nil : env
|
|
183
203
|
end
|
|
184
204
|
|
|
185
|
-
|
|
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.
|
|
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:
|
|
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: []
|