Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support IO capture from an already-started process #13374

Merged
merged 3 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions lib/ex_unit/lib/ex_unit/capture_io.ex
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,19 @@ defmodule ExUnit.CaptureIO do

See `capture_io/1` for more information.
"""
@spec capture_io(atom() | String.t() | keyword(), (-> any())) :: String.t()
def capture_io(device_input_or_options, fun)
@spec capture_io(atom() | pid() | String.t() | keyword(), (-> any())) :: String.t()
def capture_io(device_pid_input_or_options, fun)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was on the fence on whether to collapse these into fewer clauses. I can do that if you think it improves readability.


def capture_io(device, fun) when is_atom(device) and is_function(fun, 0) do
{_result, capture} = with_io(device, fun)
capture
end

def capture_io(pid, fun) when is_pid(pid) and is_function(fun, 0) do
{_result, capture} = with_io(pid, fun)
capture
end

def capture_io(input, fun) when is_binary(input) and is_function(fun, 0) do
{_result, capture} = with_io(input, fun)
capture
Expand All @@ -171,8 +176,8 @@ defmodule ExUnit.CaptureIO do

See `capture_io/1` for more information.
"""
@spec capture_io(atom(), String.t() | keyword(), (-> any())) :: String.t()
def capture_io(device, input_or_options, fun)
@spec capture_io(atom() | pid(), String.t() | keyword(), (-> any())) :: String.t()
def capture_io(device_or_pid, input_or_options, fun)

def capture_io(device, input, fun)
when is_atom(device) and is_binary(input) and is_function(fun, 0) do
Expand All @@ -186,6 +191,18 @@ defmodule ExUnit.CaptureIO do
capture
end

def capture_io(pid, input, fun)
when is_pid(pid) and is_binary(input) and is_function(fun, 0) do
{_result, capture} = with_io(pid, input, fun)
capture
end

def capture_io(pid, options, fun)
when is_pid(pid) and is_list(options) and is_function(fun, 0) do
{_result, capture} = with_io(pid, options, fun)
capture
end

@doc ~S"""
Invokes the given `fun` and returns the result and captured output.

Expand Down Expand Up @@ -215,13 +232,17 @@ defmodule ExUnit.CaptureIO do
See `with_io/1` for more information.
"""
@doc since: "1.13.0"
@spec with_io(atom() | String.t() | keyword(), (-> any())) :: {any(), String.t()}
def with_io(device_input_or_options, fun)
@spec with_io(atom() | pid() | String.t() | keyword(), (-> any())) :: {any(), String.t()}
def with_io(device_pid_input_or_options, fun)

def with_io(device, fun) when is_atom(device) and is_function(fun, 0) do
with_io(device, [], fun)
end

def with_io(pid, fun) when is_pid(pid) and is_function(fun, 0) do
with_io(pid, [], fun)
end

def with_io(input, fun) when is_binary(input) and is_function(fun, 0) do
with_io(:stdio, [input: input], fun)
end
Expand All @@ -236,8 +257,8 @@ defmodule ExUnit.CaptureIO do
See `with_io/1` for more information.
"""
@doc since: "1.13.0"
@spec with_io(atom(), String.t() | keyword(), (-> any())) :: {any(), String.t()}
def with_io(device, input_or_options, fun)
@spec with_io(atom() | pid(), String.t() | keyword(), (-> any())) :: {any(), String.t()}
def with_io(device_or_pid, input_or_options, fun)

def with_io(device, input, fun)
when is_atom(device) and is_binary(input) and is_function(fun, 0) do
Expand All @@ -249,18 +270,29 @@ defmodule ExUnit.CaptureIO do
do_with_io(map_dev(device), options, fun)
end

def with_io(pid, input, fun)
when is_pid(pid) and is_binary(input) and is_function(fun, 0) do
with_io(pid, [input: input], fun)
end

def with_io(pid, options, fun)
when is_pid(pid) and is_list(options) and is_function(fun, 0) do
do_with_io(pid, options, fun)
end

defp map_dev(:stdio), do: :standard_io
defp map_dev(:stderr), do: :standard_error
defp map_dev(other), do: other

defp do_with_io(:standard_io, options, fun) do
defp do_with_io(device_or_pid, options, fun)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have a separate clause for this instead:

Suggested change
defp do_with_io(device_or_pid, options, fun)
defp do_with_io(:standard_io, options, fun), do: do_with_io(self(), options, fun)

WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe change this in map_dev. No worries, I will merge it and play with it locally.

when device_or_pid == :standard_io or is_pid(device_or_pid) do
prompt_config = Keyword.get(options, :capture_prompt, true)
encoding = Keyword.get(options, :encoding, :unicode)
input = Keyword.get(options, :input, "")
pid = Keyword.get(options, :pid, self())

original_gl = Process.group_leader()
{:ok, capture_gl} = StringIO.open(input, capture_prompt: prompt_config, encoding: encoding)
pid = if is_pid(device_or_pid), do: device_or_pid, else: self()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally had a pid_for private function for this, but it seemed excessive.


try do
Process.group_leader(pid, capture_gl)
Expand Down
43 changes: 35 additions & 8 deletions lib/ex_unit/test/ex_unit/capture_io_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,24 @@ defmodule ExUnit.CaptureIOTest do
def init(_), do: {:ok, nil}

@impl GenServer
def handle_call({device, message}, _from, state) do
IO.puts(device, message)
{:reply, device, state}
def handle_call({:stdio, message}, _from, state) do
IO.puts(message)
{:reply, :ok, state}
end

@impl GenServer
def handle_call({:prompt, prompt}, _from, state) do
prompt
|> IO.gets()
|> IO.puts()

{:reply, :ok, state}
end

@impl GenServer
def handle_call({:stderr, message}, _from, state) do
IO.puts(:stderr, message)
{:reply, :ok, state}
end
end

Expand Down Expand Up @@ -486,16 +501,28 @@ defmodule ExUnit.CaptureIOTest do
end
end

test "capture_io with pid in options" do
test "capture_io with a separate process" do
{:ok, pid} = MockProc.start_link()

assert capture_io([pid: pid], fn ->
assert capture_io(pid, fn ->
GenServer.call(pid, {:stdio, "a"})
end) == "a\n"

assert capture_io(:stderr, [pid: pid], fn ->
GenServer.call(pid, {:stderr, "b"})
end) == "b\n"
assert capture_io(pid, [input: "b"], fn ->
GenServer.call(pid, {:prompt, "> "})
end) == "> b\n"

assert capture_io(pid, "c", fn ->
GenServer.call(pid, {:prompt, "> "})
end) == "> c\n"

assert capture_io(pid, [input: "d", capture_prompt: false], fn ->
GenServer.call(pid, {:prompt, "> "})
end) == "d\n"

assert capture_io(:stderr, fn ->
GenServer.call(pid, {:stderr, "uhoh"})
end) == "uhoh\n"

GenServer.stop(pid)
end
Expand Down
Loading