Skip to content

Exit through an injectable kernel; add Launcher for Aruba testing (1 of 4) - #172

Closed
kigster wants to merge 5 commits into
dry-rb:mainfrom
kigster:kig/auto-inject-compatibility
Closed

kigster wants to merge 5 commits into
dry-rb:mainfrom
kigster:kig/auto-inject-compatibility

Conversation

@kigster

@kigster kigster commented Oct 7, 2026

Copy link
Copy Markdown

Proposal 1 of 4 in the dry-cli plugin-hooks series.

Upstream issue/forum thread: pending (per CONTRIBUTING.md this is discussed on the forum before going to dry-rb).

Summary

CLI exits go through an injectable kernel, and a Launcher runs a CLI with Aruba's in-process signature.

 Dry::CLI#call(arguments:, stdout:, stderr:, stdin:, kernel: Kernel)
   perform_registry / perform_command
     parse
     help           → raise Halt(0)
     error          → raise Halt(1)
     spell_checker  → raise Halt(1)
   rescue Halt      → kernel.exit(status)
   rescue Signal    → kernel.exit(128 + signo)

Explanation

  • Halt is private. It exists because a test kernel's exit returns: without it, execution would fall through the help screen into code expecting a parsed command.

  • kernel: is an auto_initialize keyword (Free up #initialize for command subclasses #167), so Command#kernel is set before #initialize and never reaches it. Private Command#exit goes through it.

Launcher

Launcher is an intermediate class that swaps out standard streams (stdin, stdout, stderr, and additionally, Kernel) so that in tests the streams could be replaced with StringIO, and Kernel.exit be a no-op.

This lets you use another gem called aruba for proper CLI integration testing of the command, without the penalty of a fork(). With this Launcher pattern, Aruba can drive the gem in a single process, making tests much, much faster.

Example of how to configure Aruba

MyApp::Launcher = Dry::CLI::Launcher[MyApp::Commands]   
MyApp::Launcher.new(ARGV).execute!

# spec/spec_helper.rb
Aruba.configure do |config|
  config.command_launcher = :in_process
  config.main_class       = MyApp::Launcher
end
  • Launcher[target, stdin:, stdout:, stderr:, kernel:] pins defaults for .new(argv); unpinned ones take the globals when the launcher is created. Aruba passes all five, so its StringIOs win.

  • #execute! exits 0 unless the CLI already exited; exceptions propagate. No global stream swapping.

Evidence

  • Before: Dry.CLI(registry).call(arguments: ["x", "-h"], kernel: fake) → NoMethodError (no kernel:); in-process Aruba runs and ends the test process on exit.

  • After: spec/unit/dry/cli/kernel_spec.rb (14), launcher_spec.rb (14), spec/integration/launcher_spec.rb (3, real Aruba in-process). Full suite: 631 examples, 0 failures; rubocop clean.

Notes

  • Adds a test dependency on aruba gem

See

Blog Post on testing CLI Gems with Aruba

Note

Important

This and every one of my PRs has been produced in a pair-programming session with Claude, reviewed by me and another agent to be sure.

Dry::CLI#call and Command.new take `kernel:`, defaulting to Kernel. Help,
errors and typos stop the CLI by raising a private Halt, which #call turns
into kernel.exit, so a kernel whose exit returns (as Aruba's does) still
stops the CLI. A command's own `exit` goes through its kernel too.

Dry::CLI::Launcher[registry] runs a CLI with the arguments, streams and
kernel in the order Aruba's in-process launcher passes them, so a CLI can
be tested in the same process with StringIO streams.
Registry#tree, Dry::CLI#tree and Dry::CLI::Tree.for return a live view of
the registered commands, for gems that describe a CLI rather than run it:
help screens, shell completion, documentation generators. Until now they
could only walk CommandRegistry internals.

Tree::Node#resolve shares its lookup with dispatch (CommandRegistry.lookup),
so the command it finds is the one the CLI would run. Tree::Param is a
frozen snapshot of an option or argument, including every declared key.
Help and command listings are built as a Dry::CLI::Screen, passed through
a renderer and a list of filters, then printed to the stream its status
chooses. Renderer and filters are any #call(screen) -> screen, so they
compose with >> (procs, dry-transformer functions).

Dry::CLI.configure sets them process-wide; Dry.CLI(..., config:) per CLI.
The default renderer wraps Banner and Usage, and the existing suite passes
unchanged, so unconfigured output is byte-for-byte what it was.

This replaces overriding the private #help and #spell_checker, which is
what dry-cli-help has to do today.
Commands already work with dry-auto_inject and dry-system containers,
through Command.new. Document how, including the one rule dry-auto_inject
has for any class (a subclass's own #initialize must call super(**)), and
pin it with specs, including the failure when that rule is broken.

Also document memoizing objects built from a command's streams against
the stream, since an instance command is reused across calls, and across
tests when a CLI runs in-process.
@kigster kigster changed the title Exit through an injectable kernel; add Launcher for Aruba testing Exit through an injectable kernel; add Launcher for Aruba testing (1 of 4) Oct 8, 2026
@kigster kigster closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant