Skip to content
10 changes: 10 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,14 @@ var failOnNoTestsFlag = &cli.BoolFlag{
Destination: &cfg.FailOnNoTests,
}

var locationPrefixFlag = &cli.StringFlag{
Name: "location-prefix",
Category: "TEST RUNNER",
Usage: "Prefix to prepend to test file paths when requesting a test plan, used to match the test files reported by the test collector",
Sources: cli.EnvVars("BUILDKITE_TEST_ENGINE_LOCATION_PREFIX", "BUILDKITE_ANALYTICS_LOCATION_PREFIX"),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using only BUILDKITE_ANALYTICS_LOCATION_PREFIX is inconsistent with other environment variables namespace in bktec. The location prefix can be configured for the test collector without that environment variable (e.g via collector config object) so it might not be present. Supporting BUILDKITE_TEST_ENGINE_LOCATION_PREFIX will make it consistent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be encouraging the deprecation of BUILDKITE_ANALYTICS_LOCATION_PREFIXin favour of BUILDKITE_TEST_ENGINE_LOCATION_PREFIX in our collectors?

Destination: &cfg.LocationPrefix,
}

// Test Runner Retry Flags
var testEngineRetryCountFlag = &cli.IntFlag{
Name: "test-engine-retry-count",
Expand Down Expand Up @@ -376,6 +384,7 @@ func runCommandFlags() []cli.Flag {
resultPathFlag,
splitByExampleFlag,
failOnNoTestsFlag,
locationPrefixFlag,
// Runner Retry Flags
disableRetryMutedFlag,
retryCommandFlag,
Expand Down Expand Up @@ -413,6 +422,7 @@ func planCommandFlags() []cli.Flag {
testRunnerFlag,
resultPathFlag,
splitByExampleFlag,
locationPrefixFlag,
// Runner Retry Flags
disableRetryMutedFlag,
retryCommandFlag,
Expand Down
13 changes: 13 additions & 0 deletions docs/jest.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ export BUILDKITE_TEST_ENGINE_TEST_FILE_EXCLUDE_PATTERN=src/components
> [!TIP]
> This option accepts the pattern syntax supported by the [zzglob](https://github.com/DrJosh9000/zzglob?tab=readme-ov-file#pattern-syntax) library.

## Location prefix
If you have configured the [Buildkite test collector](https://buildkite.com/docs/test-engine/test-collection) with a location prefix, you should set the same prefix for bktec so that test file paths match those reported by the collector. You can set this using the `--location-prefix` flag or the `BUILDKITE_TEST_ENGINE_LOCATION_PREFIX` environment variable.

```sh
bktec run --location-prefix "my/prefix/"
```

Or using the environment variable:

```sh
export BUILDKITE_TEST_ENGINE_LOCATION_PREFIX=my/prefix/
```

## Automatically retry failed tests
You can configure bktec to automatically retry failed tests using the `BUILDKITE_TEST_ENGINE_RETRY_COUNT` environment variable. When this variable is set to a number greater than `0`, bktec will retry each failed test up to the specified number of times, using the following command:

Expand Down
13 changes: 13 additions & 0 deletions docs/playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ export BUILDKITE_TEST_ENGINE_TEST_FILE_EXCLUDE_PATTERN=src/components
> [!TIP]
> This option accepts the pattern syntax supported by the [zzglob](https://github.com/DrJosh9000/zzglob?tab=readme-ov-file#pattern-syntax) library.

## Location prefix
If you have configured the [Buildkite test collector](https://buildkite.com/docs/test-engine/test-collection) with a location prefix, you should set the same prefix for bktec so that test file paths match those reported by the collector. You can set this using the `--location-prefix` flag or the `BUILDKITE_TEST_ENGINE_LOCATION_PREFIX` environment variable.

```sh
bktec run --location-prefix "my/prefix/"
```

Or using the environment variable:

```sh
export BUILDKITE_TEST_ENGINE_LOCATION_PREFIX=my/prefix/
```

## Automatically retry failed tests
You can configure bktec to automatically retry failed tests using the `BUILDKITE_TEST_ENGINE_RETRY_COUNT` environment variable. When this variable is set to a number greater than `0`, bktec will retry each failed test up to the specified number of times, using either the default test command or the command specified in `BUILDKITE_TEST_ENGINE_TEST_CMD`.

Expand Down
13 changes: 13 additions & 0 deletions docs/rspec.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ export BUILDKITE_TEST_ENGINE_TEST_FILE_EXCLUDE_PATTERN=spec/models/user
> [!TIP]
> This option accepts the pattern syntax supported by the [zzglob](https://github.com/DrJosh9000/zzglob?tab=readme-ov-file#pattern-syntax) library.

## Location prefix
If you have configured the [Buildkite test collector](https://buildkite.com/docs/test-engine/test-collection) with a location prefix, you should set the same prefix for bktec so that test file paths match those reported by the collector. You can set this using the `--location-prefix` flag or the `BUILDKITE_TEST_ENGINE_LOCATION_PREFIX` environment variable.

```sh
bktec run --location-prefix "my/prefix/"
```

Or using the environment variable:

```sh
export BUILDKITE_TEST_ENGINE_LOCATION_PREFIX=my/prefix/
```

## Automatically retry failed tests
You can configure bktec to automatically retry failed tests using the `BUILDKITE_TEST_ENGINE_RETRY_COUNT` environment variable. When this variable is set to a number greater than `0`, bktec will retry each failed test up to the specified number of times, using the command set in `BUILDKITE_TEST_ENGINE_RETRY_CMD` environment variable. If this variable is not set, bktec will use either the default test command or the command specified in `BUILDKITE_TEST_ENGINE_TEST_CMD` to retry the tests.

Expand Down
35 changes: 35 additions & 0 deletions internal/command/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package command

import (
"fmt"
"path/filepath"
)

// prefixFilePaths prepends the given prefix to the file paths of the test cases.
func prefixPath(path string, prefix string) string {
if prefix == "" {
return path
}

var prefixedPath string
// Some test collectors (e.g. Rspec) report file paths with a "./" by default.
// Since `filepath.Join` ignore "./", we need to handle this case separately to avoid losing the "./" prefix.
if prefix == "./" {
prefixedPath = prefix + path
} else {
prefixedPath = filepath.Join(prefix, path)
}
return prefixedPath
}

func trimFilePathPrefix(path string, prefix string) (string, error) {
if prefix == "" {
return path, nil
}

relPath, err := filepath.Rel(prefix, path)
if err != nil {
return "", fmt.Errorf("failed to trim prefix from file path: %w", err)
}
return relPath, nil
}
90 changes: 90 additions & 0 deletions internal/command/path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package command

import (
"testing"

"github.com/google/go-cmp/cmp"
)

func TestPrefixFilePath(t *testing.T) {
path := "spec/models/user_spec.rb"

cases := []struct {
prefix string
expected string
}{
{
prefix: "",
expected: "spec/models/user_spec.rb",
},
{
prefix: "my/project",
expected: "my/project/spec/models/user_spec.rb",
},
Comment on lines +20 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we clarify the expected behaviour for this case - a custom prefix that starts with ./?

		{
			prefix:   "./my/project",
			expected: "???/spec/models/user_spec.rb",
		},

I think we'll get my/project/spec/models/user_spec.rb because of the default cleaning by the golang functions, but would that break things elsewhere?

{
prefix: "/home/user/my/project",
expected: "/home/user/my/project/spec/models/user_spec.rb",
},
{
prefix: "./",
expected: "./spec/models/user_spec.rb",
},
}

for _, c := range cases {
t.Run(c.prefix, func(t *testing.T) {
got := prefixPath(path, c.prefix)
if diff := cmp.Diff(got, c.expected); diff != "" {
t.Errorf("prefixPath() diff (-got +want):\n%s", diff)
}
})
}
}

func TestTrimFilePathPrefix(t *testing.T) {
cases := []struct {
prefix string
filePath string
expected string
}{
{
prefix: "my/project",
filePath: "my/project/spec/models/user_spec.rb",
expected: "spec/models/user_spec.rb",
},
{
filePath: "/home/user/my/project/spec/models/user_spec.rb",
prefix: "/home/user/my/project",
expected: "spec/models/user_spec.rb",
},
{
filePath: "./spec/models/user_spec.rb",
prefix: "./",
expected: "spec/models/user_spec.rb",
},
}

for _, c := range cases {
t.Run(c.prefix, func(t *testing.T) {
got, err := trimFilePathPrefix(c.filePath, c.prefix)
if err != nil {
t.Errorf("trimFilePathPrefix() error = %v", err)
}

if diff := cmp.Diff(got, c.expected); diff != "" {
t.Errorf("trimFilePathPrefix() diff (-got +want):\n%s", diff)
}
})
}
}

func TestTrimFilePathPrefix_Error(t *testing.T) {
path := "spec/foo.rb"
got, err := trimFilePathPrefix(path, "/absolute/path")
if err == nil {
t.Errorf("expected error, got nil")
}
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
66 changes: 53 additions & 13 deletions internal/command/request_param.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ import (
// that are slow or contain skipped tests. These files are then split into examples
// The remaining files are sent as is.
//
// If location prefix is configured, the file paths are prefixed when making the request to the Test Engine API,
// so that it can correctly identify the files.
//
// If tag filtering is enabled, all files are split into examples to support filtering.
// Currently only the Pytest runner supports tag filtering.
func createRequestParam(ctx context.Context, cfg *config.Config, files []string, client api.Client, runner TestRunner) (api.TestPlanParams, error) {
testFiles := []plan.TestCase{}

for _, file := range files {
testFiles = append(testFiles, plan.TestCase{
Path: file,
Path: prefixPath(file, runner.LocationPrefix()),
})
}

Expand Down Expand Up @@ -100,6 +104,39 @@ func buildSelectionParams(strategy string, params map[string]string) *api.Select
}
}

func getExamplesWithPrefix(filePaths []string, runner TestRunner) ([]plan.TestCase, error) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is called by filterAndSplitFiles and splitAllFiles since we need to handle it the same way.

prefix := runner.LocationPrefix()
Comment on lines +107 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
func getExamplesWithPrefix(filePaths []string, runner TestRunner) ([]plan.TestCase, error) {
prefix := runner.LocationPrefix()
func getExamplesWithPrefix(filePaths []string, prefix string) ([]plan.TestCase, error) {

trimmedPaths := make([]string, len(filePaths))

// runner.GetExamples will call the test runner with the file paths.
// Because the test runner expects the file paths without the prefix (it doesn't know about the prefix),
// we need to trim the prefix before passing the file paths to the runner.
for i, filePath := range filePaths {
path, err := trimFilePathPrefix(filePath, prefix)
if err != nil {
return nil, fmt.Errorf("trim file path prefix: %w", err)
}
trimmedPaths[i] = path
}

examples, err := runner.GetExamples(trimmedPaths)
if err != nil {
return nil, fmt.Errorf("get examples: %w", err)
}

// After getting the examples from the runner, we need to re-apply the prefix to the example paths
// before sending them to the Test Engine API.
if prefix != "" {
for i := range examples {
// The 'Identifier' field in an example may not always be a file path.
// Since the Test Engine API only uses the 'Path' field, we only apply the prefix to 'Path'.
examples[i].Path = prefixPath(examples[i].Path, prefix)
}
}

return examples, nil
}

// Splits all the test files into examples to support tag filtering.
func splitAllFiles(files []plan.TestCase, runner TestRunner) (api.TestPlanParamsTest, error) {
debug.Printf("Splitting all %d files", len(files))
Expand All @@ -108,9 +145,9 @@ func splitAllFiles(files []plan.TestCase, runner TestRunner) (api.TestPlanParams
filePaths = append(filePaths, file.Path)
}

examples, err := runner.GetExamples(filePaths)
examples, err := getExamplesWithPrefix(filePaths, runner)
if err != nil {
return api.TestPlanParamsTest{}, fmt.Errorf("get examples: %w", err)
return api.TestPlanParamsTest{}, err
}

debug.Printf("Got %d examples from all files", len(examples))
Expand All @@ -121,24 +158,24 @@ func splitAllFiles(files []plan.TestCase, runner TestRunner) (api.TestPlanParams
}

// filterAndSplitFiles filters the test files through the Test Engine API and splits the filtered files into examples.
// It returns the test plan parameters with the examples from the filtered files and the remaining files.
// It returns the test plan parameters with the examples from the filtered files and the remaining files that are not filtered.
// An error is returned if there is a failure in any of the process.
func filterAndSplitFiles(ctx context.Context, cfg *config.Config, client api.Client, files []plan.TestCase, runner TestRunner) (api.TestPlanParamsTest, error) {
func filterAndSplitFiles(ctx context.Context, cfg *config.Config, client api.Client, allTestFiles []plan.TestCase, runner TestRunner) (api.TestPlanParamsTest, error) {
// Filter files that need to be split.
debug.Printf("Filtering %d files", len(files))
debug.Printf("Filtering %d files", len(allTestFiles))
filteredFiles, err := client.FilterTests(ctx, cfg.SuiteSlug, api.FilterTestsParams{
Files: files,
Files: allTestFiles,
Env: cfg,
})
if err != nil {
return api.TestPlanParamsTest{}, fmt.Errorf("filter tests: %w", err)
}

// If no files are filtered, return the all files.
// If no files are filtered, return all the files.
if len(filteredFiles) == 0 {
debug.Println("No filtered files found")
return api.TestPlanParamsTest{
Files: files,
Files: allTestFiles,
}, nil
}

Expand All @@ -152,16 +189,19 @@ func filterAndSplitFiles(ctx context.Context, cfg *config.Config, client api.Cli
filteredFilesPath = append(filteredFilesPath, file.Path)
}

examples, err := runner.GetExamples(filteredFilesPath)
// The filtered files returned by the API include the location prefix in their paths,
// so we should trim the prefix before passing the file paths to the runner to get the examples,
// then re-apply the prefix to the example paths collected by the runner.
examples, err := getExamplesWithPrefix(filteredFilesPath, runner)
if err != nil {
return api.TestPlanParamsTest{}, fmt.Errorf("get examples: %w", err)
return api.TestPlanParamsTest{}, err
}

debug.Printf("Got %d examples within the filtered files", len(examples))

// Get the remaining files that are not filtered.
remainingFiles := make([]plan.TestCase, 0, len(files)-len(filteredFiles))
for _, file := range files {
remainingFiles := make([]plan.TestCase, 0, len(allTestFiles)-len(filteredFiles))
for _, file := range allTestFiles {
if _, ok := filteredFilesMap[file.Path]; !ok {
remainingFiles = append(remainingFiles, file)
}
Expand Down
Loading