backspin 0.4.1 → 0.4.2
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/.gem_release.yml +13 -0
- data/CHANGELOG.md +5 -7
- data/CLAUDE.md +5 -1
- data/Gemfile +3 -1
- data/Gemfile.lock +3 -1
- data/MATCH_ON_USAGE.md +110 -0
- data/Rakefile +5 -1
- data/backspin.gemspec +6 -3
- data/examples/match_on_example.rb +116 -0
- data/fixtures/backspin/all_and_fields.yml +15 -0
- data/fixtures/backspin/all_bypass_equality.yml +14 -0
- data/fixtures/backspin/all_checks_equality.yml +17 -0
- data/fixtures/backspin/all_for_logging.yml +13 -0
- data/fixtures/backspin/all_matcher_basic.yml +14 -0
- data/fixtures/backspin/all_matcher_custom.yml +17 -0
- data/fixtures/backspin/all_matcher_demo.yml +14 -0
- data/fixtures/backspin/all_matcher_test.yml +14 -0
- data/fixtures/backspin/all_no_short_circuit.yml +14 -0
- data/fixtures/backspin/all_pass_field_fail.yml +14 -0
- data/fixtures/backspin/all_short_circuit.yml +14 -0
- data/fixtures/backspin/all_skips_equality.yml +17 -0
- data/fixtures/backspin/all_with_equality.yml +17 -0
- data/fixtures/backspin/all_with_fields.yml +17 -0
- data/fixtures/backspin/combined_fail_demo.yml +14 -0
- data/fixtures/backspin/combined_matcher_demo.yml +14 -0
- data/fixtures/backspin/field_matcher_demo.yml +17 -0
- data/fixtures/backspin/field_matcher_values.yml +14 -0
- data/fixtures/backspin/key_confusion_test.yml +14 -0
- data/fixtures/backspin/match_on_any_fail.yml +21 -0
- data/fixtures/backspin/match_on_bad_format.yml +14 -0
- data/fixtures/backspin/match_on_fail.yml +15 -0
- data/fixtures/backspin/match_on_invalid.yml +14 -0
- data/fixtures/backspin/match_on_multiple.yml +28 -0
- data/fixtures/backspin/match_on_nil.yml +14 -0
- data/fixtures/backspin/match_on_other_fields.yml +23 -0
- data/fixtures/backspin/match_on_run_bang.yml +16 -0
- data/fixtures/backspin/match_on_run_bang_fail.yml +15 -0
- data/fixtures/backspin/match_on_single.yml +17 -0
- data/fixtures/backspin/string_symbol_test.yml +14 -0
- data/lib/backspin/command.rb +1 -5
- data/lib/backspin/command_diff.rb +98 -16
- data/lib/backspin/command_result.rb +2 -4
- data/lib/backspin/record.rb +31 -10
- data/lib/backspin/record_result.rb +20 -14
- data/lib/backspin/recorder.rb +100 -55
- data/lib/backspin/version.rb +3 -1
- data/lib/backspin.rb +34 -173
- data/release.rake +97 -0
- data/script/lint +6 -0
- metadata +54 -5
data/lib/backspin/recorder.rb
CHANGED
@@ -1,25 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "open3"
|
2
4
|
require "ostruct"
|
3
5
|
require "rspec/mocks"
|
6
|
+
require "backspin/command_result"
|
7
|
+
require "backspin/command_diff"
|
4
8
|
|
5
9
|
module Backspin
|
6
10
|
# Handles stubbing and recording of command executions
|
7
11
|
class Recorder
|
8
12
|
include RSpec::Mocks::ExampleMethods
|
9
|
-
SUPPORTED_COMMAND_TYPES = [
|
13
|
+
SUPPORTED_COMMAND_TYPES = %i[capture3 system].freeze
|
10
14
|
|
11
|
-
attr_reader :commands, :
|
15
|
+
attr_reader :commands, :mode, :record, :options
|
12
16
|
|
13
|
-
def initialize(mode: :record, record: nil)
|
17
|
+
def initialize(mode: :record, record: nil, options: {})
|
14
18
|
@mode = mode
|
15
19
|
@record = record
|
20
|
+
@options = options
|
16
21
|
@commands = []
|
17
|
-
@
|
22
|
+
@playback_index = 0
|
23
|
+
@command_diffs = []
|
18
24
|
end
|
19
25
|
|
20
|
-
def
|
26
|
+
def setup_recording_stubs(*command_types)
|
21
27
|
command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
|
22
|
-
|
23
28
|
command_types.each do |command_type|
|
24
29
|
record_call(command_type)
|
25
30
|
end
|
@@ -32,67 +37,110 @@ module Backspin
|
|
32
37
|
when :capture3
|
33
38
|
setup_capture3_call_stub
|
34
39
|
else
|
35
|
-
raise ArgumentError,
|
40
|
+
raise ArgumentError,
|
41
|
+
"Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
|
36
42
|
end
|
37
43
|
end
|
38
44
|
|
39
|
-
#
|
40
|
-
def
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
elsif command.method_class == ::Kernel::System
|
45
|
-
# For system, return true if exit status was 0
|
46
|
-
allow_any_instance_of(Object).to receive(:system).and_return(command.status == 0)
|
47
|
-
end
|
45
|
+
# Records registered commands, adds them to the record, saves the record, and returns the overall RecordResult
|
46
|
+
def perform_recording
|
47
|
+
result = yield
|
48
|
+
record.save(filter: options[:filter])
|
49
|
+
RecordResult.new(output: result, mode: :record, record: record)
|
48
50
|
end
|
49
51
|
|
50
|
-
#
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
if command.method_class == Open3::Capture3
|
55
|
-
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
56
|
-
stdout, stderr, status = original_method.call(*args)
|
57
|
-
@verification_data["stdout"] = stdout
|
58
|
-
@verification_data["stderr"] = stderr
|
59
|
-
@verification_data["status"] = status.exitstatus
|
60
|
-
[stdout, stderr, status]
|
61
|
-
end
|
62
|
-
elsif command.method_class == ::Kernel::System
|
63
|
-
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
64
|
-
# Execute the real system call
|
65
|
-
result = original_method.call(receiver, *args)
|
52
|
+
# Performs verification by executing commands and comparing with recorded values
|
53
|
+
def perform_verification
|
54
|
+
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
55
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
66
56
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
@verification_data["status"] = result ? 0 : 1
|
57
|
+
# Initialize tracking variables
|
58
|
+
@command_diffs = []
|
59
|
+
@command_index = 0
|
71
60
|
|
72
|
-
|
61
|
+
# Setup verification stubs for capture3
|
62
|
+
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
63
|
+
recorded_command = record.commands[@command_index]
|
64
|
+
|
65
|
+
if recorded_command.nil?
|
66
|
+
raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
|
73
67
|
end
|
68
|
+
|
69
|
+
stdout, stderr, status = original_method.call(*args)
|
70
|
+
|
71
|
+
actual_command = Command.new(
|
72
|
+
method_class: Open3::Capture3,
|
73
|
+
args: args,
|
74
|
+
stdout: stdout,
|
75
|
+
stderr: stderr,
|
76
|
+
status: status.exitstatus
|
77
|
+
)
|
78
|
+
|
79
|
+
@command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
|
80
|
+
@command_index += 1
|
81
|
+
[stdout, stderr, status]
|
74
82
|
end
|
83
|
+
|
84
|
+
# Setup verification stubs for system
|
85
|
+
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
86
|
+
recorded_command = record.commands[@command_index]
|
87
|
+
|
88
|
+
result = original_method.call(receiver, *args)
|
89
|
+
|
90
|
+
actual_command = Command.new(
|
91
|
+
method_class: ::Kernel::System,
|
92
|
+
args: args,
|
93
|
+
stdout: "",
|
94
|
+
stderr: "",
|
95
|
+
status: result ? 0 : 1
|
96
|
+
)
|
97
|
+
|
98
|
+
# Create CommandDiff to track the comparison
|
99
|
+
@command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: options[:matcher])
|
100
|
+
|
101
|
+
@command_index += 1
|
102
|
+
result
|
103
|
+
end
|
104
|
+
|
105
|
+
output = yield
|
106
|
+
|
107
|
+
all_verified = @command_diffs.all?(&:verified?)
|
108
|
+
|
109
|
+
RecordResult.new(
|
110
|
+
output: output,
|
111
|
+
mode: :verify,
|
112
|
+
verified: all_verified,
|
113
|
+
record: record,
|
114
|
+
command_diffs: @command_diffs
|
115
|
+
)
|
75
116
|
end
|
76
117
|
|
77
|
-
#
|
78
|
-
def
|
79
|
-
raise
|
118
|
+
# Performs playback by returning recorded values without executing actual commands
|
119
|
+
def perform_playback
|
120
|
+
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
121
|
+
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
80
122
|
|
123
|
+
# Setup replay stubs
|
81
124
|
setup_capture3_replay_stub
|
82
125
|
setup_system_replay_stub
|
126
|
+
|
127
|
+
# Execute block (all commands will be stubbed with recorded values)
|
128
|
+
output = yield
|
129
|
+
|
130
|
+
RecordResult.new(
|
131
|
+
output: output,
|
132
|
+
mode: :playback,
|
133
|
+
verified: true, # Always true for playback
|
134
|
+
record: record
|
135
|
+
)
|
83
136
|
end
|
84
137
|
|
85
138
|
private
|
86
139
|
|
87
140
|
def setup_capture3_replay_stub
|
88
|
-
allow(Open3).to receive(:capture3) do |*
|
141
|
+
allow(Open3).to receive(:capture3) do |*_args|
|
89
142
|
command = @record.next_command
|
90
143
|
|
91
|
-
# Make sure this is a capture3 command
|
92
|
-
unless command.method_class == Open3::Capture3
|
93
|
-
raise RecordNotFoundError, "Expected Open3::Capture3 command but got #{command.method_class.name}"
|
94
|
-
end
|
95
|
-
|
96
144
|
recorded_stdout = command.stdout
|
97
145
|
recorded_stderr = command.stderr
|
98
146
|
recorded_status = OpenStruct.new(exitstatus: command.status)
|
@@ -104,14 +152,10 @@ module Backspin
|
|
104
152
|
end
|
105
153
|
|
106
154
|
def setup_system_replay_stub
|
107
|
-
allow_any_instance_of(Object).to receive(:system) do |
|
155
|
+
allow_any_instance_of(Object).to receive(:system) do |_receiver, *_args|
|
108
156
|
command = @record.next_command
|
109
157
|
|
110
|
-
|
111
|
-
raise RecordNotFoundError, "Expected Kernel::System command but got #{command.method_class.name}"
|
112
|
-
end
|
113
|
-
|
114
|
-
command.status == 0
|
158
|
+
command.status.zero?
|
115
159
|
rescue NoMoreRecordingsError => e
|
116
160
|
raise RecordNotFoundError, e.message
|
117
161
|
end
|
@@ -135,7 +179,7 @@ module Backspin
|
|
135
179
|
status: status.exitstatus,
|
136
180
|
recorded_at: Time.now.iso8601
|
137
181
|
)
|
138
|
-
|
182
|
+
record.add_command(command)
|
139
183
|
|
140
184
|
[stdout, stderr, status]
|
141
185
|
end
|
@@ -154,7 +198,8 @@ module Backspin
|
|
154
198
|
args
|
155
199
|
end
|
156
200
|
|
157
|
-
stdout
|
201
|
+
stdout = ""
|
202
|
+
stderr = ""
|
158
203
|
status = result ? 0 : 1
|
159
204
|
|
160
205
|
command = Command.new(
|
@@ -165,7 +210,7 @@ module Backspin
|
|
165
210
|
status: status,
|
166
211
|
recorded_at: Time.now.iso8601
|
167
212
|
)
|
168
|
-
|
213
|
+
record.add_command(command)
|
169
214
|
|
170
215
|
result
|
171
216
|
end
|
data/lib/backspin/version.rb
CHANGED
data/lib/backspin.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "yaml"
|
2
4
|
require "fileutils"
|
3
5
|
require "open3"
|
@@ -50,22 +52,22 @@ module Backspin
|
|
50
52
|
def default_credential_patterns
|
51
53
|
[
|
52
54
|
# AWS credentials
|
53
|
-
/AKIA[0-9A-Z]{16}/,
|
54
|
-
|
55
|
-
|
55
|
+
/AKIA[0-9A-Z]{16}/, # AWS Access Key ID
|
56
|
+
%r{aws_secret_access_key\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})["']?}i, # AWS Secret Key
|
57
|
+
%r{aws_session_token\s*[:=]\s*["']?([A-Za-z0-9/+=]+)["']?}i, # AWS Session Token
|
56
58
|
|
57
59
|
# Google Cloud credentials
|
58
|
-
/AIza[0-9A-Za-z\-_]{35}/,
|
60
|
+
/AIza[0-9A-Za-z\-_]{35}/, # Google API Key
|
59
61
|
/[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com/, # Google OAuth2 client ID
|
60
|
-
/-----BEGIN (RSA )?PRIVATE KEY-----/,
|
62
|
+
/-----BEGIN (RSA )?PRIVATE KEY-----/, # Private keys
|
61
63
|
|
62
64
|
# Generic patterns
|
63
|
-
/api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i,
|
65
|
+
/api[_-]?key\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Generic API keys
|
64
66
|
/auth[_-]?token\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i, # Auth tokens
|
65
67
|
/Bearer\s+([A-Za-z0-9\-_]+)/, # Bearer tokens
|
66
|
-
/password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i,
|
67
|
-
/-p([^"'\s]{8,})/,
|
68
|
-
/secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i
|
68
|
+
/password\s*[:=]\s*["']?([^"'\s]{8,})["']?/i, # Passwords
|
69
|
+
/-p([^"'\s]{8,})/, # MySQL-style password args
|
70
|
+
/secret\s*[:=]\s*["']?([A-Za-z0-9\-_]{20,})["']?/i # Generic secrets
|
69
71
|
]
|
70
72
|
end
|
71
73
|
end
|
@@ -91,7 +93,6 @@ module Backspin
|
|
91
93
|
scrubbed = text.dup
|
92
94
|
configuration.credential_patterns.each do |pattern|
|
93
95
|
scrubbed.gsub!(pattern) do |match|
|
94
|
-
# Replace with asterisks of the same length
|
95
96
|
"*" * match.length
|
96
97
|
end
|
97
98
|
end
|
@@ -104,22 +105,39 @@ module Backspin
|
|
104
105
|
# @param options [Hash] Options for recording/verification
|
105
106
|
# @option options [Symbol] :mode (:auto) Recording mode - :auto, :record, :verify, :playback
|
106
107
|
# @option options [Proc] :filter Custom filter for recorded data
|
107
|
-
# @option options [Proc] :matcher Custom matcher for verification
|
108
|
+
# @option options [Proc, Hash] :matcher Custom matcher for verification
|
109
|
+
# - Proc: ->(recorded, actual) { ... } for full command matching
|
110
|
+
# - Hash: { stdout: ->(recorded, actual) { ... }, stderr: ->(recorded, actual) { ... } } for field-specific matching
|
111
|
+
# - Hash with :all key: { all: ->(recorded, actual) { ... }, stdout: ->(recorded, actual) { ... } } for combined matching
|
112
|
+
# When both :all and field matchers are present, both must pass for verification to succeed.
|
113
|
+
# Fields without specific matchers always use exact equality, regardless of :all presence.
|
108
114
|
# @return [RecordResult] Result object with output and status
|
109
115
|
def run(record_name, options = {}, &block)
|
110
116
|
raise ArgumentError, "record_name is required" if record_name.nil? || record_name.empty?
|
111
117
|
raise ArgumentError, "block is required" unless block_given?
|
112
118
|
|
113
|
-
record_path = build_record_path(record_name)
|
119
|
+
record_path = Record.build_record_path(record_name)
|
114
120
|
mode = determine_mode(options[:mode], record_path)
|
115
121
|
|
122
|
+
# Create or load the record based on mode
|
123
|
+
record = if mode == :record
|
124
|
+
Record.create(record_name)
|
125
|
+
else
|
126
|
+
Record.load_or_create(record_path)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Create recorder with all needed context
|
130
|
+
recorder = Recorder.new(record: record, options: options, mode: mode)
|
131
|
+
|
132
|
+
# Execute the appropriate mode
|
116
133
|
case mode
|
117
134
|
when :record
|
118
|
-
|
135
|
+
recorder.setup_recording_stubs(:capture3, :system)
|
136
|
+
recorder.perform_recording(&block)
|
119
137
|
when :verify
|
120
|
-
perform_verification(
|
138
|
+
recorder.perform_verification(&block)
|
121
139
|
when :playback
|
122
|
-
perform_playback(
|
140
|
+
recorder.perform_playback(&block)
|
123
141
|
else
|
124
142
|
raise ArgumentError, "Unknown mode: #{mode}"
|
125
143
|
end
|
@@ -136,7 +154,7 @@ module Backspin
|
|
136
154
|
|
137
155
|
if result.verified? == false
|
138
156
|
error_message = "Backspin verification failed!\n"
|
139
|
-
error_message += "Record: #{result.
|
157
|
+
error_message += "Record: #{result.record.path}\n"
|
140
158
|
|
141
159
|
# Use the error_message from the result which is now properly formatted
|
142
160
|
error_message += "\n#{result.error_message}" if result.error_message
|
@@ -155,162 +173,5 @@ module Backspin
|
|
155
173
|
# Auto mode: record if file doesn't exist, verify if it does
|
156
174
|
File.exist?(record_path) ? :verify : :record
|
157
175
|
end
|
158
|
-
|
159
|
-
def perform_recording(_record_name, record_path, options)
|
160
|
-
recorder = Recorder.new
|
161
|
-
recorder.record_calls(:capture3, :system)
|
162
|
-
|
163
|
-
output = yield
|
164
|
-
|
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]
|
169
|
-
end
|
170
|
-
|
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
|
186
|
-
|
187
|
-
def perform_verification(_record_name, record_path, options)
|
188
|
-
record = Record.load_or_create(record_path)
|
189
|
-
|
190
|
-
raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
|
191
|
-
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
192
|
-
|
193
|
-
# For verification, we need to track all commands executed
|
194
|
-
recorder = Recorder.new(mode: :verify, record: record)
|
195
|
-
recorder.setup_replay_stubs
|
196
|
-
|
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]
|
204
|
-
|
205
|
-
if recorded_command.nil?
|
206
|
-
raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
|
207
|
-
end
|
208
|
-
|
209
|
-
if recorded_command.method_class != Open3::Capture3
|
210
|
-
raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got Open3.capture3"
|
211
|
-
end
|
212
|
-
|
213
|
-
# Execute the actual command
|
214
|
-
stdout, stderr, status = original_method.call(*args)
|
215
|
-
|
216
|
-
# Create verification result
|
217
|
-
actual_result = CommandResult.new(
|
218
|
-
stdout: stdout,
|
219
|
-
stderr: stderr,
|
220
|
-
status: status.exitstatus
|
221
|
-
)
|
222
|
-
|
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
|
-
)
|
229
|
-
|
230
|
-
command_index += 1
|
231
|
-
[stdout, stderr, status]
|
232
|
-
end
|
233
|
-
|
234
|
-
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
235
|
-
recorded_command = record.commands[command_index]
|
236
|
-
|
237
|
-
if recorded_command.nil?
|
238
|
-
raise RecordNotFoundError, "No more recorded commands, but tried to execute: system #{args.inspect}"
|
239
|
-
end
|
240
|
-
|
241
|
-
if recorded_command.method_class != ::Kernel::System
|
242
|
-
raise RecordNotFoundError, "Expected #{recorded_command.method_class.name} but got system"
|
243
|
-
end
|
244
|
-
|
245
|
-
# Execute the actual command
|
246
|
-
result = original_method.call(receiver, *args)
|
247
|
-
|
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
|
-
)
|
254
|
-
|
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
|
-
)
|
261
|
-
|
262
|
-
command_index += 1
|
263
|
-
result
|
264
|
-
end
|
265
|
-
|
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"
|
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
|
-
)
|
285
|
-
end
|
286
|
-
|
287
|
-
def perform_playback(_record_name, record_path, _options)
|
288
|
-
record = Record.load_or_create(record_path)
|
289
|
-
|
290
|
-
raise RecordNotFoundError, "Record not found: #{record_path}" unless record.exists?
|
291
|
-
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
292
|
-
|
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
|
296
|
-
|
297
|
-
# Execute block (all commands will be stubbed with recorded values)
|
298
|
-
output = yield
|
299
|
-
|
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
|
-
)
|
307
|
-
end
|
308
|
-
|
309
|
-
def build_record_path(name)
|
310
|
-
backspin_dir = configuration.backspin_dir
|
311
|
-
backspin_dir.mkpath
|
312
|
-
|
313
|
-
File.join(backspin_dir, "#{name}.yml")
|
314
|
-
end
|
315
176
|
end
|
316
177
|
end
|
data/release.rake
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
|
5
|
+
# Simplified release tasks using gem-release
|
6
|
+
# Install with: gem install gem-release
|
7
|
+
|
8
|
+
namespace :release do
|
9
|
+
desc "Release a new version (bump, tag, release)"
|
10
|
+
task :version, [:level] do |t, args|
|
11
|
+
level = args[:level] || "patch"
|
12
|
+
|
13
|
+
# Pre-release checks
|
14
|
+
Rake::Task["release:check"].invoke
|
15
|
+
|
16
|
+
puts "\nReleasing #{level} version..."
|
17
|
+
|
18
|
+
# Use gem-release to bump, tag, and release to rubygems and github
|
19
|
+
sh "gem bump --version #{level} --github --tag --release"
|
20
|
+
end
|
21
|
+
|
22
|
+
desc "Create GitHub release for current version"
|
23
|
+
task :github do
|
24
|
+
version = Backspin::VERSION
|
25
|
+
|
26
|
+
if system("which gh > /dev/null 2>&1")
|
27
|
+
puts "\nCreating GitHub release for v#{version}..."
|
28
|
+
sh "gh release create v#{version} --title 'Release v#{version}' --generate-notes"
|
29
|
+
else
|
30
|
+
puts "\nGitHub CLI not found. Create release manually at:"
|
31
|
+
puts "https://github.com/rsanheim/backspin/releases/new?tag=v#{version}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
desc "Check if ready for release"
|
36
|
+
task :check do
|
37
|
+
require "open-uri"
|
38
|
+
require "json"
|
39
|
+
|
40
|
+
current_version = Backspin::VERSION
|
41
|
+
errors = []
|
42
|
+
|
43
|
+
# Check RubyGems for latest version
|
44
|
+
begin
|
45
|
+
gem_data = JSON.parse(URI.open("https://rubygems.org/api/v1/gems/backspin.json").read)
|
46
|
+
latest_version = gem_data["version"]
|
47
|
+
|
48
|
+
if Gem::Version.new(current_version) <= Gem::Version.new(latest_version)
|
49
|
+
errors << "Current version (#{current_version}) is not greater than latest released version (#{latest_version})"
|
50
|
+
else
|
51
|
+
puts "✓ Version #{current_version} is ready for release (latest: #{latest_version})"
|
52
|
+
end
|
53
|
+
rescue => e
|
54
|
+
puts "⚠ Could not check RubyGems version: #{e.message}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Check git status
|
58
|
+
if system("git diff --quiet && git diff --cached --quiet")
|
59
|
+
puts "✓ No uncommitted changes"
|
60
|
+
else
|
61
|
+
errors << "You have uncommitted changes"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Check branch
|
65
|
+
current_branch = `git rev-parse --abbrev-ref HEAD`.strip
|
66
|
+
if current_branch != "main"
|
67
|
+
puts "⚠ Not on main branch (currently on #{current_branch})"
|
68
|
+
print "Continue anyway? (y/N): "
|
69
|
+
response = $stdin.gets.chomp
|
70
|
+
errors << "Not on main branch" unless response.downcase == "y"
|
71
|
+
else
|
72
|
+
puts "✓ On main branch"
|
73
|
+
end
|
74
|
+
|
75
|
+
unless errors.empty?
|
76
|
+
puts "\n❌ Cannot release:"
|
77
|
+
errors.each { |e| puts " - #{e}" }
|
78
|
+
abort
|
79
|
+
end
|
80
|
+
|
81
|
+
puts "\n✅ All checks passed!"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Convenience tasks
|
86
|
+
desc "Release patch version"
|
87
|
+
task "release:patch" => ["release:version"]
|
88
|
+
|
89
|
+
desc "Release minor version"
|
90
|
+
task "release:minor" do
|
91
|
+
Rake::Task["release:version"].invoke("minor")
|
92
|
+
end
|
93
|
+
|
94
|
+
desc "Release major version"
|
95
|
+
task "release:major" do
|
96
|
+
Rake::Task["release:version"].invoke("major")
|
97
|
+
end
|