backspin 0.4.1 → 0.4.5

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gem_release.yml +13 -0
  3. data/CHANGELOG.md +8 -7
  4. data/CLAUDE.md +5 -1
  5. data/Gemfile +3 -1
  6. data/Gemfile.lock +3 -1
  7. data/MATCH_ON_USAGE.md +110 -0
  8. data/Rakefile +5 -1
  9. data/backspin.gemspec +6 -3
  10. data/examples/match_on_example.rb +116 -0
  11. data/fixtures/backspin/all_and_fields.yml +15 -0
  12. data/fixtures/backspin/all_bypass_equality.yml +14 -0
  13. data/fixtures/backspin/all_checks_equality.yml +17 -0
  14. data/fixtures/backspin/all_for_logging.yml +13 -0
  15. data/fixtures/backspin/all_matcher_basic.yml +14 -0
  16. data/fixtures/backspin/all_matcher_custom.yml +17 -0
  17. data/fixtures/backspin/all_matcher_demo.yml +14 -0
  18. data/fixtures/backspin/all_matcher_test.yml +14 -0
  19. data/fixtures/backspin/all_no_short_circuit.yml +14 -0
  20. data/fixtures/backspin/all_pass_field_fail.yml +14 -0
  21. data/fixtures/backspin/all_short_circuit.yml +14 -0
  22. data/fixtures/backspin/all_skips_equality.yml +17 -0
  23. data/fixtures/backspin/all_with_equality.yml +17 -0
  24. data/fixtures/backspin/all_with_fields.yml +17 -0
  25. data/fixtures/backspin/combined_fail_demo.yml +14 -0
  26. data/fixtures/backspin/combined_matcher_demo.yml +14 -0
  27. data/fixtures/backspin/field_matcher_demo.yml +17 -0
  28. data/fixtures/backspin/field_matcher_values.yml +14 -0
  29. data/fixtures/backspin/key_confusion_test.yml +14 -0
  30. data/fixtures/backspin/match_on_any_fail.yml +21 -0
  31. data/fixtures/backspin/match_on_bad_format.yml +14 -0
  32. data/fixtures/backspin/match_on_fail.yml +15 -0
  33. data/fixtures/backspin/match_on_invalid.yml +14 -0
  34. data/fixtures/backspin/match_on_multiple.yml +28 -0
  35. data/fixtures/backspin/match_on_nil.yml +14 -0
  36. data/fixtures/backspin/match_on_other_fields.yml +23 -0
  37. data/fixtures/backspin/match_on_run_bang.yml +16 -0
  38. data/fixtures/backspin/match_on_run_bang_fail.yml +15 -0
  39. data/fixtures/backspin/match_on_single.yml +17 -0
  40. data/fixtures/backspin/string_symbol_test.yml +14 -0
  41. data/lib/backspin/command.rb +1 -5
  42. data/lib/backspin/command_diff.rb +98 -16
  43. data/lib/backspin/command_result.rb +2 -4
  44. data/lib/backspin/record.rb +31 -10
  45. data/lib/backspin/record_result.rb +20 -14
  46. data/lib/backspin/recorder.rb +100 -55
  47. data/lib/backspin/version.rb +3 -1
  48. data/lib/backspin.rb +34 -173
  49. data/release.rake +104 -0
  50. data/script/lint +6 -0
  51. metadata +54 -5
@@ -0,0 +1,14 @@
1
+ ---
2
+ first_recorded_at: '2025-06-10T11:00:27-05:00'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - first output
9
+ stdout: 'first output
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-06-10T11:00:27-05:00'
@@ -0,0 +1,21 @@
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
+ - good;
11
+ - echo
12
+ - bad
13
+ - ">&2'"
14
+ stdout: 'good
15
+
16
+ '
17
+ stderr: 'bad
18
+
19
+ '
20
+ status: 0
21
+ 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
+ - test
9
+ stdout: 'test
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ 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
+ - "'Version:"
9
+ - 1.2.3'
10
+ stdout: 'Version: 1.2.3
11
+
12
+ '
13
+ stderr: ''
14
+ status: 0
15
+ 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
+ - test
9
+ stdout: 'test
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,28 @@
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
+ - "\"User:"
11
+ - alice@example.com";
12
+ - echo
13
+ - "\"Error:"
14
+ - Connection
15
+ - timeout
16
+ - at
17
+ - 10:30:00"
18
+ - ">&2;"
19
+ - exit
20
+ - 1'
21
+ stdout: 'User: alice@example.com
22
+
23
+ '
24
+ stderr: 'Error: Connection timeout at 10:30:00
25
+
26
+ '
27
+ status: 1
28
+ 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
+ - test
9
+ stdout: 'test
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,23 @@
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
+ - output;
11
+ - echo
12
+ - error
13
+ - ">&2;"
14
+ - exit
15
+ - 1'
16
+ stdout: 'output
17
+
18
+ '
19
+ stderr: 'error
20
+
21
+ '
22
+ status: 1
23
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,16 @@
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
+ - "'Process"
9
+ - 'ID:'
10
+ - 12345'
11
+ stdout: 'Process ID: 12345
12
+
13
+ '
14
+ stderr: ''
15
+ status: 0
16
+ 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
+ - "'Status:"
9
+ - OK'
10
+ stdout: 'Status: OK
11
+
12
+ '
13
+ stderr: ''
14
+ status: 0
15
+ 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
+ - "'Current"
9
+ - 'time:'
10
+ - '2025-01-06'
11
+ - 10:00:00'
12
+ stdout: 'Current time: 2025-01-06 10:00:00
13
+
14
+ '
15
+ stderr: ''
16
+ status: 0
17
+ recorded_at: '2025-05-01T12:00:00Z'
@@ -0,0 +1,14 @@
1
+ ---
2
+ first_recorded_at: '2025-06-10T11:00:57-05:00'
3
+ format_version: '2.0'
4
+ commands:
5
+ - command_type: Open3::Capture3
6
+ args:
7
+ - echo
8
+ - test output
9
+ stdout: 'test output
10
+
11
+ '
12
+ stderr: ''
13
+ status: 0
14
+ recorded_at: '2025-06-10T11:00:57-05:00'
@@ -12,11 +12,7 @@ module Backspin
12
12
  @recorded_at = recorded_at
13
13
 
14
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
- )
15
+ @result = result || CommandResult.new(stdout: stdout || "", stderr: stderr || "", status: status || 0)
20
16
  end
21
17
 
22
18
  def stdout
@@ -4,20 +4,27 @@ module Backspin
4
4
  # Represents the difference between a recorded command and actual execution
5
5
  # Handles verification and diff generation for a single command
6
6
  class CommandDiff
7
- attr_reader :recorded_command, :actual_result, :matcher
7
+ attr_reader :recorded_command, :actual_command, :matcher
8
8
 
9
- def initialize(recorded_command:, actual_result:, matcher: nil)
9
+ def initialize(recorded_command:, actual_command:, matcher: nil)
10
10
  @recorded_command = recorded_command
11
- @actual_result = actual_result
12
- @matcher = matcher
11
+ @actual_command = actual_command
12
+ @matcher = normalize_matcher(matcher)
13
13
  end
14
14
 
15
15
  # @return [Boolean] true if the command output matches
16
16
  def verified?
17
- if matcher
18
- matcher.call(recorded_command.to_h, actual_result.to_h)
17
+ # First check if method classes match
18
+ return false unless method_classes_match?
19
+
20
+ if matcher.nil?
21
+ recorded_command.result == actual_command.result
22
+ elsif matcher.is_a?(Proc) # basic all matcher: lambda { |recorded, actual| ...}
23
+ matcher.call(recorded_command.to_h, actual_command.to_h)
24
+ elsif matcher.is_a?(Hash) # matcher: {all: lambda { |recorded, actual| ...}, stdout: lambda { |recorded, actual| ...}}
25
+ verify_with_hash_matcher
19
26
  else
20
- recorded_command.result == actual_result
27
+ raise ArgumentError, "Invalid matcher type: #{matcher.class}"
21
28
  end
22
29
  end
23
30
 
@@ -27,12 +34,17 @@ module Backspin
27
34
 
28
35
  parts = []
29
36
 
30
- parts << stdout_diff if recorded_command.stdout != actual_result.stdout
37
+ # Check method class mismatch first
38
+ unless method_classes_match?
39
+ parts << "Command type mismatch: expected #{recorded_command.method_class.name}, got #{actual_command.method_class.name}"
40
+ end
41
+
42
+ parts << stdout_diff if recorded_command.stdout != actual_command.stdout
31
43
 
32
- parts << stderr_diff if recorded_command.stderr != actual_result.stderr
44
+ parts << stderr_diff if recorded_command.stderr != actual_command.stderr
33
45
 
34
- if recorded_command.status != actual_result.status
35
- parts << "Exit status: expected #{recorded_command.status}, got #{actual_result.status}"
46
+ if recorded_command.status != actual_command.status
47
+ parts << "Exit status: expected #{recorded_command.status}, got #{actual_command.status}"
36
48
  end
37
49
 
38
50
  parts.join("\n\n")
@@ -49,20 +61,90 @@ module Backspin
49
61
 
50
62
  private
51
63
 
64
+ def method_classes_match?
65
+ recorded_command.method_class == actual_command.method_class
66
+ end
67
+
68
+ def normalize_matcher(matcher)
69
+ return nil if matcher.nil?
70
+ return matcher if matcher.is_a?(Proc)
71
+
72
+ raise ArgumentError, "Matcher must be a Proc or Hash, got #{matcher.class}" unless matcher.is_a?(Hash)
73
+
74
+ # Validate hash keys and values
75
+ matcher.each do |key, value|
76
+ unless %i[all stdout stderr status].include?(key)
77
+ raise ArgumentError, "Invalid matcher key: #{key}. Must be one of: :all, :stdout, :stderr, :status"
78
+ end
79
+ raise ArgumentError, "Matcher for #{key} must be callable (Proc/Lambda)" unless value.respond_to?(:call)
80
+ end
81
+ matcher
82
+ end
83
+
84
+ def verify_with_hash_matcher
85
+ recorded_hash = recorded_command.to_h
86
+ actual_hash = actual_command.to_h
87
+
88
+ all_passed = matcher[:all].nil? || matcher[:all].call(recorded_hash, actual_hash)
89
+
90
+ fields_passed = %w[stdout stderr status].all? do |field|
91
+ field_sym = field.to_sym
92
+ if matcher[field_sym]
93
+ matcher[field_sym].call(recorded_hash[field], actual_hash[field])
94
+ else
95
+ recorded_hash[field] == actual_hash[field]
96
+ end
97
+ end
98
+
99
+ all_passed && fields_passed
100
+ end
101
+
52
102
  def failure_reason
53
103
  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
104
+
105
+ # Check method class first
106
+ unless method_classes_match?
107
+ reasons << "command type mismatch"
108
+ return reasons.join(", ")
109
+ end
110
+
111
+ if matcher.nil?
112
+ reasons << "stdout differs" if recorded_command.stdout != actual_command.stdout
113
+ reasons << "stderr differs" if recorded_command.stderr != actual_command.stderr
114
+ reasons << "exit status differs" if recorded_command.status != actual_command.status
115
+ elsif matcher.is_a?(Hash)
116
+ recorded_hash = recorded_command.to_h
117
+ actual_hash = actual_command.to_h
118
+
119
+ # Check :all matcher first
120
+ reasons << ":all matcher failed" if matcher[:all] && !matcher[:all].call(recorded_hash, actual_hash)
121
+
122
+ # Check field-specific matchers
123
+ %w[stdout stderr status].each do |field|
124
+ field_sym = field.to_sym
125
+ if matcher[field_sym]
126
+ unless matcher[field_sym].call(recorded_hash[field], actual_hash[field])
127
+ reasons << "#{field} custom matcher failed"
128
+ end
129
+ elsif recorded_hash[field] != actual_hash[field]
130
+ # Always check exact equality for fields without matchers
131
+ reasons << "#{field} differs"
132
+ end
133
+ end
134
+ else
135
+ # Proc matcher
136
+ reasons << "custom matcher failed"
137
+ end
138
+
57
139
  reasons.join(", ")
58
140
  end
59
141
 
60
142
  def stdout_diff
61
- "stdout diff:\n#{generate_line_diff(recorded_command.stdout, actual_result.stdout)}"
143
+ "stdout diff:\n#{generate_line_diff(recorded_command.stdout, actual_command.stdout)}"
62
144
  end
63
145
 
64
146
  def stderr_diff
65
- "stderr diff:\n#{generate_line_diff(recorded_command.stderr, actual_result.stderr)}"
147
+ "stderr diff:\n#{generate_line_diff(recorded_command.stderr, actual_command.stderr)}"
66
148
  end
67
149
 
68
150
  def generate_line_diff(expected, actual)
@@ -35,13 +35,11 @@ module Backspin
35
35
  def ==(other)
36
36
  return false unless other.is_a?(CommandResult)
37
37
 
38
- stdout == other.stdout &&
39
- stderr == other.stderr &&
40
- status == other.status
38
+ stdout == other.stdout && stderr == other.stderr && status == other.status
41
39
  end
42
40
 
43
41
  def inspect
44
- "#<Backspin::CommandResult status=#{status} stdout=#{stdout.inspect.truncate(50)} stderr=#{stderr.inspect.truncate(50)}>"
42
+ "#<Backspin::CommandResult status=#{status} stdout=#{stdout} stderr=#{stderr}>"
45
43
  end
46
44
 
47
45
  private
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Backspin
2
4
  class RecordFormatError < StandardError; end
3
5
 
@@ -7,12 +9,37 @@ module Backspin
7
9
  FORMAT_VERSION = "2.0"
8
10
  attr_reader :path, :commands, :first_recorded_at
9
11
 
12
+ def self.load_or_create(path)
13
+ record = new(path)
14
+ record.load_from_file if File.exist?(path)
15
+ record
16
+ end
17
+
18
+ def self.load_from_file(path)
19
+ raise Backspin::RecordNotFoundError unless File.exist?(path)
20
+
21
+ record = new(path)
22
+ record.load_from_file
23
+ record
24
+ end
25
+
26
+ def self.build_record_path(name)
27
+ backspin_dir = Backspin.configuration.backspin_dir
28
+ backspin_dir.mkpath
29
+
30
+ File.join(backspin_dir, "#{name}.yml")
31
+ end
32
+
33
+ def self.create(name)
34
+ path = build_record_path(name)
35
+ new(path)
36
+ end
37
+
10
38
  def initialize(path)
11
39
  @path = path
12
40
  @commands = []
13
41
  @first_recorded_at = nil
14
42
  @playback_index = 0
15
- load_from_file if File.exist?(@path)
16
43
  end
17
44
 
18
45
  def add_command(command)
@@ -35,7 +62,7 @@ module Backspin
35
62
  @commands = []
36
63
  @playback_index = 0
37
64
  load_from_file if File.exist?(@path)
38
- @playback_index = 0 # Reset again after loading to ensure it's at 0
65
+ @playback_index = 0 # Reset again after loading to ensure it's at 0
39
66
  end
40
67
 
41
68
  def exists?
@@ -51,9 +78,7 @@ module Backspin
51
78
  end
52
79
 
53
80
  def next_command
54
- if @playback_index >= @commands.size
55
- raise NoMoreRecordingsError, "No more recordings available for replay"
56
- end
81
+ raise NoMoreRecordingsError, "No more recordings available for replay" if @playback_index >= @commands.size
57
82
 
58
83
  command = @commands[@playback_index]
59
84
  @playback_index += 1
@@ -65,11 +90,7 @@ module Backspin
65
90
  @playback_index = 0
66
91
  end
67
92
 
68
- def self.load_or_create(path)
69
- new(path)
70
- end
71
-
72
- private
93
+ # private
73
94
 
74
95
  def load_from_file
75
96
  data = YAML.load_file(@path.to_s)
@@ -4,13 +4,14 @@ module Backspin
4
4
  # Result object for all Backspin record operations
5
5
  # Provides a consistent interface whether recording, verifying, or playing back
6
6
  class RecordResult
7
- attr_reader :output, :record_path, :commands, :mode, :command_diffs
7
+ attr_reader :output, :commands, :mode, :command_diffs
8
+ attr_reader :record
8
9
 
9
- def initialize(output:, mode:, record_path:, commands:, verified: nil, command_diffs: nil)
10
+ def initialize(output:, mode:, record:, verified: nil, command_diffs: nil)
10
11
  @output = output
11
12
  @mode = mode
12
- @record_path = record_path
13
- @commands = commands
13
+ @record = record
14
+ @commands = record.commands
14
15
  @verified = verified
15
16
  @command_diffs = command_diffs || []
16
17
  end
@@ -20,8 +21,16 @@ module Backspin
20
21
  mode == :record
21
22
  end
22
23
 
24
+ def record_path
25
+ record.path
26
+ end
27
+
23
28
  # @return [Boolean, nil] true/false for verification results, nil for recording
24
29
  def verified?
30
+ return @verified unless mode == :verify
31
+
32
+ return false if command_diffs.size < commands.size
33
+
25
34
  @verified
26
35
  end
27
36
 
@@ -33,6 +42,12 @@ module Backspin
33
42
  # @return [String, nil] Human-readable error message if verification failed
34
43
  def error_message
35
44
  return nil unless verified? == false
45
+
46
+ # Check for command count mismatch first
47
+ if command_diffs.size < commands.size
48
+ return "Expected #{commands.size} commands but only #{command_diffs.size} were executed"
49
+ end
50
+
36
51
  return "No commands to verify" if command_diffs.empty?
37
52
 
38
53
  failed_diffs = command_diffs.reject(&:verified?)
@@ -130,24 +145,15 @@ module Backspin
130
145
  playback: playback?,
131
146
  stdout: stdout,
132
147
  stderr: stderr,
133
- status: status,
134
- record_path: record_path.to_s
148
+ status: status
135
149
  }
136
150
 
137
- # Only include verified if it's not nil
138
151
  hash[:verified] = verified? unless verified?.nil?
139
-
140
- # Only include diff if present
141
152
  hash[:diff] = diff if diff
142
-
143
153
  # Include number of failed commands if in verify mode
144
154
  hash[:failed_commands] = command_diffs.count { |d| !d.verified? } if mode == :verify && command_diffs.any?
145
155
 
146
156
  hash
147
157
  end
148
-
149
- def inspect
150
- "#<Backspin::RecordResult mode=#{mode} verified=#{verified?.inspect} status=#{status}>"
151
- end
152
158
  end
153
159
  end