Files
bDS2/lib/bds/desktop/automation.ex

319 lines
8.7 KiB
Elixir

defmodule BDS.Desktop.Automation do
@moduledoc false
use GenServer
@ready_timeout 60_000
@request_timeout 30_000
def start_session(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout)
catch
:exit, _reason -> :ok
end
def snapshot(session) do
GenServer.call(session, :snapshot, @request_timeout)
end
def click(session, selector) when is_binary(selector) do
GenServer.call(session, {:click, selector}, @request_timeout)
end
def capture_screenshot(session, destination) when is_binary(destination) do
GenServer.call(session, {:capture_screenshot, destination}, @request_timeout)
end
def child_info(session) do
GenServer.call(session, :child_info, @request_timeout)
end
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
screenshot_dir = Keyword.get(opts, :screenshot_dir, System.tmp_dir!())
port = Keyword.get_lazy(opts, :port, &free_tcp_port/0)
project_root = project_root()
base_url = BDS.Desktop.url(port)
File.mkdir_p!(screenshot_dir)
ensure_http_client_started()
app_port = start_app_process(project_root, port)
:ok = wait_for_server(base_url)
driver_port = start_driver_process(project_root, base_url, screenshot_dir)
state = %{
app_port: app_port,
app_os_pid: port_os_pid(app_port),
driver_port: driver_port,
driver_os_pid: port_os_pid(driver_port),
driver_buffer: "",
base_url: base_url,
screenshot_dir: screenshot_dir
}
{reply, state} = await_driver_ready(state)
case reply do
:ok -> {:ok, state}
{:error, reason} -> {:stop, reason}
end
end
@impl true
def handle_call(:snapshot, _from, state) do
{reply, state} = driver_request(state, %{"command" => "snapshot"})
{:reply, atomize_map(reply), state}
end
def handle_call({:click, selector}, _from, state) do
{reply, state} = driver_request(state, %{"command" => "click", "selector" => selector})
{:reply, normalize_simple_reply(reply), state}
end
def handle_call({:capture_screenshot, destination}, _from, state) do
File.mkdir_p!(Path.dirname(destination))
{reply, state} =
driver_request(state, %{"command" => "screenshot", "path" => destination})
{:reply, reply, state}
end
def handle_call(:child_info, _from, state) do
{:reply, %{app_os_pid: state.app_os_pid, driver_os_pid: state.driver_os_pid}, state}
end
@impl true
def terminate(_reason, state) do
shutdown_driver(state)
shutdown_app(state)
:ok
end
defp start_app_process(project_root, port) do
executable = System.find_executable("elixir") || raise "missing elixir executable"
Port.open(
{:spawn_executable, executable},
[
:binary,
:stderr_to_stdout,
:exit_status,
:use_stdio,
:hide,
{:cd, project_root},
{:env,
[
{~c"MIX_ENV", ~c"test"},
{~c"BDS_DESKTOP_AUTOMATION", ~c"1"},
{~c"BDS_DESKTOP_PORT", String.to_charlist(Integer.to_string(port))}
]},
{:args,
[
"-S",
"mix",
"run",
"scripts/desktop_automation_app.exs"
]}
]
)
end
defp start_driver_process(project_root, base_url, screenshot_dir) do
executable = System.find_executable("node") || raise "missing node executable"
Port.open(
{:spawn_executable, executable},
[
:binary,
:stderr_to_stdout,
:exit_status,
:use_stdio,
:hide,
{:cd, project_root},
{:args,
[
Path.join([project_root, "scripts", "desktop_automation_runner.mjs"]),
base_url,
screenshot_dir
]}
]
)
end
defp await_driver_ready(state) do
receive_driver_message(state, @ready_timeout, fn message ->
case message do
%{"status" => "ready"} -> {:ok, :ok}
%{"status" => "error", "message" => reason} -> {:ok, {:error, reason}}
_other -> :continue
end
end)
end
defp driver_request(state, payload) do
ref = Integer.to_string(System.unique_integer([:positive, :monotonic]))
request = Map.put(payload, "ref", ref)
Port.command(state.driver_port, Jason.encode!(request) <> "\n")
receive_driver_message(state, @request_timeout, fn message ->
case message do
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result}
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
raise "desktop automation request failed: #{reason}"
_other ->
:continue
end
end)
end
defp safe_driver_request(state, payload) do
driver_request(state, payload)
rescue
_error -> :ok
end
defp shutdown_driver(state) do
_ = safe_driver_request(state, %{"command" => "close"})
await_port_exit(state[:driver_port], 5_000)
safe_close_port(state[:driver_port])
end
defp shutdown_app(state) do
if state[:app_port] do
Port.command(state.app_port, "stop\n")
await_port_exit(state.app_port, 10_000)
safe_close_port(state.app_port)
end
end
defp receive_driver_message(state, timeout, matcher) do
deadline = System.monotonic_time(:millisecond) + timeout
process_driver_messages(state, deadline, matcher)
end
defp process_driver_messages(state, deadline, matcher) do
{messages, buffer} = split_driver_buffer(state.driver_buffer)
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
decoded = Jason.decode!(message)
case matcher.(decoded) do
{:ok, reply} -> {:halt, {acc, reply}}
:continue -> {:cont, {acc, nil}}
end
end) do
{state, nil} ->
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
receive do
{port, {:data, data}} when port == state.driver_port ->
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher)
{port, {:exit_status, status}} when port == state.driver_port ->
raise "desktop automation driver exited with status #{status}"
{port, {:exit_status, status}} when port == state.app_port ->
raise "desktop app process exited with status #{status}"
{_port, {:data, _data}} ->
process_driver_messages(state, deadline, matcher)
after
remaining -> raise "desktop automation timed out waiting for driver response"
end
{state, reply} ->
{reply, state}
end
end
defp split_driver_buffer(buffer) do
case String.split(buffer, "\n") do
[] -> {[], ""}
[single] -> {[], single}
parts -> {Enum.drop(parts, -1), List.last(parts)}
end
end
defp wait_for_server(base_url) do
deadline = System.monotonic_time(:millisecond) + @ready_timeout
do_wait_for_server(base_url, deadline)
end
defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
_other ->
if System.monotonic_time(:millisecond) >= deadline do
raise "desktop app process did not become healthy in time"
else
Process.sleep(200)
do_wait_for_server(base_url, deadline)
end
end
end
defp free_tcp_port do
{:ok, socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true])
{:ok, port} = :inet.port(socket)
:gen_tcp.close(socket)
port
end
defp ensure_http_client_started do
_ = Application.ensure_all_started(:inets)
_ = Application.ensure_all_started(:ssl)
:ok
end
defp await_port_exit(nil, _timeout), do: :ok
defp await_port_exit(port, timeout) do
receive do
{^port, {:exit_status, _status}} -> :ok
{^port, {:data, _data}} -> await_port_exit(port, timeout)
after
timeout -> :ok
end
end
defp port_os_pid(port) do
case Port.info(port, :os_pid) do
{:os_pid, pid} when is_integer(pid) -> pid
_other -> nil
end
end
defp safe_close_port(nil), do: :ok
defp safe_close_port(port) do
Port.close(port)
catch
:error, _reason -> :ok
end
defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp project_root do
Path.expand("../../..", __DIR__)
end
end