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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +18 -6
- data/.ruby-version +1 -0
- data/CHANGELOG.md +8 -1
- data/CLAUDE.md +8 -9
- data/CONTRIBUTING.md +5 -12
- data/Gemfile.lock +3 -6
- 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 +117 -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"
|
|
@@ -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
|
|
76
|
-
# @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
|
|
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(
|
|
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.
|
|
106
|
-
recorder.perform_recording(&block)
|
|
121
|
+
recorder.perform_capture_recording(&block)
|
|
107
122
|
when :verify
|
|
108
|
-
recorder.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
139
|
+
Record.create(name)
|
|
168
140
|
else
|
|
169
141
|
Record.load_or_create(record_path)
|
|
170
142
|
end
|
|
171
143
|
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
error_message += "\n\nDiff:\n#{result.diff}"
|
|
196
|
-
end
|
|
196
|
+
result
|
|
197
|
+
end
|
|
197
198
|
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
def normalize_env(env)
|
|
200
|
+
raise ArgumentError, "env must be a Hash" unless env.is_a?(Hash)
|
|
200
201
|
|
|
201
|
-
|
|
202
|
+
env.empty? ? nil : env
|
|
202
203
|
end
|
|
203
204
|
|
|
204
|
-
|
|
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.
|
|
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: []
|