Skip to content

Commit f52bda0

Browse files
bynnZackarySantana
andauthored
DEVPROD-21516 Add test selection project command (#9375)
Co-authored-by: Zackary Santana <[email protected]>
1 parent 89826c8 commit f52bda0

File tree

7 files changed

+290
-1
lines changed

7 files changed

+290
-1
lines changed

agent/command/registry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func init() {
4949
evergreen.ShellExecCommandName: shellExecFactory,
5050
"subprocess.exec": subprocessExecFactory,
5151
"setup.initial": initialSetupFactory,
52+
"test_selection.get": testSelectionGetFactory,
5253
"timeout.update": timeoutUpdateFactory,
5354
}
5455

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package command
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
7+
"github.com/evergreen-ci/evergreen/agent/internal"
8+
"github.com/evergreen-ci/evergreen/agent/internal/client"
9+
"github.com/evergreen-ci/evergreen/rest/model"
10+
"github.com/evergreen-ci/evergreen/util"
11+
"github.com/evergreen-ci/utility"
12+
"github.com/mitchellh/mapstructure"
13+
"github.com/mongodb/grip"
14+
"github.com/pkg/errors"
15+
)
16+
17+
// // TestSelectionOutput represents the output JSON structure
18+
// type TestSelectionOutput struct {
19+
// Tests []map[string]string `json:"tests"`
20+
// }
21+
22+
type TestOutput struct {
23+
Name string `json:"name"`
24+
}
25+
26+
type TestSelectionOutput struct {
27+
Tests []TestOutput `json:"tests"`
28+
}
29+
30+
type testSelectionGet struct {
31+
// OutputFile is the path to where the JSON file should be written.
32+
// Required.
33+
OutputFile string `mapstructure:"output_file" plugin:"expand"`
34+
35+
// Tests is a list of test names to pass into the TSS API.
36+
// Optional.
37+
Tests []string `mapstructure:"tests" plugin:"expand"`
38+
39+
base
40+
}
41+
42+
func testSelectionGetFactory() Command { return &testSelectionGet{} }
43+
func (c *testSelectionGet) Name() string { return "test_selection.get" }
44+
45+
func (c *testSelectionGet) ParseParams(params map[string]any) error {
46+
if err := mapstructure.Decode(params, c); err != nil {
47+
return errors.Wrap(err, "decoding mapstructure params")
48+
}
49+
50+
return c.validate()
51+
}
52+
53+
func (c *testSelectionGet) validate() error {
54+
catcher := grip.NewSimpleCatcher()
55+
catcher.NewWhen(c.OutputFile == "", "must specify output file")
56+
57+
return catcher.Resolve()
58+
}
59+
60+
func (c *testSelectionGet) Execute(ctx context.Context, comm client.Communicator, logger client.LoggerProducer, conf *internal.TaskConfig) error {
61+
if err := util.ExpandValues(c, &conf.Expansions); err != nil {
62+
return errors.Wrap(err, "applying expansions")
63+
}
64+
65+
// Re-validate the command here, in case an expansion is not defined.
66+
if err := c.validate(); err != nil {
67+
return errors.Wrap(err, "validating command")
68+
}
69+
70+
// Resolve the output file path early so it's available for writing empty results.
71+
if !filepath.IsAbs(c.OutputFile) {
72+
c.OutputFile = GetWorkingDirectory(conf, c.OutputFile)
73+
}
74+
75+
if !c.isTestSelectionAllowed(conf) {
76+
logger.Execution().Info("Test selection is not allowed/enabled, writing empty test list")
77+
return c.writeTestList([]string{})
78+
}
79+
80+
// Build the request using task information from TaskConfig.
81+
request := model.SelectTestsRequest{
82+
Project: conf.Task.Project,
83+
Requester: conf.Task.Requester,
84+
BuildVariant: conf.Task.BuildVariant,
85+
TaskID: conf.Task.Id,
86+
TaskName: conf.Task.DisplayName,
87+
Tests: c.Tests,
88+
}
89+
90+
selectedTests, err := comm.SelectTests(ctx, conf.TaskData(), request)
91+
if err != nil {
92+
return errors.Wrap(err, "calling test selection API")
93+
}
94+
95+
// Write the results to the output file.
96+
return c.writeTestList(selectedTests)
97+
}
98+
99+
// isTestSelectionAllowed checks if test selection is allowed in the project and the running task.
100+
func (c *testSelectionGet) isTestSelectionAllowed(conf *internal.TaskConfig) bool {
101+
return utility.FromBoolPtr(conf.ProjectRef.TestSelection.Allowed) && conf.Task.TestSelectionEnabled
102+
}
103+
104+
// writeTestList writes the list of tests to the output file as JSON in the required format.
105+
func (c *testSelectionGet) writeTestList(tests []string) error {
106+
testObjects := make([]TestOutput, len(tests))
107+
for i, testName := range tests {
108+
testObjects[i] = TestOutput{Name: testName}
109+
}
110+
111+
output := TestSelectionOutput{
112+
Tests: testObjects,
113+
}
114+
115+
err := utility.WriteJSONFile(c.OutputFile, output)
116+
return errors.Wrap(err, "writing test selection output to file")
117+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package command
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/evergreen-ci/evergreen/agent/internal"
12+
"github.com/evergreen-ci/evergreen/agent/internal/client"
13+
"github.com/evergreen-ci/evergreen/model"
14+
"github.com/evergreen-ci/evergreen/model/task"
15+
"github.com/evergreen-ci/utility"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func setupTestEnv(t *testing.T) (context.Context, context.CancelFunc, *internal.TaskConfig, *client.Mock, client.LoggerProducer) {
21+
comm := client.NewMock("http://localhost.com")
22+
comm.SelectTestsResponse = []string{"test1", "test3"}
23+
conf := &internal.TaskConfig{
24+
Task: task.Task{
25+
Id: "task_id",
26+
Secret: "task_secret",
27+
Project: "project_id",
28+
Version: "version",
29+
BuildVariant: "build_variant",
30+
DisplayName: "display_name",
31+
},
32+
ProjectRef: model.ProjectRef{Id: "project_id"},
33+
}
34+
tmpDir := t.TempDir()
35+
conf.WorkDir = tmpDir
36+
f, err := os.Create(filepath.Join(tmpDir, "test.json"))
37+
require.NoError(t, err)
38+
_ = f.Close()
39+
40+
ctx, cancel := context.WithCancel(t.Context())
41+
logger, err := comm.GetLoggerProducer(ctx, &conf.Task, nil)
42+
require.NoError(t, err)
43+
44+
conf.Task.TestSelectionEnabled = true
45+
conf.ProjectRef.TestSelection.Allowed = utility.TruePtr()
46+
return ctx, cancel, conf, comm, logger
47+
}
48+
49+
func TestTestSelectionGetParseFailsWithMissingOutputFile(t *testing.T) {
50+
cmd := &testSelectionGet{}
51+
params := map[string]any{}
52+
require.Error(t, cmd.ParseParams(params))
53+
}
54+
55+
func TestParseSucceedsWithValidParams(t *testing.T) {
56+
cmd := &testSelectionGet{}
57+
params := map[string]any{
58+
"output_file": "test.json",
59+
}
60+
require.NoError(t, cmd.ParseParams(params))
61+
assert.Equal(t, "test.json", cmd.OutputFile)
62+
assert.Empty(t, cmd.Tests)
63+
64+
params["tests"] = []string{"test1", "test2"}
65+
require.NoError(t, cmd.ParseParams(params))
66+
assert.Equal(t, "test.json", cmd.OutputFile)
67+
assert.Equal(t, []string{"test1", "test2"}, cmd.Tests)
68+
}
69+
70+
func TestSkipsWhenTestSelectionNotAllowed(t *testing.T) {
71+
ctx, cancel, conf, comm, logger := setupTestEnv(t)
72+
defer cancel()
73+
cmd := &testSelectionGet{OutputFile: "test.json"}
74+
75+
// Test selection not allowed in project settings
76+
conf.ProjectRef.TestSelection.Allowed = utility.FalsePtr()
77+
require.NoError(t, cmd.Execute(ctx, comm, logger, conf))
78+
79+
var output TestSelectionOutput
80+
require.NoError(t, utility.ReadJSONFile(cmd.OutputFile, &output))
81+
assert.Empty(t, output.Tests)
82+
83+
// Test selection allowed in project settings but not enabled for task
84+
conf.ProjectRef.TestSelection.Allowed = utility.TruePtr()
85+
conf.Task.TestSelectionEnabled = false
86+
require.NoError(t, cmd.Execute(ctx, comm, logger, conf))
87+
88+
output = TestSelectionOutput{}
89+
require.NoError(t, utility.ReadJSONFile(cmd.OutputFile, &output))
90+
assert.Empty(t, output.Tests)
91+
}
92+
93+
func TestCallsAPIWhenEnabled(t *testing.T) {
94+
ctx, cancel, conf, comm, logger := setupTestEnv(t)
95+
defer cancel()
96+
cmd := &testSelectionGet{OutputFile: "test.json", Tests: []string{"test1", "test3"}}
97+
98+
require.NoError(t, cmd.Execute(ctx, comm, logger, conf))
99+
100+
// Should return the expected tests from the mock API.
101+
data, err := os.ReadFile(cmd.OutputFile)
102+
require.NoError(t, err)
103+
var output TestSelectionOutput
104+
require.NoError(t, json.Unmarshal(data, &output))
105+
require.Len(t, output.Tests, 2)
106+
assert.Equal(t, "test1", output.Tests[0].Name)
107+
assert.Equal(t, "test3", output.Tests[1].Name)
108+
109+
// Verify the API was called with correct parameters from TaskConfig
110+
assert.True(t, comm.SelectTestsCalled)
111+
assert.Equal(t, conf.Task.Project, comm.SelectTestsRequest.Project)
112+
assert.Equal(t, conf.Task.Requester, comm.SelectTestsRequest.Requester)
113+
assert.Equal(t, conf.Task.BuildVariant, comm.SelectTestsRequest.BuildVariant)
114+
assert.Equal(t, conf.Task.Id, comm.SelectTestsRequest.TaskID)
115+
assert.Equal(t, conf.Task.DisplayName, comm.SelectTestsRequest.TaskName)
116+
assert.Equal(t, []string{"test1", "test3"}, comm.SelectTestsRequest.Tests)
117+
}
118+
119+
func TestHandlesAPIError(t *testing.T) {
120+
ctx, cancel, conf, comm, logger := setupTestEnv(t)
121+
defer cancel()
122+
cmd := &testSelectionGet{OutputFile: "test.json"}
123+
124+
// Mock API error
125+
comm.SelectTestsError = errors.New("test error")
126+
127+
err := cmd.Execute(ctx, comm, logger, conf)
128+
require.Error(t, err)
129+
assert.Contains(t, err.Error(), "test error")
130+
assert.Contains(t, err.Error(), "calling test selection API")
131+
}

agent/internal/client/base_client.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,26 @@ func (c *baseCommunicator) SetDownstreamParams(ctx context.Context, downstreamPa
622622
return nil
623623
}
624624

625+
func (c *baseCommunicator) SelectTests(ctx context.Context, taskData TaskData, request restmodel.SelectTestsRequest) ([]string, error) {
626+
info := requestInfo{
627+
method: http.MethodPost,
628+
taskData: &taskData,
629+
}
630+
info.path = "select/tests"
631+
resp, err := c.retryRequest(ctx, info, request)
632+
if err != nil {
633+
return nil, util.RespError(resp, errors.Wrap(err, "calling test selection API").Error())
634+
}
635+
defer resp.Body.Close()
636+
637+
var response restmodel.SelectTestsRequest
638+
if err := utility.ReadJSON(resp.Body, &response); err != nil {
639+
return nil, errors.Wrap(err, "reading test selection response")
640+
}
641+
642+
return response.Tests, nil
643+
}
644+
625645
func (c *baseCommunicator) GetManifest(ctx context.Context, taskData TaskData) (*manifest.Manifest, error) {
626646
info := requestInfo{
627647
method: http.MethodGet,

agent/internal/client/interface.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ type SharedCommunicator interface {
112112

113113
SetDownstreamParams(ctx context.Context, downstreamParams []patchmodel.Parameter, taskData TaskData) error
114114

115+
// SelectTests calls the test selection API to get a filtered list of tests to run.
116+
SelectTests(ctx context.Context, taskData TaskData, request restmodel.SelectTestsRequest) ([]string, error)
117+
115118
// CreateInstallationTokenForClone creates an installation token for the given owner and repo if there is a GitHub app installed.
116119
CreateInstallationTokenForClone(ctx context.Context, td TaskData, owner, repo string) (string, error)
117120

agent/internal/client/mock.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/evergreen-ci/evergreen/model/testlog"
2323
"github.com/evergreen-ci/evergreen/model/testresult"
2424
"github.com/evergreen-ci/evergreen/rest/model"
25+
restmodel "github.com/evergreen-ci/evergreen/rest/model"
2526
"github.com/evergreen-ci/evergreen/util"
2627
"github.com/evergreen-ci/utility"
2728
"github.com/google/go-github/v70/github"
@@ -90,6 +91,12 @@ type Mock struct {
9091
LastMessageSent time.Time
9192
DownstreamParams []patchModel.Parameter
9293

94+
// SelectTests mock fields
95+
SelectTestsCalled bool
96+
SelectTestsRequest restmodel.SelectTestsRequest
97+
SelectTestsResponse []string
98+
SelectTestsError error
99+
93100
mu sync.RWMutex
94101
}
95102

@@ -599,6 +606,16 @@ func (c *Mock) UpsertCheckRun(ctx context.Context, td TaskData, checkRunOutput a
599606
return nil
600607
}
601608

609+
// SelectTests mocks the test selection API call
610+
func (c *Mock) SelectTests(ctx context.Context, taskData TaskData, request restmodel.SelectTestsRequest) ([]string, error) {
611+
c.SelectTestsCalled = true
612+
c.SelectTestsRequest = request
613+
if c.SelectTestsError != nil {
614+
return nil, c.SelectTestsError
615+
}
616+
return c.SelectTestsResponse, nil
617+
}
618+
602619
func (c *Mock) AssumeRole(ctx context.Context, td TaskData, request apimodels.AssumeRoleRequest) (*apimodels.AWSCredentials, error) {
603620
return c.AssumeRoleResponse, nil
604621
}

config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ var (
3636

3737
// Agent version to control agent rollover. The format is the calendar date
3838
// (YYYY-MM-DD).
39-
AgentVersion = "2025-09-22"
39+
AgentVersion = "2025-09-23"
4040
)
4141

4242
const (

0 commit comments

Comments
 (0)