8.7 KiB · text History 1f1d2a3
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