backspin 0.6.0 → 0.7.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/CHANGELOG.md +3 -0
- data/CLAUDE.md +4 -2
- data/Gemfile.lock +1 -1
- data/README.md +58 -13
- data/fixtures/backspin/all_for_logging.yml +3 -2
- data/fixtures/backspin/{use_record_filter.yml → strict_test.yml} +2 -3
- data/fixtures/backspin/version_test.yml +3 -3
- data/lib/backspin/configuration.rb +3 -0
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +31 -2
- data/script/lint +19 -3
- data/script/run_affected_tests +179 -0
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5f6f79191389f6f6f78661b402a1fa776534e37d87292471b708d0eb338779e6
|
4
|
+
data.tar.gz: 25c8091f368074e45c9f091a0beec04abaf3e5d5e883e3c6ed4d417ac8c2a349
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7f4e22608a611ee37a698f53b6959c74443272d30dd83fbe812abb80e73b068035c09873754ea7dad1289b056f2fed3c35e09c8c19944df87544268caaaeb490
|
7
|
+
data.tar.gz: e1313ad87c03e01386c6ede54ac706acf556c4667359c7c081cb60191dafa2c53a0ca5c8a58a0e8e01fe5028af7664077eda83f56fdd2c66cfa5b31bb04f0174
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
## 0.6.0 - 2025-08-13
|
4
|
+
* Introduce `Backspin.capture` for rspec-less, simpler stdout/stderr testing https://github.com/rsanheim/backspin/pull/17
|
5
|
+
|
3
6
|
## 0.5.0 - 2025-06-11
|
4
7
|
* Simplify matcher API so user provided matchers override defaults - [#14](https://github.com/rsanheim/backspin/pull/14)
|
5
8
|
* Also extract a proper `Matcher` object
|
data/CLAUDE.md
CHANGED
@@ -41,9 +41,11 @@ bin/rake standard # Alternative: Run via Rake task
|
|
41
41
|
### Core Components
|
42
42
|
|
43
43
|
**Backspin Module** (`lib/backspin.rb`)
|
44
|
-
- Main API: `
|
44
|
+
- Main API: `run`, `run!` (both raise on verification failure by default)
|
45
|
+
- Capture API: `capture` (raises `VerificationError` on verification failure by default)
|
46
|
+
- Legacy API: `call`, `verify`, `verify!`, `use_record`
|
45
47
|
- Credential scrubbing logic
|
46
|
-
- Configuration management
|
48
|
+
- Configuration management (including `raise_on_verification_failure` which defaults to `true` and affects both `run` and `capture`)
|
47
49
|
|
48
50
|
**Command Class** (`lib/backspin.rb`)
|
49
51
|
- Represents a single CLI execution
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# Backspin
|
1
|
+
# Backspin
|
2
2
|
|
3
3
|
[](https://www.ruby-lang.org/)
|
4
4
|
[](https://rubygems.org/gems/backspin)
|
@@ -33,7 +33,7 @@ And then run `bundle install`.
|
|
33
33
|
|
34
34
|
### Quick Start
|
35
35
|
|
36
|
-
The simplest way to use Backspin is with the `run` method, which automatically records on the first execution and verifies on subsequent runs
|
36
|
+
The simplest way to use Backspin is with the `run` method, which automatically records on the first execution and verifies on subsequent runs.
|
37
37
|
|
38
38
|
```ruby
|
39
39
|
require "backspin"
|
@@ -43,18 +43,20 @@ result = Backspin.run("my_command") do
|
|
43
43
|
Open3.capture3("echo hello world")
|
44
44
|
end
|
45
45
|
|
46
|
-
# Subsequent runs: verifies the output matches
|
47
|
-
|
48
|
-
Open3.capture3("echo hello world")
|
46
|
+
# Subsequent runs: verifies the output matches and raises on mismatch
|
47
|
+
Backspin.run("my_command") do
|
48
|
+
Open3.capture3("echo hello world") # Passes - output matches
|
49
49
|
end
|
50
50
|
|
51
|
-
#
|
52
|
-
Backspin.run
|
53
|
-
Open3.capture3("echo hello mars")
|
51
|
+
# This will raise an error automatically
|
52
|
+
Backspin.run("my_command") do
|
53
|
+
Open3.capture3("echo hello mars")
|
54
54
|
end
|
55
|
-
# Raises
|
55
|
+
# Raises RSpec::Expectations::ExpectationNotMetError because output doesn't match
|
56
56
|
```
|
57
57
|
|
58
|
+
By default, `Backspin.run` will raise an exception when verification fails, making your tests fail automatically. This is the recommended approach for most scenarios.
|
59
|
+
|
58
60
|
### Recording Modes
|
59
61
|
|
60
62
|
Backspin supports different modes for controlling how commands are recorded and verified:
|
@@ -83,18 +85,25 @@ result = Backspin.run("slow_command", mode: :playback) do
|
|
83
85
|
end
|
84
86
|
```
|
85
87
|
|
86
|
-
###
|
88
|
+
### The run! method
|
89
|
+
|
90
|
+
**NOTE:** This method is deprecated and will be removed soon.
|
87
91
|
|
88
|
-
The `run!` method works
|
92
|
+
The `run!` method is maintained for backwards compatibility and works identically to `run`. Since `run` now raises on verification failure by default, both methods behave the same way:
|
89
93
|
|
90
94
|
```ruby
|
91
|
-
#
|
95
|
+
# Both of these are equivalent and will raise on verification failure
|
96
|
+
Backspin.run("echo_test") do
|
97
|
+
Open3.capture3("echo hello")
|
98
|
+
end
|
99
|
+
|
92
100
|
Backspin.run!("echo_test") do
|
93
101
|
Open3.capture3("echo hello")
|
94
102
|
end
|
95
|
-
# Raises an error with detailed diff if verification fails from recorded data in "echo_test.yml"
|
96
103
|
```
|
97
104
|
|
105
|
+
For new code, we recommend using `run` as it's the primary API method.
|
106
|
+
|
98
107
|
### Custom matchers
|
99
108
|
|
100
109
|
For cases where full matching isn't suitable, you can override via `matcher:`. **NOTE**: If you provide
|
@@ -199,6 +208,42 @@ result.commands[3].stderr # curl errors if any
|
|
199
208
|
|
200
209
|
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.
|
201
210
|
|
211
|
+
### Configuration
|
212
|
+
|
213
|
+
You can configure Backspin's behavior globally:
|
214
|
+
|
215
|
+
```ruby
|
216
|
+
Backspin.configure do |config|
|
217
|
+
# Both run and capture methods will raise on verification failure by default
|
218
|
+
config.raise_on_verification_failure = false # default is true
|
219
|
+
config.backspin_dir = "spec/fixtures/cli_records" # default is "fixtures/backspin"
|
220
|
+
config.scrub_credentials = false # default is true
|
221
|
+
end
|
222
|
+
```
|
223
|
+
|
224
|
+
The `raise_on_verification_failure` setting affects both `Backspin.run` and `Backspin.capture`:
|
225
|
+
- When `true` (default): Both methods raise exceptions on verification failure
|
226
|
+
- `run` raises `RSpec::Expectations::ExpectationNotMetError`
|
227
|
+
- `capture` raises `Backspin::VerificationError` (framework-agnostic)
|
228
|
+
- When `false`: Both methods return a result with `verified?` set to false
|
229
|
+
|
230
|
+
If you need to disable the raising behavior for a specific test, you can temporarily configure it:
|
231
|
+
|
232
|
+
```ruby
|
233
|
+
# Temporarily disable raising for this block
|
234
|
+
Backspin.configure do |config|
|
235
|
+
config.raise_on_verification_failure = false
|
236
|
+
end
|
237
|
+
|
238
|
+
result = Backspin.run("my_test") do
|
239
|
+
Open3.capture3("echo different")
|
240
|
+
end
|
241
|
+
# result.verified? will be false but won't raise
|
242
|
+
|
243
|
+
# Reset configuration
|
244
|
+
Backspin.reset_configuration!
|
245
|
+
```
|
246
|
+
|
202
247
|
### Credential Scrubbing
|
203
248
|
|
204
249
|
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!
|
@@ -4,9 +4,9 @@ format_version: '2.0'
|
|
4
4
|
commands:
|
5
5
|
- command_type: Open3::Capture3
|
6
6
|
args:
|
7
|
-
-
|
8
|
-
-
|
9
|
-
stdout: 'ruby 3.4.
|
7
|
+
- echo
|
8
|
+
- ruby version 3.4.5
|
9
|
+
stdout: 'ruby version 3.4.5
|
10
10
|
|
11
11
|
'
|
12
12
|
stderr: ''
|
@@ -8,11 +8,14 @@ module Backspin
|
|
8
8
|
attr_accessor :scrub_credentials
|
9
9
|
# The directory where backspin will store its files - defaults to fixtures/backspin
|
10
10
|
attr_accessor :backspin_dir
|
11
|
+
# Whether to raise an exception when verification fails in `run` method - defaults to true
|
12
|
+
attr_accessor :raise_on_verification_failure
|
11
13
|
# Regex patterns to scrub from saved output
|
12
14
|
attr_reader :credential_patterns
|
13
15
|
|
14
16
|
def initialize
|
15
17
|
@scrub_credentials = true
|
18
|
+
@raise_on_verification_failure = true
|
16
19
|
@credential_patterns = default_credential_patterns
|
17
20
|
@backspin_dir = Pathname(Dir.pwd).join("fixtures", "backspin")
|
18
21
|
end
|
data/lib/backspin/version.rb
CHANGED
data/lib/backspin.rb
CHANGED
@@ -19,6 +19,8 @@ require "backspin/record_result"
|
|
19
19
|
module Backspin
|
20
20
|
class RecordNotFoundError < StandardError; end
|
21
21
|
|
22
|
+
class VerificationError < StandardError; end
|
23
|
+
|
22
24
|
# Include RSpec mocks methods
|
23
25
|
extend RSpec::Mocks::ExampleMethods
|
24
26
|
|
@@ -79,7 +81,7 @@ module Backspin
|
|
79
81
|
recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
|
80
82
|
|
81
83
|
# Execute the appropriate mode
|
82
|
-
case mode
|
84
|
+
result = case mode
|
83
85
|
when :record
|
84
86
|
recorder.setup_recording_stubs(:capture3, :system)
|
85
87
|
recorder.perform_recording(&block)
|
@@ -90,6 +92,17 @@ module Backspin
|
|
90
92
|
else
|
91
93
|
raise ArgumentError, "Unknown mode: #{mode}"
|
92
94
|
end
|
95
|
+
|
96
|
+
# Check if we should raise on verification failure
|
97
|
+
if configuration.raise_on_verification_failure && result.verified? == false
|
98
|
+
error_message = "Backspin verification failed!\n"
|
99
|
+
error_message += "Record: #{result.record.path}\n"
|
100
|
+
error_message += "\n#{result.error_message}" if result.error_message
|
101
|
+
|
102
|
+
raise RSpec::Expectations::ExpectationNotMetError, error_message
|
103
|
+
end
|
104
|
+
|
105
|
+
result
|
93
106
|
end
|
94
107
|
|
95
108
|
# Strict version of run that raises on verification failure
|
@@ -141,7 +154,7 @@ module Backspin
|
|
141
154
|
recorder = Recorder.new(record: record, mode: mode, matcher: matcher, filter: filter)
|
142
155
|
|
143
156
|
# Execute the appropriate mode
|
144
|
-
case mode
|
157
|
+
result = case mode
|
145
158
|
when :record
|
146
159
|
recorder.perform_capture_recording(&block)
|
147
160
|
when :verify
|
@@ -151,6 +164,22 @@ module Backspin
|
|
151
164
|
else
|
152
165
|
raise ArgumentError, "Unknown mode: #{mode}"
|
153
166
|
end
|
167
|
+
|
168
|
+
# Check if we should raise on verification failure
|
169
|
+
if configuration.raise_on_verification_failure && result.verified? == false
|
170
|
+
error_message = "Backspin verification failed!\n"
|
171
|
+
error_message += "Record: #{result.record.path}\n"
|
172
|
+
error_message += result.error_message || "Output verification failed"
|
173
|
+
|
174
|
+
# Include diff if available
|
175
|
+
if result.diff
|
176
|
+
error_message += "\n\nDiff:\n#{result.diff}"
|
177
|
+
end
|
178
|
+
|
179
|
+
raise VerificationError, error_message
|
180
|
+
end
|
181
|
+
|
182
|
+
result
|
154
183
|
end
|
155
184
|
|
156
185
|
private
|
data/script/lint
CHANGED
@@ -1,6 +1,22 @@
|
|
1
1
|
#!/usr/bin/env bash
|
2
2
|
set -euo pipefail
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
|
4
|
+
FIX_OPT="--no-fix"
|
5
|
+
while [[ $# -gt 0 ]]; do
|
6
|
+
case $1 in
|
7
|
+
-f|--fix)
|
8
|
+
FIX_OPT="--fix"
|
9
|
+
shift
|
10
|
+
;;
|
11
|
+
--fix-unsafely)
|
12
|
+
FIX_OPT="--fix-unsafely"
|
13
|
+
shift
|
14
|
+
;;
|
15
|
+
*)
|
16
|
+
echo "Error: Unknown option: $1"
|
17
|
+
exit 1
|
18
|
+
;;
|
19
|
+
esac
|
20
|
+
done
|
21
|
+
|
22
|
+
bundle exec standardrb "$@" $FIX_OPT
|
@@ -0,0 +1,179 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "json"
|
5
|
+
require "pathname"
|
6
|
+
require "open3"
|
7
|
+
|
8
|
+
class AffectedTestRunner
|
9
|
+
EXIT_SUCCESS = 0
|
10
|
+
EXIT_FAILURE = 2
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@project_dir = ENV["CLAUDE_PROJECT_DIR"]
|
14
|
+
validate_environment!
|
15
|
+
end
|
16
|
+
|
17
|
+
def run
|
18
|
+
input_data = parse_input
|
19
|
+
file_path = extract_file_path(input_data)
|
20
|
+
|
21
|
+
return EXIT_SUCCESS unless ruby_file?(file_path)
|
22
|
+
|
23
|
+
validated_path = validate_and_normalize_path(file_path)
|
24
|
+
return EXIT_SUCCESS unless validated_path
|
25
|
+
|
26
|
+
test_file = determine_test_file(validated_path)
|
27
|
+
return EXIT_SUCCESS unless test_file
|
28
|
+
|
29
|
+
run_tests(test_file)
|
30
|
+
rescue => e
|
31
|
+
abort_to_claude("Unexpected error: #{e.message}")
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def validate_environment!
|
37
|
+
unless @project_dir
|
38
|
+
abort_to_claude("CLAUDE_PROJECT_DIR environment variable not set")
|
39
|
+
end
|
40
|
+
|
41
|
+
unless Dir.exist?(@project_dir)
|
42
|
+
abort_to_claude("CLAUDE_PROJECT_DIR does not exist: #{@project_dir}")
|
43
|
+
end
|
44
|
+
|
45
|
+
@project_path = Pathname.new(@project_dir).realpath
|
46
|
+
rescue => e
|
47
|
+
abort_to_claude("Invalid CLAUDE_PROJECT_DIR: #{e.message}")
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse_input
|
51
|
+
input = $stdin.read
|
52
|
+
JSON.parse(input)
|
53
|
+
rescue JSON::ParserError => e
|
54
|
+
abort_to_claude("Invalid JSON input: #{e.message}")
|
55
|
+
end
|
56
|
+
|
57
|
+
def extract_file_path(data)
|
58
|
+
file_path = data.dig("tool_input", "file_path")
|
59
|
+
|
60
|
+
unless file_path && !file_path.empty?
|
61
|
+
abort_to_claude("No file_path provided in input")
|
62
|
+
end
|
63
|
+
|
64
|
+
file_path
|
65
|
+
end
|
66
|
+
|
67
|
+
def ruby_file?(file_path)
|
68
|
+
file_path.end_with?(".rb")
|
69
|
+
end
|
70
|
+
|
71
|
+
def validate_and_normalize_path(file_path)
|
72
|
+
# Expand the path to get absolute path
|
73
|
+
expanded_path = File.expand_path(file_path, @project_dir)
|
74
|
+
normalized_path = Pathname.new(expanded_path).cleanpath
|
75
|
+
|
76
|
+
# Check if the path is within the project directory
|
77
|
+
unless normalized_path.to_s.start_with?(@project_path.to_s)
|
78
|
+
log_info("File path outside project directory: #{file_path}")
|
79
|
+
return nil
|
80
|
+
end
|
81
|
+
|
82
|
+
# Convert back to relative path from project root for consistency
|
83
|
+
normalized_path.relative_path_from(@project_path).to_s
|
84
|
+
rescue => e
|
85
|
+
log_info("Invalid file path: #{file_path} - #{e.message}")
|
86
|
+
nil
|
87
|
+
end
|
88
|
+
|
89
|
+
def determine_test_file(file_path)
|
90
|
+
log_info("Determining tests for: #{file_path}")
|
91
|
+
|
92
|
+
if spec_file?(file_path)
|
93
|
+
# If a spec file was modified, run just that spec
|
94
|
+
test_file = file_path
|
95
|
+
log_info("Running single spec: #{test_file}")
|
96
|
+
elsif lib_file?(file_path)
|
97
|
+
# If a lib file was modified, try to find corresponding spec
|
98
|
+
test_file = lib_to_spec_path(file_path)
|
99
|
+
|
100
|
+
if File.exist?(File.join(@project_dir, test_file))
|
101
|
+
log_info("Running corresponding spec: #{test_file}")
|
102
|
+
else
|
103
|
+
# No corresponding spec found, run all tests
|
104
|
+
log_info("No corresponding spec found, running all tests")
|
105
|
+
test_file = "spec"
|
106
|
+
end
|
107
|
+
else
|
108
|
+
# For other Ruby files, run all tests
|
109
|
+
log_info("Running all tests")
|
110
|
+
test_file = "spec"
|
111
|
+
end
|
112
|
+
|
113
|
+
# Validate test file/directory exists
|
114
|
+
full_test_path = File.join(@project_dir, test_file)
|
115
|
+
unless File.exist?(full_test_path)
|
116
|
+
abort_to_claude("Test file/directory does not exist: #{test_file}")
|
117
|
+
end
|
118
|
+
|
119
|
+
test_file
|
120
|
+
end
|
121
|
+
|
122
|
+
def spec_file?(file_path)
|
123
|
+
file_path.match?(%r{^.*spec/.*_spec\.rb$})
|
124
|
+
end
|
125
|
+
|
126
|
+
def lib_file?(file_path)
|
127
|
+
file_path.match?(%r{^.*lib/.*})
|
128
|
+
end
|
129
|
+
|
130
|
+
def lib_to_spec_path(lib_path)
|
131
|
+
# Convert lib/backspin/foo.rb to spec/backspin/foo_spec.rb
|
132
|
+
lib_path.sub(%r{^(.*/)?lib/}, '\1spec/')
|
133
|
+
.sub(/\.rb$/, "_spec.rb")
|
134
|
+
end
|
135
|
+
|
136
|
+
def run_tests(test_file)
|
137
|
+
rspec_path = File.join(@project_dir, "bin", "rspec")
|
138
|
+
|
139
|
+
unless File.executable?(rspec_path)
|
140
|
+
abort_to_claude("rspec binary not found or not executable: #{rspec_path}")
|
141
|
+
end
|
142
|
+
|
143
|
+
log_info("Running: bin/rspec #{test_file}")
|
144
|
+
|
145
|
+
# Change to project directory for command execution
|
146
|
+
Dir.chdir(@project_dir) do
|
147
|
+
stdout, stderr, status = Open3.capture3("bin/rspec", test_file)
|
148
|
+
|
149
|
+
# Output both stdout and stderr
|
150
|
+
$stdout.print stdout
|
151
|
+
$stderr.print stderr
|
152
|
+
|
153
|
+
if status.success?
|
154
|
+
log_info("✅ Tests passed")
|
155
|
+
EXIT_SUCCESS
|
156
|
+
else
|
157
|
+
warn("❌ Tests failed - fix before continuing")
|
158
|
+
EXIT_FAILURE
|
159
|
+
end
|
160
|
+
end
|
161
|
+
rescue => e
|
162
|
+
abort_to_claude("Failed to run tests: #{e.message}")
|
163
|
+
end
|
164
|
+
|
165
|
+
def log_info(message)
|
166
|
+
warn(message)
|
167
|
+
end
|
168
|
+
|
169
|
+
def abort_to_claude(message)
|
170
|
+
warn("❌ ERROR: #{message}")
|
171
|
+
exit EXIT_FAILURE
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Run the script
|
176
|
+
if __FILE__ == $0
|
177
|
+
runner = AffectedTestRunner.new
|
178
|
+
exit runner.run
|
179
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: backspin
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rob Sanheim
|
@@ -112,11 +112,11 @@ files:
|
|
112
112
|
- fixtures/backspin/playback_system.yml
|
113
113
|
- fixtures/backspin/playback_test.yml
|
114
114
|
- fixtures/backspin/stderr_test.yml
|
115
|
+
- fixtures/backspin/strict_test.yml
|
115
116
|
- fixtures/backspin/string_symbol_test.yml
|
116
117
|
- fixtures/backspin/system_echo.yml
|
117
118
|
- fixtures/backspin/system_false.yml
|
118
119
|
- fixtures/backspin/timestamp_test.yml
|
119
|
-
- fixtures/backspin/use_record_filter.yml
|
120
120
|
- fixtures/backspin/verify_system.yml
|
121
121
|
- fixtures/backspin/verify_system_diff.yml
|
122
122
|
- fixtures/backspin/version_test.yml
|
@@ -132,6 +132,7 @@ files:
|
|
132
132
|
- lib/backspin/version.rb
|
133
133
|
- release.rake
|
134
134
|
- script/lint
|
135
|
+
- script/run_affected_tests
|
135
136
|
homepage: https://github.com/rsanheim/backspin
|
136
137
|
licenses:
|
137
138
|
- MIT
|
@@ -153,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
154
|
- !ruby/object:Gem::Version
|
154
155
|
version: '0'
|
155
156
|
requirements: []
|
156
|
-
rubygems_version: 3.6.
|
157
|
+
rubygems_version: 3.6.9
|
157
158
|
specification_version: 4
|
158
159
|
summary: Record and replay CLI interactions for testing
|
159
160
|
test_files: []
|