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.
@@ -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 = "2.0"
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"] == "2.0"
99
- raise RecordFormatError, "Invalid record format: expected format version 2.0"
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 = data["commands"].map { |command_data| Command.from_h(command_data) }
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
@@ -1,221 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
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 stubbing and recording of command executions
7
+ # Handles capture-mode recording and verification
11
8
  class Recorder
12
- include RSpec::Mocks::ExampleMethods
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
- def record_call(command_type)
35
- case command_type
36
- when :system
37
- setup_system_call_stub
38
- when :capture3
39
- setup_capture3_call_stub
40
- else
41
- raise ArgumentError,
42
- "Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
43
- end
44
- end
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
- # Records registered commands, adds them to the record, saves the record, and returns the overall RecordResult
47
- def perform_recording
48
- result = yield
31
+ record.add_command(command)
49
32
  record.save(filter: @filter)
50
- RecordResult.new(output: result, mode: :record, record: record)
33
+
34
+ RecordResult.new(output: output, mode: :record, record: record)
51
35
  end
52
36
 
53
- # Performs verification by executing commands and comparing with recorded values
54
- def perform_verification
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
- # Initialize tracking variables
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
- # Setup verification stubs for system
86
- allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
87
- recorded_command = record.commands[@command_index]
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
- RecordResult.new(
111
- output: output,
112
- mode: :verify,
113
- verified: all_verified,
114
- record: record,
115
- command_diffs: @command_diffs
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
- # Performs playback by returning recorded values without executing actual commands
120
- def perform_playback
121
- raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
122
- raise RecordNotFoundError, "No commands found in record" if record.empty?
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: :playback,
134
- verified: true, # Always true for playback
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
- # Performs capture recording by intercepting all stdout/stderr output
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
- # Create temporary files for capturing output
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
- # Get the recorded command (should be only one for capture)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Backspin
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end