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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +3 -1
- data/CHANGELOG.md +4 -0
- data/CLAUDE.md +6 -6
- data/CONTRIBUTING.md +3 -5
- data/Gemfile +1 -1
- data/Gemfile.lock +2 -2
- data/README.md +113 -34
- data/backspin.gemspec +2 -2
- data/bin/rake +27 -0
- data/bin/rspec +27 -0
- data/fixtures/backspin/all_mode_filter.yml +14 -0
- data/fixtures/backspin/credential_filter.yml +18 -0
- data/fixtures/backspin/echo_hello.yml +14 -0
- data/fixtures/backspin/echo_verify.yml +14 -0
- data/fixtures/backspin/episodes_filter.yml +26 -0
- data/fixtures/backspin/failure_test.yml +14 -0
- data/fixtures/backspin/full_data_filter.yml +17 -0
- data/fixtures/backspin/mixed_calls.yml +24 -0
- data/fixtures/backspin/multi_command.yml +34 -0
- data/fixtures/backspin/multi_command_filter.yml +26 -0
- data/fixtures/backspin/multi_field_filter.yml +13 -0
- data/fixtures/backspin/multi_system.yml +20 -0
- data/fixtures/backspin/nil_filter.yml +14 -0
- data/fixtures/backspin/none_mode_test.yml +14 -0
- data/fixtures/backspin/path_test.yml +17 -0
- data/fixtures/backspin/playback_system.yml +12 -0
- data/fixtures/backspin/playback_test.yml +14 -0
- data/fixtures/backspin/stderr_test.yml +19 -0
- data/fixtures/backspin/system_echo.yml +12 -0
- data/fixtures/backspin/system_false.yml +18 -0
- data/fixtures/backspin/timestamp_test.yml +18 -0
- data/fixtures/backspin/use_record_filter.yml +15 -0
- data/fixtures/backspin/verify_system.yml +12 -0
- data/fixtures/backspin/verify_system_diff.yml +11 -0
- data/fixtures/backspin/version_test.yml +14 -0
- data/lib/backspin/command.rb +33 -14
- data/lib/backspin/command_diff.rb +88 -0
- data/lib/backspin/command_result.rb +60 -0
- data/lib/backspin/record.rb +2 -2
- data/lib/backspin/record_result.rb +153 -0
- data/lib/backspin/recorder.rb +4 -23
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +169 -287
- metadata +33 -4
@@ -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,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,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,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'
|
data/lib/backspin/command.rb
CHANGED
@@ -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, :
|
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
|
-
|
80
|
+
case arg
|
81
|
+
when String
|
63
82
|
Backspin.scrub_text(arg)
|
64
|
-
|
83
|
+
when Array
|
65
84
|
scrub_args(arg)
|
66
|
-
|
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
|
data/lib/backspin/record.rb
CHANGED
@@ -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" =>
|
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
|