local/mix/upgrade_compatibility_test.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

defmodule Mix.Tasks.AntikytheraLocal.UpgradeCompatibilityTest do
  @shortdoc "Runs tests to ensure that antikythera's upgrade doesn't break backward-compatibility"
  @moduledoc """
  #{@shortdoc}.

  It works in the following sequence:

  - Starts an antikythera instance of a version specified by a git ref, with gears in specified directories installed
  - (If [testgear] is used, establishes websocket connection to [testgear]. Periodically sends echo requests)
  - Upgrades the antikythera instance to new version specified by another git ref
  - Run blackbox tests defined in each gear, checking whether their external behaviors are kept intact
  - (Raises if the websocket connection established above was abnormally closed before here)

  [testgear]: https://github.com/access-company/testgear

  ## Usage

      $ mix antikythera_local.upgrade_compatibility_test <comma_separated_gear_directories> [git_ref_from[ git_ref_to]]

  - `comma_separated_gear_directories` can be absolute or relative paths to directories, such as `../testgear,/path/to/another_gear`.
      - It assumes basenames of paths exactly match gear_names, for [testgear] detection.
  - `git_ref_from` defaults to `HEAD^`. `git_ref_to` defaults to `HEAD`.
  """

  use Mix.Task
  alias AntikytheraLocal.RunningEnvironment

  @antikythera_instance_name Antikythera.Env.antikythera_instance_name()

  def run([comma_separated_gear_dirs | git_refs]) do
    # to fetch current versions via `Antikythera.Httpc`
    {:ok, _} = Application.ensure_all_started(:hackney)
    ensure_working_repository_clean()
    branch = current_branch()
    gears_map = gears_to_test(comma_separated_gear_dirs)
    has_testgear? = Map.has_key?(gears_map, "testgear")
    {hash_from, hash_to} = upgrade_from_and_to(git_refs)

    status =
      try do
        start_antikythera_local(gears_map, hash_from)
        prepare_and_run_blackbox_test(has_testgear?, gears_map, hash_to)
      after
        checkout_and_deps_get(branch)
        _ = run_mix_task(["antikythera_local.stop"])
      end

    exit({:shutdown, status})
  end

  defp gears_to_test(comma_separated_gear_dirs) do
    comma_separated_gear_dirs
    |> String.split(",", trim: true)
    |> Map.new(fn dir -> {Path.basename(dir), dir} end)
  end

  defp upgrade_from_and_to(git_refs) do
    case git_refs do
      [] -> {commit_hash("HEAD^"), commit_hash("HEAD")}
      [from] -> {commit_hash(from), commit_hash("HEAD")}
      [from, to] -> {commit_hash(from), commit_hash(to)}
    end
  end

  defp current_branch() do
    case System.cmd("git", ["symbolic-ref", "--quiet", "--short", "HEAD"]) do
      {branch, 0} -> String.trim_trailing(branch)
      # HEAD is not a symbolic ref
      {_, 1} -> commit_hash("HEAD")
    end
  end

  defp commit_hash(ref) do
    {hash, 0} = System.cmd("git", ["rev-parse", ref])
    String.trim_trailing(hash)
  end

  defp start_antikythera_local(gears_map, hash_from) do
    gear_names = Enum.join(Map.keys(gears_map), ", ")
    IO.puts("Start #{@antikythera_instance_name} (#{hash_from}) with #{gear_names}")
    checkout_and_deps_get(hash_from)
    clean()
    gear_dirs = Map.values(gears_map)
    0 = run_mix_task(["antikythera_local.start" | gear_dirs])
  end

  defp prepare_and_run_blackbox_test(has_testgear?, gears_map, hash_to) do
    with_websocket_client_process(has_testgear?, fn ->
      IO.puts("Now upgrade #{@antikythera_instance_name} to #{hash_to}")
      checkout_and_deps_get(hash_to)
      clean()
      0 = run_mix_task(["antikythera_local.prepare_core"])
      RunningEnvironment.wait_until_upgrade_applied(@antikythera_instance_name, hash_to)

      run_blackbox_test(gears_map)
    end)
  end

  defp checkout_and_deps_get(ref) do
    # `mix.lock` file can have modifications if some package info (e.g. build tool) is missing in `hash_from` version.
    # We discard the diff (possibly a conflict) before `git checkout`.
    {output, 0} = System.cmd("git", ["diff", "--name-only", "HEAD"])

    case String.split(output, "\n", trim: true) do
      [] ->
        :ok

      ["mix.lock"] ->
        {_, 0} = System.cmd("git", ["checkout", "mix.lock"])
        # fail if other files have modifications
    end

    {_, 0} = System.cmd("git", ["checkout", ref], stderr_to_stdout: true)
    0 = run_mix_task(["deps.get"])
  end

  defp ensure_working_repository_clean() do
    {"", 0} = System.cmd("git", ["diff", "HEAD"])
  end

  defp clean() do
    build_dir = Mix.Project.build_path() |> Path.dirname()
    File.rm_rf!(Path.join([build_dir, "prod", "lib", "antikythera"]))
    File.rm_rf!(Path.join([build_dir, "prod", "lib", "#{@antikythera_instance_name}"]))
  end

  defp run_mix_task(args) do
    run_mix_task(args, [], false)
  end

  defp run_mix_task(args, opts, always_output?) do
    {output, status} = System.cmd("mix", args, opts)

    cond do
      status != 0 -> IO.puts("running mix task #{inspect(args)} failed! output =\n#{output}")
      always_output? -> IO.puts(output)
      true -> :ok
    end

    status
  end

  defp run_blackbox_test(gears_map) do
    Enum.each(gears_map, fn {gear_name, gear_dir} ->
      IO.puts("Test whether #{gear_name} is correctly functioning")
      env = %{"TEST_MODE" => "blackbox_local"}
      0 = run_mix_task(["test"], [env: env, cd: gear_dir], true)
    end)
  end

  defmodule WS do
    if Mix.env() == :test do
      use Antikythera.Test.WebsocketClient
      def base_url(), do: "ws://testgear.localhost:8080"

      def send_loop() do
        send_loop(__MODULE__.spawn_link("/ws?name=upgrade_test"))
      end

      defp send_loop(ws_client_pid) do
        :timer.sleep(500)
        send_json(ws_client_pid, %{"command" => "echo"})

        receive do
          {{:text, _json_string}, ^ws_client_pid} -> send_loop(ws_client_pid)
          :exit -> :ok
        after
          1_000 -> raise "No reply from ws server!"
        end
      end
    else
      # Avoid using :websocket_client in case of non-test environment
      def send_loop(), do: :ok
    end
  end

  defp with_websocket_client_process(false, f) do
    f.()
  end

  defp with_websocket_client_process(true, f) do
    IO.puts("Start websocket connection to testgear")
    # Load `WS` module before calling `f.()`
    send_loop_closure = fn -> WS.send_loop() end
    # wait for initialization of the target web server
    :timer.sleep(5_000)
    {sender_pid, ref} = spawn_monitor(send_loop_closure)
    f.()
    send(sender_pid, :exit)

    receive do
      {:DOWN, ^ref, :process, ^sender_pid, reason} ->
        if reason != :normal do
          raise "Error in websocket client: #{inspect(reason)}"
        end
    end
  end
end