backspin 0.2.1 → 0.4.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.
data/bin/rspec ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
+
13
+ bundle_binstub = File.expand_path("bundle", __dir__)
14
+
15
+ if File.file?(bundle_binstub)
16
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17
+ load(bundle_binstub)
18
+ else
19
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21
+ end
22
+ end
23
+
24
+ require "rubygems"
25
+ require "bundler/setup"
26
+
27
+ load Gem.bin_path("rspec-core", "rspec")
data/bin/setup CHANGED
@@ -3,6 +3,4 @@ set -euo pipefail
3
3
  IFS=$'\n\t'
4
4
  set -vx
5
5
 
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here
6
+ bundle install
@@ -1,31 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_result"
4
+
1
5
  module Backspin
2
6
  class Command
3
- attr_reader :args, :stdout, :stderr, :status, :recorded_at, :method_class
7
+ attr_reader :args, :result, :recorded_at, :method_class
4
8
 
5
- def initialize(method_class:, args:, stdout: nil, stderr: nil, status: nil, recorded_at: nil)
9
+ def initialize(method_class:, args:, stdout: nil, stderr: nil, status: nil, result: nil, recorded_at: nil)
6
10
  @method_class = method_class
7
11
  @args = args
8
- @stdout = stdout
9
- @stderr = stderr
10
- @status = status
11
12
  @recorded_at = recorded_at
13
+
14
+ # Accept either a CommandResult or individual stdout/stderr/status
15
+ @result = result || CommandResult.new(
16
+ stdout: stdout || "",
17
+ stderr: stderr || "",
18
+ status: status || 0
19
+ )
20
+ end
21
+
22
+ def stdout
23
+ @result.stdout
24
+ end
25
+
26
+ def stderr
27
+ @result.stderr
28
+ end
29
+
30
+ def status
31
+ @result.status
12
32
  end
13
33
 
14
34
  # Convert to hash for YAML serialization
15
35
  def to_h(filter: nil)
16
36
  data = {
17
37
  "command_type" => @method_class.name,
18
- "args" => @args,
19
- "stdout" => Backspin.scrub_text(@stdout),
20
- "stderr" => Backspin.scrub_text(@stderr),
21
- "status" => @status,
38
+ "args" => scrub_args(@args),
39
+ "stdout" => Backspin.scrub_text(@result.stdout),
40
+ "stderr" => Backspin.scrub_text(@result.stderr),
41
+ "status" => @result.status,
22
42
  "recorded_at" => @recorded_at
23
43
  }
24
44
 
25
45
  # Apply filter if provided
26
- if filter
27
- data = filter.call(data)
28
- end
46
+ data = filter.call(data) if filter
29
47
 
30
48
  data
31
49
  end
@@ -52,6 +70,25 @@ module Backspin
52
70
  recorded_at: data["recorded_at"]
53
71
  )
54
72
  end
73
+
74
+ private
75
+
76
+ def scrub_args(args)
77
+ return args unless Backspin.configuration.scrub_credentials && args
78
+
79
+ args.map do |arg|
80
+ case arg
81
+ when String
82
+ Backspin.scrub_text(arg)
83
+ when Array
84
+ scrub_args(arg)
85
+ when Hash
86
+ arg.transform_values { |v| v.is_a?(String) ? Backspin.scrub_text(v) : v }
87
+ else
88
+ arg
89
+ end
90
+ end
91
+ end
55
92
  end
56
93
  end
57
94
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backspin
4
+ # Represents the difference between a recorded command and actual execution
5
+ # Handles verification and diff generation for a single command
6
+ class CommandDiff
7
+ attr_reader :recorded_command, :actual_result, :matcher
8
+
9
+ def initialize(recorded_command:, actual_result:, matcher: nil)
10
+ @recorded_command = recorded_command
11
+ @actual_result = actual_result
12
+ @matcher = matcher
13
+ end
14
+
15
+ # @return [Boolean] true if the command output matches
16
+ def verified?
17
+ if matcher
18
+ matcher.call(recorded_command.to_h, actual_result.to_h)
19
+ else
20
+ recorded_command.result == actual_result
21
+ end
22
+ end
23
+
24
+ # @return [String, nil] Human-readable diff if not verified
25
+ def diff
26
+ return nil if verified?
27
+
28
+ parts = []
29
+
30
+ parts << stdout_diff if recorded_command.stdout != actual_result.stdout
31
+
32
+ parts << stderr_diff if recorded_command.stderr != actual_result.stderr
33
+
34
+ if recorded_command.status != actual_result.status
35
+ parts << "Exit status: expected #{recorded_command.status}, got #{actual_result.status}"
36
+ end
37
+
38
+ parts.join("\n\n")
39
+ end
40
+
41
+ # @return [String] Single line summary for error messages
42
+ def summary
43
+ if verified?
44
+ "✓ Command verified"
45
+ else
46
+ "✗ Command failed: #{failure_reason}"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def failure_reason
53
+ reasons = []
54
+ reasons << "stdout differs" if recorded_command.stdout != actual_result.stdout
55
+ reasons << "stderr differs" if recorded_command.stderr != actual_result.stderr
56
+ reasons << "exit status differs" if recorded_command.status != actual_result.status
57
+ reasons.join(", ")
58
+ end
59
+
60
+ def stdout_diff
61
+ "stdout diff:\n#{generate_line_diff(recorded_command.stdout, actual_result.stdout)}"
62
+ end
63
+
64
+ def stderr_diff
65
+ "stderr diff:\n#{generate_line_diff(recorded_command.stderr, actual_result.stderr)}"
66
+ end
67
+
68
+ def generate_line_diff(expected, actual)
69
+ expected_lines = (expected || "").lines
70
+ actual_lines = (actual || "").lines
71
+
72
+ diff_lines = []
73
+ max_lines = [expected_lines.length, actual_lines.length].max
74
+
75
+ max_lines.times do |i|
76
+ expected_line = expected_lines[i]
77
+ actual_line = actual_lines[i]
78
+
79
+ if expected_line != actual_line
80
+ diff_lines << "-#{expected_line.chomp}" if expected_line
81
+ diff_lines << "+#{actual_line.chomp}" if actual_line
82
+ end
83
+ end
84
+
85
+ diff_lines.join("\n")
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backspin
4
+ # Represents the result of executing a command
5
+ # Stores stdout, stderr, and exit status
6
+ class CommandResult
7
+ attr_reader :stdout, :stderr, :status
8
+
9
+ def initialize(stdout:, stderr:, status:)
10
+ @stdout = stdout
11
+ @stderr = stderr
12
+ @status = normalize_status(status)
13
+ end
14
+
15
+ # @return [Boolean] true if the command succeeded (exit status 0)
16
+ def success?
17
+ status.zero?
18
+ end
19
+
20
+ # @return [Boolean] true if the command failed (non-zero exit status)
21
+ def failure?
22
+ !success?
23
+ end
24
+
25
+ # @return [Hash] Hash representation of the result
26
+ def to_h
27
+ {
28
+ "stdout" => stdout,
29
+ "stderr" => stderr,
30
+ "status" => status
31
+ }
32
+ end
33
+
34
+ # Compare two results for equality
35
+ def ==(other)
36
+ return false unless other.is_a?(CommandResult)
37
+
38
+ stdout == other.stdout &&
39
+ stderr == other.stderr &&
40
+ status == other.status
41
+ end
42
+
43
+ def inspect
44
+ "#<Backspin::CommandResult status=#{status} stdout=#{stdout.inspect.truncate(50)} stderr=#{stderr.inspect.truncate(50)}>"
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_status(status)
50
+ case status
51
+ when Integer
52
+ status
53
+ when Process::Status
54
+ status.exitstatus
55
+ else
56
+ status.respond_to?(:exitstatus) ? status.exitstatus : status.to_i
57
+ end
58
+ end
59
+ end
60
+ end
@@ -4,6 +4,7 @@ module Backspin
4
4
  class NoMoreRecordingsError < StandardError; end
5
5
 
6
6
  class Record
7
+ FORMAT_VERSION = "2.0"
7
8
  attr_reader :path, :commands, :first_recorded_at
8
9
 
9
10
  def initialize(path)
@@ -22,10 +23,9 @@ module Backspin
22
23
 
23
24
  def save(filter: nil)
24
25
  FileUtils.mkdir_p(File.dirname(@path))
25
- # New format: top-level metadata with commands array
26
26
  record_data = {
27
27
  "first_recorded_at" => @first_recorded_at,
28
- "format_version" => "2.0",
28
+ "format_version" => FORMAT_VERSION,
29
29
  "commands" => @commands.map { |cmd| cmd.to_h(filter: filter) }
30
30
  }
31
31
  File.write(@path, record_data.to_yaml)
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backspin
4
+ # Result object for all Backspin record operations
5
+ # Provides a consistent interface whether recording, verifying, or playing back
6
+ class RecordResult
7
+ attr_reader :output, :record_path, :commands, :mode, :command_diffs
8
+
9
+ def initialize(output:, mode:, record_path:, commands:, verified: nil, command_diffs: nil)
10
+ @output = output
11
+ @mode = mode
12
+ @record_path = record_path
13
+ @commands = commands
14
+ @verified = verified
15
+ @command_diffs = command_diffs || []
16
+ end
17
+
18
+ # @return [Boolean] true if this result is from recording
19
+ def recorded?
20
+ mode == :record
21
+ end
22
+
23
+ # @return [Boolean, nil] true/false for verification results, nil for recording
24
+ def verified?
25
+ @verified
26
+ end
27
+
28
+ # @return [Boolean] true if this result is from playback mode
29
+ def playback?
30
+ mode == :playback
31
+ end
32
+
33
+ # @return [String, nil] Human-readable error message if verification failed
34
+ def error_message
35
+ return nil unless verified? == false
36
+ return "No commands to verify" if command_diffs.empty?
37
+
38
+ failed_diffs = command_diffs.reject(&:verified?)
39
+ return "All commands verified" if failed_diffs.empty?
40
+
41
+ msg = "Output verification failed for #{failed_diffs.size} command(s):\n\n"
42
+
43
+ command_diffs.each_with_index do |diff, idx|
44
+ next if diff.verified?
45
+
46
+ msg += "Command #{idx + 1}: #{diff.summary}\n"
47
+ msg += diff.diff
48
+ msg += "\n\n" if idx < command_diffs.size - 1
49
+ end
50
+
51
+ msg
52
+ end
53
+
54
+ # @return [String, nil] Combined diff from all failed commands
55
+ def diff
56
+ return nil if command_diffs.empty?
57
+
58
+ failed_diffs = command_diffs.reject(&:verified?)
59
+ return nil if failed_diffs.empty?
60
+
61
+ diff_parts = []
62
+ command_diffs.each_with_index do |cmd_diff, idx|
63
+ diff_parts << "Command #{idx + 1}:\n#{cmd_diff.diff}" unless cmd_diff.verified?
64
+ end
65
+
66
+ diff_parts.join("\n\n")
67
+ end
68
+
69
+ # Convenience accessors for command output
70
+ # For single command (common case), these provide direct access
71
+ # For multiple commands, use all_stdout, all_stderr, etc.
72
+
73
+ # @return [String, nil] stdout from the first command
74
+ def stdout
75
+ commands.first&.result&.stdout
76
+ end
77
+
78
+ # @return [String, nil] stderr from the first command
79
+ def stderr
80
+ commands.first&.result&.stderr
81
+ end
82
+
83
+ # @return [Integer, nil] exit status from the first command
84
+ def status
85
+ commands.first&.result&.status
86
+ end
87
+
88
+ # Multiple command accessors
89
+
90
+ # @return [Array<String>] stdout from all commands
91
+ def all_stdout
92
+ commands.map { |cmd| cmd.result.stdout }
93
+ end
94
+
95
+ # @return [Array<String>] stderr from all commands
96
+ def all_stderr
97
+ commands.map { |cmd| cmd.result.stderr }
98
+ end
99
+
100
+ # @return [Array<Integer>] exit status from all commands
101
+ def all_status
102
+ commands.map { |cmd| cmd.result.status }
103
+ end
104
+
105
+ # @return [Boolean] true if this result contains multiple commands
106
+ def multiple_commands?
107
+ commands.size > 1
108
+ end
109
+
110
+ # @return [Boolean] true if all commands succeeded (exit status 0)
111
+ def success?
112
+ if multiple_commands?
113
+ # Check all commands - if any command has non-zero status, we're not successful
114
+ commands.all? { |cmd| cmd.result.status.zero? }
115
+ else
116
+ status&.zero? || false
117
+ end
118
+ end
119
+
120
+ # @return [Boolean] true if any command failed (non-zero exit status)
121
+ def failure?
122
+ !success?
123
+ end
124
+
125
+ # @return [Hash] Summary of the result for debugging
126
+ def to_h
127
+ hash = {
128
+ mode: mode,
129
+ recorded: recorded?,
130
+ playback: playback?,
131
+ stdout: stdout,
132
+ stderr: stderr,
133
+ status: status,
134
+ record_path: record_path.to_s
135
+ }
136
+
137
+ # Only include verified if it's not nil
138
+ hash[:verified] = verified? unless verified?.nil?
139
+
140
+ # Only include diff if present
141
+ hash[:diff] = diff if diff
142
+
143
+ # Include number of failed commands if in verify mode
144
+ hash[:failed_commands] = command_diffs.count { |d| !d.verified? } if mode == :verify && command_diffs.any?
145
+
146
+ hash
147
+ end
148
+
149
+ def inspect
150
+ "#<Backspin::RecordResult mode=#{mode} verified=#{verified?.inspect} status=#{status}>"
151
+ end
152
+ end
153
+ end
@@ -6,6 +6,7 @@ module Backspin
6
6
  # Handles stubbing and recording of command executions
7
7
  class Recorder
8
8
  include RSpec::Mocks::ExampleMethods
9
+ SUPPORTED_COMMAND_TYPES = [:capture3, :system]
9
10
 
10
11
  attr_reader :commands, :verification_data, :mode, :record
11
12
 
@@ -17,7 +18,7 @@ module Backspin
17
18
  end
18
19
 
19
20
  def record_calls(*command_types)
20
- command_types = [:capture3, :system] if command_types.empty?
21
+ command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
21
22
 
22
23
  command_types.each do |command_type|
23
24
  record_call(command_type)
@@ -31,7 +32,7 @@ module Backspin
31
32
  when :capture3
32
33
  setup_capture3_call_stub
33
34
  else
34
- raise ArgumentError, "Unknown command type: #{command_type}"
35
+ raise ArgumentError, "Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
35
36
  end
36
37
  end
37
38
 
@@ -66,7 +67,6 @@ module Backspin
66
67
  # For system calls, we only track the exit status
67
68
  @verification_data["stdout"] = ""
68
69
  @verification_data["stderr"] = ""
69
- # Derive exit status from result: true = 0, false = non-zero
70
70
  @verification_data["status"] = result ? 0 : 1
71
71
 
72
72
  result
@@ -107,12 +107,10 @@ module Backspin
107
107
  allow_any_instance_of(Object).to receive(:system) do |receiver, *args|
108
108
  command = @record.next_command
109
109
 
110
- # Make sure this is a system command
111
110
  unless command.method_class == ::Kernel::System
112
111
  raise RecordNotFoundError, "Expected Kernel::System command but got #{command.method_class.name}"
113
112
  end
114
113
 
115
- # Return true if exit status was 0, false otherwise
116
114
  command.status == 0
117
115
  rescue NoMoreRecordingsError => e
118
116
  raise RecordNotFoundError, e.message
@@ -121,17 +119,14 @@ module Backspin
121
119
 
122
120
  def setup_capture3_call_stub
123
121
  allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
124
- # Execute the real command
125
122
  stdout, stderr, status = original_method.call(*args)
126
123
 
127
- # Parse command args
128
124
  cmd_args = if args.length == 1 && args.first.is_a?(String)
129
125
  args.first.split(" ")
130
126
  else
131
127
  args
132
128
  end
133
129
 
134
- # Create command with interaction data
135
130
  command = Command.new(
136
131
  method_class: Open3::Capture3,
137
132
  args: cmd_args,
@@ -142,17 +137,12 @@ module Backspin
142
137
  )
143
138
  @commands << command
144
139
 
145
- # Store output for later access (last one wins)
146
- Backspin.last_output = stdout
147
-
148
- # Return original result
149
140
  [stdout, stderr, status]
150
141
  end
151
142
  end
152
143
 
153
144
  def setup_system_call_stub
154
145
  allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
155
- # Execute the real system call
156
146
  result = original_method.call(receiver, *args)
157
147
 
158
148
  # Parse command args based on how system was called
@@ -164,14 +154,9 @@ module Backspin
164
154
  args
165
155
  end
166
156
 
167
- # For system calls, stdout and stderr are not captured
168
- # The caller of system() doesn't have access to them
169
- stdout = ""
170
- stderr = ""
171
- # Derive exit status from result: true = 0, false = non-zero, nil = command failed
157
+ stdout, stderr = "", ""
172
158
  status = result ? 0 : 1
173
159
 
174
- # Create command with interaction data
175
160
  command = Command.new(
176
161
  method_class: ::Kernel::System,
177
162
  args: parsed_args,
@@ -182,10 +167,6 @@ module Backspin
182
167
  )
183
168
  @commands << command
184
169
 
185
- # Store output for later access (for consistency with capture3)
186
- Backspin.last_output = stdout
187
-
188
- # Return the original result (true/false/nil)
189
170
  result
190
171
  end
191
172
  end
@@ -1,3 +1,3 @@
1
1
  module Backspin
2
- VERSION = "0.2.1"
2
+ VERSION = "0.4.0"
3
3
  end