backspin 0.3.0 → 0.4.1

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +3 -1
  3. data/CHANGELOG.md +4 -0
  4. data/CLAUDE.md +6 -6
  5. data/CONTRIBUTING.md +3 -5
  6. data/Gemfile +1 -1
  7. data/Gemfile.lock +2 -2
  8. data/README.md +113 -34
  9. data/backspin.gemspec +2 -2
  10. data/bin/rake +27 -0
  11. data/bin/rspec +27 -0
  12. data/fixtures/backspin/all_mode_filter.yml +14 -0
  13. data/fixtures/backspin/credential_filter.yml +18 -0
  14. data/fixtures/backspin/echo_hello.yml +14 -0
  15. data/fixtures/backspin/echo_verify.yml +14 -0
  16. data/fixtures/backspin/episodes_filter.yml +26 -0
  17. data/fixtures/backspin/failure_test.yml +14 -0
  18. data/fixtures/backspin/full_data_filter.yml +17 -0
  19. data/fixtures/backspin/mixed_calls.yml +24 -0
  20. data/fixtures/backspin/multi_command.yml +34 -0
  21. data/fixtures/backspin/multi_command_filter.yml +26 -0
  22. data/fixtures/backspin/multi_field_filter.yml +13 -0
  23. data/fixtures/backspin/multi_system.yml +20 -0
  24. data/fixtures/backspin/nil_filter.yml +14 -0
  25. data/fixtures/backspin/none_mode_test.yml +14 -0
  26. data/fixtures/backspin/path_test.yml +17 -0
  27. data/fixtures/backspin/playback_system.yml +12 -0
  28. data/fixtures/backspin/playback_test.yml +14 -0
  29. data/fixtures/backspin/stderr_test.yml +19 -0
  30. data/fixtures/backspin/system_echo.yml +12 -0
  31. data/fixtures/backspin/system_false.yml +18 -0
  32. data/fixtures/backspin/timestamp_test.yml +18 -0
  33. data/fixtures/backspin/use_record_filter.yml +15 -0
  34. data/fixtures/backspin/verify_system.yml +12 -0
  35. data/fixtures/backspin/verify_system_diff.yml +11 -0
  36. data/fixtures/backspin/version_test.yml +14 -0
  37. data/lib/backspin/command.rb +33 -14
  38. data/lib/backspin/command_diff.rb +88 -0
  39. data/lib/backspin/command_result.rb +60 -0
  40. data/lib/backspin/record.rb +2 -2
  41. data/lib/backspin/record_result.rb +153 -0
  42. data/lib/backspin/recorder.rb +4 -23
  43. data/lib/backspin/version.rb +1 -1
  44. data/lib/backspin.rb +169 -287
  45. metadata +33 -4
@@ -0,0 +1,14 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - "'test'"
9
+ stdout: 'test
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,14 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - "'original'"
9
+ stdout: 'original
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,17 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - "'File"
9
+ - saved
10
+ - to
11
+ - "/Users/testuser/project/output.txt'"
12
+ stdout: 'File saved to PROJECT_ROOT/output.txt
13
+
14
+ '
15
+ stderr: ''
16
+ status: 0
17
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,12 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Kernel::System
6
+ args:
7
+ - echo
8
+ - recorded
9
+ stdout: ''
10
+ stderr: ''
11
+ status: 0
12
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,14 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - original
9
+ stdout: 'original
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,19 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - sh
8
+ - "-c"
9
+ - "'echo"
10
+ - error
11
+ - ">&2;"
12
+ - exit
13
+ - 1'
14
+ stdout: ''
15
+ stderr: 'error
16
+
17
+ '
18
+ status: 1
19
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,12 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Kernel::System
6
+ args:
7
+ - echo
8
+ - hello
9
+ stdout: ''
10
+ stderr: ''
11
+ status: 0
12
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,18 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Kernel::System
6
+ args:
7
+ - 'true'
8
+ stdout: ''
9
+ stderr: ''
10
+ status: 0
11
+ recorded_at: '2025-05-01T12:00:00Z'
12
+ - command_type: Kernel::System
13
+ args:
14
+ - 'false'
15
+ stdout: ''
16
+ stderr: ''
17
+ status: 1
18
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,18 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - "'Test"
9
+ - run
10
+ - at
11
+ - '2024-01-15'
12
+ - 10:30:45'
13
+ stdout: 'Test run at TIMESTAMP
14
+
15
+ '
16
+ stderr: ''
17
+ status: 0
18
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,15 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - "'hello"
9
+ - world'
10
+ stdout: 'HELLO WORLD
11
+
12
+ '
13
+ stderr: ''
14
+ status: 0
15
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,12 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Kernel::System
6
+ args:
7
+ - echo
8
+ - hello
9
+ stdout: ''
10
+ stderr: ''
11
+ status: 0
12
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,11 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Kernel::System
6
+ args:
7
+ - 'true'
8
+ stdout: ''
9
+ stderr: ''
10
+ status: 0
11
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,14 @@
1
+ ---
2
+ first_recorded_at: '2025-05-01T12:00:00Z'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - ruby
8
+ - "--version"
9
+ stdout: 'ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [arm64-darwin24]
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -1,14 +1,34 @@
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
@@ -16,16 +36,14 @@ module Backspin
16
36
  data = {
17
37
  "command_type" => @method_class.name,
18
38
  "args" => scrub_args(@args),
19
- "stdout" => Backspin.scrub_text(@stdout),
20
- "stderr" => Backspin.scrub_text(@stderr),
21
- "status" => @status,
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
@@ -59,11 +77,12 @@ module Backspin
59
77
  return args unless Backspin.configuration.scrub_credentials && args
60
78
 
61
79
  args.map do |arg|
62
- if arg.is_a?(String)
80
+ case arg
81
+ when String
63
82
  Backspin.scrub_text(arg)
64
- elsif arg.is_a?(Array)
83
+ when Array
65
84
  scrub_args(arg)
66
- elsif arg.is_a?(Hash)
85
+ when Hash
67
86
  arg.transform_values { |v| v.is_a?(String) ? Backspin.scrub_text(v) : v }
68
87
  else
69
88
  arg
@@ -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