1
0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2026-10-24 16:59:19 +03:00
Files
runner/act/exprparser/mutator_test.go
Mathieu Fenniak ac3c190811 feat: merge reusable expansion caller's 'if' into expanded jobs (#1509)
Fixes #1501.  As a jobparser upgrade, it requires Runner be released and Forgejo be upgraded to the new version.

When a reusable workflow caller has an `if:` condition on the job, it is placed on the expanded jobs that come from it.  If an inner job of a reusable workflow also has an `if:` condition, the two conditions are `&&`'d together.

I originally planned to evaluate the `if` condition during job parsing, as described in [a comment on #1501](https://code.forgejo.org/forgejo/runner/issues/1501#issuecomment-85822).  However, I realized that a common usage for `if` is job-status checks like `if: failed()`, and that cannot be evaluated at expansion time.

Manually tested with these workflows:

Caller:
```
on:
  pull_request:

jobs:
  top-true-inner-true:
    if: true
    uses: ./.forgejo/workflows/shared/child.yml
    with:
      value: first

  top-true-inner-false:
    if: true
    uses: ./.forgejo/workflows/shared/child.yml
    with:
      value: not first

  top-false:
    if: false
    uses: ./.forgejo/workflows/shared/child.yml
    with:
      value: would be second
```

Child:
```
name: Child workflow
on:
  workflow_call:
    inputs:
      value:
        type: string
        required: true

jobs:
  child-workflow:
    runs-on: docker
    if: ${{ inputs.value == 'first' }}
    steps:
      - name: Echo
        run: echo ${{ inputs.value }}
```

<!--start release-notes-assistant-->
<!--URL:https://code.forgejo.org/forgejo/runner-->
- features
  - [PR](https://code.forgejo.org/forgejo/runner/pulls/1509): <!--number 1509 --><!--line 0 --><!--description ZmVhdDogbWVyZ2UgcmV1c2FibGUgZXhwYW5zaW9uIGNhbGxlcidzICdpZicgaW50byBleHBhbmRlZCBqb2Jz-->feat: merge reusable expansion caller's 'if' into expanded jobs<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/1509
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.code.forgejo.org>
2026-05-05 01:00:02 +00:00

226 lines
6.1 KiB
Go

package exprparser
import (
"strings"
"testing"
"github.com/rhysd/actionlint"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v3"
)
func TestRender(t *testing.T) {
testCases := []struct {
input string
}{
{input: "'hello'"},
{input: "123"},
{input: "123.456"},
{input: "null"},
{input: "true"},
{input: "false"},
{input: "jobs"},
{input: "jobs[0]"},
{input: "jobs.needs.input.thing"},
{input: "jobs.*"},
{input: "!false"},
{input: "1 > 2"},
{input: "true || false"},
{input: "somefunc(1, 2, 3)"},
{input: "somefunc(1)"},
{input: "somefunc()"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
parser := actionlint.NewExprParser()
exprNode, exprErr := parser.Parse(actionlint.NewExprLexer(tc.input + "}}"))
require.Nil(t, exprErr)
var builder strings.Builder
err := render(&builder, exprNode)
require.NoError(t, err)
assert.Equal(t, tc.input, builder.String())
})
}
}
// Tests that without any mutation configuration, the mutator can round-trip an expression without change.
func TestNoopMutator(t *testing.T) {
testCases := []struct {
input string
}{
{input: "'hello'"},
{input: "123"},
{input: "123.456"},
{input: "null"},
{input: "true"},
{input: "false"},
{input: "jobs"},
{input: "jobs[0]"},
{input: "jobs.needs.input.thing"},
{input: "jobs.*"},
{input: "!false"},
{input: "1 > 2"},
{input: "true || false"},
{input: "somefunc(1, 2, 3)"},
{input: "somefunc(1)"},
{input: "somefunc()"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
parser := actionlint.NewExprParser()
exprNode, exprErr := parser.Parse(actionlint.NewExprLexer(tc.input + "}}"))
require.Nil(t, exprErr)
mutator := &mutator{}
newExprNode, hasEffect, err := mutator.mutate(exprNode)
require.NoError(t, err)
var builder strings.Builder
err = render(&builder, newExprNode)
require.NoError(t, err)
assert.False(t, hasEffect)
assert.Equal(t, tc.input, builder.String())
})
}
}
func TestVariableAccessMutator(t *testing.T) {
vam := &VariableAccessMutator{
Variable: "jobs",
Rewriter: func(property actionlint.ExprNode) actionlint.ExprNode {
// jobs[property] has been accessed; rewrite to outputs[property]
return &actionlint.IndexAccessNode{
Operand: &actionlint.VariableNode{Name: "outputs"},
Index: property,
}
},
}
t.Run("object deref", func(t *testing.T) {
retval, err := vam.SingleNodeMutation(&actionlint.ObjectDerefNode{
Receiver: &actionlint.VariableNode{Name: "jobs"},
Property: "some-job",
})
require.NoError(t, err)
var builder strings.Builder
err = render(&builder, retval)
require.NoError(t, err)
assert.Equal(t, "outputs['some-job']", builder.String())
})
t.Run("array deref", func(t *testing.T) {
retval, err := vam.SingleNodeMutation(&actionlint.IndexAccessNode{
Operand: &actionlint.VariableNode{Name: "jobs"},
Index: &actionlint.FuncCallNode{
Callee: "format",
Args: []actionlint.ExprNode{
&actionlint.StringNode{Value: "hello world"},
},
},
})
require.NoError(t, err)
var builder strings.Builder
err = render(&builder, retval)
require.NoError(t, err)
assert.Equal(t, "outputs[format('hello world')]", builder.String())
})
}
func TestMutate(t *testing.T) {
vam1 := &VariableAccessMutator{
Variable: "variable",
Rewriter: func(property actionlint.ExprNode) actionlint.ExprNode {
// jobs[property] has been accessed; rewrite to outputs[property]
return &actionlint.IndexAccessNode{
Operand: &actionlint.VariableNode{Name: "outputs"},
Index: property,
}
},
}
vam2 := &VariableAccessMutator{
Variable: "constant",
Rewriter: func(property actionlint.ExprNode) actionlint.ExprNode {
// jobs[property] has been accessed; rewrite to outputs[property]
return &actionlint.IndexAccessNode{
Operand: &actionlint.VariableNode{Name: "forgejo"},
Index: property,
}
},
}
output, err := Mutate("Hello ${{ variable.content }}, welcome to ${{ constant.real-world }}.", vam1, vam2)
require.NoError(t, err)
assert.Equal(t, "${{ format('Hello {0}, welcome to {1}.', outputs['content'], forgejo['real-world']) }}", output)
// Verify that a string with no mutations performed is returned identical to input.
input := "Hello ${{ vars.content }}, welcome to ${{ consts.real-world }}."
output, err = Mutate(input, vam1, vam2)
require.NoError(t, err)
assert.Equal(t, input, output)
}
func TestMutateYamlNode(t *testing.T) {
testCases := []struct {
input string
output string
}{
{
input: "hello ${{ var.world }}",
output: "${{ format('hello {0}', rewritten-var['world']) }}\n",
},
{
input: "3.1415926",
output: "3.1415926\n",
},
{
input: "- hello ${{ var.world }}\n- goodbye, ${{ var.something }}\n",
output: "- ${{ format('hello {0}', rewritten-var['world']) }}\n- ${{ format('goodbye, {0}', rewritten-var['something']) }}\n",
},
{
input: "key: hello ${{ var.world }}\n",
output: "key: ${{ format('hello {0}', rewritten-var['world']) }}\n",
},
// `jobs.<job_id>.if` and `jobs.<job_id>.steps[*].if` in workflows have a special behaviour -- the `${{ ... }}`
// wrapper can be omitted. `MutateYamlNode` implements this by automatically wrapping nodes with a key `if`.
{
input: "if: ${{ var.world }}\n",
output: "if: ${{ rewritten-var['world'] }}\n",
},
{
input: "if: var.world\n",
output: "if: ${{ rewritten-var['world'] }}\n",
},
}
vam := &VariableAccessMutator{
Variable: "var",
Rewriter: func(property actionlint.ExprNode) actionlint.ExprNode {
return &actionlint.IndexAccessNode{
Operand: &actionlint.VariableNode{Name: "rewritten-var"},
Index: property,
}
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
var node yaml.Node
err := yaml.Unmarshal([]byte(tc.input), &node)
require.NoError(t, err)
err = MutateYamlNode(node.Content[0], vam)
require.NoError(t, err)
myYaml, err := yaml.Marshal(node.Content[0])
require.NoError(t, err)
assert.Equal(t, tc.output, string(myYaml))
})
}
}