backspin 0.8.0 → 0.10.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.
@@ -1,63 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Backspin
4
- # Handles matching logic between recorded and actual commands
4
+ # Handles matching logic between expected and actual snapshots.
5
5
  class Matcher
6
- attr_reader :config, :recorded_command, :actual_command
6
+ attr_reader :config, :expected, :actual
7
7
 
8
- def initialize(config:, recorded_command:, actual_command:)
8
+ def initialize(config:, expected:, actual:)
9
9
  @config = normalize_config(config)
10
- @recorded_command = recorded_command
11
- @actual_command = actual_command
10
+ @expected = expected
11
+ @actual = actual
12
12
  end
13
13
 
14
- # @return [Boolean] true if commands match according to the configured matcher
14
+ # @return [Boolean] true if snapshots match according to configured matcher
15
15
  def match?
16
- if config.nil?
17
- # Default behavior: check all fields for equality
18
- default_matcher.call(recorded_command.to_h, actual_command.to_h)
19
- elsif config.is_a?(Proc)
20
- config.call(recorded_command.to_h, actual_command.to_h)
21
- elsif config.is_a?(Hash)
22
- verify_with_hash_matcher
23
- else
24
- raise ArgumentError, "Invalid matcher type: #{config.class}"
25
- end
16
+ evaluation[:match]
26
17
  end
27
18
 
28
19
  # @return [String] reason why matching failed
29
20
  def failure_reason
30
- reasons = []
31
-
32
- if config.nil?
33
- # Default matcher checks all fields
34
- recorded_hash = recorded_command.to_h
35
- actual_hash = actual_command.to_h
36
-
37
- reasons << "stdout differs" if recorded_hash["stdout"] != actual_hash["stdout"]
38
- reasons << "stderr differs" if recorded_hash["stderr"] != actual_hash["stderr"]
39
- reasons << "exit status differs" if recorded_hash["status"] != actual_hash["status"]
40
- elsif config.is_a?(Hash)
41
- recorded_hash = recorded_command.to_h
42
- actual_hash = actual_command.to_h
43
-
44
- # Only check matchers that were provided
45
- config.each do |field, matcher_proc|
46
- case field
47
- when :all
48
- reasons << ":all matcher failed" unless matcher_proc.call(recorded_hash, actual_hash)
49
- when :stdout, :stderr, :status
50
- unless matcher_proc.call(recorded_hash[field.to_s], actual_hash[field.to_s])
51
- reasons << "#{field} custom matcher failed"
52
- end
53
- end
54
- end
55
- else
56
- # Proc matcher
57
- reasons << "custom matcher failed"
58
- end
59
-
60
- reasons.join(", ")
21
+ evaluation[:reason]
61
22
  end
62
23
 
63
24
  private
@@ -78,32 +39,73 @@ module Backspin
78
39
  config
79
40
  end
80
41
 
81
- def verify_with_hash_matcher
82
- recorded_hash = recorded_command.to_h
83
- actual_hash = actual_command.to_h
42
+ def evaluation
43
+ @evaluation ||= if config.nil?
44
+ evaluate_default
45
+ elsif config.is_a?(Proc)
46
+ evaluate_proc
47
+ elsif config.is_a?(Hash)
48
+ evaluate_hash
49
+ else
50
+ raise ArgumentError, "Invalid matcher type: #{config.class}"
51
+ end
52
+ end
53
+
54
+ def evaluate_default
55
+ reasons = []
56
+ reasons << "stdout differs" if expected.stdout != actual.stdout
57
+ reasons << "stderr differs" if expected.stderr != actual.stderr
58
+ reasons << "exit status differs" if expected.status != actual.status
84
59
 
85
- # Override-based: only run matchers that are explicitly provided
86
- # Use map to ensure all matchers run, then check if all passed
87
- results = config.map do |field, matcher_proc|
88
- case field
60
+ {match: reasons.empty?, reason: reasons.join(", ")}
61
+ end
62
+
63
+ def evaluate_proc
64
+ match = !!config.call(deep_dup(expected_hash), deep_dup(actual_hash))
65
+ reason = match ? "" : "custom matcher failed"
66
+ {match: match, reason: reason}
67
+ end
68
+
69
+ def evaluate_hash
70
+ reasons = []
71
+
72
+ config.each do |field, matcher_proc|
73
+ passed = case field
89
74
  when :all
90
- matcher_proc.call(recorded_hash, actual_hash)
75
+ matcher_proc.call(deep_dup(expected_hash), deep_dup(actual_hash))
91
76
  when :stdout, :stderr, :status
92
- matcher_proc.call(recorded_hash[field.to_s], actual_hash[field.to_s])
77
+ matcher_proc.call(deep_dup(expected.public_send(field)), deep_dup(actual.public_send(field)))
93
78
  else
94
- # This should never happen due to normalize_config validation
95
79
  raise ArgumentError, "Unknown field: #{field}"
96
80
  end
81
+
82
+ next if passed
83
+
84
+ reasons << ":all matcher failed" if field == :all
85
+ reasons << "#{field} custom matcher failed" if %i[stdout stderr status].include?(field)
97
86
  end
98
87
 
99
- results.all?
88
+ {match: reasons.empty?, reason: reasons.join(", ")}
89
+ end
90
+
91
+ def expected_hash
92
+ @expected_hash ||= expected.to_h
93
+ end
94
+
95
+ def actual_hash
96
+ @actual_hash ||= actual.to_h
100
97
  end
101
98
 
102
- def default_matcher
103
- @default_matcher ||= lambda do |recorded, actual|
104
- recorded["stdout"] == actual["stdout"] &&
105
- recorded["stderr"] == actual["stderr"] &&
106
- recorded["status"] == actual["status"]
99
+ def deep_dup(value)
100
+ case value
101
+ when Hash
102
+ value.transform_values { |entry| deep_dup(entry) }
103
+ when Array
104
+ value.map { |entry| deep_dup(entry) }
105
+ when String
106
+ value.dup
107
+ else
108
+ value
107
109
  end
108
110
  end
109
111
  end
@@ -4,8 +4,8 @@ module Backspin
4
4
  class RecordFormatError < StandardError; end
5
5
 
6
6
  class Record
7
- FORMAT_VERSION = "3.0"
8
- attr_reader :path, :commands, :first_recorded_at
7
+ FORMAT_VERSION = "4.0"
8
+ attr_reader :path, :snapshot, :recorded_at
9
9
 
10
10
  def self.load_or_create(path)
11
11
  record = new(path)
@@ -35,28 +35,31 @@ module Backspin
35
35
 
36
36
  def initialize(path)
37
37
  @path = path
38
- @commands = []
39
- @first_recorded_at = nil
38
+ @snapshot = nil
39
+ @recorded_at = nil
40
40
  end
41
41
 
42
- def add_command(command)
43
- @commands << command
44
- @first_recorded_at ||= command.recorded_at
42
+ def set_snapshot(snapshot)
43
+ @snapshot = snapshot
44
+ @recorded_at ||= snapshot.recorded_at
45
45
  self
46
46
  end
47
47
 
48
48
  def save(filter: nil)
49
49
  FileUtils.mkdir_p(File.dirname(@path))
50
+ snapshot_data = @snapshot&.to_h
51
+ snapshot_data = filter.call(deep_dup(snapshot_data)) if snapshot_data && filter
50
52
  record_data = {
51
- "first_recorded_at" => @first_recorded_at,
52
53
  "format_version" => FORMAT_VERSION,
53
- "commands" => @commands.map { |cmd| cmd.to_h(filter: filter) }
54
+ "recorded_at" => @recorded_at,
55
+ "snapshot" => snapshot_data
54
56
  }
55
57
  File.write(@path, record_data.to_yaml)
56
58
  end
57
59
 
58
60
  def reload
59
- @commands = []
61
+ @snapshot = nil
62
+ @recorded_at = nil
60
63
  load_from_file if File.exist?(@path)
61
64
  end
62
65
 
@@ -65,19 +68,14 @@ module Backspin
65
68
  end
66
69
 
67
70
  def empty?
68
- @commands.empty?
69
- end
70
-
71
- def size
72
- @commands.size
71
+ @snapshot.nil?
73
72
  end
74
73
 
75
74
  def clear
76
- @commands = []
75
+ @snapshot = nil
76
+ @recorded_at = nil
77
77
  end
78
78
 
79
- # private
80
-
81
79
  def load_from_file
82
80
  data = YAML.load_file(@path.to_s)
83
81
 
@@ -85,15 +83,30 @@ module Backspin
85
83
  raise RecordFormatError, "Invalid record format: expected format version #{FORMAT_VERSION}"
86
84
  end
87
85
 
88
- commands = data["commands"]
89
- unless commands.is_a?(Array)
90
- raise RecordFormatError, "Invalid record format: missing commands"
86
+ snapshot_data = data["snapshot"]
87
+ unless snapshot_data.is_a?(Hash)
88
+ raise RecordFormatError, "Invalid record format: missing snapshot"
91
89
  end
92
90
 
93
- @first_recorded_at = data["first_recorded_at"]
94
- @commands = commands.map { |command_data| Command.from_h(command_data) }
91
+ @recorded_at = data["recorded_at"]
92
+ @snapshot = Snapshot.from_h(snapshot_data)
95
93
  rescue Psych::SyntaxError => e
96
94
  raise RecordFormatError, "Invalid record format: #{e.message}"
97
95
  end
96
+
97
+ private
98
+
99
+ def deep_dup(value)
100
+ case value
101
+ when Hash
102
+ value.transform_values { |entry| deep_dup(entry) }
103
+ when Array
104
+ value.map { |entry| deep_dup(entry) }
105
+ when String
106
+ value.dup
107
+ else
108
+ value
109
+ end
110
+ end
98
111
  end
99
112
  end
@@ -6,21 +6,22 @@ require "backspin/command_diff"
6
6
  module Backspin
7
7
  # Handles capture-mode recording and verification
8
8
  class Recorder
9
- attr_reader :mode, :record, :matcher, :filter
9
+ attr_reader :mode, :record, :matcher, :filter, :filter_on
10
10
 
11
- def initialize(mode: :record, record: nil, matcher: nil, filter: nil)
11
+ def initialize(mode: :record, record: nil, matcher: nil, filter: nil, filter_on: :both)
12
12
  @mode = mode
13
13
  @record = record
14
14
  @matcher = matcher
15
15
  @filter = filter
16
+ @filter_on = filter_on
16
17
  end
17
18
 
18
19
  # Performs capture recording by intercepting all stdout/stderr output
19
20
  def perform_capture_recording
20
21
  captured_stdout, captured_stderr, output = capture_output { yield }
21
22
 
22
- command = Command.new(
23
- method_class: Backspin::Capturer,
23
+ actual_snapshot = Snapshot.new(
24
+ command_type: Backspin::Capturer,
24
25
  args: ["<captured block>"],
25
26
  stdout: captured_stdout,
26
27
  stderr: captured_stderr,
@@ -28,29 +29,31 @@ module Backspin
28
29
  recorded_at: Time.now.iso8601
29
30
  )
30
31
 
31
- record.add_command(command)
32
+ record.set_snapshot(actual_snapshot)
32
33
  record.save(filter: @filter)
33
34
 
34
- RecordResult.new(output: output, mode: :record, record: record)
35
+ BackspinResult.new(
36
+ mode: :record,
37
+ record_path: record.path,
38
+ actual: actual_snapshot,
39
+ output: output
40
+ )
35
41
  end
36
42
 
37
43
  # Performs capture verification by capturing output and comparing with recorded values
38
44
  def perform_capture_verification
39
45
  raise RecordNotFoundError, "Record not found: #{record.path}" unless record.exists?
40
- raise RecordNotFoundError, "No commands found in record #{record.path}" if record.empty?
41
- if record.commands.size != 1
42
- raise RecordFormatError, "Invalid record format: expected 1 command for capture, found #{record.commands.size}"
43
- end
46
+ raise RecordNotFoundError, "No snapshot found in record #{record.path}" if record.empty?
44
47
 
45
- recorded_command = record.commands.first
46
- unless recorded_command.method_class == Backspin::Capturer
48
+ expected_snapshot = record.snapshot
49
+ unless expected_snapshot.command_type == Backspin::Capturer
47
50
  raise RecordFormatError, "Invalid record format: expected Backspin::Capturer for capture"
48
51
  end
49
52
 
50
53
  captured_stdout, captured_stderr, output = capture_output { yield }
51
54
 
52
- actual_command = Command.new(
53
- method_class: Backspin::Capturer,
55
+ actual_snapshot = Snapshot.new(
56
+ command_type: Backspin::Capturer,
54
57
  args: ["<captured block>"],
55
58
  stdout: captured_stdout,
56
59
  stderr: captured_stderr,
@@ -58,17 +61,21 @@ module Backspin
58
61
  )
59
62
 
60
63
  command_diff = CommandDiff.new(
61
- recorded_command: recorded_command,
62
- actual_command: actual_command,
63
- matcher: @matcher
64
+ expected: expected_snapshot,
65
+ actual: actual_snapshot,
66
+ matcher: @matcher,
67
+ filter: @filter,
68
+ filter_on: @filter_on
64
69
  )
65
70
 
66
- RecordResult.new(
67
- output: output,
71
+ BackspinResult.new(
68
72
  mode: :verify,
73
+ record_path: record.path,
74
+ actual: actual_snapshot,
75
+ expected: expected_snapshot,
69
76
  verified: command_diff.verified?,
70
- record: record,
71
- command_diffs: [command_diff]
77
+ command_diff: command_diff,
78
+ output: output
72
79
  )
73
80
  end
74
81
 
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Backspin
4
+ # Represents a single captured execution snapshot.
5
+ class Snapshot
6
+ attr_reader :command_type, :args, :env, :stdout, :stderr, :status, :recorded_at
7
+
8
+ def initialize(command_type:, args:, env: nil, stdout: "", stderr: "", status: 0, recorded_at: nil)
9
+ @command_type = command_type
10
+ @args = sanitize_args(args)
11
+ @env = env.nil? ? nil : sanitize_env(env)
12
+ @stdout = Backspin.scrub_text((stdout || "").dup).freeze
13
+ @stderr = Backspin.scrub_text((stderr || "").dup).freeze
14
+ @status = status || 0
15
+ @recorded_at = recorded_at.nil? ? nil : recorded_at.dup.freeze
16
+ @serialized_hash = build_serialized_hash
17
+ end
18
+
19
+ def success?
20
+ status.zero?
21
+ end
22
+
23
+ def failure?
24
+ !success?
25
+ end
26
+
27
+ def to_h
28
+ @serialized_hash
29
+ end
30
+
31
+ def self.from_h(data)
32
+ command_type = case data["command_type"]
33
+ when "Open3::Capture3"
34
+ Open3::Capture3
35
+ when "Backspin::Capturer"
36
+ Backspin::Capturer
37
+ else
38
+ raise RecordFormatError, "Unknown command type: #{data["command_type"]}"
39
+ end
40
+
41
+ new(
42
+ command_type: command_type,
43
+ args: data["args"],
44
+ env: data["env"],
45
+ stdout: data["stdout"],
46
+ stderr: data["stderr"],
47
+ status: data["status"],
48
+ recorded_at: data["recorded_at"]
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def build_serialized_hash
55
+ data = {
56
+ "command_type" => command_type.name,
57
+ "args" => args,
58
+ "stdout" => stdout,
59
+ "stderr" => stderr,
60
+ "status" => status,
61
+ "recorded_at" => recorded_at
62
+ }
63
+ data["env"] = env if env
64
+ deep_freeze(data)
65
+ end
66
+
67
+ def scrub_args(value)
68
+ return value unless Backspin.configuration.scrub_credentials && value
69
+
70
+ case value
71
+ when String
72
+ Backspin.scrub_text(value)
73
+ when Array
74
+ value.map { |entry| scrub_args(entry) }
75
+ when Hash
76
+ value.transform_values { |entry| entry.is_a?(String) ? Backspin.scrub_text(entry) : entry }
77
+ else
78
+ value
79
+ end
80
+ end
81
+
82
+ def scrub_env(value)
83
+ return value unless Backspin.configuration.scrub_credentials && value
84
+
85
+ value.transform_values { |entry| entry.is_a?(String) ? Backspin.scrub_text(entry) : entry }
86
+ end
87
+
88
+ def sanitize_args(value)
89
+ deep_freeze(scrub_args(deep_dup(value)))
90
+ end
91
+
92
+ def sanitize_env(value)
93
+ deep_freeze(scrub_env(deep_dup(value)))
94
+ end
95
+
96
+ def deep_freeze(value)
97
+ case value
98
+ when Hash
99
+ value.each_value { |v| deep_freeze(v) }
100
+ when Array
101
+ value.each { |v| deep_freeze(v) }
102
+ end
103
+ value.freeze
104
+ end
105
+
106
+ def deep_dup(value)
107
+ case value
108
+ when Hash
109
+ value.transform_values { |entry| deep_dup(entry) }
110
+ when Array
111
+ value.map { |entry| deep_dup(entry) }
112
+ when String
113
+ value.dup
114
+ else
115
+ value
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ # Define the Open3::Capture3 class for identification.
122
+ module Open3
123
+ class Capture3; end
124
+ end
125
+
126
+ # Define the Backspin::Capturer class for identification.
127
+ module Backspin
128
+ class Capturer; end
129
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Backspin
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end