Skip to main content

lib/mix/tasks/testcontainers/test.ex

defmodule Mix.Tasks.TestcontainerEx.Test do
  @moduledoc """
  Runs Docker-dependent tests with automatic Ryuk setup and teardown.

  This task handles the full lifecycle:
    1. Ensures Docker is available (starts Colima if needed)
    2. Starts a Ryuk reaper container for automatic test container cleanup
    3. Runs the requested tests (including :needs_dock tests)
    4. Stops Ryuk and cleans up on completion

  ## Usage

      # Run all tests including Docker-dependent ones
      mix testcontainer_ex.test

      # Run only Docker-dependent tests
      mix testcontainer_ex.test --only needs_dock

      # Run a specific test file
      mix testcontainer_ex.test test/container/postgres_container_test.exs

      # Run with a specific tag
      mix testcontainer_ex.test --include needs_dock --exclude dood_limitation

      # Only set up Ryuk (don't run tests)
      mix testcontainer_ex.test --setup-only

      # Tear down Ryuk and clean up
      mix testcontainer_ex.test --teardown

  ## Options

      --setup-only    Start Ryuk and exit (don't run tests)
      --teardown      Stop Ryuk and clean up test containers
      --only TAG      Run only tests with this tag (can be repeated)
      --include TAG   Include tests with this tag (passed to mix test)
      --exclude TAG   Exclude tests with this tag (passed to mix test)

  ## Environment variables

      RYUK_IMAGE      Ryuk image (default: testcontainers/ryuk:0.14.0)
      RYUK_PORT       Host port for Ryuk (default: auto)
      TEST_TIMEOUT    Timeout in ms (default: 300000)
  """

  use Mix.Task

  @impl true
  def run(args) do
    {opts, rest, _} =
      OptionParser.parse(args,
        strict: [
          setup_only: :boolean,
          teardown: :boolean,
          only: :string,
          include: :string,
          exclude: :string
        ]
      )

    if opts[:teardown] do
      teardown()
    else
      setup()

      if opts[:setup_only] != true do
        run_tests(rest, opts)
      end
    end
  end

  # ── Docker check ──────────────────────────────────────────────────────────────

  defp ensure_docker! do
    case System.find_executable("docker") do
      nil ->
        Mix.raise("Docker is not installed or not in PATH")

      _docker ->
        :ok
    end
  end

  defp ensure_colima! do
    if System.find_executable("colima") do
      case System.cmd("colima", ["status"], stderr_to_stdout: true) do
        {_, 0} ->
          Mix.shell().info("[testcontainer_ex] Colima is running")

        _ ->
          Mix.shell().info("[testcontainer_ex] Starting Colima...")
          {_, 0} = System.cmd("colima", ["start"], stderr_to_stdout: true)
          Mix.shell().info("[testcontainer_ex] Colima started")
      end
    end
  end

  defp wait_for_docker! do
    Mix.shell().info("[testcontainer_ex] Waiting for Docker daemon...")

    1..60
    |> Enum.reduce_while(:timeout, fn _attempt, _acc ->
      case System.cmd("docker", ["info"], stderr_to_stdout: true) do
        {_, 0} ->
          Mix.shell().info("[testcontainer_ex] Docker daemon is ready")
          {:halt, :ok}

        _ ->
          Process.sleep(1_000)
          {:cont, :timeout}
      end
    end)
    |> case do
      :ok -> :ok
      :timeout -> Mix.raise("Docker daemon did not become ready after 60s")
    end
  end

  defp docker_socket_paths do
    [
      "/var/run/docker.sock",
      Path.expand("~/.docker/run/docker.sock"),
      Path.expand("~/.docker/desktop/docker.sock"),
      Path.expand("~/.colima/default/docker.sock"),
      Path.expand("~/.colima/docker.sock")
    ]
  end

  defp detect_docker_socket_path do
    Enum.find_value(docker_socket_paths(), fn path ->
      case File.stat(path) do
        {:ok, stat} ->
          if :erlang.band(stat.mode, 0o170000) == 0o140000 do
            path
          end

        _ ->
          nil
      end
    end) || Mix.raise("Could not find Docker socket")
  end

  # ── Ryuk management ───────────────────────────────────────────────────────────

  defp start_ryuk do
    ryuk_image = System.get_env("RYUK_IMAGE", "testcontainers/ryuk:0.14.0")
    container_name = "testcontainer_ex-ryuk-#{:erlang.unique_integer([:positive])}"

    # Clean up orphaned Ryuk containers
    System.cmd(
      "docker",
      ["ps", "-a", "--filter", "name=testcontainer_ex-ryuk", "--format", "{{.ID}}"],
      stderr_to_stdout: true
    )
    |> elem(0)
    |> String.split("\n", trim: true)
    |> Enum.each(fn id ->
      System.cmd("docker", ["rm", "-f", id], stderr_to_stdout: true)
    end)

    # Pull Ryuk image
    Mix.shell().info("[testcontainer_ex] Pulling Ryuk image: #{ryuk_image}")
    {_, 0} = System.cmd("docker", ["pull", ryuk_image], stderr_to_stdout: true)

    # Detect Docker socket
    docker_socket_path = detect_docker_socket_path()
    Mix.shell().info("[testcontainer_ex] Using Docker socket: #{docker_socket_path}")
    System.put_env("CONTAINER_ENGINE_HOST", "unix://#{docker_socket_path}")

    # Determine how to give Ryuk access to Docker:
    # - Linux: bind-mount the Docker socket directly
    # - macOS (Colima/Docker Desktop): use host network or TCP, because the
    #   socket path is inside a VM that containers can't bind-mount from the host
    os_type = os_type()

    mount_args =
      case os_type do
        :linux ->
          ["-v", "#{docker_socket_path}:/var/run/docker.sock"]

        :macos ->
          ["--network", "host"]

        :windows ->
          ["-v", "//var/run/docker.sock:/var/run/docker.sock"]
      end

    # Start Ryuk container
    ryuk_port = System.get_env("RYUK_PORT", "0")
    port_arg = if ryuk_port == "0", do: "-p", else: "-p#{ryuk_port}:8080"

    docker_args =
      [
        "run",
        "-d",
        "--name",
        container_name,
        "--restart",
        "unless-stopped"
      ] ++
        mount_args ++
        if os_type == :macos do
          # With --network host, -p is not needed
          ["--privileged", ryuk_image]
        else
          [port_arg, "8080", "--privileged", ryuk_image]
        end

    {output, 0} =
      System.cmd("docker", docker_args, stderr_to_stdout: true)

    ryuk_id = String.trim(output)
    Mix.shell().info("[testcontainer_ex] Ryuk container started: #{String.slice(ryuk_id, 0, 12)}")

    # Store PID for cleanup
    pid_file = Path.join(System.tmp_dir!(), "testcontainer_ex-ryuk.pid")
    File.write!(pid_file, "#{container_name}\n#{ryuk_id}\n")

    # Wait for Ryuk to be ready
    Mix.shell().info("[testcontainer_ex] Waiting for Ryuk to be ready...")
    Process.sleep(3_000)

    case System.cmd("docker", ["inspect", "--format", "{{.State.Running}}", ryuk_id],
           stderr_to_stdout: true
         ) do
      {"true\n", 0} ->
        Mix.shell().info("[testcontainer_ex] Ryuk is running")

      _ ->
        Mix.shell().info(
          "[testcontainer_ex] Ryuk container may not be healthy, continuing anyway"
        )
    end

    # Register cleanup via System.at_exit
    System.at_exit(fn _ ->
      teardown_ryuk(container_name)
      :ok
    end)
  end

  defp os_type do
    case :os.type() do
      {:win32, _} -> :windows
      {:unix, :darwin} -> :macos
      {:unix, _} -> :linux
    end
  end

  defp setup do
    ensure_docker!()
    ensure_colima!()
    wait_for_docker!()
    start_ryuk()
  end

  defp teardown do
    pid_file = Path.join(System.tmp_dir!(), "testcontainer_ex-ryuk.pid")

    if File.exists?(pid_file) do
      [container_name | _] = File.read!(pid_file) |> String.split("\n")
      teardown_ryuk(container_name)
      File.rm!(pid_file)
    else
      # Try to find any Ryuk containers
      {output, 0} =
        System.cmd(
          "docker",
          ["ps", "-a", "--filter", "name=testcontainer_ex-ryuk", "--format", "{{.Names}}"],
          stderr_to_stdout: true
        )

      output
      |> String.split("\n", trim: true)
      |> Enum.each(&teardown_ryuk/1)
    end

    # Clean up test containers
    Mix.shell().info("[testcontainer_ex] Cleaning up test containers...")

    System.cmd(
      "docker",
      ["ps", "-a", "--filter", "label=org.testcontainer_ex", "--format", "{{.ID}}"],
      stderr_to_stdout: true
    )
    |> elem(0)
    |> String.split("\n", trim: true)
    |> Enum.each(fn id ->
      System.cmd("docker", ["rm", "-f", id], stderr_to_stdout: true)
    end)

    Mix.shell().info("[testcontainer_ex] Cleanup complete")
  end

  defp teardown_ryuk(container_name) do
    case System.cmd("docker", ["ps", "--filter", "name=#{container_name}", "--format", "{{.ID}}"],
           stderr_to_stdout: true
         ) do
      {"", _} ->
        :ok

      {id, _} ->
        id = String.trim(id)
        Mix.shell().info("[testcontainer_ex] Stopping Ryuk: #{String.slice(id, 0, 12)}")
        System.cmd("docker", ["stop", id], stderr_to_stdout: true)
        System.cmd("docker", ["rm", id], stderr_to_stdout: true)
        Mix.shell().info("[testcontainer_ex] Ryuk removed")
    end
  end

  # ── Test execution ────────────────────────────────────────────────────────────

  defp run_tests(rest, opts) do
    timeout = String.to_integer(System.get_env("TEST_TIMEOUT", "300000"))

    mix_test_args = [
      "--exclude",
      "flaky",
      "--timeout",
      to_string(timeout)
    ]

    # Handle --only tag
    mix_test_args =
      case opts[:only] do
        nil -> mix_test_args
        _tag -> mix_test_args ++ ["--include", "needs_dock", "--exclude", "dood_limitation"]
      end

    # Handle --include tags
    mix_test_args =
      case opts[:include] do
        nil -> mix_test_args
        tag -> mix_test_args ++ ["--include", tag]
      end

    # Handle --exclude tags
    mix_test_args =
      case opts[:exclude] do
        nil -> mix_test_args
        tag -> mix_test_args ++ ["--exclude", tag]
      end

    # Add any remaining args (test files, line numbers, etc.)
    all_args = mix_test_args ++ rest

    Mix.shell().info("[testcontainer_ex] Running: mix test #{Enum.join(all_args, " ")}")

    {_, exit_code} =
      System.cmd("mix", ["test" | all_args],
        into: IO.stream(:stdio, :line),
        env: [{"MIX_ENV", "test"}]
      )

    if exit_code != 0 do
      Mix.raise("mix test failed with exit code #{exit_code}")
    end
  end
end