backspin 0.3.0 → 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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +3 -1
- data/CHANGELOG.md +4 -0
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -3
- data/Gemfile +1 -1
- data/Gemfile.lock +2 -2
- data/README.md +107 -33
- data/backspin.gemspec +2 -2
- data/bin/rake +27 -0
- data/bin/rspec +27 -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 +167 -285
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 59bc6e711951434fc84dd2c2694f69aa5b00ef3bf878b81c5b4e97beeb430ad6
|
4
|
+
data.tar.gz: f9784962ef86ad73e0eb1c8388217700e477bc60ce0528b5dd16f08bc5c2f637
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e4151f4a28909e1a80706a30520b268f5114124230df9869a3603d5502194f4885ca8dff19e15a9a8ffe5f559b14695614f9f113c66e82ccc105de5b7ae17006
|
7
|
+
data.tar.gz: a66f71d1a4b2e9404ca39178920d8b88686f5e8cec2fe62f282b1cda9de031c1041cdfa1007ee9dc725c24cfb1c4dad5445213091a20c0faf23165705a8d264e
|
data/.circleci/config.yml
CHANGED
@@ -7,6 +7,7 @@ orbs:
|
|
7
7
|
|
8
8
|
jobs:
|
9
9
|
build:
|
10
|
+
resource_class: medium
|
10
11
|
parameters:
|
11
12
|
ruby-version:
|
12
13
|
type: string
|
@@ -15,7 +16,8 @@ jobs:
|
|
15
16
|
|
16
17
|
steps:
|
17
18
|
- checkout
|
18
|
-
- ruby/install-deps
|
19
|
+
- ruby/install-deps:
|
20
|
+
key: gems-v1-ruby<< parameters.ruby-version >>
|
19
21
|
- run:
|
20
22
|
name: Run specs and lint
|
21
23
|
command: bundle exec rake
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,9 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## [0.4.0] - 2025-06-06
|
4
|
+
|
5
|
+
Simpler, unified API: `Backspin.run` and `Backspin run!` methods that automatically record on first use and verify on subsequent runs. `run!` will raise an error if results differ, whereas `run` will return the result for the caller to decide what to do with
|
6
|
+
|
3
7
|
## [0.3.0] - 2025-06-05
|
4
8
|
- Scrub credentials from command arguments
|
5
9
|
|
data/CLAUDE.md
CHANGED
@@ -17,14 +17,14 @@ bin/setup
|
|
17
17
|
### Testing
|
18
18
|
```bash
|
19
19
|
bin/rake spec # Run all tests
|
20
|
-
rspec spec/[file] # Run specific test file
|
21
|
-
rspec spec/[file]:[line] # Run specific test
|
20
|
+
bin/rspec spec/[file] # Run specific test file
|
21
|
+
bin/rspec spec/[file]:[line] # Run specific test
|
22
22
|
```
|
23
23
|
|
24
24
|
### Building and Releasing
|
25
25
|
```bash
|
26
|
-
|
27
|
-
|
26
|
+
bin/rake install # Install gem locally for testing
|
27
|
+
bin/rake release # Release to RubyGems (updates version, tags, pushes)
|
28
28
|
```
|
29
29
|
|
30
30
|
### Code Quality
|
data/CONTRIBUTING.md
CHANGED
@@ -161,7 +161,6 @@ end
|
|
161
161
|
|
162
162
|
- Keep changes focused and atomic
|
163
163
|
- Include tests for new functionality
|
164
|
-
- Maintain backward compatibility when possible
|
165
164
|
- Update examples in README.md if changing public APIs
|
166
165
|
- Ensure CI passes (tests against Ruby 3.2, 3.3, and 3.4)
|
167
166
|
|
@@ -203,8 +202,7 @@ We welcome feature requests! When proposing new features:
|
|
203
202
|
1. Check existing issues to avoid duplicates
|
204
203
|
2. Describe the use case and motivation
|
205
204
|
3. Provide code examples of how the feature would work
|
206
|
-
4.
|
207
|
-
5. Be open to discussion and alternative approaches
|
205
|
+
4. Be open to discussion and alternative approaches
|
208
206
|
|
209
207
|
## Additional Resources
|
210
208
|
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
backspin (0.
|
4
|
+
backspin (0.4.0)
|
5
5
|
ostruct (~> 0.5.0)
|
6
6
|
rspec-mocks (~> 3.0)
|
7
7
|
|
@@ -80,7 +80,7 @@ DEPENDENCIES
|
|
80
80
|
backspin!
|
81
81
|
rake (~> 13.0)
|
82
82
|
rspec (~> 3.0)
|
83
|
-
standard
|
83
|
+
standard
|
84
84
|
timecop (~> 0.9)
|
85
85
|
|
86
86
|
BUNDLED WITH
|
data/README.md
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
# Backspin [](https://badge.fury.io/rb/backspin) [](https://dl.circleci.com/status-badge/redirect/gh/rsanheim/backspin/tree/main)
|
2
2
|
|
3
|
-
Backspin records and replays CLI interactions in Ruby for easy snapshot testing of command-line interfaces. Currently supports `Open3.capture3` and `system` and requires `rspec-mocks`. More system calls and
|
3
|
+
Backspin records and replays CLI interactions in Ruby for easy snapshot testing of command-line interfaces. Currently supports `Open3.capture3` and `system` and requires `rspec-mocks`. More system calls and test integrations are welcome - send a PR!
|
4
4
|
|
5
|
-
**NOTE:** Backspin
|
5
|
+
**NOTE:** Backspin should be considered alpha quality software while pre v1.0. It is in heavy development, and you can expect the API to change. It is being developed in conjunction with production CLI apps, so the API will be refined and improved as we get to 1.0.
|
6
6
|
|
7
7
|
Inspired by [VCR](https://github.com/vcr/vcr) and other [golden master](https://en.wikipedia.org/wiki/Golden_master_(software_development)) libraries.
|
8
8
|
|
@@ -24,73 +24,147 @@ And then run `bundle install`.
|
|
24
24
|
|
25
25
|
## Usage
|
26
26
|
|
27
|
-
###
|
27
|
+
### Quick Start
|
28
|
+
|
29
|
+
The simplest way to use Backspin is with the `run` method, which automatically records on the first execution and verifies on subsequent runs:
|
28
30
|
|
29
31
|
```ruby
|
30
|
-
require "backspin"
|
32
|
+
require "backspin"
|
33
|
+
|
34
|
+
# First run: records the output
|
35
|
+
result = Backspin.run("my_command") do
|
36
|
+
Open3.capture3("echo hello world")
|
37
|
+
end
|
31
38
|
|
32
|
-
#
|
33
|
-
result = Backspin.
|
34
|
-
|
35
|
-
# This will save the output to `spec/backspin_data/echo_hello.yaml`.
|
39
|
+
# Subsequent runs: verifies the output matches
|
40
|
+
result = Backspin.run("my_command") do
|
41
|
+
Open3.capture3("echo hello world")
|
36
42
|
end
|
37
43
|
|
44
|
+
# Use run! to automatically fail tests on mismatch
|
45
|
+
Backspin.run!("my_command") do
|
46
|
+
Open3.capture3("echo hello mars")
|
47
|
+
end
|
48
|
+
# Raises an error because stdout will not match the recorded output
|
38
49
|
```
|
39
50
|
|
40
|
-
###
|
51
|
+
### Recording Modes
|
52
|
+
|
53
|
+
Backspin supports different modes for controlling how commands are recorded and verified:
|
41
54
|
|
42
55
|
```ruby
|
43
|
-
#
|
44
|
-
result = Backspin.
|
56
|
+
# Auto mode (default): Record on first run, verify on subsequent runs
|
57
|
+
result = Backspin.run("my_command") do
|
58
|
+
Open3.capture3("echo hello")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Explicit record mode: Always record, overwriting existing recordings
|
62
|
+
result = Backspin.run("echo_test", mode: :record) do
|
45
63
|
Open3.capture3("echo hello")
|
46
64
|
end
|
65
|
+
# This will save the output to `spec/backspin_data/echo_test.yml`.
|
47
66
|
|
67
|
+
# Explicit verify mode: Always verify against existing recording
|
68
|
+
result = Backspin.run("echo_test", mode: :verify) do
|
69
|
+
Open3.capture3("echo hello")
|
70
|
+
end
|
48
71
|
expect(result.verified?).to be true
|
72
|
+
|
73
|
+
# Playback mode: Return recorded output without running the command
|
74
|
+
result = Backspin.run("slow_command", mode: :playback) do
|
75
|
+
Open3.capture3("slow_command") # Not executed - returns recorded output
|
76
|
+
end
|
49
77
|
```
|
50
78
|
|
51
|
-
### Using
|
79
|
+
### Using run! for automatic test failures
|
80
|
+
|
81
|
+
The `run!` method works exactly like `run` but automatically fails the test if verification fails:
|
52
82
|
|
53
83
|
```ruby
|
54
84
|
# Automatically fail the test if output doesn't match
|
55
|
-
Backspin.
|
85
|
+
Backspin.run!("echo_test") do
|
56
86
|
Open3.capture3("echo hello")
|
57
87
|
end
|
88
|
+
# Raises an error with detailed diff if verification fails from recorded data in "echo_test.yml"
|
58
89
|
```
|
59
90
|
|
60
|
-
###
|
91
|
+
### Custom matchers
|
92
|
+
|
93
|
+
For cases where exact matching isn't suitable, you can provide custom verification logic:
|
61
94
|
|
62
95
|
```ruby
|
63
|
-
#
|
64
|
-
result = Backspin.
|
65
|
-
|
96
|
+
# Use custom logic to verify output
|
97
|
+
result = Backspin.run("version_check",
|
98
|
+
matcher: ->(recorded, actual) {
|
99
|
+
# Just check that both start with "ruby"
|
100
|
+
recorded["stdout"].start_with?("ruby") &&
|
101
|
+
actual["stdout"].start_with?("ruby")
|
102
|
+
}) do
|
103
|
+
Open3.capture3("ruby --version")
|
66
104
|
end
|
67
105
|
```
|
68
106
|
|
69
|
-
###
|
107
|
+
### Working with the Result Object
|
108
|
+
|
109
|
+
The API returns a `RecordResult` object with helpful methods:
|
70
110
|
|
71
111
|
```ruby
|
72
|
-
|
73
|
-
|
74
|
-
matcher: ->(recorded, actual) {
|
75
|
-
# Just check that both start with "ruby"
|
76
|
-
recorded["stdout"].start_with?("ruby") &&
|
77
|
-
actual["stdout"].start_with?("ruby")
|
78
|
-
}) do
|
79
|
-
Open3.capture3("ruby --version")
|
112
|
+
result = Backspin.run("my_test") do
|
113
|
+
Open3.capture3("echo out; echo err >&2; exit 42")
|
80
114
|
end
|
115
|
+
|
116
|
+
# Check the mode
|
117
|
+
result.recorded? # true on first run
|
118
|
+
result.verified? # true/false on subsequent runs, nil when recording
|
119
|
+
result.playback? # true in playback mode
|
120
|
+
|
121
|
+
# Access output (first command for single commands)
|
122
|
+
result.stdout # "out\n"
|
123
|
+
result.stderr # "err\n"
|
124
|
+
result.status # 42
|
125
|
+
result.success? # false (non-zero exit)
|
126
|
+
result.output # The raw return value from the block
|
127
|
+
|
128
|
+
# Debug information
|
129
|
+
result.record_path # Path to the YAML file
|
130
|
+
result.error_message # Human-readable error if verification failed
|
131
|
+
result.diff # Diff between expected and actual output
|
81
132
|
```
|
82
133
|
|
83
|
-
###
|
134
|
+
### Multiple Commands
|
84
135
|
|
85
|
-
|
136
|
+
Backspin automatically records and verifies all commands executed in a block:
|
86
137
|
|
87
138
|
```ruby
|
88
|
-
|
89
|
-
|
90
|
-
Open3.capture3("
|
139
|
+
result = Backspin.run("multi_command_test") do
|
140
|
+
# All of these commands will be recorded
|
141
|
+
version, = Open3.capture3("ruby --version")
|
142
|
+
files, = Open3.capture3("ls -la")
|
143
|
+
system("echo 'Processing...'") # Note: system doesn't capture output
|
144
|
+
data, stderr, = Open3.capture3("curl https://api.example.com/data")
|
145
|
+
|
146
|
+
# Return whatever you need
|
147
|
+
{ version: version.strip, file_count: files.lines.count, data: data }
|
91
148
|
end
|
149
|
+
|
150
|
+
# Access individual command results
|
151
|
+
result.commands.size # 4
|
152
|
+
result.multiple_commands? # true
|
153
|
+
|
154
|
+
# For multiple commands, use these accessors
|
155
|
+
result.all_stdout # Array of stdout from each command
|
156
|
+
result.all_stderr # Array of stderr from each command
|
157
|
+
result.all_status # Array of exit statuses
|
158
|
+
|
159
|
+
# Or access specific commands
|
160
|
+
result.commands[0].stdout # Ruby version output
|
161
|
+
result.commands[1].stdout # ls output
|
162
|
+
result.commands[2].status # system call exit status (stdout is empty)
|
163
|
+
result.commands[3].stderr # curl errors if any
|
92
164
|
```
|
93
165
|
|
166
|
+
When verifying multiple commands, Backspin ensures all commands match in the exact order they were recorded. If any command differs, you'll get a detailed error showing which commands failed.
|
167
|
+
|
94
168
|
### Credential Scrubbing
|
95
169
|
|
96
170
|
If the CLI interaction you are recording contains sensitive data in stdout or stderr, you should be careful to make sure it is not recorded to yaml!
|
@@ -102,7 +176,7 @@ A tool like [trufflehog](https://github.com/trufflesecurity/trufflehog) or [gitl
|
|
102
176
|
|
103
177
|
```ruby
|
104
178
|
# This will automatically scrub AWS keys, API tokens, passwords, etc.
|
105
|
-
Backspin.
|
179
|
+
Backspin.run("aws_command") do
|
106
180
|
Open3.capture3("aws s3 ls")
|
107
181
|
end
|
108
182
|
|
@@ -146,4 +220,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/rsanhe
|
|
146
220
|
|
147
221
|
## License
|
148
222
|
|
149
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
223
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/backspin.gemspec
CHANGED
@@ -20,8 +20,8 @@ Gem::Specification.new do |spec|
|
|
20
20
|
spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
|
21
21
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
22
22
|
end
|
23
|
-
spec.bindir = "
|
24
|
-
spec.executables = spec.files.grep(%r{
|
23
|
+
spec.bindir = "exe"
|
24
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
25
25
|
spec.require_paths = ["lib"]
|
26
26
|
|
27
27
|
spec.add_dependency "rspec-mocks", "~> 3.0"
|
data/bin/rake
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 'rake' 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("rake", "rake")
|
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/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)
|