12.0 KiB · text History b868907
defmodule GitGud.Workflows.MatrixTest do
  @moduledoc """
  Matrix expansion, checked against the examples in GitHub's own
  workflow-syntax documentation — `include` and `exclude` are the parts
  people actually get wrong, and "it seemed reasonable" is not a
  specification.
  """
  use ExUnit.Case, async: true

  alias GitGud.Workflows.Matrix

  defp strategy(matrix), do: %{"matrix" => matrix}

  defp expand!(strategy, contexts \\ %{}) do
    {:ok, combinations} = Matrix.expand(strategy, contexts)
    combinations
  end

  describe "expand/1" do
    test "no strategy is one leg with nothing in it" do
      assert Matrix.expand(nil) == {:ok, [%{}]}
      assert Matrix.expand(%{}) == {:ok, [%{}]}
      assert Matrix.expand(%{"fail-fast" => false}) == {:ok, [%{}]}
      assert Matrix.expand(strategy(%{})) == {:ok, [%{}]}
    end

    test "one axis is one job per value" do
      assert expand!(strategy(%{"arch" => ["amd64", "arm64"]})) == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64"}
             ]
    end

    test "two axes are their product" do
      combinations =
        expand!(strategy(%{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"]}))

      assert length(combinations) == 4

      assert %{"arch" => "amd64", "otp" => "27"} in combinations
      assert %{"arch" => "arm64", "otp" => "28"} in combinations
    end

    test "a scalar value is a single-value axis" do
      assert expand!(strategy(%{"arch" => "arm64"})) == [%{"arch" => "arm64"}]
    end

    test "expansion is stable — map order must not decide job order" do
      matrix = %{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"], "os" => ["a", "b"]}

      assert expand!(strategy(matrix)) == expand!(strategy(matrix))
    end
  end

  describe "exclude" do
    test "drops combinations matching every key it names" do
      combinations =
        expand!(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "otp" => ["27", "28"],
            "exclude" => [%{"arch" => "arm64", "otp" => "27"}]
          })
        )

      assert length(combinations) == 3
      refute %{"arch" => "arm64", "otp" => "27"} in combinations
    end

    test "a partial exclude drops every combination it matches" do
      combinations =
        expand!(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "otp" => ["27", "28"],
            "exclude" => [%{"arch" => "arm64"}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64", "otp" => "27"},
               %{"arch" => "amd64", "otp" => "28"}
             ]
    end
  end

  describe "include" do
    test "adds keys to the combinations it matches rather than appending a job" do
      combinations =
        expand!(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "include" => [%{"arch" => "arm64", "coverage" => true}]
          })
        )

      # Still two jobs — the arm64 one just gained a key.
      assert combinations == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64", "coverage" => true}
             ]
    end

    test "an entry matching nothing becomes a combination of its own" do
      combinations =
        expand!(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "include" => [%{"arch" => "riscv64"}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64"},
               %{"arch" => "riscv64"}
             ]
    end

    test "an entry with no overlapping keys applies to every combination" do
      combinations =
        expand!(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "include" => [%{"otp" => "28"}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64", "otp" => "28"},
               %{"arch" => "arm64", "otp" => "28"}
             ]
    end

    test "include runs after exclude, so it can add a combination back" do
      combinations =
        expand!(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "exclude" => [%{"arch" => "arm64"}],
            "include" => [%{"arch" => "arm64", "experimental" => true}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64", "experimental" => true}
             ]
    end

    test "an include-only matrix is just its entries" do
      combinations =
        expand!(
          strategy(%{
            "include" => [
              %{"arch" => "amd64", "runner" => "big"},
              %{"arch" => "arm64", "runner" => "small"}
            ]
          })
        )

      assert combinations == [
               %{"arch" => "amd64", "runner" => "big"},
               %{"arch" => "arm64", "runner" => "small"}
             ]
    end
  end

  describe "substitute/2" do
    test "resolves matrix references in a runs-on" do
      assert Matrix.substitute("${{ matrix.arch }}", %{"arch" => "arm64"}) == "arm64"
      assert Matrix.substitute("${{matrix.arch}}", %{"arch" => "arm64"}) == "arm64"
      assert Matrix.substitute("linux-${{ matrix.arch }}", %{"arch" => "arm64"}) == "linux-arm64"
    end

    test "resolves inside a list, which is how an architecture is usually selected" do
      assert Matrix.substitute(["self-hosted", "${{ matrix.arch }}"], %{"arch" => "arm64"}) ==
               ["self-hosted", "arm64"]
    end

    test "an unknown key resolves to empty, as it does in Actions" do
      assert Matrix.substitute("${{ matrix.nope }}", %{"arch" => "arm64"}) == ""
    end

    test "leaves other expressions alone — the runner evaluates those" do
      assert Matrix.substitute("${{ github.sha }}", %{"arch" => "arm64"}) == "${{ github.sha }}"
    end

    test "non-strings pass through" do
      assert Matrix.substitute(42, %{}) == 42
      assert Matrix.substitute(nil, %{}) == nil
    end
  end

  describe "labels/1" do
    test "a scalar and a one-element list are the same thing" do
      assert Matrix.labels("ubuntu-latest") == ["ubuntu-latest"]
      assert Matrix.labels(["ubuntu-latest"]) == ["ubuntu-latest"]
    end

    test "keeps every label in a list — all of them must be satisfied" do
      assert Matrix.labels(["self-hosted", "arm64"]) == ["self-hosted", "arm64"]
    end

    test "drops blanks and duplicates" do
      assert Matrix.labels(["self-hosted", "", "self-hosted"]) == ["self-hosted"]
    end
  end

  describe "pin_definition/2" do
    test "pins the matrix to this leg so the runner expands it to exactly one job" do
      definition = %{
        "runs-on" => "self-hosted",
        "strategy" => %{"matrix" => %{"arch" => ["amd64", "arm64"]}, "fail-fast" => false},
        "steps" => [%{"run" => "echo ${{ matrix.arch }}"}]
      }

      pinned = Matrix.pin_definition(definition, %{"arch" => "arm64"})

      # One value, so act fans out to one job and the `matrix` context
      # still resolves for the step.
      # `fail-fast` rides along: the forge reads it back off the stored
      # definition, and this is the only copy that survives.
      assert pinned["strategy"] == %{"matrix" => %{"arch" => ["arm64"]}, "fail-fast" => false}
      assert pinned["steps"] == definition["steps"]
    end

    test "keeps `max-parallel`, which claiming reads back off the leg" do
      definition = %{
        "strategy" => %{"matrix" => "${{ fromJSON(needs.setup.outputs.m) }}", "max-parallel" => 1}
      }

      assert Matrix.pin_definition(definition, %{"os" => "a"})["strategy"] ==
               %{"matrix" => %{"os" => ["a"]}, "max-parallel" => 1}
    end

    test "a job with no matrix loses its strategy entirely" do
      definition = %{"strategy" => %{"fail-fast" => false}, "runs-on" => "x"}

      assert Matrix.pin_definition(definition, %{}) == %{"runs-on" => "x"}
    end
  end

  describe "suffix/1 and fail_fast?/1" do
    test "names a leg the way Actions does" do
      assert Matrix.suffix(%{}) == nil
      assert Matrix.suffix(%{"arch" => "arm64"}) == "arm64"
      assert Matrix.suffix(%{"arch" => "arm64", "otp" => "28"}) == "arm64, 28"
    end

    test "fail-fast defaults on, and only an explicit false turns it off" do
      assert Matrix.fail_fast?(nil)
      assert Matrix.fail_fast?(%{"matrix" => %{}})
      refute Matrix.fail_fast?(%{"fail-fast" => false})
      refute Matrix.fail_fast?(%{"fail-fast" => "false"})
    end
  end

  describe "expressions" do
    @needs %{"needs" => %{"setup" => %{"result" => "success", "outputs" => %{}}}}

    defp with_output(name, value), do: put_in(@needs, ["needs", "setup", "outputs", name], value)

    test "a whole matrix from an earlier job's output" do
      contexts = with_output("m", ~s({"os": ["a", "b"], "include": [{"os": "b", "big": true}]}))

      assert expand!(strategy("${{ fromJSON(needs.setup.outputs.m) }}"), contexts) == [
               %{"os" => "a"},
               %{"os" => "b", "big" => true}
             ]
    end

    test "one axis from an expression, beside a literal one" do
      contexts = with_output("targets", ~s(["x86", "arm"]))

      assert expand!(
               strategy(%{
                 "target" => "${{ fromJSON(needs.setup.outputs.targets) }}",
                 "otp" => ["28"]
               }),
               contexts
             ) == [%{"otp" => "28", "target" => "x86"}, %{"otp" => "28", "target" => "arm"}]
    end

    test "include from an expression, and an entry of one" do
      contexts = with_output("extra", ~s([{"os": "c"}]))

      assert expand!(
               strategy(%{
                 "os" => ["a"],
                 "include" => "${{ fromJSON(needs.setup.outputs.extra) }}"
               }),
               contexts
             ) == [%{"os" => "a"}, %{"os" => "c"}]

      contexts = with_output("one", ~s({"os": "d"}))

      assert expand!(
               strategy(%{
                 "os" => ["a"],
                 "include" => ["${{ fromJSON(needs.setup.outputs.one) }}"]
               }),
               contexts
             ) == [%{"os" => "a"}, %{"os" => "d"}]
    end

    test "bad JSON, or the wrong shape, is an error" do
      assert {:error, {:expression, _}} =
               Matrix.expand(
                 strategy("${{ fromJSON(needs.setup.outputs.m) }}"),
                 with_output("m", "nope")
               )

      assert {:error, {:not_object, _}} =
               Matrix.expand(
                 strategy("${{ fromJSON(needs.setup.outputs.m) }}"),
                 with_output("m", "[1]")
               )

      assert {:error, {:not_list, "axis os"}} =
               Matrix.expand(
                 strategy(%{"os" => "${{ fromJSON(needs.setup.outputs.m) }}"}),
                 with_output("m", ~s({"a": 1}))
               )

      # A context the expression reads that wasn't supplied.
      assert {:error, {:expression, _}} =
               Matrix.expand(strategy("${{ fromJSON(needs.setup.outputs.m) }}"))
    end

    test "a matrix exclude empties is an error, as on GitHub" do
      assert Matrix.expand(strategy(%{"os" => ["a"], "exclude" => [%{"os" => "a"}]})) ==
               {:error, :empty_matrix}

      assert Matrix.expand(
               strategy("${{ fromJSON(needs.setup.outputs.m) }}"),
               with_output("m", ~s({"os": []}))
             ) == {:error, :empty_matrix}

      assert Matrix.describe_error(:empty_matrix) =~ "no combinations"
    end

    test "dynamic?/1 and matrix?/1" do
      assert Matrix.dynamic?(strategy("${{ fromJSON(needs.setup.outputs.m) }}"))
      assert Matrix.dynamic?(strategy(%{"os" => ["a", "${{ vars.OS }}"]}))
      refute Matrix.dynamic?(strategy(%{"os" => ["a", "b"]}))
      refute Matrix.dynamic?(nil)

      assert Matrix.matrix?(strategy("${{ fromJSON(needs.setup.outputs.m) }}"))
      refute Matrix.matrix?(strategy(%{}))
    end

    test "max_parallel/1" do
      assert Matrix.max_parallel(%{"max-parallel" => 2}) == 2
      assert Matrix.max_parallel(%{"max-parallel" => "3"}) == 3
      assert Matrix.max_parallel(%{"max-parallel" => 0}) == nil
      assert Matrix.max_parallel(%{"max-parallel" => "${{ vars.N }}"}) == nil
      assert Matrix.max_parallel(nil) == nil
    end
  end
end