defmodule GitGud.Workflows.ExprTest do
use ExUnit.Case, async: true
alias GitGud.Workflows.Expr
@contexts %{
"github" => %{
"workflow" => "ci",
"ref" => "refs/heads/main",
"head_ref" => "",
"run_id" => "42",
"event" => %{"pull_request" => %{"number" => 7}, "forced" => false}
},
"inputs" => %{"env" => "prod"},
"vars" => %{"REGION" => "eu"},
"matrix" => %{"target" => "arm64", "n" => 2},
"needs" => %{"build" => %{"result" => "success", "outputs" => %{"tag" => "v1"}}}
}
defp i(string), do: Expr.interpolate(string, @contexts)
describe "the patterns concurrency groups are made of" do
test "workflow and ref" do
assert i("${{ github.workflow }}-${{ github.ref }}") == {:ok, "ci-refs/heads/main"}
end
test "a pull request's number, else the ref" do
assert i("${{ github.event.pull_request.number || github.ref }}") == {:ok, "7"}
assert Expr.interpolate(
"${{ github.event.pull_request.number || github.ref }}",
put_in(@contexts, ["github", "event"], %{})
) == {:ok, "refs/heads/main"}
end
test "head_ref, else the run id: an empty string is falsy" do
assert i("${{ github.head_ref || github.run_id }}") == {:ok, "42"}
end
test "inputs, vars, matrix and needs" do
assert i("deploy-${{ inputs.env }}") == {:ok, "deploy-prod"}
assert i("${{ vars.REGION }}") == {:ok, "eu"}
assert i("${{ matrix.target }}-${{ matrix.n }}") == {:ok, "arm64-2"}
assert i("${{ needs.build.outputs.tag }}") == {:ok, "v1"}
end
test "index syntax, case-insensitively" do
assert i("${{ inputs['env'] }}") == {:ok, "prod"}
assert i("${{ GitHub.Workflow }}") == {:ok, "ci"}
assert i("${{ github['event']['pull_request'].number }}") == {:ok, "7"}
end
test "format" do
assert i("${{ format('{0}-{1}', github.workflow, inputs.env) }}") == {:ok, "ci-prod"}
assert i("${{ format('{{literal}} {0}', 'x') }}") == {:ok, "{literal} x"}
end
test "text without an expression is itself" do
assert i("deploy-prod") == {:ok, "deploy-prod"}
end
end
describe "operators and values" do
test "|| falls back over null and empty string, && keeps the falsy operand" do
assert i("${{ github.nothing || 'fallback' }}") == {:ok, "fallback"}
assert i("${{ '' || null || 'x' }}") == {:ok, "x"}
assert i("${{ github.head_ref && 'yes' }}") == {:ok, ""}
assert i("${{ github.workflow && 'yes' }}") == {:ok, "yes"}
end
test "== and != compare strings ignoring case, and loosely across types" do
assert Expr.evaluate("github.ref == 'REFS/HEADS/MAIN'", @contexts) == {:ok, true}
assert Expr.evaluate("github.ref != 'refs/heads/main'", @contexts) == {:ok, false}
assert Expr.evaluate("github.run_id == 42", @contexts) == {:ok, true}
assert Expr.evaluate("null == 0", @contexts) == {:ok, true}
assert Expr.evaluate("'abc' == 0", @contexts) == {:ok, false}
end
test "!, parentheses and precedence" do
assert Expr.evaluate("!github.event.forced", @contexts) == {:ok, true}
assert Expr.evaluate("!(true && false) || false", @contexts) == {:ok, true}
assert Expr.evaluate("false || true && false", @contexts) == {:ok, false}
end
test "values stringify as GitHub does" do
assert i("${{ null }}|${{ true }}|${{ false }}|${{ 1.0 }}|${{ 0x10 }}|${{ 'it''s' }}") ==
{:ok, "|true|false|1|16|it's"}
end
test "a path through something missing is null" do
assert i("${{ github.event.nope.deeper }}") == {:ok, ""}
end
end
describe "what isn't supported is an error, not a guess" do
test "other functions" do
assert i("${{ hashFiles('**/Cargo.lock') }}") == :error
assert i("${{ join(matrix, ',') }}") == :error
end
test "the status functions" do
for f <- ~w(success always failure cancelled),
do: assert(Expr.evaluate("#{f}()", @contexts) == :error)
end
test "a context nobody supplied" do
assert i("${{ env.FOO }}") == :error
end
test "unknown syntax" do
assert i("${{ github.* }}") == :error
assert i("${{ github.ref <> 'x' }}") == :error
assert i("${{ \"double\" }}") == :error
assert i("${{ github.ref") == :error
end
test "an object where a string was wanted" do
assert i("${{ github.event }}") == :error
end
end
describe "a matrix computed by an earlier job" do
defp r(string, contexts \\ @contexts), do: Expr.resolve(string, contexts)
test "fromJSON yields the value, not its text" do
contexts = put_in(@contexts, ["needs", "build", "outputs", "m"], ~s({"os":["a","b"]}))
assert r("${{ fromJSON(needs.build.outputs.m) }}", contexts) ==
{:ok, %{"os" => ["a", "b"]}}
assert r(" ${{ fromJSON('[1, 2]') }} ") == {:ok, [1, 2]}
end
test "bad JSON is an error" do
assert r("${{ fromJSON('nope') }}") == :error
assert r("${{ fromJSON(needs.build.outputs.missing) }}") == :error
end
test "toJSON is the value's JSON" do
assert {:ok, json} = r("${{ toJSON(matrix) }}")
assert Jason.decode!(json) == %{"target" => "arm64", "n" => 2}
end
test "anything but one whole expression is a string" do
assert r("${{ matrix.target }}-${{ matrix.n }}") == {:ok, "arm64-2"}
assert r("plain") == {:ok, "plain"}
assert r("${{ format('{{0}}') }}") == {:ok, "{0}"}
end
end
defp e(expression), do: Expr.evaluate(expression, @contexts)
describe "what a job's `if:` commonly reads" do
test "startsWith and endsWith ignore case, casting to strings" do
assert e("startsWith(github.ref, 'refs/heads/')") == {:ok, true}
assert e("startsWith(github.ref, 'REFS/HEADS/')") == {:ok, true}
assert e("startsWith(github.ref, 'refs/tags/v')") == {:ok, false}
assert e("endsWith(github.ref, '/MAIN')") == {:ok, true}
assert e("startsWith(matrix.n, 2)") == {:ok, true}
assert e("startsWith(github.nothing, '')") == {:ok, true}
end
test "contains: a substring ignoring case, or an element of an array" do
assert e("contains(github.ref, 'MAIN')") == {:ok, true}
assert e("contains(github.ref, 'dev')") == {:ok, false}
assert e("contains(fromJSON('[\"push\", \"Schedule\"]'), 'schedule')") == {:ok, true}
assert e("contains(fromJSON('[1, 2]'), '2')") == {:ok, true}
assert e("contains(fromJSON('[1, 2]'), 3)") == {:ok, false}
end
test "an array or object where a string was wanted is an error" do
assert e("startsWith(github.event, 'x')") == :error
end
test "<, <=, >, >= compare numbers, and strings ignoring case" do
assert e("matrix.n > 1") == {:ok, true}
assert e("matrix.n >= 2") == {:ok, true}
assert e("matrix.n < 2") == {:ok, false}
assert e("matrix.n <= '2'") == {:ok, true}
assert e("'a' < 'B'") == {:ok, true}
assert e("'x' > 1") == {:ok, false}
assert e("'x' < 1") == {:ok, false}
end
test "comparisons bind tighter than ==" do
assert e("matrix.n > 1 == true") == {:ok, true}
assert e("1 < 2 && 3 >= 4") == {:ok, false}
end
end
describe "condition/2" do
test "is the expression's truthiness, with or without ${{ }}" do
assert Expr.condition("startsWith(github.ref, 'refs/tags/v')", @contexts) == {:ok, false}
assert Expr.condition("${{ startsWith(github.ref, 'refs/tags/v') }}", @contexts) ==
{:ok, false}
assert Expr.condition(" ${{ github.ref == 'refs/heads/main' }} ", @contexts) == {:ok, true}
assert Expr.condition("github.head_ref", @contexts) == {:ok, false}
assert Expr.condition("matrix.n", @contexts) == {:ok, true}
assert Expr.condition("0", @contexts) == {:ok, false}
assert Expr.condition("null", @contexts) == {:ok, false}
end
test "a YAML boolean is itself" do
assert Expr.condition(false, @contexts) == {:ok, false}
assert Expr.condition(true, @contexts) == {:ok, true}
end
test "is an error for what only the runner can decide" do
assert Expr.condition("steps.x.outputs.y == 'z'", @contexts) == :error
assert Expr.condition("always()", @contexts) == :error
assert Expr.condition("${{ success() && github.ref == 'x' }}", @contexts) == :error
assert Expr.condition("false && always()", @contexts) == :error
assert Expr.condition("env.DEPLOY == 'true'", @contexts) == :error
end
test "is an error for anything but one whole expression" do
assert Expr.condition("${{ github.ref }} == 'x'", @contexts) == :error
assert Expr.condition("", @contexts) == :error
assert Expr.condition("${{ }}", @contexts) == :error
assert Expr.condition(1, @contexts) == :error
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
8.7 KiB · text
History
b868907