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/record.rb
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
module Backspin
|
|
4
4
|
class RecordFormatError < StandardError; end
|
|
5
5
|
|
|
6
|
-
class NoMoreRecordingsError < StandardError; end
|
|
7
|
-
|
|
8
6
|
class Record
|
|
9
|
-
FORMAT_VERSION = "
|
|
7
|
+
FORMAT_VERSION = "3.0"
|
|
10
8
|
attr_reader :path, :commands, :first_recorded_at
|
|
11
9
|
|
|
12
10
|
def self.load_or_create(path)
|
|
@@ -39,7 +37,6 @@ module Backspin
|
|
|
39
37
|
@path = path
|
|
40
38
|
@commands = []
|
|
41
39
|
@first_recorded_at = nil
|
|
42
|
-
@playback_index = 0
|
|
43
40
|
end
|
|
44
41
|
|
|
45
42
|
def add_command(command)
|
|
@@ -60,9 +57,7 @@ module Backspin
|
|
|
60
57
|
|
|
61
58
|
def reload
|
|
62
59
|
@commands = []
|
|
63
|
-
@playback_index = 0
|
|
64
60
|
load_from_file if File.exist?(@path)
|
|
65
|
-
@playback_index = 0 # Reset again after loading to ensure it's at 0
|
|
66
61
|
end
|
|
67
62
|
|
|
68
63
|
def exists?
|
|
@@ -77,17 +72,8 @@ module Backspin
|
|
|
77
72
|
@commands.size
|
|
78
73
|
end
|
|
79
74
|
|
|
80
|
-
def next_command
|
|
81
|
-
raise NoMoreRecordingsError, "No more recordings available for replay" if @playback_index >= @commands.size
|
|
82
|
-
|
|
83
|
-
command = @commands[@playback_index]
|
|
84
|
-
@playback_index += 1
|
|
85
|
-
command
|
|
86
|
-
end
|
|
87
|
-
|
|
88
75
|
def clear
|
|
89
76
|
@commands = []
|
|
90
|
-
@playback_index = 0
|
|
91
77
|
end
|
|
92
78
|
|
|
93
79
|
# private
|
|
@@ -95,12 +81,17 @@ module Backspin
|
|
|
95
81
|
def load_from_file
|
|
96
82
|
data = YAML.load_file(@path.to_s)
|
|
97
83
|
|
|
98
|
-
unless data.is_a?(Hash) && data["format_version"] ==
|
|
99
|
-
raise RecordFormatError, "Invalid record format: expected format version
|
|
84
|
+
unless data.is_a?(Hash) && data["format_version"] == FORMAT_VERSION
|
|
85
|
+
raise RecordFormatError, "Invalid record format: expected format version #{FORMAT_VERSION}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
commands = data["commands"]
|
|
89
|
+
unless commands.is_a?(Array)
|
|
90
|
+
raise RecordFormatError, "Invalid record format: missing commands"
|
|
100
91
|
end
|
|
101
92
|
|
|
102
93
|
@first_recorded_at = data["first_recorded_at"]
|
|
103
|
-
@commands =
|
|
94
|
+
@commands = commands.map { |command_data| Command.from_h(command_data) }
|
|
104
95
|
rescue Psych::SyntaxError => e
|
|
105
96
|
raise RecordFormatError, "Invalid record format: #{e.message}"
|
|
106
97
|
end
|
|
@@ -34,11 +34,6 @@ module Backspin
|
|
|
34
34
|
@verified
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# @return [Boolean] true if this result is from playback mode
|
|
38
|
-
def playback?
|
|
39
|
-
mode == :playback
|
|
40
|
-
end
|
|
41
|
-
|
|
42
37
|
# @return [String, nil] Human-readable error message if verification failed
|
|
43
38
|
def error_message
|
|
44
39
|
return nil unless verified? == false
|
|
@@ -142,7 +137,6 @@ module Backspin
|
|
|
142
137
|
hash = {
|
|
143
138
|
mode: mode,
|
|
144
139
|
recorded: recorded?,
|
|
145
|
-
playback: playback?,
|
|
146
140
|
stdout: stdout,
|
|
147
141
|
stderr: stderr,
|
|
148
142
|
status: status
|
data/lib/backspin/recorder.rb
CHANGED
|
@@ -1,221 +1,91 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "ostruct"
|
|
5
|
-
require "rspec/mocks"
|
|
6
|
-
require "backspin/command_result"
|
|
3
|
+
require "tempfile"
|
|
7
4
|
require "backspin/command_diff"
|
|
8
5
|
|
|
9
6
|
module Backspin
|
|
10
|
-
# Handles
|
|
7
|
+
# Handles capture-mode recording and verification
|
|
11
8
|
class Recorder
|
|
12
|
-
|
|
13
|
-
SUPPORTED_COMMAND_TYPES = %i[capture3 system].freeze
|
|
14
|
-
|
|
15
|
-
attr_reader :commands, :mode, :record, :matcher, :filter
|
|
9
|
+
attr_reader :mode, :record, :matcher, :filter
|
|
16
10
|
|
|
17
11
|
def initialize(mode: :record, record: nil, matcher: nil, filter: nil)
|
|
18
12
|
@mode = mode
|
|
19
13
|
@record = record
|
|
20
14
|
@matcher = matcher
|
|
21
15
|
@filter = filter
|
|
22
|
-
@commands = []
|
|
23
|
-
@playback_index = 0
|
|
24
|
-
@command_diffs = []
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def setup_recording_stubs(*command_types)
|
|
28
|
-
command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
|
|
29
|
-
command_types.each do |command_type|
|
|
30
|
-
record_call(command_type)
|
|
31
|
-
end
|
|
32
16
|
end
|
|
33
17
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
18
|
+
# Performs capture recording by intercepting all stdout/stderr output
|
|
19
|
+
def perform_capture_recording
|
|
20
|
+
captured_stdout, captured_stderr, output = capture_output { yield }
|
|
21
|
+
|
|
22
|
+
command = Command.new(
|
|
23
|
+
method_class: Backspin::Capturer,
|
|
24
|
+
args: ["<captured block>"],
|
|
25
|
+
stdout: captured_stdout,
|
|
26
|
+
stderr: captured_stderr,
|
|
27
|
+
status: 0,
|
|
28
|
+
recorded_at: Time.now.iso8601
|
|
29
|
+
)
|
|
45
30
|
|
|
46
|
-
|
|
47
|
-
def perform_recording
|
|
48
|
-
result = yield
|
|
31
|
+
record.add_command(command)
|
|
49
32
|
record.save(filter: @filter)
|
|
50
|
-
|
|
33
|
+
|
|
34
|
+
RecordResult.new(output: output, mode: :record, record: record)
|
|
51
35
|
end
|
|
52
36
|
|
|
53
|
-
# Performs verification by
|
|
54
|
-
def
|
|
37
|
+
# Performs capture verification by capturing output and comparing with recorded values
|
|
38
|
+
def perform_capture_verification
|
|
55
39
|
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
|
56
40
|
raise RecordNotFoundError, "No commands found in record #{record.path}" if record.empty?
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@command_diffs = []
|
|
60
|
-
@command_index = 0
|
|
61
|
-
|
|
62
|
-
# Setup verification stubs for capture3
|
|
63
|
-
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
|
64
|
-
recorded_command = record.commands[@command_index]
|
|
65
|
-
|
|
66
|
-
if recorded_command.nil?
|
|
67
|
-
raise RecordNotFoundError, "No more recorded commands, but tried to execute: #{args.inspect}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
stdout, stderr, status = original_method.call(*args)
|
|
71
|
-
|
|
72
|
-
actual_command = Command.new(
|
|
73
|
-
method_class: Open3::Capture3,
|
|
74
|
-
args: args,
|
|
75
|
-
stdout: stdout,
|
|
76
|
-
stderr: stderr,
|
|
77
|
-
status: status.exitstatus
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
@command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: @matcher)
|
|
81
|
-
@command_index += 1
|
|
82
|
-
[stdout, stderr, status]
|
|
41
|
+
if record.commands.size != 1
|
|
42
|
+
raise RecordFormatError, "Invalid record format: expected 1 command for capture, found #{record.commands.size}"
|
|
83
43
|
end
|
|
84
44
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
result = original_method.call(receiver, *args)
|
|
90
|
-
|
|
91
|
-
actual_command = Command.new(
|
|
92
|
-
method_class: ::Kernel::System,
|
|
93
|
-
args: args,
|
|
94
|
-
stdout: "",
|
|
95
|
-
stderr: "",
|
|
96
|
-
status: result ? 0 : 1
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
# Create CommandDiff to track the comparison
|
|
100
|
-
@command_diffs << CommandDiff.new(recorded_command: recorded_command, actual_command: actual_command, matcher: @matcher)
|
|
101
|
-
|
|
102
|
-
@command_index += 1
|
|
103
|
-
result
|
|
45
|
+
recorded_command = record.commands.first
|
|
46
|
+
unless recorded_command.method_class == Backspin::Capturer
|
|
47
|
+
raise RecordFormatError, "Invalid record format: expected Backspin::Capturer for capture"
|
|
104
48
|
end
|
|
105
49
|
|
|
106
|
-
output = yield
|
|
107
|
-
|
|
108
|
-
all_verified = @command_diffs.all?(&:verified?)
|
|
50
|
+
captured_stdout, captured_stderr, output = capture_output { yield }
|
|
109
51
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
52
|
+
actual_command = Command.new(
|
|
53
|
+
method_class: Backspin::Capturer,
|
|
54
|
+
args: ["<captured block>"],
|
|
55
|
+
stdout: captured_stdout,
|
|
56
|
+
stderr: captured_stderr,
|
|
57
|
+
status: 0
|
|
116
58
|
)
|
|
117
|
-
end
|
|
118
59
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# Setup replay stubs
|
|
125
|
-
setup_capture3_replay_stub
|
|
126
|
-
setup_system_replay_stub
|
|
127
|
-
|
|
128
|
-
# Execute block (all commands will be stubbed with recorded values)
|
|
129
|
-
output = yield
|
|
60
|
+
command_diff = CommandDiff.new(
|
|
61
|
+
recorded_command: recorded_command,
|
|
62
|
+
actual_command: actual_command,
|
|
63
|
+
matcher: @matcher
|
|
64
|
+
)
|
|
130
65
|
|
|
131
66
|
RecordResult.new(
|
|
132
67
|
output: output,
|
|
133
|
-
mode: :
|
|
134
|
-
verified:
|
|
135
|
-
record: record
|
|
68
|
+
mode: :verify,
|
|
69
|
+
verified: command_diff.verified?,
|
|
70
|
+
record: record,
|
|
71
|
+
command_diffs: [command_diff]
|
|
136
72
|
)
|
|
137
73
|
end
|
|
138
74
|
|
|
139
|
-
|
|
140
|
-
def perform_capture_recording
|
|
141
|
-
require "tempfile"
|
|
142
|
-
|
|
143
|
-
# Create temporary files for capturing output
|
|
144
|
-
stdout_tempfile = Tempfile.new("backspin_stdout")
|
|
145
|
-
stderr_tempfile = Tempfile.new("backspin_stderr")
|
|
146
|
-
|
|
147
|
-
begin
|
|
148
|
-
# Save original file descriptors
|
|
149
|
-
original_stdout_fd = $stdout.dup
|
|
150
|
-
original_stderr_fd = $stderr.dup
|
|
151
|
-
|
|
152
|
-
# Redirect both Ruby IO and file descriptors
|
|
153
|
-
$stdout.reopen(stdout_tempfile)
|
|
154
|
-
$stderr.reopen(stderr_tempfile)
|
|
155
|
-
|
|
156
|
-
# Execute the block
|
|
157
|
-
result = yield
|
|
158
|
-
|
|
159
|
-
# Flush and read captured output
|
|
160
|
-
$stdout.flush
|
|
161
|
-
$stderr.flush
|
|
162
|
-
stdout_tempfile.rewind
|
|
163
|
-
stderr_tempfile.rewind
|
|
164
|
-
|
|
165
|
-
captured_stdout = stdout_tempfile.read
|
|
166
|
-
captured_stderr = stderr_tempfile.read
|
|
167
|
-
|
|
168
|
-
# Create a single command representing all captured output
|
|
169
|
-
command = Command.new(
|
|
170
|
-
method_class: Backspin::Capturer,
|
|
171
|
-
args: ["<captured block>"],
|
|
172
|
-
stdout: captured_stdout,
|
|
173
|
-
stderr: captured_stderr,
|
|
174
|
-
status: 0,
|
|
175
|
-
recorded_at: Time.now.iso8601
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
record.add_command(command)
|
|
179
|
-
record.save(filter: @filter)
|
|
180
|
-
|
|
181
|
-
RecordResult.new(output: result, mode: :record, record: record)
|
|
182
|
-
ensure
|
|
183
|
-
# Restore original file descriptors
|
|
184
|
-
$stdout.reopen(original_stdout_fd)
|
|
185
|
-
$stderr.reopen(original_stderr_fd)
|
|
186
|
-
original_stdout_fd.close
|
|
187
|
-
original_stderr_fd.close
|
|
188
|
-
|
|
189
|
-
# Clean up temp files
|
|
190
|
-
stdout_tempfile.close!
|
|
191
|
-
stderr_tempfile.close!
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# Performs capture verification by capturing output and comparing with recorded values
|
|
196
|
-
def perform_capture_verification
|
|
197
|
-
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
|
198
|
-
raise RecordNotFoundError, "No commands found in record #{record.path}" if record.empty?
|
|
199
|
-
|
|
200
|
-
require "tempfile"
|
|
75
|
+
private
|
|
201
76
|
|
|
202
|
-
|
|
77
|
+
def capture_output
|
|
203
78
|
stdout_tempfile = Tempfile.new("backspin_stdout")
|
|
204
79
|
stderr_tempfile = Tempfile.new("backspin_stderr")
|
|
80
|
+
original_stdout_fd = $stdout.dup
|
|
81
|
+
original_stderr_fd = $stderr.dup
|
|
205
82
|
|
|
206
83
|
begin
|
|
207
|
-
# Save original file descriptors
|
|
208
|
-
original_stdout_fd = $stdout.dup
|
|
209
|
-
original_stderr_fd = $stderr.dup
|
|
210
|
-
|
|
211
|
-
# Redirect both Ruby IO and file descriptors
|
|
212
84
|
$stdout.reopen(stdout_tempfile)
|
|
213
85
|
$stderr.reopen(stderr_tempfile)
|
|
214
86
|
|
|
215
|
-
# Execute the block
|
|
216
87
|
output = yield
|
|
217
88
|
|
|
218
|
-
# Flush and read captured output
|
|
219
89
|
$stdout.flush
|
|
220
90
|
$stderr.flush
|
|
221
91
|
stdout_tempfile.rewind
|
|
@@ -224,141 +94,16 @@ module Backspin
|
|
|
224
94
|
captured_stdout = stdout_tempfile.read
|
|
225
95
|
captured_stderr = stderr_tempfile.read
|
|
226
96
|
|
|
227
|
-
|
|
228
|
-
recorded_command = record.commands.first
|
|
229
|
-
|
|
230
|
-
# Create actual command from captured output
|
|
231
|
-
actual_command = Command.new(
|
|
232
|
-
method_class: Backspin::Capturer,
|
|
233
|
-
args: ["<captured block>"],
|
|
234
|
-
stdout: captured_stdout,
|
|
235
|
-
stderr: captured_stderr,
|
|
236
|
-
status: 0
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
# Create CommandDiff for comparison
|
|
240
|
-
command_diff = CommandDiff.new(
|
|
241
|
-
recorded_command: recorded_command,
|
|
242
|
-
actual_command: actual_command,
|
|
243
|
-
matcher: @matcher
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
RecordResult.new(
|
|
247
|
-
output: output,
|
|
248
|
-
mode: :verify,
|
|
249
|
-
verified: command_diff.verified?,
|
|
250
|
-
record: record,
|
|
251
|
-
command_diffs: [command_diff]
|
|
252
|
-
)
|
|
97
|
+
[captured_stdout, captured_stderr, output]
|
|
253
98
|
ensure
|
|
254
|
-
# Restore original file descriptors
|
|
255
99
|
$stdout.reopen(original_stdout_fd)
|
|
256
100
|
$stderr.reopen(original_stderr_fd)
|
|
257
101
|
original_stdout_fd.close
|
|
258
102
|
original_stderr_fd.close
|
|
259
103
|
|
|
260
|
-
# Clean up temp files
|
|
261
104
|
stdout_tempfile.close!
|
|
262
105
|
stderr_tempfile.close!
|
|
263
106
|
end
|
|
264
107
|
end
|
|
265
|
-
|
|
266
|
-
# Performs capture playback - executes block normally but could optionally suppress output
|
|
267
|
-
def perform_capture_playback
|
|
268
|
-
raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
|
|
269
|
-
raise RecordNotFoundError, "No commands found in record" if record.empty?
|
|
270
|
-
|
|
271
|
-
# For now, just execute the block normally
|
|
272
|
-
# In the future, we could optionally suppress output or return recorded output
|
|
273
|
-
output = yield
|
|
274
|
-
|
|
275
|
-
RecordResult.new(
|
|
276
|
-
output: output,
|
|
277
|
-
mode: :playback,
|
|
278
|
-
verified: true,
|
|
279
|
-
record: record
|
|
280
|
-
)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
private
|
|
284
|
-
|
|
285
|
-
def setup_capture3_replay_stub
|
|
286
|
-
allow(Open3).to receive(:capture3) do |*_args|
|
|
287
|
-
command = @record.next_command
|
|
288
|
-
|
|
289
|
-
recorded_stdout = command.stdout
|
|
290
|
-
recorded_stderr = command.stderr
|
|
291
|
-
recorded_status = OpenStruct.new(exitstatus: command.status)
|
|
292
|
-
|
|
293
|
-
[recorded_stdout, recorded_stderr, recorded_status]
|
|
294
|
-
rescue NoMoreRecordingsError => e
|
|
295
|
-
raise RecordNotFoundError, e.message
|
|
296
|
-
end
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def setup_system_replay_stub
|
|
300
|
-
allow_any_instance_of(Object).to receive(:system) do |_receiver, *_args|
|
|
301
|
-
command = @record.next_command
|
|
302
|
-
|
|
303
|
-
command.status.zero?
|
|
304
|
-
rescue NoMoreRecordingsError => e
|
|
305
|
-
raise RecordNotFoundError, e.message
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def setup_capture3_call_stub
|
|
310
|
-
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
|
311
|
-
stdout, stderr, status = original_method.call(*args)
|
|
312
|
-
|
|
313
|
-
cmd_args = if args.length == 1 && args.first.is_a?(String)
|
|
314
|
-
args.first.split(" ")
|
|
315
|
-
else
|
|
316
|
-
args
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
command = Command.new(
|
|
320
|
-
method_class: Open3::Capture3,
|
|
321
|
-
args: cmd_args,
|
|
322
|
-
stdout: stdout,
|
|
323
|
-
stderr: stderr,
|
|
324
|
-
status: status.exitstatus,
|
|
325
|
-
recorded_at: Time.now.iso8601
|
|
326
|
-
)
|
|
327
|
-
record.add_command(command)
|
|
328
|
-
|
|
329
|
-
[stdout, stderr, status]
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def setup_system_call_stub
|
|
334
|
-
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
|
335
|
-
result = original_method.call(receiver, *args)
|
|
336
|
-
|
|
337
|
-
# Parse command args based on how system was called
|
|
338
|
-
parsed_args = if args.empty? && receiver.is_a?(String)
|
|
339
|
-
# Single string form - split the command string
|
|
340
|
-
receiver.split(" ")
|
|
341
|
-
else
|
|
342
|
-
# Multi-arg form - already an array
|
|
343
|
-
args
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
stdout = ""
|
|
347
|
-
stderr = ""
|
|
348
|
-
status = result ? 0 : 1
|
|
349
|
-
|
|
350
|
-
command = Command.new(
|
|
351
|
-
method_class: ::Kernel::System,
|
|
352
|
-
args: parsed_args,
|
|
353
|
-
stdout: stdout,
|
|
354
|
-
stderr: stderr,
|
|
355
|
-
status: status,
|
|
356
|
-
recorded_at: Time.now.iso8601
|
|
357
|
-
)
|
|
358
|
-
record.add_command(command)
|
|
359
|
-
|
|
360
|
-
result
|
|
361
|
-
end
|
|
362
|
-
end
|
|
363
108
|
end
|
|
364
109
|
end
|
data/lib/backspin/version.rb
CHANGED