angry_io 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +20 -5
- data/lib/angry_io/minitest.rb +14 -3
- data/lib/angry_io/rspec.rb +28 -0
- data/lib/angry_io/version.rb +1 -1
- data/lib/angry_io.rb +130 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: efd2cce37a69f805e637ea406088049b150bb0ff9192a7423100add8b7a29342
|
|
4
|
+
data.tar.gz: 4e25659b95836fc83544b295cf40b6428d094cf194356ff1e4e0c0b8e7c1ad42
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 72682994c9e8647d1405a46aa15366c377325f31396767f6f6efe0ef6e6722f875bdce839a0094c04db309e39cb691a05d3daa43d5b4aeff2372430f1e69cee1
|
|
7
|
+
data.tar.gz: 51ee62f1212076d50984ed31c221760464fbd2f15990abd8f4c241fd4294bcc259bffa5b9c46ce54942ab6a5b178582272cd9c09de08dae7fa67baa874bf7e0b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-09-15
|
|
4
|
+
|
|
5
|
+
- Reopening `$stdout`/`$stderr` onto a real IO (e.g. `$stdout.reopen(File::NULL)`) no longer raises `TypeError` under `AngryIo::Stream`. `Stream#reopen` now delegates an IO argument to the original real stream it replaced (a `dup2` that survives the caller closing the other IO) and hands the global back so writes reach the reopened IO; non-IO arguments still defer to StringIO's own buffer reset. The new `AngryIo.real_stream_for` returns the real stream a swapped buffer replaced.
|
|
6
|
+
- ActiveSupport's `capture` and `silence_stream` (from `ActiveSupport::Testing::Stream`) now work alongside AngryIo. They use the same reopen-onto-IO pattern as `capture_subprocess_io`, so AngryIo now runs them under `with_real_streams`, substituting the real stream for the StringIO buffer that `silence_stream` binds at call time.
|
|
7
|
+
- `AngryIo.with_real_streams` is now reentrant: a nested call (e.g. ActiveSupport's `capture` wrapping `quietly`, whose nested `silence_stream` re-enters the helper) no longer swaps the globals back to the Angry buffers while the outer call still expects the real streams. It no-ops when the real streams are already current.
|
|
8
|
+
|
|
9
|
+
## [0.3.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
- Zero-byte writes (e.g. `$stderr.print("")`) no longer raise; `AngryIo::Stream` now raises only when a write would actually emit output. The error message changed from StringIO's `not opened for writing` to `AngryIo::Stream is not writable: ...`, which includes the offending output.
|
|
12
|
+
- Minitest's `capture_subprocess_io` and RSpec's `to_stdout_from_any_process` / `to_stderr_from_any_process` matchers now work alongside AngryIo. These helpers reopen `$stdout`/`$stderr` onto Tempfiles so subprocesses inherit the file descriptors, which fails on the StringIO-based `AngryIo::Stream`; AngryIo now restores the real streams for their duration via a new `AngryIo.with_real_streams` helper.
|
|
13
|
+
|
|
3
14
|
## [0.2.0] - 2026-09-04
|
|
4
15
|
|
|
5
16
|
- Remove the `angry_io/enable_for_ci_true` convenience require; require `angry_io/rspec` or `angry_io/minitest` directly instead.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AngryIo
|
|
2
2
|
|
|
3
|
-
Friends don't let friends write tests or specs that
|
|
3
|
+
Friends don't let friends write tests or specs that output to stdout/stderr. When you look at your test output, you should only see green dots, right?
|
|
4
4
|
|
|
5
5
|
AngryIo replaces `$stdout` and `$stderr` during your test suite with an IO that _raises on write_, so accidental output fails loudly instead of quietly cluttering your output. It ships self-registering adapters for both RSpec and Minitest, gated by a configurable `enabled` callable.
|
|
6
6
|
|
|
@@ -32,10 +32,12 @@ require "angry_io/rspec" # or "angry_io/minitest"
|
|
|
32
32
|
By default, AngryIo is always on, and every test that writes to `$stdout` or `$stderr` now raises:
|
|
33
33
|
|
|
34
34
|
```
|
|
35
|
-
IOError: not
|
|
35
|
+
IOError: AngryIo::Stream is not writable: "your output here"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Zero-byte writes (e.g. `$stderr.print("")`) emit nothing, so they are allowed.
|
|
39
|
+
|
|
40
|
+
If you want to allow test output in some environments but not in others — e.g., allow output when testing locally, but fail on CI — you can configure like this:
|
|
39
41
|
|
|
40
42
|
```ruby
|
|
41
43
|
# On by default; turn it off in environments where you'll allow real output.
|
|
@@ -66,9 +68,22 @@ class PrintTest < Minitest::Test
|
|
|
66
68
|
end
|
|
67
69
|
```
|
|
68
70
|
|
|
71
|
+
The same call works in Minitest's spec format, inside the `describe` block:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# Minitest spec format — opt out a whole describe block
|
|
75
|
+
describe "printing" do
|
|
76
|
+
i_absolutely_need_to_write_to_stdout!
|
|
77
|
+
|
|
78
|
+
it "prints" do
|
|
79
|
+
puts "ok"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
69
84
|
## Configuration
|
|
70
85
|
|
|
71
|
-
`AngryIo.configure` yields a config struct with
|
|
86
|
+
`AngryIo.configure` yields a config struct with one field:
|
|
72
87
|
|
|
73
88
|
| Field | Default | Description |
|
|
74
89
|
| --- | --- | --- |
|
|
@@ -76,7 +91,7 @@ end
|
|
|
76
91
|
|
|
77
92
|
## How it works
|
|
78
93
|
|
|
79
|
-
`AngryIo::Stream` is a `StringIO`
|
|
94
|
+
`AngryIo::Stream` is a `StringIO` whose write methods raise an `IOError` when given anything but an empty string. Around each test, the adapter swaps `$stdout` and `$stderr` to fresh `AngryIo::Stream` instances and restores the originals (closing the buffers) in an `ensure`.
|
|
80
95
|
|
|
81
96
|
## Development
|
|
82
97
|
|
data/lib/angry_io/minitest.rb
CHANGED
|
@@ -24,10 +24,21 @@ module AngryIo
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# capture_subprocess_io reopens $stdout/$stderr onto Tempfiles so
|
|
28
|
+
# subprocesses inherit the file descriptors, which fails on StringIO-based
|
|
29
|
+
# streams. A test calling it captures output on purpose, so restore the
|
|
30
|
+
# real streams for the duration of the capture.
|
|
31
|
+
module CaptureSubprocessIo
|
|
32
|
+
def capture_subprocess_io(&block)
|
|
33
|
+
AngryIo.with_real_streams { super }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
27
37
|
def self.setup!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
::Minitest::Test.extend(ClassMethods)
|
|
39
|
+
::Minitest::Test.prepend(Adapter)
|
|
40
|
+
::Minitest::Assertions.prepend(CaptureSubprocessIo)
|
|
41
|
+
AngryIo.hook_active_support_stream!
|
|
31
42
|
end
|
|
32
43
|
end
|
|
33
44
|
end
|
data/lib/angry_io/rspec.rb
CHANGED
|
@@ -8,6 +8,29 @@ require "angry_io"
|
|
|
8
8
|
# registration works regardless of Bundler.require ordering.
|
|
9
9
|
module AngryIo
|
|
10
10
|
module RSpec
|
|
11
|
+
# The to_*_from_any_process matchers reopen $stdout/$stderr onto Tempfiles
|
|
12
|
+
# so subprocesses inherit the file descriptors, which fails on
|
|
13
|
+
# StringIO-based streams. An example using these matchers captures output
|
|
14
|
+
# on purpose, so restore the real streams while the matcher grabs the
|
|
15
|
+
# stream...
|
|
16
|
+
module FromAnyProcess
|
|
17
|
+
def to_stdout_from_any_process
|
|
18
|
+
AngryIo.with_real_streams { super }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_stderr_from_any_process
|
|
22
|
+
AngryIo.with_real_streams { super }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ...and while the block runs, so Ruby-level writes inside the expect block
|
|
27
|
+
# are captured by the Tempfile instead of raising against the Angry stream.
|
|
28
|
+
module CaptureStreamToTempfile
|
|
29
|
+
def capture(block)
|
|
30
|
+
AngryIo.with_real_streams { super }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
11
34
|
def self.setup!
|
|
12
35
|
::RSpec.configure do |config|
|
|
13
36
|
config.around do |example|
|
|
@@ -16,9 +39,14 @@ module AngryIo
|
|
|
16
39
|
AngryIo.around_streams(opted_out: opt_out) { example.run }
|
|
17
40
|
end
|
|
18
41
|
end
|
|
42
|
+
|
|
43
|
+
::RSpec::Matchers::BuiltIn::Output.prepend(FromAnyProcess)
|
|
44
|
+
::RSpec::Matchers::BuiltIn::CaptureStreamToTempfile.prepend(CaptureStreamToTempfile)
|
|
45
|
+
AngryIo.hook_active_support_stream!
|
|
19
46
|
end
|
|
20
47
|
end
|
|
21
48
|
end
|
|
22
49
|
|
|
23
50
|
require "rspec/core"
|
|
51
|
+
require "rspec/expectations"
|
|
24
52
|
AngryIo::RSpec.setup!
|
data/lib/angry_io/version.rb
CHANGED
data/lib/angry_io.rb
CHANGED
|
@@ -5,12 +5,84 @@ require_relative "angry_io/version"
|
|
|
5
5
|
|
|
6
6
|
module AngryIo
|
|
7
7
|
# An IO that raises when you write to it, so tests can't silently pollute
|
|
8
|
-
# stdout/stderr.
|
|
9
|
-
#
|
|
8
|
+
# stdout/stderr. Zero-byte writes like `$stderr.print("")` emit nothing and
|
|
9
|
+
# are allowed; anything that would produce output raises an IOError.
|
|
10
10
|
class Stream < StringIO
|
|
11
11
|
def initialize
|
|
12
|
-
super(
|
|
12
|
+
super(+"")
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
# StringIO's print, puts, printf, <<, syswrite and write_nonblock all
|
|
16
|
+
# funnel through #write, so this one guard catches them.
|
|
17
|
+
def write(*strings)
|
|
18
|
+
strings.each do |string|
|
|
19
|
+
next if string.to_s.empty?
|
|
20
|
+
|
|
21
|
+
raise IOError, "AngryIo::Stream is not writable: #{string.to_s.inspect}"
|
|
22
|
+
end
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# StringIO#putc writes directly in C, bypassing #write. It always emits a
|
|
27
|
+
# byte, so it always refuses.
|
|
28
|
+
def putc(char)
|
|
29
|
+
raise IOError, "AngryIo::Stream is not writable: #{char.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `IO#reopen(io)` redirects a real $stdout/$stderr onto another IO (a
|
|
33
|
+
# socket, a File, etc.) so the process's output flows there. StringIO only
|
|
34
|
+
# accepts a String, so reopening onto an IO would raise
|
|
35
|
+
# `TypeError: can't convert IO into StringIO` — which breaks code that
|
|
36
|
+
# reopens the streams after a fork. Delegate to the original real stream we
|
|
37
|
+
# replaced (which dup2's the other IO's fd onto the stdout/stderr fd,
|
|
38
|
+
# surviving the caller later closing the other IO), then hand the global
|
|
39
|
+
# back so subsequent writes reach the reopened IO instead of this guard.
|
|
40
|
+
# Non-IO args defer to StringIO's own reopen (buffer reset).
|
|
41
|
+
def reopen(other = nil, *rest)
|
|
42
|
+
if other.is_a?(IO) && (swap = Thread.current.thread_variable_get(:angry_io_swap))
|
|
43
|
+
real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
|
|
44
|
+
if equal?(stdout_buffer)
|
|
45
|
+
real_stdout.reopen(other)
|
|
46
|
+
$stdout = real_stdout
|
|
47
|
+
elsif equal?(stderr_buffer)
|
|
48
|
+
real_stderr.reopen(other)
|
|
49
|
+
$stderr = real_stderr
|
|
50
|
+
else
|
|
51
|
+
# Not one of the swapped buffers (a standalone Stream someone assigned
|
|
52
|
+
# to $stdout outside the adapter): no original to delegate to, so match
|
|
53
|
+
# StringIO and raise TypeError instead of silently no-op'ing.
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
self
|
|
57
|
+
elsif other.nil? && rest.empty?
|
|
58
|
+
super() # StringIO#reopen() resets the buffer
|
|
59
|
+
else
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ActiveSupport::Testing::Stream#capture reopens $stdout/$stderr onto a
|
|
66
|
+
# Tempfile and later restores them via `reopen(dup)` — a dup2-then-restore
|
|
67
|
+
# pattern that only works on real IOs (Tempfile is a Delegator, and StringIO
|
|
68
|
+
# can't be reopened onto one). Restore the real streams for the duration of a
|
|
69
|
+
# capture/silence, the same way Minitest's capture_subprocess_io is handled.
|
|
70
|
+
module ActiveSupportStreamCapture
|
|
71
|
+
def capture(stream)
|
|
72
|
+
AngryIo.with_real_streams { super }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def silence_stream(stream)
|
|
76
|
+
# `stream` is bound at call time, so under AngryIo it's the StringIO
|
|
77
|
+
# buffer — which AS can't actually reopen onto IO::NULL (it just resets
|
|
78
|
+
# the buffer), leaking the block's writes to the real stream. Substitute
|
|
79
|
+
# the real stream it replaced so AS silences (and the block writes to) the
|
|
80
|
+
# real IO.
|
|
81
|
+
real = AngryIo.real_stream_for(stream)
|
|
82
|
+
AngryIo.with_real_streams { super(real || stream) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private :capture, :silence_stream
|
|
14
86
|
end
|
|
15
87
|
|
|
16
88
|
Config = Struct.new(:enabled) do
|
|
@@ -42,15 +114,70 @@ module AngryIo
|
|
|
42
114
|
stderr_buffer = Stream.new
|
|
43
115
|
$stdout = stdout_buffer
|
|
44
116
|
$stderr = stderr_buffer
|
|
117
|
+
previous_swap = Thread.current.thread_variable_get(:angry_io_swap)
|
|
118
|
+
Thread.current.thread_variable_set(:angry_io_swap, [original_stdout, original_stderr, stdout_buffer, stderr_buffer])
|
|
45
119
|
|
|
46
120
|
begin
|
|
47
121
|
yield
|
|
48
122
|
ensure
|
|
123
|
+
Thread.current.thread_variable_set(:angry_io_swap, previous_swap)
|
|
49
124
|
$stdout = original_stdout
|
|
50
125
|
$stderr = original_stderr
|
|
51
126
|
stdout_buffer.close
|
|
52
127
|
stderr_buffer.close
|
|
53
128
|
end
|
|
54
129
|
end
|
|
130
|
+
|
|
131
|
+
# Run the block with the pre-swap $stdout/$stderr restored, re-swapping the
|
|
132
|
+
# AngryIo::Stream buffers afterwards. Helpers like Minitest's
|
|
133
|
+
# capture_subprocess_io need this: they reopen $stdout/$stderr onto
|
|
134
|
+
# Tempfiles so subprocesses inherit the file descriptors, which only works
|
|
135
|
+
# on real IOs. No-ops when no swap is active, or when the real streams are
|
|
136
|
+
# already current (a nested call inside another with_real_streams — e.g.
|
|
137
|
+
# ActiveSupport's `capture` wrapping `quietly` — so the inner ensure doesn't
|
|
138
|
+
# clobber the outer's swap-back).
|
|
139
|
+
def with_real_streams
|
|
140
|
+
swap = Thread.current.thread_variable_get(:angry_io_swap)
|
|
141
|
+
return yield unless swap
|
|
142
|
+
|
|
143
|
+
real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
|
|
144
|
+
return yield if $stdout.equal?(real_stdout) && $stderr.equal?(real_stderr)
|
|
145
|
+
|
|
146
|
+
$stdout = real_stdout
|
|
147
|
+
$stderr = real_stderr
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
yield
|
|
151
|
+
ensure
|
|
152
|
+
$stdout = stdout_buffer
|
|
153
|
+
$stderr = stderr_buffer
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# If `stream` is one of the active swap's AngryIo::Stream buffers, return the
|
|
158
|
+
# real stream it replaced; otherwise nil. Used by
|
|
159
|
+
# ActiveSupportStreamCapture#silence_stream to redirect silencing onto the
|
|
160
|
+
# real IO instead of the StringIO buffer.
|
|
161
|
+
def real_stream_for(stream)
|
|
162
|
+
swap = Thread.current.thread_variable_get(:angry_io_swap)
|
|
163
|
+
return nil unless swap
|
|
164
|
+
|
|
165
|
+
real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
|
|
166
|
+
if stream.equal?(stdout_buffer)
|
|
167
|
+
real_stdout
|
|
168
|
+
elsif stream.equal?(stderr_buffer)
|
|
169
|
+
real_stderr
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Prepend hooks so ActiveSupport::Testing::Stream#capture / #silence_stream
|
|
174
|
+
# run with the real $stdout/$stderr restored (see ActiveSupportStreamCapture).
|
|
175
|
+
# No-ops when ActiveSupport isn't loaded.
|
|
176
|
+
def hook_active_support_stream!
|
|
177
|
+
require "active_support/testing/stream"
|
|
178
|
+
ActiveSupport::Testing::Stream.prepend(ActiveSupportStreamCapture)
|
|
179
|
+
rescue LoadError
|
|
180
|
+
# ActiveSupport isn't available; nothing to hook.
|
|
181
|
+
end
|
|
55
182
|
end
|
|
56
183
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: angry_io
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benjamin Quorning
|
|
@@ -49,7 +49,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
49
49
|
- !ruby/object:Gem::Version
|
|
50
50
|
version: '0'
|
|
51
51
|
requirements: []
|
|
52
|
-
rubygems_version: 4.0.
|
|
52
|
+
rubygems_version: 4.0.20
|
|
53
53
|
specification_version: 4
|
|
54
54
|
summary: An IO that raises on write, to keep tests from polluting stdout/stderr.
|
|
55
55
|
test_files: []
|