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 +4 -4
- data/.circleci/config.yml +12 -4
- data/.github/workflows/release.yml +24 -0
- data/.gitignore +0 -1
- data/CHANGELOG.md +10 -0
- data/CLAUDE.md +4 -1
- data/CONTRIBUTING.md +16 -0
- data/Gemfile.lock +1 -1
- data/README.md +57 -5
- data/Rakefile +0 -2
- data/fixtures/projects/dummy_cli_gem/Gemfile.lock +2 -2
- data/lib/backspin/backspin_result.rb +12 -4
- data/lib/backspin/command_diff.rb +98 -9
- data/lib/backspin/configuration.rb +5 -1
- data/lib/backspin/snapshot.rb +15 -11
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +29 -15
- data/mise.toml +1 -0
- metadata +3 -4
- data/.gem_release.yml +0 -13
- data/release.rake +0 -105
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34e60a632038ea59c7a0e95c64068032a5d3cf79584c99d622e36c6f5c610ec7
|
|
4
|
+
data.tar.gz: 49c6fc2e2902dc796072c6032c96862abf2111ad1220bd327ecc615ddb50f388
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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-
|
|
27
|
+
key: gems-v2-mise-ruby<< parameters.ruby-version >>
|
|
20
28
|
- run:
|
|
21
|
-
name: Run specs
|
|
22
|
-
command:
|
|
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
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
|
|
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
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
|
-
|
|
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
|
|
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
|
|
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`.
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ../../..
|
|
3
3
|
specs:
|
|
4
|
-
backspin (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.
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
87
|
-
actual_lines = (actual
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
97
|
-
diff_lines << "-#{
|
|
98
|
-
diff_lines << "+#{
|
|
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
|
-
|
|
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
|
data/lib/backspin/snapshot.rb
CHANGED
|
@@ -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
|
-
@
|
|
13
|
-
@
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
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) ?
|
|
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
|
-
|
|
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)
|
data/lib/backspin/version.rb
CHANGED
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
|
-
#
|
|
130
|
-
#
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
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.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
|
-
- ".
|
|
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.
|
|
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
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
|