Skip to content

Commit 463f960

Browse files
Refactor shell runner (#20)
* Remove FixtureRunner in favor of fake Open3 * Use Open3.capture2e instead of Open3.capture3
1 parent a9497a3 commit 463f960

5 files changed

Lines changed: 63 additions & 232 deletions

File tree

‎lib/commands.rb‎

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,8 @@
44
require "json"
55

66
module ImportmapUpdate
7-
# Abstracts execution of external commands (bin/importmap) so the rest
8-
# of the codebase doesn't shell out directly. This is the seam tests hook
9-
# into — production code runs commands for real, tests inject a
10-
# FixtureRunner that replays pre-recorded (argv → stdout, exit) tuples.
11-
#
12-
# The interface deliberately mirrors what Open3.capture3 returns:
13-
#
14-
# runner.run("bin/importmap", "outdated")
15-
# # => Result(stdout: "...", stderr: "...", success: true, exit: 0)
16-
#
17-
# Commands are passed as an argv array, not a shell string. That's both
18-
# safer (no shell-injection surprises from package names) and easier to
19-
# match against fixture keys.
207
module Commands
21-
Result = Data.define(:stdout, :stderr, :exit_code) do
8+
Result = Data.define(:output, :exit_code) do
229
def success?
2310
exit_code == 0
2411
end
@@ -30,33 +17,24 @@ class CommandError < StandardError
3017
def initialize(argv, result)
3118
@argv = argv
3219
@result = result
33-
super("`#{argv.join(" ")}` exited #{result.exit_code}: #{result.stderr.strip}")
20+
super("`#{argv.join(" ")}` exited #{result.exit_code}: #{result.output.strip}")
3421
end
3522
end
3623

37-
# Production runner: actually executes the command.
3824
class ShellRunner
39-
# @param cwd [String, nil] working directory (defaults to current)
40-
def initialize(cwd: nil)
25+
def initialize(cwd: nil, open3: Open3)
4126
@cwd = cwd
27+
@open3 = open3
4228
end
4329

4430
def run(*argv)
4531
opts = {}
4632
opts[:chdir] = @cwd if @cwd
4733
Bundler.with_unbundled_env do
48-
stdout, stderr, status = Open3.capture3(*argv, opts)
49-
Result.new(stdout:, stderr:, exit_code: status.exitstatus)
34+
output, status = @open3.capture2e(*argv, **opts)
35+
Result.new(output:, exit_code: status.exitstatus)
5036
end
5137
end
52-
53-
# Raises on non-zero exit. Use when you have no recovery strategy
54-
# and just want to surface the error to the caller.
55-
def run!(*argv)
56-
result = run(*argv)
57-
raise CommandError.new(argv, result) unless result.success?
58-
result
59-
end
6038
end
6139
end
6240
end

‎test/commands_test.rb‎

Lines changed: 2 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -6,122 +6,17 @@
66
class CommandsTest < Minitest::Test
77
Commands = ImportmapUpdate::Commands
88

9-
# ---- ShellRunner ----
10-
119
def test_shell_runner_captures_stdout
1210
result = Commands::ShellRunner.new.run("echo", "hello world")
13-
assert_equal "hello world\n", result.stdout
11+
assert_equal "hello world\n", result.output
1412
assert_predicate result, :success?
1513
assert_equal 0, result.exit_code
1614
end
1715

1816
def test_shell_runner_captures_stderr_and_failure
19-
# `sh -c` lets us write to stderr cleanly without depending on a
20-
# specific binary's behavior.
2117
result = Commands::ShellRunner.new.run("sh", "-c", "echo oops 1>&2; exit 3")
22-
assert_equal "oops\n", result.stderr
18+
assert_equal "oops\n", result.output
2319
assert_equal 3, result.exit_code
2420
refute_predicate result, :success?
2521
end
26-
27-
def test_shell_runner_bang_raises_on_non_zero_exit
28-
err = assert_raises(Commands::CommandError) do
29-
Commands::ShellRunner.new.run!("sh", "-c", "echo nope 1>&2; exit 1")
30-
end
31-
assert_equal 1, err.result.exit_code
32-
assert_includes err.message, "exited 1"
33-
assert_includes err.message, "nope"
34-
end
35-
36-
def test_shell_runner_argv_is_safe_from_shell_metacharacters
37-
# argv-style invocation must not interpret `;` as a command separator.
38-
# If it did, this test would attempt to delete a file.
39-
result = Commands::ShellRunner.new.run("echo", "a; rm -rf /b")
40-
assert_equal "a; rm -rf /b\n", result.stdout
41-
end
42-
43-
# ---- FixtureRunner: literal matching ----
44-
45-
def test_fixture_runner_returns_recorded_result_for_exact_argv_match
46-
runner = Commands::FixtureRunner.new
47-
runner.add(
48-
pattern: ["bin/importmap", "outdated"],
49-
stdout: "| Package | Current | Latest |\n"
50-
)
51-
result = runner.run("bin/importmap", "outdated")
52-
assert_equal "| Package | Current | Latest |\n", result.stdout
53-
assert_predicate result, :success?
54-
end
55-
56-
def test_fixture_runner_records_calls_in_order
57-
runner = Commands::FixtureRunner.new
58-
runner.add(pattern: ["bin/importmap", "outdated"], stdout: "")
59-
runner.add(pattern: ["bin/importmap", "audit"], stdout: "")
60-
runner.run("bin/importmap", "outdated")
61-
runner.run("bin/importmap", "audit")
62-
assert_equal [
63-
["bin/importmap", "outdated"],
64-
["bin/importmap", "audit"]
65-
], runner.calls
66-
end
67-
68-
def test_fixture_runner_first_matching_pattern_wins
69-
# Order matters: if you register a fallback first, it'll swallow more
70-
# specific patterns. This test pins the behavior so callers know to
71-
# register specific patterns before general ones.
72-
runner = Commands::FixtureRunner.new
73-
runner.add(pattern: ["bin/importmap", "pin", "lodash@4.17.21"], stdout: "first\n")
74-
runner.add(pattern: ["bin/importmap", "pin", "lodash@4.17.21"], stdout: "second\n")
75-
assert_equal "first\n", runner.run("bin/importmap", "pin", "lodash@4.17.21").stdout
76-
end
77-
78-
# ---- FixtureRunner: regex matching ----
79-
80-
def test_fixture_runner_supports_regex_elements_in_patterns
81-
# Package versions change; the pattern uses a regex to allow any semver.
82-
runner = Commands::FixtureRunner.new
83-
runner.add(
84-
pattern: ["bin/importmap", "pin", /\Alodash@\d+\.\d+\.\d+\z/],
85-
stdout: "Pinned lodash\n"
86-
)
87-
assert_equal "Pinned lodash\n", runner.run("bin/importmap", "pin", "lodash@4.17.21").stdout
88-
end
89-
90-
def test_fixture_runner_regex_must_match_exactly_at_position
91-
runner = Commands::FixtureRunner.new
92-
runner.add(pattern: ["bin/importmap", "pin", /\Alodash@/], stdout: "ok\n")
93-
err = assert_raises(RuntimeError) do
94-
runner.run("bin/importmap", "pin", "axios@1.7.0")
95-
end
96-
assert_match(/No fixture matched/, err.message)
97-
end
98-
99-
# ---- FixtureRunner: misses and errors ----
100-
101-
def test_fixture_runner_raises_clearly_when_no_pattern_matches
102-
runner = Commands::FixtureRunner.new
103-
runner.add(pattern: ["bin/importmap", "outdated"], stdout: "")
104-
err = assert_raises(RuntimeError) { runner.run("bin/importmap", "audit") }
105-
assert_includes err.message, "No fixture matched"
106-
assert_includes err.message, "audit"
107-
end
108-
109-
def test_fixture_runner_argv_size_mismatch_does_not_match
110-
# A 2-element pattern must not match a 3-element call.
111-
runner = Commands::FixtureRunner.new
112-
runner.add(pattern: ["bin/importmap", "outdated"], stdout: "ok\n")
113-
assert_raises(RuntimeError) { runner.run("bin/importmap", "outdated", "--verbose") }
114-
end
115-
116-
def test_fixture_runner_bang_raises_command_error_on_recorded_failure
117-
runner = Commands::FixtureRunner.new
118-
runner.add(
119-
pattern: ["bin/importmap", "pin", "lodash@4.17.21"],
120-
stderr: "network error",
121-
exit_code: 1
122-
)
123-
err = assert_raises(Commands::CommandError) { runner.run!("bin/importmap", "pin", "lodash@4.17.21") }
124-
assert_equal 1, err.result.exit_code
125-
assert_includes err.message, "network error"
126-
end
12722
end

0 commit comments

Comments
 (0)