angry_io 0.1.0 → 0.3.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: 8333b37ab325a405861724fec5d256e85255551879736909482389b3a203c14c
4
- data.tar.gz: 2b3f00b29dc6573e4ddf3ef33b7c91856ee49d56d31d2a0b0b293a59ca02adec
3
+ metadata.gz: 597667423eb4ee7ad9daaf21e0ee59e86f361a07e37a5b6d7bdc03a89978ead6
4
+ data.tar.gz: df59a437ba441870cc0d6788748ea115d632b9c7770266a1ef901e4a966cb413
5
5
  SHA512:
6
- metadata.gz: ef3525263035d341ada9c302472c468676a2196df6a1b57ed9b67d09e7c2d1bde7148dd18ca6fc6ed888e78bff6949ea97f4d645914d08e3a21094db77c306b4
7
- data.tar.gz: 3ca41798772a5e17b5406b4d70446edb08eedffb21f5c62c1115c22e849e1b397cbe3ea5dec41bf44e3b84da4fcc0fcfc5e565396294226d8fe922ab9bc72e54
6
+ metadata.gz: 44154fa02cb72e8cbf89c5c24b3a4996a4600b0e3d17c484a50222875ea1b7fdc9c830150a7b618cee54eb05ce6cdab82fb1bc67dcebc4b42d7facd4578a5429
7
+ data.tar.gz: '083c923145f4247d847613ba082f1f9d509fd7dd7cdb9bce34b5db89f722051e5ca07ef4057af6c111a63656fa3fdf8be19957dce330a1cdf98babbb2c80e046'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-09-06
4
+
5
+ - 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.
6
+ - 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.
7
+
8
+ ## [0.2.0] - 2026-09-04
9
+
10
+ - Remove the `angry_io/enable_for_ci_true` convenience require; require `angry_io/rspec` or `angry_io/minitest` directly instead.
11
+ - Remove the configurable `opt_out_metadata` field; the RSpec opt-out metadata is now always `:i_absolutely_need_to_write_to_stdout`.
12
+ - Adapters no longer swallow `LoadError` when their framework is missing; requiring an adapter now hard-requires its framework.
13
+
3
14
  ## [0.1.0] - 2026-09-04
4
15
 
5
16
  - Initial release
data/README.md CHANGED
@@ -1,18 +1,16 @@
1
1
  # AngryIo
2
2
 
3
- Friends don't let friends write tests or specs that outputs to stdout/stderr. When you look at your test output, you should only see green dots, right?
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
 
7
- The name and the default opt-out metadata are a nod to [minitest](https://github.com/minitest/minitest)'s `i_suck_and_my_tests_are_order_dependent!`.
8
-
9
7
  ## Installation
10
8
 
11
9
  Add to your application's Gemfile:
12
10
 
13
11
  ```ruby
14
12
  group :test do
15
- gem "angry_io", require: "angry_io/enable_for_ci_true"
13
+ gem "angry_io"
16
14
  end
17
15
  ```
18
16
 
@@ -24,17 +22,28 @@ gem install angry_io
24
22
 
25
23
  ## Usage
26
24
 
27
- The simplest setup is to point the Gemfile `require:` at `angry_io/enable_for_ci_true`. That file turns AngryIo on only when the `CI` environment variable is `"true"` — a convention set by GitHub Actions, GitLab CI, CircleCI, Travis, and others — and wires up whichever test framework is loaded (RSpec or Minitest); the other is a no-op.
25
+ Depending on your test framework, you should require either `angry_io/rspec` or `angry_io/minitest`:
28
26
 
29
27
  ```ruby
30
- # Gemfile
31
- gem "angry_io", require: "angry_io/enable_for_ci_true"
28
+ # spec_helper.rb (RSpec) or test_helper.rb (Minitest)
29
+ require "angry_io/rspec" # or "angry_io/minitest"
32
30
  ```
33
31
 
34
- That's it. Under `CI=true`, every test that writes to `$stdout` or `$stderr` raises:
32
+ By default, AngryIo is always on, and every test that writes to `$stdout` or `$stderr` now raises:
35
33
 
36
34
  ```
37
- IOError: not opened for writing
35
+ IOError: AngryIo::Stream is not writable: "your output here"
36
+ ```
37
+
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:
41
+
42
+ ```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
38
47
  ```
39
48
 
40
49
  ### Opting out
@@ -59,34 +68,30 @@ class PrintTest < Minitest::Test
59
68
  end
60
69
  ```
61
70
 
62
- ### Manual setup
63
-
64
- If you'd rather control the gate yourself, require an adapter directly. `enabled` defaults to always-on, so it's most useful as a kill switch for environments where you need real output:
71
+ The same call works in Minitest's spec format, inside the `describe` block:
65
72
 
66
73
  ```ruby
67
- # spec_helper.rb (RSpec) or test_helper.rb (Minitest)
68
- require "angry_io/rspec" # or "angry_io/minitest"
74
+ # Minitest spec format — opt out a whole describe block
75
+ describe "printing" do
76
+ i_absolutely_need_to_write_to_stdout!
69
77
 
70
- # On by default; turn it off in environments where you need real output.
71
- AngryIo.configure do |config|
72
- config.enabled = -> { ENV["ANGRY_IO"] != "off" }
78
+ it "prints" do
79
+ puts "ok"
80
+ end
73
81
  end
74
82
  ```
75
83
 
76
- Requiring `angry_io/rspec` or `angry_io/minitest` self-registers the hook if that framework is loaded; requiring plain `angry_io` gives you just the `AngryIo::Stream` class with no side effects.
77
-
78
84
  ## Configuration
79
85
 
80
- `AngryIo.configure` yields a config struct with two fields:
86
+ `AngryIo.configure` yields a config struct with one field:
81
87
 
82
88
  | Field | Default | Description |
83
89
  | --- | --- | --- |
84
90
  | `enabled` | `-> { true }` | A callable returning whether AngryIo is active. Invoked once per test, so it can read env vars or feature flags live. |
85
- | `opt_out_metadata` | `:i_absolutely_need_to_write_to_stdout` | The RSpec metadata symbol that opts an example out. |
86
91
 
87
92
  ## How it works
88
93
 
89
- `AngryIo::Stream` is a `StringIO` backed by a frozen empty string, which makes `StringIO` refuse writes with an `IOError`. Around each test, the adapter swaps `$stdout` and `$stderr` to fresh `AngryIo::Stream` instances and restores the originals (closing the buffers) in an `ensure`.
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
95
 
91
96
  ## Development
92
97
 
@@ -4,10 +4,8 @@ require "angry_io"
4
4
 
5
5
  # Prepends an override of +Minitest::Test#run+ that swaps $stdout/$stderr to
6
6
  # AngryIo::Stream instances around each test. A test class opts out by calling
7
- # +i_absolutely_need_to_write_to_stdout!+ in its body — a nod to minitest's own
8
- # +i_suck_and_my_tests_are_order_dependent!+. Force-loads Minitest so
9
- # registration works regardless of Bundler.require ordering; no-op if Minitest
10
- # isn't installed.
7
+ # +i_absolutely_need_to_write_to_stdout!+ in its body. Force-loads Minitest so
8
+ # registration works regardless of Bundler.require ordering.
11
9
  module AngryIo
12
10
  module Minitest
13
11
  module Adapter
@@ -26,17 +24,23 @@ module AngryIo
26
24
  end
27
25
  end
28
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
+
29
37
  def self.setup!
30
- test = ::Minitest::Test
31
- test.extend(ClassMethods)
32
- test.prepend(Adapter)
38
+ ::Minitest::Test.extend(ClassMethods)
39
+ ::Minitest::Test.prepend(Adapter)
40
+ ::Minitest::Assertions.prepend(CaptureSubprocessIo)
33
41
  end
34
42
  end
35
43
  end
36
44
 
37
- begin
38
- require "minitest"
39
- AngryIo::Minitest.setup!
40
- rescue LoadError
41
- # Minitest isn't installed; nothing to wire up.
42
- end
45
+ require "minitest"
46
+ AngryIo::Minitest.setup!
@@ -3,27 +3,49 @@
3
3
  require "angry_io"
4
4
 
5
5
  # Registers an +around+ hook that swaps $stdout/$stderr to AngryIo::Stream
6
- # instances during each example, unless the example opts out via the configured
7
- # metadata (default +:i_absolutely_need_to_write_to_stdout+). Force-loads
8
- # rspec-core so registration works regardless of Bundler.require ordering;
9
- # no-op if RSpec isn't installed.
6
+ # instances during each example, unless the example opts out via the
7
+ # +:i_absolutely_need_to_write_to_stdout+ metadata. Force-loads rspec-core so
8
+ # registration works regardless of Bundler.require ordering.
10
9
  module AngryIo
11
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
+
12
34
  def self.setup!
13
35
  ::RSpec.configure do |config|
14
36
  config.around do |example|
15
- opt_out = example.metadata.key?(AngryIo.config.opt_out_metadata) &&
16
- example.metadata[AngryIo.config.opt_out_metadata]
37
+ opt_out = example.metadata.key?(:i_absolutely_need_to_write_to_stdout) &&
38
+ example.metadata[:i_absolutely_need_to_write_to_stdout]
17
39
  AngryIo.around_streams(opted_out: opt_out) { example.run }
18
40
  end
19
41
  end
42
+
43
+ ::RSpec::Matchers::BuiltIn::Output.prepend(FromAnyProcess)
44
+ ::RSpec::Matchers::BuiltIn::CaptureStreamToTempfile.prepend(CaptureStreamToTempfile)
20
45
  end
21
46
  end
22
47
  end
23
48
 
24
- begin
25
- require "rspec/core"
26
- AngryIo::RSpec.setup!
27
- rescue LoadError
28
- # RSpec isn't installed; nothing to wire up.
29
- end
49
+ require "rspec/core"
50
+ require "rspec/expectations"
51
+ AngryIo::RSpec.setup!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AngryIo
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/angry_io.rb CHANGED
@@ -5,18 +5,34 @@ 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. Backed by a frozen empty string, which makes StringIO
9
- # refuse writes with an IOError ("not opened for writing").
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
+ 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}"
13
30
  end
14
31
  end
15
32
 
16
- Config = Struct.new(:enabled, :opt_out_metadata) do
33
+ Config = Struct.new(:enabled) do
17
34
  def initialize
18
35
  self.enabled = -> { true }
19
- self.opt_out_metadata = :i_absolutely_need_to_write_to_stdout
20
36
  end
21
37
  end
22
38
 
@@ -43,15 +59,39 @@ module AngryIo
43
59
  stderr_buffer = Stream.new
44
60
  $stdout = stdout_buffer
45
61
  $stderr = stderr_buffer
62
+ previous_swap = Thread.current.thread_variable_get(:angry_io_swap)
63
+ Thread.current.thread_variable_set(:angry_io_swap, [original_stdout, original_stderr, stdout_buffer, stderr_buffer])
46
64
 
47
65
  begin
48
66
  yield
49
67
  ensure
68
+ Thread.current.thread_variable_set(:angry_io_swap, previous_swap)
50
69
  $stdout = original_stdout
51
70
  $stderr = original_stderr
52
71
  stdout_buffer.close
53
72
  stderr_buffer.close
54
73
  end
55
74
  end
75
+
76
+ # Run the block with the pre-swap $stdout/$stderr restored, re-swapping the
77
+ # AngryIo::Stream buffers afterwards. Helpers like Minitest's
78
+ # capture_subprocess_io need this: they reopen $stdout/$stderr onto
79
+ # Tempfiles so subprocesses inherit the file descriptors, which only works
80
+ # on real IOs. No-ops when no swap is active.
81
+ def with_real_streams
82
+ swap = Thread.current.thread_variable_get(:angry_io_swap)
83
+ return yield unless swap
84
+
85
+ real_stdout, real_stderr, stdout_buffer, stderr_buffer = swap
86
+ $stdout = real_stdout
87
+ $stderr = real_stderr
88
+
89
+ begin
90
+ yield
91
+ ensure
92
+ $stdout = stdout_buffer
93
+ $stderr = stderr_buffer
94
+ end
95
+ end
56
96
  end
57
97
  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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Quorning
@@ -23,7 +23,6 @@ files:
23
23
  - LICENSE.txt
24
24
  - README.md
25
25
  - lib/angry_io.rb
26
- - lib/angry_io/enable_for_ci_true.rb
27
26
  - lib/angry_io/minitest.rb
28
27
  - lib/angry_io/rspec.rb
29
28
  - lib/angry_io/version.rb
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Require this file from your Gemfile (+require: "angry_io/enable_for_ci_true"+)
4
- # to turn AngryIo on only when the +CI+ environment variable is +"true"+ — a
5
- # convention set by GitHub Actions, GitLab CI, CircleCI, Travis, and others.
6
- # Whichever test framework is loaded (RSpec or Minitest) wires itself up; the
7
- # other is a no-op.
8
-
9
- require "angry_io"
10
-
11
- AngryIo.configure do |config|
12
- config.enabled = -> { ENV["CI"] == "true" }
13
- end
14
-
15
- require "angry_io/rspec"
16
- require "angry_io/minitest"