diff --git a/CHANGELOG.md b/CHANGELOG.md index 7091033e..96ded5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,23 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ```ruby style.bold.red["%s"] % "boom" # => "\e[1;31mboom\e[0m" ``` +- `kernel:` for `Dry::CLI#call` and `Dry::CLI::Command.new`, and `Dry::CLI::Launcher`, for running a CLI in the same process as its tests. (@kigster) + + The CLI and its commands now exit through the kernel they are given, which defaults to `Kernel`. A test can pass an object that records the exit status instead of ending the process. Inside a command, `exit` goes to `#kernel`, so a command's own `exit(1)` is recorded too. + + `Dry::CLI::Launcher` takes the arguments, streams and kernel in the order Aruba's in-process launcher passes them: + + ```ruby + # lib/my_app/launcher.rb + MyApp::Launcher = Dry::CLI::Launcher[MyApp::Commands] + + # exe/my_app + MyApp::Launcher.new(ARGV).execute! + + # spec/spec_helper.rb + Aruba.configure do |config| + config.command_launcher = :in_process + config.main_class = MyApp::Launcher - Enforce `required: true` on options. (@capripot and @timriley in #122) Before, `required: true` was accepted on options but did nothing. Now, if a required option is not given, the command does not run, and prints an error that names the missing option. Required options are marked as REQUIRED in the help output. A required option with a `default:` always has a value, so it is not enforced and not marked as required. diff --git a/Gemfile b/Gemfile index cf470f66..c2d4cde1 100644 --- a/Gemfile +++ b/Gemfile @@ -12,3 +12,6 @@ gem "dry-types", require: false unless ENV["CI"] gem "yard", require: false end + +# Runs a CLI in-process through Dry::CLI::Launcher in spec/integration/launcher_spec.rb +gem "aruba", require: false diff --git a/lib/dry/cli.rb b/lib/dry/cli.rb index ae978dcd..a1a8c361 100644 --- a/lib/dry/cli.rb +++ b/lib/dry/cli.rb @@ -23,6 +23,26 @@ class CLI require "dry/cli/banner" require "dry/cli/inflector" require "dry/cli/dispatch" + require "dry/cli/launcher" + + # Stops the CLI with an exit status. + # + # Raised where the CLI has printed help or an error, and rescued by {#call}, which hands the + # status to the kernel. Stopping by raising means that a kernel whose `exit` returns, such as + # the one Aruba uses to run a CLI in-process, still stops the CLI at that point. + # + # @api private + class Halt < StandardError + # @return [Integer] the exit status + attr_reader :status + + # @param status [Integer] the exit status + def initialize(status) + super("exit #{status}") + @status = status + end + end + private_constant :Halt autoload :Spinner, "dry/cli/spinner" @@ -93,11 +113,17 @@ def initialize(command_or_registry = nil, &block) # @param stderr [IO] the error output (defaults to `$stderr`) # @param stdin [IO] the standard input (defaults to `$stdin`) # @param stdout [IO] the standard output (defaults to `$stdout`) + # @param kernel [#exit] what the CLI and its commands exit through (defaults to `Kernel`). + # Only an exit is ever sent to it, so a test can pass an object that records the status + # instead of ending the process. See {Dry::CLI::Launcher}. # # @since 0.1.0 - def call(arguments: ARGV, stderr: $stderr, stdin: $stdin, stdout: $stdout) + def call(arguments: ARGV, stderr: $stderr, stdin: $stdin, stdout: $stdout, kernel: Kernel) @stderr, @stdin, @stdout = Stream.for(stderr), stdin, Stream.for(stdout) + @kernel = kernel kommand ? perform_command(arguments) : perform_registry(arguments) + rescue Halt => exception + kernel.exit(exception.status) rescue SignalException => exception signal_exception(exception) rescue Errno::EPIPE @@ -123,6 +149,9 @@ def call(arguments: ARGV, stderr: $stderr, stdin: $stdin, stdout: $stdout) # @api private attr_reader :stdout + # @api private + attr_reader :kernel + # Invoke the CLI if singular command passed # # @param arguments [Array] the command line arguments @@ -184,10 +213,10 @@ def build_command(command) unless command.is_a?(Class) return command unless command.is_a?(Command) - return command.with_streams(stderr:, stdin:, stdout:) + return command.with_streams(stderr:, stdin:, stdout:, kernel:) end - return command.new(stderr:, stdin:, stdout:) if CLI.command?(command) + return command.new(stderr:, stdin:, stdout:, kernel:) if CLI.command?(command) command.new end @@ -196,14 +225,14 @@ def build_command(command) # @api private def help(command, prog_name, long: false) stdout.puts Banner.call(command, prog_name, long: long) - exit(0) # Successful exit + halt(0) end # @since 0.6.0 # @api private def error(result) stderr.puts(result.error) - exit(1) + halt(1) end # @since 1.1.1 @@ -211,7 +240,18 @@ def spell_checker(result, arguments) spell_checker = SpellChecker.call(result, arguments) stderr.puts "#{spell_checker}\n\n" if spell_checker stderr.puts Usage.call(result) - exit(1) + halt(1) + end + + # Stops the CLI, which {#call} turns into an exit through the kernel. + # + # @param status [Integer] the exit status + # + # @raise [Halt] always + # + # @api private + def halt(status) + raise Halt, status end # Handles Exit codes for signals @@ -220,7 +260,7 @@ def spell_checker(result, arguments) # @since 0.7.0 # @api private def signal_exception(exception) - exit(128 + exception.signo) + kernel.exit(128 + exception.signo) end # Check if command diff --git a/lib/dry/cli/command.rb b/lib/dry/cli/command.rb index 8709abe2..463f2c4a 100644 --- a/lib/dry/cli/command.rb +++ b/lib/dry/cli/command.rb @@ -576,8 +576,8 @@ def self.new(*args, **kwargs, &block) # @see .auto_initialize_keywords # # @api private - private def auto_initialize(stderr: nil, stdin: nil, stdout: nil) - set_streams(stderr:, stdin:, stdout:) + private def auto_initialize(stderr: nil, stdin: nil, stdout: nil, kernel: nil) + set_streams(stderr:, stdin:, stdout:, kernel:) end extend Forwardable @@ -660,14 +660,35 @@ def stdout @stdout_stream ||= Stream.for(@stdout) end - # Returns a copy of this command, configured to write to the given streams. + # What this command exits through + # + # The CLI gives every command the kernel it was called with. Unless it was given a different + # one, this is Ruby's own `Kernel`, so {#exit} behaves as `Kernel#exit` does. + # + # @return [#exit] the kernel given to this command, or `Kernel` + # + # @example + # class MyCommand + # def call + # kernel.exit(2) unless File.exist?("Gemfile") + # end + # end + # + # @api public + # @since x.y.z + def kernel + @kernel || Kernel + end + + # Returns a copy of this command, configured to write to the given streams, and to exit + # through the given kernel. # # Called on a command registered as an instance, since it is constructed before the CLI is # invoked, and therefore before it knows where its output should go. # # @api private - def with_streams(stderr:, stdin:, stdout:) - dup.send(:set_streams, stderr:, stdin:, stdout:) + def with_streams(stderr:, stdin:, stdout:, kernel: nil) + dup.send(:set_streams, stderr:, stdin:, stdout:, kernel:) end private @@ -675,10 +696,11 @@ def with_streams(stderr:, stdin:, stdout:) # @see #with_streams # # @api private - def set_streams(stderr:, stdin:, stdout:) + def set_streams(stderr:, stdin:, stdout:, kernel: nil) @stderr = stderr @stdin = stdin @stdout = stdout + @kernel = kernel @stderr_stream = nil @stdout_stream = nil @@ -711,6 +733,28 @@ def puts(*args) def print(*args) stdout.print(*args) end + + # Exits through the command's own {#kernel}, rather than `Kernel` itself. + # + # With the default kernel this is `Kernel#exit`. A kernel given for testing may record the + # status and return instead of ending the process, so return after calling this when the + # rest of `#call` must not run. + # + # @param status [Boolean, Integer] the exit status, as for `Kernel#exit` + # + # @example + # class MyCommand + # def call + # return exit(1) unless File.exist?("Gemfile") + # + # puts "Found a Gemfile" + # end + # end + # + # @since x.y.z + def exit(status = true) + kernel.exit(status) + end end end end diff --git a/lib/dry/cli/launcher.rb b/lib/dry/cli/launcher.rb new file mode 100644 index 00000000..f133650c --- /dev/null +++ b/lib/dry/cli/launcher.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "delegate" + +module Dry + class CLI + # Runs a CLI from its executable, or in-process from a test. + # + # A launcher takes everything the CLI talks to (the arguments, the three standard streams, and + # the kernel it exits through) as arguments, in the order Aruba's in-process launcher passes + # them. Tests can then run the CLI in the same process as the test, with a StringIO for each + # stream, and an object that records the exit status in place of `Kernel`. + # + # Bind a launcher to your registry, or to a single command, with {.[]}. + # + # @example The launcher, and the executable that uses it + # # lib/my_app/launcher.rb + # module MyApp + # Launcher = Dry::CLI::Launcher[MyApp::Commands] + # end + # + # # exe/my_app + # MyApp::Launcher.new(ARGV).execute! + # + # @example Running it in-process with Aruba + # Aruba.configure do |config| + # config.command_launcher = :in_process + # config.main_class = MyApp::Launcher + # end + # + # @api public + # @since x.y.z + class Launcher + # What each parameter of {#initialize} after `argv` falls back to, read when a launcher is + # created rather than when this file is loaded. + # + # @api private + GLOBALS = { + stdin: -> { $stdin }, + stdout: -> { $stdout }, + stderr: -> { $stderr }, + kernel: -> { Kernel } + }.freeze + + class << self + # Returns a launcher for the given registry or command. + # + # Each keyword pins the default for its parameter of {#initialize}, for when the executable + # passes only `argv`. A launcher called with all five arguments, as Aruba does, uses those + # instead. A parameter without a pinned default takes its global (`$stdin`, `$stdout`, + # `$stderr` or `Kernel`) at the time the launcher is created, not at the time this method + # is called. + # + # @param target [Dry::CLI::Registry, Dry::CLI::Command] what to run + # @param stdin [IO, nil] the default input + # @param stdout [IO, nil] the default output + # @param stderr [IO, nil] the default error output + # @param kernel [#exit, nil] the default kernel + # + # @return [Class] a subclass of {Launcher} bound to `target` + # + # @example + # Launcher = Dry::CLI::Launcher[MyApp::Commands, stdout: SyncedStdout.new] + # + # @api public + # @since x.y.z + def [](target, stdin: nil, stdout: nil, stderr: nil, kernel: nil) + defaults = {stdin:, stdout:, stderr:, kernel:}.compact.freeze + + Class.new(self) do + @target = target + @defaults = defaults + end + end + + # @return [Dry::CLI::Registry, Dry::CLI::Command, nil] what this launcher runs + # + # @api private + attr_reader :target + + # @return [Hash{Symbol => Object}] the defaults pinned by {.[]} + # + # @api private + def defaults + @defaults || {} + end + end + + # @return [Array] the command line arguments + # + # @api public + attr_reader :argv + + # @return [IO] the input + # + # @api public + attr_reader :stdin + + # @return [IO] the output + # + # @api public + attr_reader :stdout + + # @return [IO] the error output + # + # @api public + attr_reader :stderr + + # @return [#exit] what the CLI exits through + # + # @api public + attr_reader :kernel + + # @param argv [Array] the command line arguments + # @param stdin [IO, nil] the input, or nil for the default + # @param stdout [IO, nil] the output, or nil for the default + # @param stderr [IO, nil] the error output, or nil for the default + # @param kernel [#exit, nil] what to exit through, or nil for the default + # + # @api public + # @since x.y.z + def initialize(argv = ARGV, stdin = nil, stdout = nil, stderr = nil, kernel = nil) + given = {stdin:, stdout:, stderr:, kernel:}.compact + values = GLOBALS.transform_values(&:call).merge(self.class.defaults, given) + + @argv = argv + @stdin = values.fetch(:stdin) + @stdout = values.fetch(:stdout) + @stderr = values.fetch(:stderr) + @kernel = values.fetch(:kernel) + end + + # Runs the CLI, then exits with a status of 0 unless the CLI or a command exited already. + # + # Exceptions raised by a command are not rescued, so a test running the CLI in-process sees + # the original error and its backtrace. + # + # @raise [ArgumentError] if the launcher was not bound with {.[]} + # + # @api public + # @since x.y.z + def execute! + target = self.class.target or + raise ArgumentError, "no CLI to launch: bind one with Dry::CLI::Launcher[MyApp::Commands]" + + exits = ExitTracker.new(kernel) + Dry.CLI(target).call(arguments: argv, stdin:, stdout:, stderr:, kernel: exits) + kernel.exit(0) unless exits.exited? + end + + # Passes everything through to a kernel, noting whether `exit` was called. + # + # A kernel used for testing records the status and returns, so without this, the launcher's + # final exit would overwrite a status the CLI had already set. + # + # @api private + class ExitTracker < SimpleDelegator + # @param status [Boolean, Integer] the exit status + # + # @return [Object] whatever the kernel returns, when it returns at all + def exit(status) + @exited = true + __getobj__.exit(status) + end + + # @return [Boolean] whether `exit` was called + def exited? + @exited == true + end + end + private_constant :ExitTracker + end + end +end diff --git a/spec/integration/launcher_spec.rb b/spec/integration/launcher_spec.rb new file mode 100644 index 00000000..77fe6633 --- /dev/null +++ b/spec/integration/launcher_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "aruba/api" +require "aruba/rspec" + +# A launcher for the CLI the other integration specs run as a separate process, run here in the +# same process as the spec. +FooLauncher = Dry::CLI::Launcher[Foo::CLI::Commands] + +RSpec.describe "Launcher, in-process with Aruba", type: :aruba do + before do + aruba.config.command_launcher = :in_process + aruba.config.main_class = FooLauncher + end + + it "captures a command's output and exit status" do + run_command_and_stop("foo version", fail_on_error: false) + + expect(last_command_started).to have_output("v1.0.0") + expect(last_command_started).to have_exit_status(0) + end + + it "captures help" do + run_command_and_stop("foo version --help", fail_on_error: false) + + expect(last_command_started.stdout).to include("Usage:") + expect(last_command_started).to have_exit_status(0) + end + + it "captures an unknown command on stderr, with its exit status" do + run_command_and_stop("foo nope", fail_on_error: false) + + expect(last_command_started.stderr).to include("Commands:") + expect(last_command_started).to have_exit_status(1) + end +end diff --git a/spec/support/fake_kernel.rb b/spec/support/fake_kernel.rb new file mode 100644 index 00000000..7cd72e91 --- /dev/null +++ b/spec/support/fake_kernel.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module RSpec + module Support + # Records exits instead of ending the process, like the kernel Aruba runs a CLI in-process with. + class FakeKernel + attr_reader :exits + + def initialize + @exits = [] + end + + def exit(status) + @exits << status + nil + end + + def exitstatus + case exits.last + when nil, true then 0 + when false then 1 + else exits.last + end + end + end + end +end diff --git a/spec/unit/dry/cli/command_spec.rb b/spec/unit/dry/cli/command_spec.rb index 4725bab2..07f64d4d 100644 --- a/spec/unit/dry/cli/command_spec.rb +++ b/spec/unit/dry/cli/command_spec.rb @@ -106,7 +106,7 @@ def initialize(*args, **kwargs, &block) describe "auto-initialized keywords" do it "names the keywords .new gives to #auto_initialize" do - expect(Dry::CLI::Command.auto_initialize_keywords).to eq %i[stderr stdin stdout] + expect(Dry::CLI::Command.auto_initialize_keywords).to eq %i[stderr stdin stdout kernel] end it "assigns a subclass's own keyword before #initialize runs" do @@ -170,7 +170,7 @@ def initialize(**kwargs) command = command_class.new(stdout: out, fs: "fs", system_call: "call", nested: true) expect(command_class.auto_initialize_keywords) - .to eq %i[stderr stdin stdout fs system_call nested] + .to eq %i[stderr stdin stdout kernel fs system_call nested] expect(command.seen[0].raw).to be out expect(command.seen[1..]).to eq ["fs", "call", true] end @@ -183,7 +183,7 @@ def initialize(**kwargs) end end - expect(Class.new(base).auto_initialize_keywords).to eq %i[stderr stdin stdout fs] + expect(Class.new(base).auto_initialize_keywords).to eq %i[stderr stdin stdout kernel fs] end it "collects the keywords declared anywhere in the command's ancestry" do @@ -220,7 +220,7 @@ def auto_initialize(from_include: nil, **kwargs) ) expect(command_class.auto_initialize_keywords) - .to eq %i[stderr stdin stdout from_include own from_prepend] + .to eq %i[stderr stdin stdout kernel from_include own from_prepend] expect([command.from_prepend, command.from_include, command.own]) .to eq %w[prepend include own] end diff --git a/spec/unit/dry/cli/kernel_spec.rb b/spec/unit/dry/cli/kernel_spec.rb new file mode 100644 index 00000000..34ef8cd0 --- /dev/null +++ b/spec/unit/dry/cli/kernel_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "stringio" + +RSpec.describe "Exiting through a kernel" do + let(:kernel) { RSpec::Support::FakeKernel.new } + let(:stdout) { StringIO.new } + let(:stderr) { StringIO.new } + + def run(cli, *arguments) + cli.call(arguments:, stdout:, stderr:, kernel:) + end + + context "with a registry" do + let(:cli) { Dry.CLI(Foo::CLI::Commands) } + + it "exits with 0 after printing a command's help" do + run(cli, "console", "--help") + + expect(stdout.string).to include("Usage:\n rspec console") + expect(kernel.exits).to eq([0]) + end + + it "exits with 1 after printing an error" do + run(cli, "console", "--engine=nope") + + expect(stderr.string).to include("was called with invalid argument") + expect(kernel.exits).to eq([1]) + end + + it "exits with 1 after a typo" do + run(cli, "consol") + + expect(stderr.string).to include("Did you mean: 'console'") + expect(kernel.exits).to eq([1]) + end + + it "does not exit after a command runs" do + run(cli, "console") + + expect(stdout.string).to eq("console - engine: \n") + expect(kernel.exits).to be_empty + end + + it "stops at the help, even when the kernel returns from exit" do + expect { run(cli, "console", "-h") }.not_to raise_error + expect(stdout.string).not_to include("console - engine") + end + end + + context "with a single command" do + let(:cli) { Dry.CLI(Baz::CLI) } + + it "exits with 0 after printing help" do + run(cli, "-h") + + expect(stdout.string).to include("Usage:") + expect(kernel.exits).to eq([0]) + end + + it "exits with 1 when a required argument is missing" do + run(cli) + + expect(stderr.string).to include("was called with no arguments") + expect(kernel.exits).to eq([1]) + end + end + + context "when a signal interrupts the CLI" do + let(:command) do + Class.new(Dry::CLI::Command) do + def call(**) = raise(Interrupt) + end + end + + it "exits with 128 plus the signal number" do + run(Dry.CLI(command)) + + expect(kernel.exits).to eq([130]) + end + end + + context "when a command exits" do + let(:command) do + Class.new(Dry::CLI::Command) do + def call(**) + return exit(3) if stdin.eof? + + puts "not reached" + end + end + end + + it "exits through the CLI's kernel" do + Dry.CLI(command).call(arguments: [], stdin: StringIO.new, stdout:, stderr:, kernel:) + + expect(kernel.exits).to eq([3]) + expect(stdout.string).to be_empty + end + + it "exits through the kernel given to an instance command" do + Dry.CLI(command.new).call(arguments: [], stdin: StringIO.new, stdout:, stderr:, kernel:) + + expect(kernel.exits).to eq([3]) + end + + it "exits through Kernel when given no kernel" do + expect { command.new(stdin: StringIO.new).send(:call) }.to raise_error(SystemExit) { |error| + expect(error.status).to eq(3) + } + end + end + + describe Dry::CLI::Command do + subject(:command) { Class.new(described_class).new(kernel:) } + + it "exposes its kernel" do + expect(command.kernel).to be(kernel) + end + + it "defaults to Kernel" do + expect(Class.new(described_class).new.kernel).to be(Kernel) + end + + it "keeps the kernel out of #initialize" do + klass = Class.new(described_class) do + attr_reader :name + + def initialize(name:) = @name = name # rubocop:disable Lint/MissingSuper + end + + expect(klass.new(name: "x", kernel:)).to have_attributes(name: "x", kernel:) + end + end +end diff --git a/spec/unit/dry/cli/launcher_spec.rb b/spec/unit/dry/cli/launcher_spec.rb new file mode 100644 index 00000000..13ba687e --- /dev/null +++ b/spec/unit/dry/cli/launcher_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "stringio" + +RSpec.describe Dry::CLI::Launcher do + let(:kernel) { RSpec::Support::FakeKernel.new } + + # A helper built from the command's streams, memoized the documented way: against the stream it + # was built for, so a reused instance never writes to the streams of an earlier call. + let(:reporter) do + Class.new(Dry::CLI::Command) do + def call(**) = report.puts("reported") + + def report + @report = nil unless @report_stream.equal?(stdout) + @report_stream = stdout + @report ||= Struct.new(:io) { def puts(text) = io.puts(text) }.new(stdout) + end + end + end + + let(:registry) do + reporter = self.reporter + Module.new do + extend Dry::CLI::Registry + + register "hello", (Class.new(Dry::CLI::Command) do + option :name, default: "world" + + def call(name:, **) = puts("Hello, #{name}!") + end) + register "report", reporter + register "report-instance", reporter.new + register "fail", (Class.new(Dry::CLI::Command) do + def call(**) = exit(3) + end) + end + end + + let(:launcher) { described_class[registry] } + + def launch(*argv) + Struct.new(:stdout, :stderr, :kernel).new(StringIO.new, StringIO.new, RSpec::Support::FakeKernel.new).tap do |run| + launcher.new(argv, StringIO.new, run.stdout, run.stderr, run.kernel).execute! + end + end + + it "runs a command and exits with 0" do + run = launch("hello") + + expect(run.stdout.string).to eq("Hello, world!\n") + expect(run.kernel.exits).to eq([0]) + end + + it "keeps the status of a command that exits" do + expect(launch("fail").kernel.exits).to eq([3]) + end + + it "prints help to its own stdout and exits with 0" do + run = launch("hello", "--help") + + expect(run.stdout.string).to include("Usage:") + expect(run.kernel.exits).to eq([0]) + end + + it "prints errors to its own stderr and exits with 1" do + run = launch("nope") + + expect(run.stderr.string).to include("Commands:") + expect(run.stdout.string).to be_empty + expect(run.kernel.exits).to eq([1]) + end + + it "gives each run of a class command its own streams" do + first = launch("report") + second = launch("report") + + expect([first.stdout.string, second.stdout.string]).to eq(["reported\n", "reported\n"]) + end + + it "gives each run of an instance command its own streams" do + first = launch("report-instance") + second = launch("report-instance") + + expect([first.stdout.string, second.stdout.string]).to eq(["reported\n", "reported\n"]) + end + + it "lets exceptions from a command through" do + failing = Class.new(Dry::CLI::Command) { def call(**) = raise("boom") } + + expect { described_class[failing].new([], nil, StringIO.new, StringIO.new, kernel).execute! } + .to raise_error(RuntimeError, "boom") + end + + it "runs a single command" do + stdout = StringIO.new + described_class[Baz::CLI].new(%w[one], nil, stdout, StringIO.new, kernel).execute! + + expect(stdout.string).to include("mandatory_arg: one") + expect(kernel.exits).to eq([0]) + end + + it "refuses to run when it is not bound to a CLI" do + expect { described_class.new([]).execute! }.to raise_error(ArgumentError, /no CLI to launch/) + end + + describe "defaults" do + let(:pinned) { StringIO.new } + + it "uses what .[] pinned when given only argv" do + described_class[registry, stdout: pinned, kernel:].new(%w[hello]).execute! + + expect(pinned.string).to eq("Hello, world!\n") + expect(kernel.exits).to eq([0]) + end + + it "prefers what it is given over what .[] pinned" do + given = StringIO.new + described_class[registry, stdout: pinned].new(%w[hello], nil, given, nil, kernel).execute! + + expect(given.string).to eq("Hello, world!\n") + expect(pinned.string).to be_empty + end + + it "takes the globals when it is created, not when it is bound" do + bound = described_class[registry] + output = StringIO.new + with_stdout(output) { bound.new(%w[hello], nil, nil, nil, kernel).execute! } + + expect(output.string).to eq("Hello, world!\n") + end + + it "exits through Kernel by default" do + expect { described_class[registry].new(%w[hello], nil, StringIO.new).execute! } + .to raise_error(SystemExit) { |error| expect(error.status).to eq(0) } + end + + it "exposes what it was given" do + launched = described_class[registry].new(%w[hello], :in, :out, :err, kernel) + + expect(launched).to have_attributes(argv: %w[hello], stdin: :in, stdout: :out, stderr: :err, kernel:) + end + end +end