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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e49507f2d196ebd4d7b4d39a6013d840224a357272fb5ba4880702e26d28c8d9
4
- data.tar.gz: cfee20f6ff8b65c622bfc9482d85e49c78f78e1e9a2c91b5f09f71e2b1d61663
3
+ metadata.gz: 5f6f79191389f6f6f78661b402a1fa776534e37d87292471b708d0eb338779e6
4
+ data.tar.gz: 25c8091f368074e45c9f091a0beec04abaf3e5d5e883e3c6ed4d417ac8c2a349
5
5
  SHA512:
6
- metadata.gz: 660d3af239a57512fbd74eecf2f7fc81c91a6fc42c0a95957952f01931bcd2b5f5387af2004557af985c8c3ef6f37c6b284e41b757af2919a241d5408060b25e
7
- data.tar.gz: 6a0b7f7e2bc4533e80e8011fb17c1f851c31ddd6e831300efec0589423f541640e1ace953c75f066e8a9efc26c6e3fef886cc87941d204e09d1a2302d58adcbc
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: `call`, `verify`, `verify!`, `use_record`
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- backspin (0.6.0)
4
+ backspin (0.7.0)
5
5
  ostruct
6
6
  rspec-mocks (~> 3)
7
7
 
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Backspin
1
+ # Backspin
2
2
 
3
3
  [![Ruby](https://img.shields.io/badge/ruby-%23CC342D.svg?style=flat&logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
4
4
  [![Gem Version](https://img.shields.io/gem/v/backspin)](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
- result = Backspin.run("my_command") do
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
- # Use run! to automatically fail tests on mismatch
52
- Backspin.run!("my_command") do
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 an error because stdout will not match the recorded output
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
- ### Using run! for automatic test failures
88
+ ### The run! method
89
+
90
+ **NOTE:** This method is deprecated and will be removed soon.
87
91
 
88
- The `run!` method works exactly like `run` but automatically fails the test if verification fails:
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
- # Automatically fail the test if output doesn't match
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,8 +4,9 @@ format_version: '2.0'
4
4
  commands:
5
5
  - command_type: Open3::Capture3
6
6
  args:
7
- - date
8
- stdout: 'Wed Jun 11 02:48:13 CDT 2025
7
+ - echo
8
+ - 'test output with : colons'
9
+ stdout: 'test output with : colons
9
10
 
10
11
  '
11
12
  stderr: ''
@@ -5,9 +5,8 @@ commands:
5
5
  - command_type: Open3::Capture3
6
6
  args:
7
7
  - echo
8
- - "'hello"
9
- - world'
10
- stdout: 'HELLO WORLD
8
+ - exact output
9
+ stdout: 'exact output
11
10
 
12
11
  '
13
12
  stderr: ''
@@ -4,9 +4,9 @@ format_version: '2.0'
4
4
  commands:
5
5
  - command_type: Open3::Capture3
6
6
  args:
7
- - ruby
8
- - "--version"
9
- stdout: 'ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [arm64-darwin24]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Backspin
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
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
- # Run Standard Ruby linter
5
- echo "Running Standard Ruby linter..."
6
- bundle exec standardrb "$@"
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.6.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.7
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: []