Current section
Files
Jump to
Current section
Files
lib/mix/tasks/test.websocket.ex
defmodule Mix.Tasks.Test.Websocket do
@shortdoc "Runs tests against a containerized remote WebDriver endpoint"
@moduledoc """
Starts Selenium standalone container(s), configures Cerberus to use remote
WebDriver endpoint(s), and runs `mix test` with forwarded args in a single
invocation.
This gives a global remote-browser invocation similar to websocket-based
remote test flows in companion projects.
## Usage
mix test.websocket
mix test.websocket --warnings-as-errors
mix test.websocket --browsers chrome,firefox
Optional flag:
- `--browsers` comma-separated list of `chrome`, `firefox`, or `all`
(default: `all`)
Optional env vars:
- `CERBERUS_REMOTE_SELENIUM_BROWSERS` fallback when `--browsers` is not set
(default: `all`)
- `CERBERUS_REMOTE_SELENIUM_IMAGE` fallback image for all browsers
- `CERBERUS_REMOTE_SELENIUM_IMAGE_CHROME`
(default: `selenium/standalone-chromium:126.0`)
- `CERBERUS_REMOTE_SELENIUM_IMAGE_FIREFOX`
(default: `selenium/standalone-firefox:126.0`)
"""
use Mix.Task
@default_selenium_images %{
chrome: "selenium/standalone-chrome:145.0-20260222",
firefox: "selenium/standalone-firefox:148.0-20260222"
}
@impl Mix.Task
def run(args) do
if Mix.env() == :test do
{browsers, test_args} = extract_task_args!(args)
ensure_docker!()
docker_host = docker_host_address()
browser_containers =
Map.new(browsers, fn browser ->
image = selenium_image(browser)
container_id = start_selenium_container!(image)
webdriver_url = "http://127.0.0.1:#{mapped_port!(container_id)}"
wait_for_webdriver_ready!(webdriver_url)
{browser, %{container_id: container_id, webdriver_url: webdriver_url}}
end)
default_browser = default_browser_lane(browsers)
browser_urls = Map.new(browser_containers, fn {browser, info} -> {browser, info.webdriver_url} end)
try do
Mix.shell().info(
"Running tests with remote websocket browsers=#{Enum.map_join(browsers, ",", &Atom.to_string/1)} " <>
"(default browser lane=#{default_browser})"
)
run_test_suite!(test_args, browser_urls, default_browser, docker_host)
after
Enum.each(browser_containers, fn {_browser, %{container_id: container_id}} ->
_ = docker_rm_force(container_id)
end)
end
else
rerun_in_test_env!(args)
end
end
defp rerun_in_test_env!(args) do
mix_executable = System.find_executable("mix") || "mix"
command_args = ["test.websocket" | args]
{_, status} =
System.cmd(mix_executable, command_args,
env: [{"MIX_ENV", "test"}],
stderr_to_stdout: true,
into: IO.stream(:stdio, :line)
)
if status != 0 do
Mix.raise("mix test.websocket failed in test env with status #{status}")
end
end
defp run_test_suite!(test_args, browser_urls, default_browser, docker_host)
when is_list(test_args) and is_map(browser_urls) and default_browser in [:chrome, :firefox] and
is_binary(docker_host) do
mix_executable = System.find_executable("mix") || "mix"
command_args = ["test" | test_args]
default_webdriver_url = Map.fetch!(browser_urls, default_browser)
browser_url_envs =
Enum.map(browser_urls, fn {browser, webdriver_url} ->
{"WEBDRIVER_URL_" <> (browser |> Atom.to_string() |> String.upcase()), webdriver_url}
end)
env = [
{"MIX_ENV", "test"},
{"CERBERUS_REMOTE_WEBDRIVER", "1"},
{"CERBERUS_BROWSER_NAME", Atom.to_string(default_browser)},
{"WEBDRIVER_URL", default_webdriver_url},
{"CERBERUS_BASE_URL_HOST", docker_host}
| browser_url_envs
]
{_, status} =
System.cmd(mix_executable, command_args,
env: env,
stderr_to_stdout: true,
into: IO.stream(:stdio, :line)
)
if status != 0 do
Mix.raise("mix test.websocket failed with status #{status}")
end
end
defp default_browser_lane(browsers) when is_list(browsers) do
env_browser = preferred_default_browser_from_env()
cond do
env_browser in browsers ->
env_browser
:chrome in browsers ->
:chrome
true ->
List.first(browsers)
end
end
defp preferred_default_browser_from_env do
case System.get_env("CERBERUS_BROWSER_NAME") do
"chrome" -> :chrome
"firefox" -> :firefox
_ -> nil
end
end
defp extract_task_args!(args) when is_list(args) do
{browsers_arg, test_args} = take_browsers_arg(args, nil, [])
browsers = parse_browsers!(browsers_arg || System.get_env("CERBERUS_REMOTE_SELENIUM_BROWSERS", "all"))
{browsers, test_args}
end
defp take_browsers_arg([], browsers, acc), do: {browsers, Enum.reverse(acc)}
defp take_browsers_arg(["--browsers", value | rest], _browsers, acc), do: take_browsers_arg(rest, value, acc)
defp take_browsers_arg(["--browsers" | _rest], _browsers, _acc) do
Mix.raise("missing value for --browsers")
end
defp take_browsers_arg([<<"--browsers=", value::binary>> | rest], _browsers, acc),
do: take_browsers_arg(rest, value, acc)
defp take_browsers_arg(["-b", value | rest], _browsers, acc), do: take_browsers_arg(rest, value, acc)
defp take_browsers_arg(["-b" | _rest], _browsers, _acc) do
Mix.raise("missing value for -b")
end
defp take_browsers_arg([arg | rest], browsers, acc), do: take_browsers_arg(rest, browsers, [arg | acc])
defp parse_browsers!(value) when is_binary(value) do
value
|> String.split(",", trim: true)
|> Enum.flat_map(&expand_browser_token!/1)
|> Enum.uniq()
|> case do
[] -> Mix.raise("no browsers selected for --browsers")
browsers -> browsers
end
end
defp expand_browser_token!(token) do
case token |> String.trim() |> String.downcase() do
"chrome" -> [:chrome]
"firefox" -> [:firefox]
"all" -> [:chrome, :firefox]
other -> Mix.raise("unsupported browser token #{inspect(other)}; use chrome, firefox, or all")
end
end
defp selenium_image(browser) when browser in [:chrome, :firefox] do
global_image =
"CERBERUS_REMOTE_SELENIUM_IMAGE"
|> System.get_env()
|> normalize_non_empty_string(nil)
specific_image =
case browser do
:chrome ->
System.get_env("CERBERUS_REMOTE_SELENIUM_IMAGE_CHROME")
:firefox ->
System.get_env("CERBERUS_REMOTE_SELENIUM_IMAGE_FIREFOX")
end
normalize_non_empty_string(specific_image, global_image || @default_selenium_images[browser])
end
defp normalize_non_empty_string(value, default) when is_binary(value) do
if value |> String.trim() |> byte_size() > 0, do: value, else: default
end
defp normalize_non_empty_string(_value, default), do: default
defp ensure_docker! do
case System.find_executable("docker") do
nil ->
Mix.raise("docker executable not found in PATH")
docker_path ->
case System.cmd(docker_path, ["info"], stderr_to_stdout: true) do
{_, 0} ->
:ok
{output, status} ->
Mix.raise("docker info failed with status #{status}: #{String.trim(output)}")
end
end
end
defp start_selenium_container!(image) do
{output, status} = System.cmd("docker", ["run", "--rm", "-d", "-p", "127.0.0.1::4444", image], stderr_to_stdout: true)
if status == 0 do
case parse_container_id(output) do
{:ok, container_id} ->
container_id
:error ->
Mix.raise("failed to parse selenium container id from docker output: #{String.trim(output)}")
end
else
Mix.raise("failed to start selenium container: #{String.trim(output)}")
end
end
defp parse_container_id(output) when is_binary(output) do
output
|> String.split("\n", trim: true)
|> Enum.reverse()
|> Enum.find(&String.match?(&1, ~r/^[a-f0-9]{12,}$/i))
|> case do
nil -> :error
container_id -> {:ok, container_id}
end
end
defp mapped_port!(container_id) do
{output, status} = System.cmd("docker", ["port", container_id, "4444/tcp"], stderr_to_stdout: true)
if status == 0 do
output
|> String.trim()
|> String.split(":")
|> List.last()
else
Mix.raise("failed to resolve selenium container port: #{String.trim(output)}")
end
end
defp docker_rm_force(container_id) do
System.cmd("docker", ["rm", "-f", container_id], stderr_to_stdout: true)
end
defp docker_host_address do
case :os.type() do
{:unix, :darwin} ->
"host.docker.internal"
{:unix, :linux} ->
case System.cmd("docker", [
"network",
"inspect",
"bridge",
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}"
]) do
{gateway, 0} when gateway != "" -> String.trim(gateway)
_ -> "172.17.0.1"
end
_ ->
"host.docker.internal"
end
end
defp wait_for_webdriver_ready!(webdriver_url) do
_ = :inets.start()
_ = :ssl.start()
do_wait_for_webdriver_ready!(webdriver_url, 30)
end
defp do_wait_for_webdriver_ready!(_webdriver_url, 0) do
Mix.raise("webdriver service did not become ready in time")
end
defp do_wait_for_webdriver_ready!(webdriver_url, attempts_left) do
status_url = webdriver_url <> "/status"
case :httpc.request(:get, {String.to_charlist(status_url), []}, [timeout: 1_000], body_format: :binary) do
{:ok, {{_version, 200, _reason}, _headers, _body}} ->
:ok
_ ->
Process.sleep(500)
do_wait_for_webdriver_ready!(webdriver_url, attempts_left - 1)
end
end
end