angry_io 0.4.0 → 0.10.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: efd2cce37a69f805e637ea406088049b150bb0ff9192a7423100add8b7a29342
4
- data.tar.gz: 4e25659b95836fc83544b295cf40b6428d094cf194356ff1e4e0c0b8e7c1ad42
3
+ metadata.gz: 9b3f6e7d3135cb218b85c49be54b4be8f023d3349d73ad3caf225addcf161fbe
4
+ data.tar.gz: b7e58a351af2597e9cf33598f8e08c70428384da5ce125d24f4f7ac86dc6cb54
5
5
  SHA512:
6
- metadata.gz: 72682994c9e8647d1405a46aa15366c377325f31396767f6f6efe0ef6e6722f875bdce839a0094c04db309e39cb691a05d3daa43d5b4aeff2372430f1e69cee1
7
- data.tar.gz: 51ee62f1212076d50984ed31c221760464fbd2f15990abd8f4c241fd4294bcc259bffa5b9c46ce54942ab6a5b178582272cd9c09de08dae7fa67baa874bf7e0b
6
+ metadata.gz: bc6d5b2543aa8e10b3eef820ce422d3df46fa80b970d87de294d7266bcfc2ca0124cee0fc365a38c35029248f6cbb3daa4b0ad1a943e3295ec08521b3ac56cc1
7
+ data.tar.gz: 66448faebe489ae41a5a3f031339e646738cea271c4e8cc9764fb022dd469c0960baa8990a3de9c2ab72c93a391d9cf89ab8f078da28c8d3b4c31aad666410da
data/CHANGELOG.md CHANGED
@@ -1,22 +1,13 @@
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
-
14
- ## [0.2.0] - 2026-09-04
15
-
16
- - Remove the `angry_io/enable_for_ci_true` convenience require; require `angry_io/rspec` or `angry_io/minitest` directly instead.
17
- - Remove the configurable `opt_out_metadata` field; the RSpec opt-out metadata is now always `:i_absolutely_need_to_write_to_stdout`.
18
- - Adapters no longer swallow `LoadError` when their framework is missing; requiring an adapter now hard-requires its framework.
19
-
20
- ## [0.1.0] - 2026-09-04
21
-
22
- - Initial release
3
+ ## [0.10.0] - 2026-09-18
4
+
5
+ - AngryIO guards the real `STDOUT`/`STDERR` objects during tests — a write goes through to the stream and then raises `IOError`, so accidental output fails loudly and the offending line is visible in the test output right before the failure. Because the guard lives on the stream objects themselves, code holding earlier references (e.g. `Logger.new($stdout)` at boot) cannot bypass it; `Kernel#warn` and `Warning.warn` are intercepted at the source, since they write to stderr without dispatching to `$stderr#write`.
6
+ - Self-registering adapters for Minitest and RSpec arm the guard around each test, with an opt-out per test class / example via `i_absolutely_need_to_write_to_stdout`.
7
+ - `AngryIO.enabled` is a callable gating enforcement (default `-> { true }`), invoked once per test — e.g. `-> { ENV["CI"] == "true" }` to enforce on CI while leaving local debuggers usable.
8
+ - Known limits: the arming flag is thread-local, so writes from threads spawned mid-test are not guarded, and rebinding `$stdout` to another IO bypasses the guard.
9
+ - Zero-byte writes (e.g. `$stderr.print("")`) emit nothing and are allowed; anything that would produce output raises. `warn("")` still raises: `warn` appends a newline per message, so an empty message is not a zero-byte write.
10
+ - Minitest's `capture_subprocess_io` and RSpec's `to_stdout_from_any_process` / `to_stderr_from_any_process` matchers work alongside AngryIO: they reopen the streams onto Tempfiles so subprocesses inherit the file descriptors, and the adapters run them under the new `AngryIO.disarmed` so the block's Ruby-level writes are captured instead of raising. `capture_io`, `assert_output`, and `assert_silent` rebind the globals to unguarded StringIOs and already worked.
11
+ - Reopening a guarded stream (e.g. `$stdout.reopen(File::NULL)` after a fork) is treated as a deliberate redirect of the process's output: it goes through on the real IO and releases that stream from the guard for the rest of the test. A failed reopen raises before releasing, so the guard stays in place.
12
+ - ActiveSupport's `capture`, `quietly`, and `silence_stream` (from `ActiveSupport::Testing::Stream`) work alongside AngryIO, disarmed like the other capture helpers.
13
+ - A guarded write that raises flushes the stream first, so the offending line really does appear right before the failure even when the stream is block-buffered (Ruby buffers non-TTY stdout, e.g. on CI; Minitest sets `$stdout.sync = true` but RSpec leaves it false). While armed, `syswrite` and `write_nonblock` also flush before writing, so buffered output (e.g. the test runner's progress dots, printed while the guard is unarmed) goes out in order instead of tripping Ruby's `syswrite for buffered IO` warning, which `WarningGuard` would otherwise turn into a raise against an innocent write.
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # AngryIo
1
+ # AngryIO
2
2
 
3
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
- 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.
5
+ AngryIO guards the real `$stdout` and `$stderr` during your test suite: a write goes through to the stream and then immediately _raises_, so accidental output fails loudly instead of quietly cluttering your output — and the offending line is right there in your test output, next to the failure, making it easy to find what printed. Because the guard lives on the stream objects themselves (not on the globals), code that captured a reference earlier — e.g. `Logger.new($stdout)` at boot — can't bypass it. AngryIO ships self-registering adapters for both RSpec and Minitest, gated by a configurable `enabled` callable.
6
6
 
7
7
  ## Installation
8
8
 
@@ -29,21 +29,21 @@ Depending on your test framework, you should require either `angry_io/rspec` or
29
29
  require "angry_io/rspec" # or "angry_io/minitest"
30
30
  ```
31
31
 
32
- By default, AngryIo is always on, and every test that writes to `$stdout` or `$stderr` now raises:
32
+ By default, AngryIO is always on, and every test that writes to `$stdout` or `$stderr` now prints the offending output and then raises:
33
33
 
34
34
  ```
35
- IOError: AngryIo::Stream is not writable: "your output here"
35
+ your output here
36
+ IOError: AngryIO: a test wrote to $stdout: "your output here"
36
37
  ```
37
38
 
38
39
  Zero-byte writes (e.g. `$stderr.print("")`) emit nothing, so they are allowed.
39
40
 
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:
41
+ If you want to allow test output in some environments but not in others — e.g., fail on CI, but allow output when testing locally so REPL debuggers like `binding.irb` or `pry` work — set the `enabled` predicate:
41
42
 
42
43
  ```ruby
43
- # On by default; turn it off in environments where you'll allow real output.
44
- AngryIo.configure do |config|
45
- config.enabled = -> { ENV["CI"] == "true" }
46
- end
44
+ # On by default; turn it off in environments where you'll allow real output
45
+ # (e.g. locally, so a debugger can write to stdout without raising).
46
+ AngryIO.enabled = -> { ENV["CI"] == "true" }
47
47
  ```
48
48
 
49
49
  ### Opting out
@@ -83,15 +83,15 @@ end
83
83
 
84
84
  ## Configuration
85
85
 
86
- `AngryIo.configure` yields a config struct with one field:
87
-
88
- | Field | Default | Description |
89
- | --- | --- | --- |
90
- | `enabled` | `-> { true }` | A callable returning whether AngryIo is active. Invoked once per test, so it can read env vars or feature flags live. |
86
+ `AngryIO.enabled` is a callable returning whether AngryIO is active. It defaults to `-> { true }` and is invoked once per test, so it can read env vars or feature flags live — e.g. `-> { ENV["CI"] == "true" }` to enforce on CI while leaving local debuggers usable.
91
87
 
92
88
  ## How it works
93
89
 
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`.
90
+ At load time, AngryIO prepends a small guard module onto the `STDOUT` and `STDERR` objects, overriding their write methods. Around each test, the adapter arms the guard via a thread-local flag, and disarms it in an `ensure`. When armed, a write passes through to the real stream and then raises an `IOError`; zero-byte writes (e.g. `$stderr.print("")`) emit nothing and are allowed.
91
+
92
+ Guarding the stream objects — rather than swapping the `$stdout`/`$stderr` globals — means anything holding a reference to the real streams (like a `Logger.new($stdout)` from boot time) is guarded too. `Kernel#warn` and `Warning.warn` write to stderr at C level without dispatching to `$stderr#write`, so AngryIO intercepts them at the source as well. Helpers that capture output on purpose keep working: `capture_io` rebinds the globals to unguarded `StringIO`s, and the adapters disarm the guard around `capture_subprocess_io`, RSpec's `to_*_from_any_process` matchers, and ActiveSupport's `capture`/`silence_stream`, which reopen the streams onto Tempfiles. Reopening a guarded stream yourself (e.g. `$stdout.reopen(File::NULL)` after a fork) is treated as a deliberate redirect: it goes through and releases that stream from the guard for the rest of the test.
93
+
94
+ Two limits to be aware of: the arming flag is thread-local, so writes from threads spawned mid-test are not guarded; and rebinding `$stdout` to another IO bypasses the guard (we can't guard an object we've never seen).
95
95
 
96
96
  ## Development
97
97
 
@@ -113,4 +113,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
113
113
 
114
114
  ## Code of Conduct
115
115
 
116
- Everyone interacting in the AngryIo project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/bquorning/angry_io/blob/main/CODE_OF_CONDUCT.md).
116
+ Everyone interacting in the AngryIO project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/bquorning/angry_io/blob/main/CODE_OF_CONDUCT.md).
@@ -2,15 +2,15 @@
2
2
 
3
3
  require "angry_io"
4
4
 
5
- # Prepends an override of +Minitest::Test#run+ that swaps $stdout/$stderr to
6
- # AngryIo::Stream instances around each test. A test class opts out by calling
5
+ # Prepends an override of +Minitest::Test#run+ that arms AngryIO's write-guard
6
+ # on STDOUT/STDERR around each test. A test class opts out by calling
7
7
  # +i_absolutely_need_to_write_to_stdout!+ in its body. Force-loads Minitest so
8
8
  # registration works regardless of Bundler.require ordering.
9
- module AngryIo
9
+ module AngryIO
10
10
  module Minitest
11
11
  module Adapter
12
12
  def run
13
- AngryIo.around_streams(opted_out: self.class.i_absolutely_need_to_write_to_stdout?) { super }
13
+ AngryIO.around_streams(opted_out: self.class.i_absolutely_need_to_write_to_stdout?) { super }
14
14
  end
15
15
  end
16
16
 
@@ -25,12 +25,12 @@ module AngryIo
25
25
  end
26
26
 
27
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.
28
+ # subprocesses inherit the file descriptors — a deliberate redirect whose
29
+ # output is captured, not pollution. Disarm the guard for the duration of
30
+ # the capture.
31
31
  module CaptureSubprocessIo
32
32
  def capture_subprocess_io(&block)
33
- AngryIo.with_real_streams { super }
33
+ AngryIO.disarmed { super }
34
34
  end
35
35
  end
36
36
 
@@ -38,10 +38,10 @@ module AngryIo
38
38
  ::Minitest::Test.extend(ClassMethods)
39
39
  ::Minitest::Test.prepend(Adapter)
40
40
  ::Minitest::Assertions.prepend(CaptureSubprocessIo)
41
- AngryIo.hook_active_support_stream!
41
+ AngryIO.hook_active_support_stream!
42
42
  end
43
43
  end
44
44
  end
45
45
 
46
46
  require "minitest"
47
- AngryIo::Minitest.setup!
47
+ AngryIO::Minitest.setup!
@@ -2,32 +2,31 @@
2
2
 
3
3
  require "angry_io"
4
4
 
5
- # Registers an +around+ hook that swaps $stdout/$stderr to AngryIo::Stream
6
- # instances during each example, unless the example opts out via the
5
+ # Registers an +around+ hook that arms AngryIO's write-guard on STDOUT/STDERR
6
+ # during each example, unless the example opts out via the
7
7
  # +:i_absolutely_need_to_write_to_stdout+ metadata. Force-loads rspec-core so
8
8
  # registration works regardless of Bundler.require ordering.
9
- module AngryIo
9
+ module AngryIO
10
10
  module RSpec
11
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...
12
+ # so subprocesses inherit the file descriptors. An example using these
13
+ # matchers captures output on purpose, so disarm the guard while the
14
+ # matcher grabs the stream...
16
15
  module FromAnyProcess
17
16
  def to_stdout_from_any_process
18
- AngryIo.with_real_streams { super }
17
+ AngryIO.disarmed { super }
19
18
  end
20
19
 
21
20
  def to_stderr_from_any_process
22
- AngryIo.with_real_streams { super }
21
+ AngryIO.disarmed { super }
23
22
  end
24
23
  end
25
24
 
26
25
  # ...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.
26
+ # are captured by the Tempfile instead of raising against the guard.
28
27
  module CaptureStreamToTempfile
29
28
  def capture(block)
30
- AngryIo.with_real_streams { super }
29
+ AngryIO.disarmed { super }
31
30
  end
32
31
  end
33
32
 
@@ -36,17 +35,17 @@ module AngryIo
36
35
  config.around do |example|
37
36
  opt_out = example.metadata.key?(:i_absolutely_need_to_write_to_stdout) &&
38
37
  example.metadata[:i_absolutely_need_to_write_to_stdout]
39
- AngryIo.around_streams(opted_out: opt_out) { example.run }
38
+ AngryIO.around_streams(opted_out: opt_out) { example.run }
40
39
  end
41
40
  end
42
41
 
43
42
  ::RSpec::Matchers::BuiltIn::Output.prepend(FromAnyProcess)
44
43
  ::RSpec::Matchers::BuiltIn::CaptureStreamToTempfile.prepend(CaptureStreamToTempfile)
45
- AngryIo.hook_active_support_stream!
44
+ AngryIO.hook_active_support_stream!
46
45
  end
47
46
  end
48
47
  end
49
48
 
50
49
  require "rspec/core"
51
50
  require "rspec/expectations"
52
- AngryIo::RSpec.setup!
51
+ AngryIO::RSpec.setup!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module AngryIo
4
- VERSION = "0.4.0"
3
+ module AngryIO
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/angry_io.rb CHANGED
@@ -1,178 +1,188 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
3
  require_relative "angry_io/version"
5
4
 
6
- module AngryIo
7
- # An IO that raises when you write to it, so tests can't silently pollute
8
- # stdout/stderr. Zero-byte writes like `$stderr.print("")` emit nothing and
9
- # are allowed; anything that would produce output raises an IOError.
10
- class Stream < StringIO
11
- def initialize
12
- super(+"")
5
+ module AngryIO
6
+ # Prepended to the STDOUT/STDERR singletons. While armed (a test is running
7
+ # under AngryIO.around_streams and hasn't opted out), a write goes through to
8
+ # the real stream and *then* raises IOError — the offending output appears in
9
+ # the test output right before the failure, so it's easy to see what printed.
10
+ # Guarding the real stream objects, instead of swapping the globals, means
11
+ # code that captured a reference earlier (e.g. `Logger.new($stdout)` at boot)
12
+ # can't bypass the guard.
13
+ module Guard
14
+ # IO#print, puts, printf and << all funnel through #write, so this one
15
+ # guard catches them.
16
+ def write(*strings)
17
+ result = super
18
+ AngryIO.check_output!(self, strings)
19
+ result
13
20
  end
14
21
 
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?
22
+ # syswrite and write_nonblock bypass #write, so they need their own guards.
23
+ # Both warn ("syswrite for buffered IO") when the stream has buffered
24
+ # output pending — and buffered output can be there without us knowing
25
+ # (e.g. the test runner's progress dots, printed between tests while the
26
+ # guard is unarmed). The WarningGuard would turn that warning into a raise
27
+ # against an innocent write, so flush first while armed: the pending output
28
+ # goes out in order, the buffer stays clean, and no warning fires.
29
+ def syswrite(string)
30
+ flush if AngryIO.armed?
31
+ result = super
32
+ AngryIO.check_output!(self, [string])
33
+ result
34
+ end
20
35
 
21
- raise IOError, "AngryIo::Stream is not writable: #{string.to_s.inspect}"
22
- end
23
- super
36
+ def write_nonblock(string, **options)
37
+ flush if AngryIO.armed?
38
+ result = super
39
+ AngryIO.check_output!(self, [string])
40
+ result
24
41
  end
25
42
 
26
- # StringIO#putc writes directly in C, bypassing #write. It always emits a
27
- # byte, so it always refuses.
43
+ # putc writes directly in C, bypassing #write.
28
44
  def putc(char)
29
- raise IOError, "AngryIo::Stream is not writable: #{char.inspect}"
45
+ result = super
46
+ AngryIO.check_output!(self, [char])
47
+ result
30
48
  end
31
49
 
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
50
+ # Reopening the stream (e.g. `$stdout.reopen(File::NULL)` after a fork) is
51
+ # a deliberate redirect of the process's output: let it through and release
52
+ # this stream from the guard for the rest of the test, so subsequent writes
53
+ # reach the reopened target instead of raising. A failed reopen raises
54
+ # before releasing, so the guard stays in place.
55
+ #
56
+ # `reopen(other_io)` also turns the receiver into a copy of the other IO,
57
+ # replacing its singleton class — which strips this very module off the
58
+ # stream. Re-prepend it afterwards so the guard survives; a no-op when the
59
+ # module is still present (e.g. after a path reopen).
60
+ def reopen(*args)
61
+ result = super
62
+ singleton_class.prepend(AngryIO::Guard)
63
+ AngryIO.release!(self) if AngryIO.armed?
64
+ result
65
+ end
66
+ end
67
+
68
+ # Kernel#warn writes to stderr at C level without dispatching to
69
+ # $stderr#write (when $stderr is a real IO), so it bypasses Guard. Intercept
70
+ # it at the source. warn always appends a newline per message, so even an
71
+ # empty message emits output; a message-less warn emits nothing.
72
+ module WarnGuard
73
+ def warn(*messages, **options)
74
+ result = super
75
+ AngryIO.check_output!($stderr, messages.map { |m| m.to_s.empty? ? "\n" : m })
76
+ result
77
+ end
78
+ end
79
+
80
+ # Warning.warn (deprecations and other Ruby-level warnings) is the same:
81
+ # it writes to stderr without dispatching to $stderr#write.
82
+ module WarningGuard
83
+ def warn(message, **options)
84
+ result = super
85
+ AngryIO.check_output!($stderr, [message])
86
+ result
62
87
  end
63
88
  end
64
89
 
65
90
  # 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.
91
+ # Tempfile and #silence_stream reopens onto IO::NULL — deliberate redirects
92
+ # whose block output is captured or silenced on purpose, not test pollution.
93
+ # Disarm the guard for the duration, the same way Minitest's
94
+ # capture_subprocess_io is handled.
70
95
  module ActiveSupportStreamCapture
71
96
  def capture(stream)
72
- AngryIo.with_real_streams { super }
97
+ AngryIO.disarmed { super }
73
98
  end
74
99
 
75
100
  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) }
101
+ AngryIO.disarmed { super }
83
102
  end
84
103
 
85
104
  private :capture, :silence_stream
86
105
  end
87
106
 
88
- Config = Struct.new(:enabled) do
89
- def initialize
90
- self.enabled = -> { true }
91
- end
92
- end
93
-
94
- @config = Config.new
107
+ @enabled = -> { true }
95
108
 
96
109
  class << self
97
- attr_reader :config
98
-
99
- def configure
100
- yield @config
101
- end
110
+ attr_accessor :enabled
102
111
 
103
- # Swap $stdout and $stderr to AngryIo::Stream instances around the given
104
- # block, restoring them (and closing the buffers) in an ensure. No-ops when
105
- # +opted_out+ is true or when +config.enabled+ returns false, so the block
106
- # runs untouched.
112
+ # Arm the write-guard on STDOUT/STDERR around the given block, restoring
113
+ # the previous state in an ensure. No-ops when +opted_out+ is true or when
114
+ # +enabled+ returns false, so the block runs untouched.
107
115
  def around_streams(opted_out: false)
108
116
  return yield if opted_out
109
- return yield unless config.enabled.call
117
+ return yield unless enabled.call
110
118
 
111
- original_stdout = $stdout
112
- original_stderr = $stderr
113
- stdout_buffer = Stream.new
114
- stderr_buffer = Stream.new
115
- $stdout = stdout_buffer
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])
119
+ previous_armed = Thread.current.thread_variable_get(:angry_io_armed)
120
+ previous_released = Thread.current.thread_variable_get(:angry_io_released)
121
+ Thread.current.thread_variable_set(:angry_io_armed, true)
122
+ Thread.current.thread_variable_set(:angry_io_released, [])
119
123
 
120
124
  begin
121
125
  yield
122
126
  ensure
123
- Thread.current.thread_variable_set(:angry_io_swap, previous_swap)
124
- $stdout = original_stdout
125
- $stderr = original_stderr
126
- stdout_buffer.close
127
- stderr_buffer.close
127
+ Thread.current.thread_variable_set(:angry_io_armed, previous_armed)
128
+ Thread.current.thread_variable_set(:angry_io_released, previous_released)
128
129
  end
129
130
  end
130
131
 
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)
132
+ # Run the block with the guard disarmed. Helpers like Minitest's
133
+ # capture_subprocess_io or ActiveSupport's capture need this: they redirect
134
+ # the streams on purpose, so the block's writes are captured, not errors.
135
+ def disarmed
136
+ previous = Thread.current.thread_variable_get(:angry_io_armed)
137
+ Thread.current.thread_variable_set(:angry_io_armed, false)
138
+ yield
139
+ ensure
140
+ Thread.current.thread_variable_set(:angry_io_armed, previous)
141
+ end
145
142
 
146
- $stdout = real_stdout
147
- $stderr = real_stderr
143
+ # Whether the write-guard is armed on the current thread. The flag is
144
+ # thread-local (not fiber-local), so captures running inside a Fiber —
145
+ # e.g. Enumerator-based code — see the same state as their test.
146
+ def armed?
147
+ Thread.current.thread_variable_get(:angry_io_armed) || false
148
+ end
148
149
 
149
- begin
150
- yield
151
- ensure
152
- $stdout = stdout_buffer
153
- $stderr = stderr_buffer
154
- end
150
+ # Release a stream from the guard for the rest of the surrounding test
151
+ # (see Guard#reopen).
152
+ def release!(io)
153
+ released = Thread.current.thread_variable_get(:angry_io_released)
154
+ released << io if released && !released.include?(io)
155
155
  end
156
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
157
+ # Called by Guard (and the warn intercepts) after a write has gone through
158
+ # to the real stream. Raises IOError if the guard is armed, the target is
159
+ # one of the guarded streams that hasn't been released, and the write would
160
+ # actually emit output (zero-byte writes are allowed). A $stdout/$stderr
161
+ # the user rebound to another object (e.g. capture_io's StringIO) is not
162
+ # guarded, so writes to it are fine.
163
+ # standard:disable Style/GlobalStdStream — we mean the real stream objects, not the globals
164
+ def check_output!(io, strings)
165
+ return unless armed?
166
+ return unless io.equal?(STDOUT) || io.equal?(STDERR)
167
+ return if Thread.current.thread_variable_get(:angry_io_released)&.include?(io)
168
+
169
+ offending = strings.map(&:to_s).reject(&:empty?)
170
+ return if offending.empty?
171
+
172
+ name = io.equal?(STDOUT) ? "$stdout" : "$stderr"
173
+ # Flush so the offending line really does appear right before the failure
174
+ # even when the stream is block-buffered ($stdout.sync == false, e.g. on
175
+ # CI or under RSpec) — and so the buffer is left empty, keeping a later
176
+ # syswrite from tripping Ruby's "syswrite for buffered IO" warning (which
177
+ # WarningGuard turns into a raise) in an innocent test.
178
+ io.flush
179
+ raise IOError, "AngryIO: a test wrote to #{name}:\n #{offending.join}"
171
180
  end
181
+ # standard:enable Style/GlobalStdStream
172
182
 
173
183
  # 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.
184
+ # run with the guard disarmed (see ActiveSupportStreamCapture). No-ops when
185
+ # ActiveSupport isn't loaded.
176
186
  def hook_active_support_stream!
177
187
  require "active_support/testing/stream"
178
188
  ActiveSupport::Testing::Stream.prepend(ActiveSupportStreamCapture)
@@ -181,3 +191,11 @@ module AngryIo
181
191
  end
182
192
  end
183
193
  end
194
+
195
+ # standard:disable Style/GlobalStdStream — guard the real stream objects, not the globals
196
+ STDOUT.singleton_class.prepend(AngryIO::Guard)
197
+ STDERR.singleton_class.prepend(AngryIO::Guard)
198
+ # standard:enable Style/GlobalStdStream
199
+ Kernel.prepend(AngryIO::WarnGuard)
200
+ Kernel.singleton_class.prepend(AngryIO::WarnGuard) # warn is a module_function
201
+ Warning.singleton_class.prepend(AngryIO::WarningGuard)
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.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Quorning
@@ -9,10 +9,10 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: AngryIo replaces $stdout/$stderr during tests with an IO that raises
13
- on write, so accidental output fails loudly instead of cluttering CI logs. Ships
14
- RSpec and Minitest adapters that self-register, gated by a configurable enabled
15
- callable.
12
+ description: 'AngryIO guards the real $stdout/$stderr during tests: a write goes through
13
+ to the stream and then raises, so accidental output fails loudly and the offending
14
+ line is visible in the test output. Ships RSpec and Minitest adapters that self-register,
15
+ gated by a configurable enabled callable.'
16
16
  email:
17
17
  - bquorning@zendesk.com
18
18
  executables: []
@@ -51,5 +51,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
51
51
  requirements: []
52
52
  rubygems_version: 4.0.20
53
53
  specification_version: 4
54
- summary: An IO that raises on write, to keep tests from polluting stdout/stderr.
54
+ summary: 'Guards stdout/stderr during tests: writes go through, then raise.'
55
55
  test_files: []