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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.0 KiB · text
History
b868907