backspin 0.13.0 → 0.15.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: 3810a17b9b213b010cadd7419559fda50aed8dee3faad5865e824e765921339d
4
- data.tar.gz: 400594d38ca7937da9095c78eb6578d5d384077a1cbbf78db97f06e133c6dcd0
3
+ metadata.gz: 34e60a632038ea59c7a0e95c64068032a5d3cf79584c99d622e36c6f5c610ec7
4
+ data.tar.gz: 49c6fc2e2902dc796072c6032c96862abf2111ad1220bd327ecc615ddb50f388
5
5
  SHA512:
6
- metadata.gz: c2614110086b787c10b31504ae0eed80b32adc7a72d61709f6fba6245e093b35f92816227faebf781b3257e25b766c45676c47e74422176ae07fbc3eafaabce2
7
- data.tar.gz: 6bb11f527a1aebc8ed495ccde7ca4deddf87b33f33f160cf76da13e02bc994e9b397cbaa59df2f30656cbd4a589ea70ebe24194743ac96a9b639fcda4cd9bf22
6
+ metadata.gz: de757488de5502e56a41e9910d7e118e93bcb1e77ef93c79014d048d59c5ac83b01644dd67c5a61a10996aa5d3ee3f372f957672cafecddcf7af9e6a4c329843
7
+ data.tar.gz: 805494ee3a1e0e40de7f3b1b05373d73d067798e0aed698989e5662d80ed6ef1d3b0af6a24bb6a3f1f3bc3a3affc609867c4b6daffe62c7291ee881b59499803
data/.circleci/config.yml CHANGED
@@ -12,14 +12,22 @@ jobs:
12
12
  ruby-version:
13
13
  type: string
14
14
  docker:
15
- - image: cimg/ruby:<< parameters.ruby-version >>
15
+ - image: ghcr.io/jdx/mise:2026.9.15-debian
16
+ environment:
17
+ MISE_RUBY_VERSION: << parameters.ruby-version >>
18
+ MISE_YES: "1"
16
19
  steps:
17
20
  - checkout
21
+ - run:
22
+ name: Install project tools
23
+ command: |
24
+ apt-get update && apt-get install -y --no-install-recommends build-essential libyaml-dev
25
+ mise install
18
26
  - ruby/install-deps:
19
- key: gems-v1-ruby<< parameters.ruby-version >>
27
+ key: gems-v2-mise-ruby<< parameters.ruby-version >>
20
28
  - run:
21
- name: Run specs
22
- command: bundle exec rake spec
29
+ name: Run specs with Plur
30
+ command: plur spec
23
31
  - run:
24
32
  name: Run fake gem specs
25
33
  command: bundle exec rake spec:fake_gem
@@ -0,0 +1,24 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ previous-ref:
7
+ description: Tag or commit before the version increase (for retrying an unpublished release)
8
+ required: true
9
+ type: string
10
+ pull_request:
11
+ types: [opened, synchronize, reopened]
12
+ push:
13
+ branches: [main]
14
+
15
+ permissions:
16
+ contents: write
17
+ id-token: write
18
+
19
+ jobs:
20
+ release:
21
+ uses: rsanheim/.github/.github/workflows/gem-release.yml@4ced52859dd60019b930a3f2023424e77b37ee2a
22
+ with:
23
+ gem: backspin
24
+ ruby-version: "4.0.0"
data/.gitignore CHANGED
@@ -7,7 +7,6 @@
7
7
  /tmp/
8
8
  /.claude
9
9
  /.cursor
10
- /checksums/
11
10
 
12
11
  /backspin*.gem
13
12
  # rspec failure tracking
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+ * Added `stdin:` to `Backspin.run` and `Backspin.compare` to pipe a String into the command, replacing `sh -c` wrappers. For `compare`, give a side as `{command:, stdin:}`. Stdin is saved on every snapshot like stdout and stderr (empty when not given), so re-recorded files gain a `stdin` line, but it is not compared.
5
+ * Changed `Backspin.compare` to skip credential scrubbing, so filters, matchers, and diffs see raw output and scrubbing can no longer hide mismatches (generic patterns could turn different paths into identical star runs). `run` and `capture` still scrub.
6
+ * Added `env:` to `Backspin.compare` side Hashes (`{command:, env:, stdin:}`), so each command can get its own environment. A side's env replaces the top-level `env:` for that side, and each snapshot records the env it ran with.
7
+
8
+ ## 0.14.0
9
+ * Improved verification errors with per-field summaries for stdout, stderr, and exit status, plus three lines of context around output changes. Long diffs are limited to 50 lines; set `BACKSPIN_FULL_DIFF=1` to show the full diff.
10
+ * Preserved custom matcher failure reasons and made missing final newlines visible in output diffs.
11
+ * Fixed `backspin_dir` configuration to accept String paths as well as Pathname objects.
12
+
3
13
  ## 0.13.0
4
14
  * Added `Backspin.compare(reference:, actual:)` for differential testing - runs both commands live and compares their filtered output, with no record file. Only stdout, stderr, and exit status are compared, so the two commands may differ in argv and env without normalization.
5
15
  * Added `Backspin::ReferenceCommandError`, raised when a compare's reference command produces no output at all (usually a sign it failed to start), rather than comparing empty to empty.
data/CLAUDE.md CHANGED
@@ -24,9 +24,12 @@ bin/rspec spec/[file]:[line] # Run specific test
24
24
  ### Building and Releasing
25
25
  ```bash
26
26
  bin/rake install # Install gem locally for testing
27
- bin/rake release # Release to RubyGems (updates version, tags, pushes)
27
+ bin/rake build # Build the gem locally
28
28
  ```
29
29
 
30
+ Use the [shared release guide](https://github.com/rsanheim/.github#readme) and
31
+ [Backspin quick reference](CONTRIBUTING.md#release-maintainer-setup) for releases.
32
+
30
33
  ### Code Quality
31
34
  ```bash
32
35
  script/lint # Run Standard Ruby linter
data/CONTRIBUTING.md CHANGED
@@ -11,6 +11,7 @@ Note that Backspin is in early development and the API _will_ change before stab
11
11
  - [Making Changes](#making-changes)
12
12
  - [Testing](#testing)
13
13
  - [Submitting Changes](#submitting-changes)
14
+ - [Release Maintainer Setup](#release-maintainer-setup)
14
15
  - [Code Style](#code-style)
15
16
  - [Reporting Issues](#reporting-issues)
16
17
  - [Feature Requests](#feature-requests)
@@ -161,6 +162,21 @@ end
161
162
  - Update examples in README.md if changing public APIs
162
163
  - Ensure CI passes (tests against Ruby 3.2, 3.3, 3.4, and 4.0)
163
164
 
165
+ ## Release Maintainer Setup
166
+
167
+ Setup, trusted publishing, workflow updates, verification, and recovery live in
168
+ [the shared release guide](https://github.com/rsanheim/.github#readme).
169
+
170
+ Backspin quick reference:
171
+
172
+ 1. Update `lib/backspin/version.rb` and add matching notes to `CHANGELOG.md`.
173
+ 2. Synchronize Backspin's version in `Gemfile.lock` and
174
+ `fixtures/projects/dummy_cli_gem/Gemfile.lock` (including its checksum entry).
175
+ 3. Run the normal checks and `BUNDLE_FROZEN=true bundle exec rake build`.
176
+ 4. Merge the reviewed PR to `main` to publish.
177
+
178
+ The caller is [`.github/workflows/release.yml`](.github/workflows/release.yml).
179
+
164
180
  ## Code Style
165
181
 
166
182
  Backspin uses [Standard Ruby](https://github.com/standardrb/standard) for code formatting. Run the linter before committing:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- backspin (0.13.0)
4
+ backspin (0.15.0)
5
5
  logger
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -92,6 +92,26 @@ result.verified? # true when they match
92
92
 
93
93
  Only stdout, stderr, and exit status are compared, so the two commands can differ in argv and environment without any normalization. `filter` and `matcher` work exactly as they do for `Backspin.run`, and a mismatch raises `Backspin::VerificationError` unless `raise_on_verification_failure` is disabled.
94
94
 
95
+ To pipe data into a command's stdin, give that side as a Hash with `command:` and `stdin:`:
96
+
97
+ ```ruby
98
+ Backspin.compare(
99
+ reference: {command: ["jq", "."], stdin: '{"a": 1}'},
100
+ actual: {command: ["my-jq", "."], stdin: '{"a": 1}'}
101
+ )
102
+ ```
103
+
104
+ A side Hash can also take `env:`, which replaces the top-level `env:` for that side:
105
+
106
+ ```ruby
107
+ Backspin.compare(
108
+ reference: {command: ["jq", "."], env: {"HOME" => reference_home}},
109
+ actual: {command: ["my-jq", "."], env: {"HOME" => actual_home}}
110
+ )
111
+ ```
112
+
113
+ `compare` records nothing, so it does not scrub credentials. Filters, matchers, and failure diffs see raw output.
114
+
95
115
  If the reference command produces no output at all - usually a sign it failed to start rather than that its output is genuinely empty - `compare` raises `Backspin::ReferenceCommandError` instead of comparing empty to empty.
96
116
 
97
117
  ### Recording Modes
@@ -143,6 +163,7 @@ record_count: 3 # increments on each write
143
163
  snapshot:
144
164
  command_type: "Open3::Capture3"
145
165
  args: ["echo", "hello"]
166
+ stdin: ""
146
167
  stdout: "hello\n"
147
168
  stderr: ""
148
169
  status: 0
@@ -164,6 +185,14 @@ Backspin.run(
164
185
 
165
186
  If `env:` is not provided, it is not passed to `Open3.capture3` and is not recorded.
166
187
 
188
+ ### Standard Input
189
+
190
+ ```ruby
191
+ Backspin.run(["cat"], name: "with_stdin", stdin: "hello from stdin")
192
+ ```
193
+
194
+ `stdin:` is passed to `Open3.capture3` as `stdin_data:` and saved in the record, but only stdout, stderr, and exit status are verified. `stdin:` is not supported with the block form.
195
+
167
196
  ### Custom Matchers
168
197
 
169
198
  For cases where full matching isn't suitable, you can override via `matcher:`. **NOTE**: If you provide
@@ -253,6 +282,23 @@ result.error_message # Human-readable error if verification failed
253
282
  result.diff # Diff between expected and actual output
254
283
  ```
255
284
 
285
+ ### Verification diffs
286
+
287
+ Verification errors include the failure reason and a summary of changes to stdout, stderr,
288
+ and exit status. Output diffs show three lines of context around each change, with `...`
289
+ where unchanged lines are omitted. A `\ No newline at end of output` marker identifies
290
+ output without a final newline.
291
+
292
+ Diffs are limited to 50 lines by default. To see every diff line, run your tests with:
293
+
294
+ ```sh
295
+ BACKSPIN_FULL_DIFF=1 bundle exec rake spec
296
+ ```
297
+
298
+ The limit applies to `result.diff` and the diff portion of `result.error_message`. The
299
+ environment variable removes the size limit; unchanged lines outside the context windows
300
+ are still omitted.
301
+
256
302
  ### Configuration
257
303
 
258
304
  You can configure Backspin's behavior globally:
@@ -261,7 +307,7 @@ You can configure Backspin's behavior globally:
261
307
  Backspin.configure do |config|
262
308
  config.raise_on_verification_failure = false # default is true
263
309
  config.backspin_dir = "spec/fixtures/cli_records" # default is "fixtures/backspin"
264
- config.scrub_credentials = false # default is true
310
+ config.scrub_credentials = false # default is true; scrubs run/capture records
265
311
  end
266
312
  ```
267
313
 
@@ -314,7 +360,7 @@ end
314
360
 
315
361
  If the CLI interaction you are recording contains sensitive data in stdout/stderr, you should be careful to make sure it is not recorded to YAML.
316
362
 
317
- By default, Backspin automatically tries to scrub common credential patterns from recorded stdout, stderr, args, and env values. Always review your record files before commiting them to source control.
363
+ Backspin scrubs credentials from everything it records: by default it tries to scrub common credential patterns from stdout, stderr, args, env, and stdin before `run` and `capture` save them. `compare` records nothing, so it does not scrub. Always review your record files before commiting them to source control.
318
364
 
319
365
  ```ruby
320
366
  # This will automatically scrub AWS keys, API tokens, passwords, etc.
@@ -344,16 +390,22 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
344
390
  This repo also includes a decoupled full-stack fixture gem at `fixtures/projects/dummy_cli_gem` that uses Backspin the way downstream projects do. Run it with:
345
391
 
346
392
  ```bash
347
- bundle exec rake full_stack:dummy_app
393
+ bundle exec rake spec:fake_gem
348
394
  ```
349
395
 
350
396
  To re-record that fixture's committed YAML snapshots:
351
397
 
352
398
  ```bash
353
- bundle exec rake full_stack:record_dummy_app
399
+ bundle exec rake spec:fake_gem_record
354
400
  ```
355
401
 
356
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
402
+ To install this gem onto your local machine, run `bundle exec rake install`.
403
+
404
+ ### Releasing
405
+
406
+ See the [shared release guide](https://github.com/rsanheim/.github#readme) for
407
+ setup and operation, and the [Backspin quick reference](CONTRIBUTING.md#release-maintainer-setup)
408
+ for the files to update in a release PR.
357
409
 
358
410
  ## Contributing
359
411
 
data/Rakefile CHANGED
@@ -34,5 +34,3 @@ namespace :spec do
34
34
  end
35
35
 
36
36
  task default: %i[spec standard]
37
-
38
- load "release.rake" if File.exist?("release.rake")
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ../../..
3
3
  specs:
4
- backspin (0.13.0)
4
+ backspin (0.15.0)
5
5
  logger
6
6
 
7
7
  PATH
@@ -40,7 +40,7 @@ DEPENDENCIES
40
40
  rspec (~> 3)
41
41
 
42
42
  CHECKSUMS
43
- backspin (0.13.0)
43
+ backspin (0.15.0)
44
44
  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
45
45
  dummy_cli_gem (0.1.0)
46
46
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
@@ -36,10 +36,18 @@ module Backspin
36
36
  return nil unless verified? == false
37
37
  return "Output verification failed" unless @command_diff
38
38
 
39
- msg = "Output verification failed:\n\n"
40
- msg += @command_diff.summary
41
- msg += "\n#{@command_diff.diff}" if @command_diff.diff
42
- msg
39
+ parts = ["Output verification failed:", @command_diff.summary, "", "Summary:"]
40
+ @command_diff.field_summary.each do |line|
41
+ parts << " #{line}"
42
+ end
43
+
44
+ diff = @command_diff.diff
45
+ if diff && !diff.empty?
46
+ parts << ""
47
+ parts << diff
48
+ end
49
+
50
+ parts.join("\n")
43
51
  end
44
52
 
45
53
  def success?
@@ -3,6 +3,9 @@
3
3
  module Backspin
4
4
  # Represents the difference between expected and actual snapshots.
5
5
  class CommandDiff
6
+ CONTEXT_LINES = 3
7
+ MAX_DIFF_LINES = 50
8
+
6
9
  attr_reader :expected, :actual, :matcher
7
10
 
8
11
  def initialize(expected:, actual:, matcher: nil, filter: nil, filter_on: :both)
@@ -26,6 +29,15 @@ module Backspin
26
29
  @verified = @matcher.match?
27
30
  end
28
31
 
32
+ # @return [Array<String>] Per-field change status lines
33
+ def field_summary
34
+ lines = []
35
+ lines << stdout_field_summary
36
+ lines << stderr_field_summary
37
+ lines << status_field_summary
38
+ lines
39
+ end
40
+
29
41
  # @return [String, nil] Human-readable diff if not verified
30
42
  def diff
31
43
  return nil if verified?
@@ -48,7 +60,8 @@ module Backspin
48
60
  parts << "Exit status: expected #{expected_compare.status}, got #{actual_compare.status}"
49
61
  end
50
62
 
51
- parts.join("\n\n")
63
+ result = parts.join("\n\n")
64
+ maybe_truncate(result)
52
65
  end
53
66
 
54
67
  # @return [String] Single line summary for error messages
@@ -62,6 +75,30 @@ module Backspin
62
75
 
63
76
  private
64
77
 
78
+ def stdout_field_summary
79
+ if expected_compare.stdout == actual_compare.stdout
80
+ "stdout: unchanged"
81
+ else
82
+ "stdout: changed"
83
+ end
84
+ end
85
+
86
+ def stderr_field_summary
87
+ if expected_compare.stderr == actual_compare.stderr
88
+ "stderr: unchanged"
89
+ else
90
+ "stderr: changed"
91
+ end
92
+ end
93
+
94
+ def status_field_summary
95
+ if expected_compare.status == actual_compare.status
96
+ "status: unchanged"
97
+ else
98
+ "status: changed (expected #{expected_compare.status}, actual #{actual_compare.status})"
99
+ end
100
+ end
101
+
65
102
  def command_types_match?
66
103
  expected.command_type == actual.command_type
67
104
  end
@@ -83,25 +120,77 @@ module Backspin
83
120
  end
84
121
 
85
122
  def generate_line_diff(expected, actual)
86
- expected_lines = (expected || "").lines
87
- actual_lines = (actual || "").lines
123
+ expected_lines = split_lines(expected)
124
+ actual_lines = split_lines(actual)
125
+ max_lines = [expected_lines.length, actual_lines.length].max
126
+
127
+ changed = Array.new(max_lines, false)
128
+ max_lines.times do |i|
129
+ changed[i] = (expected_lines[i] != actual_lines[i])
130
+ end
131
+
132
+ visible = Array.new(max_lines, false)
133
+ max_lines.times do |i|
134
+ next unless changed[i]
135
+ range_start = [i - CONTEXT_LINES, 0].max
136
+ range_end = [i + CONTEXT_LINES, max_lines - 1].min
137
+ (range_start..range_end).each { |j| visible[j] = true }
138
+ end
88
139
 
89
140
  diff_lines = []
90
- max_lines = [expected_lines.length, actual_lines.length].max
141
+ first_visible = visible.index(true)
142
+ diff_lines << "..." if first_visible&.positive?
143
+ in_hunk = false
91
144
 
92
145
  max_lines.times do |i|
93
- expected_line = expected_lines[i]
94
- actual_line = actual_lines[i]
146
+ unless visible[i]
147
+ if in_hunk
148
+ diff_lines << "..."
149
+ in_hunk = false
150
+ end
151
+ next
152
+ end
153
+
154
+ in_hunk = true
95
155
 
96
- if expected_line != actual_line
97
- diff_lines << "-#{expected_line.chomp}" if expected_line
98
- diff_lines << "+#{actual_line.chomp}" if actual_line
156
+ if changed[i]
157
+ diff_lines << "-#{render_line(expected_lines[i])}" if i < expected_lines.length
158
+ diff_lines << "+#{render_line(actual_lines[i])}" if i < actual_lines.length
159
+ else
160
+ line = expected_lines[i] || actual_lines[i]
161
+ diff_lines << " #{render_line(line)}"
99
162
  end
100
163
  end
101
164
 
102
165
  diff_lines.join("\n")
103
166
  end
104
167
 
168
+ def split_lines(value)
169
+ (value || "").lines
170
+ end
171
+
172
+ def render_line(line)
173
+ rendered = line.to_s.chomp
174
+ return rendered if line.to_s.end_with?("\n")
175
+
176
+ "#{rendered}\n\\ No newline at end of output"
177
+ end
178
+
179
+ def maybe_truncate(diff_text)
180
+ return diff_text if full_diff?
181
+
182
+ lines = diff_text.lines
183
+ return diff_text if lines.length <= MAX_DIFF_LINES
184
+
185
+ truncated = lines.first(MAX_DIFF_LINES).join
186
+ truncated.chomp!
187
+ truncated + "\n(diff truncated, set BACKSPIN_FULL_DIFF=1 for full output)"
188
+ end
189
+
190
+ def full_diff?
191
+ ENV["BACKSPIN_FULL_DIFF"] == "1"
192
+ end
193
+
105
194
  attr_reader :expected_compare
106
195
 
107
196
  attr_reader :actual_compare
@@ -8,7 +8,7 @@ module Backspin
8
8
  class Configuration
9
9
  attr_accessor :scrub_credentials
10
10
  # The directory where backspin will store its files - defaults to fixtures/backspin
11
- attr_accessor :backspin_dir
11
+ attr_reader :backspin_dir
12
12
  # Whether to raise an exception when verification fails in `run`/`capture` - defaults to true
13
13
  attr_accessor :raise_on_verification_failure
14
14
  # Logger for Backspin diagnostics - defaults to WARN level, logfmt-lite format
@@ -24,6 +24,10 @@ module Backspin
24
24
  @logger = default_logger
25
25
  end
26
26
 
27
+ def backspin_dir=(path)
28
+ @backspin_dir = Pathname.new(path)
29
+ end
30
+
27
31
  def add_credential_pattern(pattern)
28
32
  @credential_patterns << pattern
29
33
  end
@@ -3,14 +3,16 @@
3
3
  module Backspin
4
4
  # Represents a single captured execution snapshot.
5
5
  class Snapshot
6
- attr_reader :command_type, :args, :env, :stdout, :stderr, :status, :recorded_at
6
+ attr_reader :command_type, :args, :env, :stdin, :stdout, :stderr, :status, :recorded_at
7
7
 
8
- def initialize(command_type:, args:, env: nil, stdout: "", stderr: "", status: 0, recorded_at: nil)
8
+ def initialize(command_type:, args:, env: nil, stdin: "", stdout: "", stderr: "", status: 0, recorded_at: nil, scrub: Backspin.configuration.scrub_credentials)
9
+ @scrub = scrub
9
10
  @command_type = command_type
10
11
  @args = sanitize_args(args)
11
12
  @env = env.nil? ? nil : sanitize_env(env)
12
- @stdout = Backspin.scrub_text((stdout || "").dup).freeze
13
- @stderr = Backspin.scrub_text((stderr || "").dup).freeze
13
+ @stdin = scrub_string((stdin || "").dup).freeze
14
+ @stdout = scrub_string((stdout || "").dup).freeze
15
+ @stderr = scrub_string((stderr || "").dup).freeze
14
16
  @status = status || 0
15
17
  @recorded_at = recorded_at.nil? ? nil : recorded_at.dup.freeze
16
18
  @serialized_hash = build_serialized_hash
@@ -42,6 +44,7 @@ module Backspin
42
44
  command_type: command_type,
43
45
  args: data["args"],
44
46
  env: data["env"],
47
+ stdin: data["stdin"],
45
48
  stdout: data["stdout"],
46
49
  stderr: data["stderr"],
47
50
  status: data["status"],
@@ -55,6 +58,7 @@ module Backspin
55
58
  data = {
56
59
  "command_type" => command_type.name,
57
60
  "args" => args,
61
+ "stdin" => stdin,
58
62
  "stdout" => stdout,
59
63
  "stderr" => stderr,
60
64
  "status" => status,
@@ -64,25 +68,25 @@ module Backspin
64
68
  deep_freeze(data)
65
69
  end
66
70
 
67
- def scrub_args(value)
68
- return value unless Backspin.configuration.scrub_credentials && value
71
+ def scrub_string(text)
72
+ @scrub ? Backspin.scrub_text(text) : text
73
+ end
69
74
 
75
+ def scrub_args(value)
70
76
  case value
71
77
  when String
72
- Backspin.scrub_text(value)
78
+ scrub_string(value)
73
79
  when Array
74
80
  value.map { |entry| scrub_args(entry) }
75
81
  when Hash
76
- value.transform_values { |entry| entry.is_a?(String) ? Backspin.scrub_text(entry) : entry }
82
+ value.transform_values { |entry| entry.is_a?(String) ? scrub_string(entry) : entry }
77
83
  else
78
84
  value
79
85
  end
80
86
  end
81
87
 
82
88
  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 }
89
+ value.transform_values { |entry| entry.is_a?(String) ? scrub_string(entry) : entry }
86
90
  end
87
91
 
88
92
  def sanitize_args(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Backspin
4
- VERSION = "0.13.0"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/backspin.rb CHANGED
@@ -76,17 +76,19 @@ module Backspin
76
76
  # @param command [String, Array] Command to execute via Open3.capture3
77
77
  # @param name [String] Name for the record file
78
78
  # @param env [Hash] Environment variables to pass to Open3.capture3
79
+ # @param stdin [String] Data written to the command's stdin (recorded, not compared)
79
80
  # @param mode [Symbol] Recording mode - :auto, :record, :verify
80
81
  # @param matcher [Proc, Hash] Custom matcher for verification
81
82
  # @param filter [Proc] Custom filter for recorded data/canonicalization
82
83
  # @param filter_on [Symbol] Filter application mode - :both (default), :record
83
84
  # @return [BackspinResult] Aggregate result for this run
84
- def run(command = nil, name:, env: nil, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
85
+ def run(command = nil, name:, env: nil, stdin: nil, mode: :auto, matcher: nil, filter: nil, filter_on: :both, &block)
85
86
  validate_filter_on!(filter_on)
86
87
 
87
88
  if block_given?
88
89
  raise ArgumentError, "command must be omitted when using a block" unless command.nil?
89
90
  raise ArgumentError, "env is not supported when using a block" unless env.nil?
91
+ raise ArgumentError, "stdin is not supported when using a block" unless stdin.nil?
90
92
 
91
93
  return perform_capture(name, mode: mode, matcher: matcher, filter: filter, filter_on: filter_on, &block)
92
94
  end
@@ -97,6 +99,7 @@ module Backspin
97
99
  command,
98
100
  name: name,
99
101
  env: env,
102
+ stdin: stdin,
100
103
  mode: mode,
101
104
  matcher: matcher,
102
105
  filter: filter,
@@ -126,22 +129,31 @@ module Backspin
126
129
  # Only stdout, stderr, and status are compared, so the two commands may
127
130
  # differ in argv and env without any normalization.
128
131
  #
129
- # @param reference [String, Array] Command that defines the correct output
130
- # @param actual [String, Array] Command under test
132
+ # Either side may be a Hash of {command:, env:, stdin:} to give that command
133
+ # its own env or stdin. A side's env replaces the top-level env for that
134
+ # side. Stdin is recorded on each snapshot, not compared.
135
+ #
136
+ # Credentials are not scrubbed, so the filter, matcher, and diff see raw output.
137
+ #
138
+ # @param reference [String, Array, Hash] Command that defines the correct output
139
+ # @param actual [String, Array, Hash] Command under test
131
140
  # @param env [Hash] Environment variables passed to both commands
132
141
  # @param matcher [Proc, Hash] Custom matcher for verification
133
142
  # @param filter [Proc] Custom filter applied to both sides before comparing
134
143
  # @return [BackspinResult] expected = reference, actual = command under test
135
144
  def compare(reference:, actual:, env: nil, matcher: nil, filter: nil)
136
- normalized_env = normalize_env(env)
145
+ reference_command, reference_env, reference_stdin = reference.is_a?(Hash) ? reference.values_at(:command, :env, :stdin) : [reference, nil, nil]
146
+ actual_command, actual_env, actual_stdin = actual.is_a?(Hash) ? actual.values_at(:command, :env, :stdin) : [actual, nil, nil]
147
+ reference_env = normalize_env(reference_env || env)
148
+ actual_env = normalize_env(actual_env || env)
137
149
 
138
- expected_snapshot, = capture_command_snapshot(reference, normalized_env)
150
+ expected_snapshot, = capture_command_snapshot(reference_command, reference_env, reference_stdin, scrub: false)
139
151
  if expected_snapshot.stdout.empty? && expected_snapshot.stderr.empty?
140
152
  raise ReferenceCommandError,
141
153
  "Reference command produced no output (exit status #{expected_snapshot.status}): #{reference.inspect}"
142
154
  end
143
155
 
144
- actual_snapshot, = capture_command_snapshot(actual, normalized_env)
156
+ actual_snapshot, = capture_command_snapshot(actual_command, actual_env, actual_stdin, scrub: false)
145
157
 
146
158
  command_diff = CommandDiff.new(
147
159
  expected: expected_snapshot,
@@ -188,7 +200,7 @@ module Backspin
188
200
  result
189
201
  end
190
202
 
191
- def perform_command_run(command, name:, env:, mode:, matcher:, filter:, filter_on:)
203
+ def perform_command_run(command, name:, env:, stdin:, mode:, matcher:, filter:, filter_on:)
192
204
  record_path = Record.build_record_path(name)
193
205
  mode = determine_mode(mode, record_path)
194
206
  validate_mode!(mode)
@@ -199,7 +211,7 @@ module Backspin
199
211
 
200
212
  result = case mode
201
213
  when :record
202
- actual_snapshot, output = capture_command_snapshot(command, normalized_env)
214
+ actual_snapshot, output = capture_command_snapshot(command, normalized_env, stdin)
203
215
  record.set_snapshot(actual_snapshot)
204
216
  record.save(filter: filter)
205
217
  BackspinResult.new(
@@ -217,7 +229,7 @@ module Backspin
217
229
  raise RecordFormatError, "Invalid record format: expected Open3::Capture3 for run"
218
230
  end
219
231
 
220
- actual_snapshot, output = capture_command_snapshot(command, normalized_env)
232
+ actual_snapshot, output = capture_command_snapshot(command, normalized_env, stdin)
221
233
  command_diff = CommandDiff.new(
222
234
  expected: expected_snapshot,
223
235
  actual: actual_snapshot,
@@ -243,16 +255,18 @@ module Backspin
243
255
  result
244
256
  end
245
257
 
246
- def capture_command_snapshot(command, env)
247
- stdout, stderr, status = execute_command(command, env)
258
+ def capture_command_snapshot(command, env, stdin, scrub: configuration.scrub_credentials)
259
+ stdout, stderr, status = execute_command(command, env, stdin)
248
260
  snapshot = Snapshot.new(
249
261
  command_type: Open3::Capture3,
250
262
  args: command,
251
263
  env: env,
264
+ stdin: stdin,
252
265
  stdout: stdout,
253
266
  stderr: stderr,
254
267
  status: status.exitstatus,
255
- recorded_at: Time.now.utc.iso8601
268
+ recorded_at: Time.now.utc.iso8601,
269
+ scrub: scrub
256
270
  )
257
271
  [snapshot, [stdout, stderr, status]]
258
272
  end
@@ -264,13 +278,13 @@ module Backspin
264
278
  env.empty? ? nil : env
265
279
  end
266
280
 
267
- def execute_command(command, env)
281
+ def execute_command(command, env, stdin)
268
282
  case command
269
283
  when String
270
- env ? Open3.capture3(env, command) : Open3.capture3(command)
284
+ env ? Open3.capture3(env, command, stdin_data: stdin) : Open3.capture3(command, stdin_data: stdin)
271
285
  when Array
272
286
  raise ArgumentError, "command array cannot be empty" if command.empty?
273
- env ? Open3.capture3(env, *command) : Open3.capture3(*command)
287
+ env ? Open3.capture3(env, *command, stdin_data: stdin) : Open3.capture3(*command, stdin_data: stdin)
274
288
  else
275
289
  raise ArgumentError, "command must be a String or Array"
276
290
  end
data/mise.toml CHANGED
@@ -1,2 +1,3 @@
1
1
  [tools]
2
2
  ruby = "4.0.0"
3
+ "github:rsanheim/plur" = { version = "latest", minimum_release_age = "0s" }
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.13.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Sanheim
@@ -33,7 +33,7 @@ extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
35
  - ".circleci/config.yml"
36
- - ".gem_release.yml"
36
+ - ".github/workflows/release.yml"
37
37
  - ".gitignore"
38
38
  - ".rspec"
39
39
  - ".ruby-version"
@@ -84,7 +84,6 @@ files:
84
84
  - lib/backspin/snapshot.rb
85
85
  - lib/backspin/version.rb
86
86
  - mise.toml
87
- - release.rake
88
87
  - script/lint
89
88
  homepage: https://github.com/rsanheim/backspin
90
89
  licenses:
@@ -107,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
106
  - !ruby/object:Gem::Version
108
107
  version: '0'
109
108
  requirements: []
110
- rubygems_version: 4.0.16
109
+ rubygems_version: 4.0.3
111
110
  specification_version: 4
112
111
  summary: Record and replay CLI interactions for testing
113
112
  test_files: []
data/.gem_release.yml DELETED
@@ -1,13 +0,0 @@
1
- # Configuration for gem-release
2
- bump:
3
- commit: true
4
- message: "Bump version to %{version}"
5
- skip_ci: false
6
- push: true
7
-
8
- tag:
9
- tag: v%{version}
10
- sign: true
11
-
12
- release:
13
- host: https://rubygems.org
data/release.rake DELETED
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
-
5
- # Simplified release tasks using gem-release
6
- # Install with: gem install gem-release
7
- # https://github.com/svenfuchs/gem-release
8
- namespace :release do
9
- desc "Release a new version (bump, tag, release)"
10
- task :version, [:level] do |t, args|
11
- level = args[:level] || "patch"
12
-
13
- # Pre-release checks
14
- # Rake::Task["release:check"].invoke
15
-
16
- puts "\nReleasing #{level} version..."
17
-
18
- # Use gem-release to bump, tag, and release to rubygems and github
19
- sh "gem bump --version #{level}"
20
- new_version = File.read("lib/backspin/version.rb").match(/VERSION = "(\d+\.\d+\.\d+)"/)[1]
21
-
22
- sh "bundle install"
23
- sh "git commit -am 'Bump version to #{new_version}'"
24
- sh "git push"
25
-
26
- sh "gem release --tag --push"
27
- Rake::Task["release:github"].invoke(new_version)
28
- end
29
-
30
- desc "Create GitHub release for specified version or current version"
31
- task :github, [:version] do |t, args|
32
- version = args[:version] || Backspin::VERSION
33
-
34
- if system("which gh > /dev/null 2>&1")
35
- puts "\nCreating GitHub release for v#{version}..."
36
- sh "gh release create v#{version} --title 'Release v#{version}' --generate-notes"
37
- else
38
- puts "\nGitHub CLI not found. Create release manually at:"
39
- puts "https://github.com/rsanheim/backspin/releases/new?tag=v#{version}"
40
- end
41
- end
42
-
43
- desc "Check if ready for release"
44
- task :check do
45
- require "open-uri"
46
- require "json"
47
-
48
- current_version = Backspin::VERSION
49
- errors = []
50
-
51
- # Check RubyGems for latest version
52
- begin
53
- gem_data = JSON.parse(URI.open("https://rubygems.org/api/v1/gems/backspin.json").read)
54
- latest_version = gem_data["version"]
55
-
56
- if Gem::Version.new(current_version) <= Gem::Version.new(latest_version)
57
- errors << "Current version (#{current_version}) is not greater than latest released version (#{latest_version})"
58
- else
59
- puts "✓ Version #{current_version} is ready for release (latest: #{latest_version})"
60
- end
61
- rescue => e
62
- puts "⚠ Could not check RubyGems version: #{e.message}"
63
- end
64
-
65
- # Check git status
66
- if system("git diff --quiet && git diff --cached --quiet")
67
- puts "✓ No uncommitted changes"
68
- else
69
- errors << "You have uncommitted changes"
70
- end
71
-
72
- # Check branch
73
- current_branch = `git rev-parse --abbrev-ref HEAD`.strip
74
- if current_branch != "main"
75
- puts "⚠ Not on main branch (currently on #{current_branch})"
76
- print "Continue anyway? (y/N): "
77
- response = $stdin.gets.chomp
78
- errors << "Not on main branch" unless response.downcase == "y"
79
- else
80
- puts "✓ On main branch"
81
- end
82
-
83
- unless errors.empty?
84
- puts "\n❌ Cannot release:"
85
- errors.each { |e| puts " - #{e}" }
86
- abort
87
- end
88
-
89
- puts "\n✅ All checks passed!"
90
- end
91
- end
92
-
93
- # Convenience tasks
94
- desc "Release patch version"
95
- task "release:patch" => ["release:version"]
96
-
97
- desc "Release minor version"
98
- task "release:minor" do
99
- Rake::Task["release:version"].invoke("minor")
100
- end
101
-
102
- desc "Release major version"
103
- task "release:major" do
104
- Rake::Task["release:version"].invoke("major")
105
- end