// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: GPL-3.0-or-later package actions import ( "errors" "testing" actions_model "forgejo.org/models/actions" "forgejo.org/models/repo" "forgejo.org/modules/webhook" act_model "code.forgejo.org/forgejo/runner/v12/act/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestConfigureActionRunConcurrency(t *testing.T) { for _, tc := range []struct { name string concurrency *act_model.RawConcurrency vars map[string]string inputs map[string]any runEvent webhook.HookEventType expectedConcurrencyGroup string expectedConcurrencyType actions_model.ConcurrencyMode }{ // Before the introduction of concurrency groups, push & pull_request_sync would cancel runs on the same repo, // reference, workflow, and event -- these cases cover undefined concurrency group and backwards compatibility // checks. { name: "backwards compatibility push", runEvent: webhook.HookEventPush, expectedConcurrencyGroup: "refs/head/main_testing.yml_push__auto", expectedConcurrencyType: actions_model.CancelInProgress, }, { name: "backwards compatibility pull_request_sync", runEvent: webhook.HookEventPullRequestSync, expectedConcurrencyGroup: "refs/head/main_testing.yml_pull_request_sync__auto", expectedConcurrencyType: actions_model.CancelInProgress, }, { name: "backwards compatibility other event", runEvent: webhook.HookEventWorkflowDispatch, expectedConcurrencyGroup: "refs/head/main_testing.yml_workflow_dispatch__auto", expectedConcurrencyType: actions_model.UnlimitedConcurrency, }, { name: "fully-specified cancel-in-progress", concurrency: &act_model.RawConcurrency{ Group: "abc", CancelInProgress: "true", }, runEvent: webhook.HookEventPullRequestSync, expectedConcurrencyGroup: "abc", expectedConcurrencyType: actions_model.CancelInProgress, }, { name: "fully-specified queue-behind", concurrency: &act_model.RawConcurrency{ Group: "abc", CancelInProgress: "false", }, runEvent: webhook.HookEventPullRequestSync, expectedConcurrencyGroup: "abc", expectedConcurrencyType: actions_model.QueueBehind, }, { name: "no concurrency group, cancel-in-progress: false", concurrency: &act_model.RawConcurrency{ CancelInProgress: "false", }, runEvent: webhook.HookEventPullRequestSync, expectedConcurrencyGroup: "refs/head/main_testing.yml_pull_request_sync__auto", expectedConcurrencyType: actions_model.UnlimitedConcurrency, }, { name: "interpreted values", concurrency: &act_model.RawConcurrency{ Group: "${{ github.workflow }}-${{ github.ref }}", CancelInProgress: "${{ !contains(github.ref, 'release/')}}", }, runEvent: webhook.HookEventPullRequestSync, expectedConcurrencyGroup: "testing.yml-refs/head/main", expectedConcurrencyType: actions_model.CancelInProgress, }, { name: "interpreted values with inputs and vars", concurrency: &act_model.RawConcurrency{ Group: "${{ inputs.abc }}-${{ vars.def }}", }, inputs: map[string]any{"abc": "123"}, vars: map[string]string{"def": "456"}, runEvent: webhook.HookEventPullRequestSync, expectedConcurrencyGroup: "123-456", expectedConcurrencyType: actions_model.CancelInProgress, }, } { t.Run(tc.name, func(t *testing.T) { workflow := &act_model.Workflow{RawConcurrency: tc.concurrency} run := &actions_model.ActionRun{ Ref: "refs/head/main", WorkflowID: "testing.yml", Event: tc.runEvent, TriggerEvent: string(tc.runEvent), Repo: &repo.Repository{}, } err := ConfigureActionRunConcurrency(workflow, run, tc.vars, tc.inputs) require.NoError(t, err) if tc.expectedConcurrencyGroup == "" { assert.Empty(t, run.ConcurrencyGroup, "empty ConcurrencyGroup") } else { assert.Equal(t, tc.expectedConcurrencyGroup, run.ConcurrencyGroup) } assert.Equal(t, tc.expectedConcurrencyType, run.ConcurrencyType) }) } } func TestResolveDispatchInputAcceptsValidInput(t *testing.T) { for _, tc := range []struct { name string key string value string input act_model.WorkflowDispatchInput expected string expectedError func(err error) bool }{ { name: "on_converted_to_true", key: "my_boolean", value: "on", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}}, expected: "true", }, // It might make sense to validate booleans in the future and then turn it into an error. { name: "ON_stays_ON", key: "my_boolean", value: "ON", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}}, expected: "ON", }, { name: "true_stays_true", key: "my_boolean", value: "true", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}}, expected: "true", }, { name: "false_stays_false", key: "my_boolean", value: "false", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}}, expected: "false", }, { name: "empty_results_in_default_value_true", key: "my_boolean", value: "", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Default: "true", Type: "boolean", Options: []string{}}, expected: "true", }, { name: "empty_results_in_default_value_false", key: "my_boolean", value: "", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Default: "false", Type: "boolean", Options: []string{}}, expected: "false", }, { name: "string_results_in_input", key: "my_string", value: "hello", input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "string", Options: []string{}}, expected: "hello", }, { name: "string_option_results_in_input", value: "a", input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "string", Options: []string{"a", "b"}}, expected: "a", }, // Test ensures that the old behaviour (ignoring option mismatch) is retained. It might // make sense to turn it into an error in the future. { name: "invalid_string_option_results_in_input", key: "option", value: "c", input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "string", Options: []string{"a", "b"}}, expected: "c", }, { name: "number_results_in_input", key: "my_number", value: "123", input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "number", Options: []string{}}, expected: "123", }, { name: "empty_value_skipped", key: "my_number", value: "", input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "number", Options: []string{}}, expectedError: func(err error) bool { return errors.Is(err, ErrSkipDispatchInput) }, }, { name: "required_missing", key: "my_number", value: "", input: act_model.WorkflowDispatchInput{Required: true, Type: "number", Options: []string{}}, expectedError: IsInputRequiredErr, }, } { t.Run(tc.name, func(t *testing.T) { actual, err := resolveDispatchInput(tc.key, tc.value, tc.input) if tc.expectedError != nil { assert.True(t, tc.expectedError(err)) } else { assert.Equal(t, tc.expected, actual) } }) } } func TestResolveDispatchInputRejectsInvalidInput(t *testing.T) { for _, tc := range []struct { name string key string value string input act_model.WorkflowDispatchInput expected error }{ { name: "missing_required_boolean", key: "missing_boolean", value: "", input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: true, Type: "boolean", Options: []string{}}, expected: InputRequiredErr{Name: "a boolean"}, }, { name: "missing_required_boolean_without_description", key: "missing_boolean", value: "", input: act_model.WorkflowDispatchInput{Required: true, Type: "boolean", Options: []string{}}, expected: InputRequiredErr{Name: "missing_boolean"}, }, { name: "missing_required_string", key: "missing_string", value: "", input: act_model.WorkflowDispatchInput{Description: "a string", Required: true, Type: "string", Options: []string{}}, expected: InputRequiredErr{Name: "a string"}, }, { name: "missing_required_string_without_description", key: "missing_string", value: "", input: act_model.WorkflowDispatchInput{Required: true, Type: "string", Options: []string{}}, expected: InputRequiredErr{Name: "missing_string"}, }, } { t.Run(tc.name, func(t *testing.T) { _, err := resolveDispatchInput(tc.key, tc.value, tc.input) assert.Equal(t, tc.expected, err) }) } }