Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Tag aware sharding for cucumber-playwright #933

Merged
merged 20 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/saucectl.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2467,6 +2467,10 @@
"spec"
]
},
"shardTagsEnabled": {
"description": "When sharding is configured and the suite is configured to filter scenarios by tag expression, let saucectl filter test files before executing.",
"type": "boolean"
},
"timeout": {
"$ref": "#/allOf/8/then/definitions/suite/properties/timeout"
},
Expand Down
4 changes: 4 additions & 0 deletions api/v1alpha/framework/playwright-cucumberjs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@
"spec"
]
},
"shardTagsEnabled": {
"description": "When sharding is configured and the suite is configured to filter scenarios by tag expression, let saucectl filter test files before executing.",
"type": "boolean"
},
"timeout": {
"$ref": "../subschema/common.schema.json#/definitions/timeout"
},
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/cucumber/gherkin/go/v28 v28.0.0 // indirect
github.com/cucumber/messages/go/v24 v24.0.1 // indirect
github.com/cucumber/tag-expressions/go/v6 v6.1.0 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cucumber/gherkin/go/v28 v28.0.0 h1:SBqwscPOhe83JF0ukpEj+4QZ2ScOpPQByC0gD3cXBkg=
github.com/cucumber/gherkin/go/v28 v28.0.0/go.mod h1:HVwDrzWvtsVbkxHw6KVZFA79x5uSLb+ajzS0BXuHiE8=
github.com/cucumber/messages/go/v24 v24.0.1 h1:jajAQDk3fPa4RhIANE+NOxGdCKQdi7RYjd8wdKXnOu4=
github.com/cucumber/messages/go/v24 v24.0.1/go.mod h1:ns4Befq4c4n9/B5APpTlBu5kXL1DVE4+5bbe0vSV4fc=
github.com/cucumber/tag-expressions/go/v6 v6.1.0 h1:YOhnlISh/lyPZrLojFbJVzocv7TGhzOhB9aULN8A7Sg=
github.com/cucumber/tag-expressions/go/v6 v6.1.0/go.mod h1:6scGHUy3RLnbNq8un7XNoopF2qR/0RMgqolQH/TkycY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -135,6 +141,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
Expand Down
27 changes: 27 additions & 0 deletions internal/cucumber/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cucumber
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
Expand All @@ -11,6 +12,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/saucelabs/saucectl/internal/concurrency"
"github.com/saucelabs/saucectl/internal/config"
"github.com/saucelabs/saucectl/internal/cucumber/tag"
"github.com/saucelabs/saucectl/internal/fpath"
"github.com/saucelabs/saucectl/internal/insights"
"github.com/saucelabs/saucectl/internal/msg"
Expand Down Expand Up @@ -65,6 +67,7 @@ type Suite struct {
PlatformName string `yaml:"platformName,omitempty" json:"platformName"`
Env map[string]string `yaml:"env,omitempty" json:"env"`
Shard string `yaml:"shard,omitempty" json:"shard"`
ShardTagsEnabled bool `yaml:"shardTagsEnabled,omitempty" json:"-"`
Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout"`
ScreenResolution string `yaml:"screenResolution,omitempty" json:"screenResolution"`
PreExec []string `yaml:"preExec,omitempty" json:"preExec"`
Expand Down Expand Up @@ -240,6 +243,30 @@ func shardSuites(rootDir string, suites []Suite, ccy int) ([]Suite, error) {
msg.SuiteSplitNoMatch(s.Name, rootDir, s.Options.Paths)
return []Suite{}, fmt.Errorf("suite '%s' patterns have no matching files", s.Name)
}

if s.ShardTagsEnabled && len(s.Options.Tags) > 0 {
tags := make([]string, len(s.Options.Tags))
for i, t := range s.Options.Tags {
tags[i] = fmt.Sprintf("(%s)", t)
}
tagExp := strings.Join(tags, " and ")

var unmatched []string
files, unmatched = tag.MatchFiles(os.DirFS(rootDir), files, tagExp)

if len(files) == 0 {
log.Error().
Str("suiteName", s.Name).
Str("tagExpression", tagExp).
Msg("No files match the configured tagExpressions")
} else if len(unmatched) > 0 {
log.Info().
Str("suiteName", s.Name).
Str("tagExpression", tagExp).
Msgf("Files filtered out by tagExpression: [%s]", unmatched)
}
}

excludedFiles, err := fpath.FindFiles(rootDir, s.Options.ExcludedTestFiles, fpath.FindByShellPattern)
if err != nil {
return []Suite{}, err
Expand Down
64 changes: 64 additions & 0 deletions internal/cucumber/tag/matcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Package tag defines functions to parse cucumber feature files and filter them by cucumber tag expressions
package tag

import (
"io/fs"

gherkin "github.com/cucumber/gherkin/go/v28"
messages "github.com/cucumber/messages/go/v24"
tagexpressions "github.com/cucumber/tag-expressions/go/v6"
"github.com/rs/zerolog/log"
)

// MatchFiles finds feature files that include scenarios with tags that match the given tag expression.
// A tag expression is a simple boolean expression including the logical operators "and", "or", "not".
func MatchFiles(sys fs.FS, files []string, tagExpression string) (matched []string, unmatched []string) {
tagMatcher, err := tagexpressions.Parse(tagExpression)

if err != nil {
return matched, unmatched

}

uuid := &messages.UUID{}

for _, filename := range files {
f, err := sys.Open(filename)
if err != nil {
continue
}
defer f.Close()

doc, err := gherkin.ParseGherkinDocument(f, uuid.NewId)
if err != nil {
log.Warn().
Str("filename", filename).
Msg("Could not parse file. It will be excluded from sharded execution.")
continue
}
scenarios := gherkin.Pickles(*doc, filename, uuid.NewId)

hasMatch := false
for _, s := range scenarios {
if match(s.Tags, tagMatcher) {
matched = append(matched, filename)
hasMatch = true
break
}
}

if !hasMatch {
unmatched = append(unmatched, filename)
}
}
return matched, unmatched
}

func match(tags []*messages.PickleTag, matcher tagexpressions.Evaluatable) bool {
tagNames := make([]string, len(tags))
for i, t := range tags {
tagNames[i] = t.Name
}

return matcher.Evaluate(tagNames)
}
142 changes: 142 additions & 0 deletions internal/cucumber/tag/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package tag

import (
"testing"
"testing/fstest"

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

func TestMatchFiles(t *testing.T) {
mockFS := fstest.MapFS{
"scenario1.feature": {
Data: []byte(`
@act1
Feature: Scenario 1

@interior @nomatch
Scenario: Dinner scene
When Turkey is served
Then I say "bon appetit!"
`),
},
"scenario2.feature": {
Data: []byte(`
@act3
Feature: Scenario 2

@exterior @nomatch
Scenario: Exterior scene
When The character exits the house
Then The camera pans out to show the exterior

@interior @nomatch
Scenario: Interior scene
When The character enters the house
Then The character's leitmotif starts
`),
},
"scenario3.feature": {
Data: []byte(`
@act3 @credits
Feature: Scenario 3

@nomatch
Scenario: Epilogue
When The credits reach mid point
Then Start the first mid-credit scene

@nomatch
Scenario: Last Bonus Scene
When The credits reach the end
Then Start the end-credit scene
`),
},
}

files := []string{
"scenario1.feature",
"scenario2.feature",
"scenario3.feature",
}

tests := []struct {
name string
files []string
tagExpression string
wantMatched []string
wantUnmatched []string
}{
{
name: "matches a single tag",
files: files,
tagExpression: "@act1",
wantMatched: []string{
"scenario1.feature",
},
wantUnmatched: []string{
"scenario2.feature",
"scenario3.feature",
},
},
{
name: "matches scenario tag",
files: files,
tagExpression: "@interior",
wantMatched: []string{
"scenario1.feature",
"scenario2.feature",
},
wantUnmatched: []string{
"scenario3.feature",
},
},
{
name: "matches multiple tags",
files: files,
tagExpression: "@act3 and @credits",
wantMatched: []string{
"scenario3.feature",
},
wantUnmatched: []string{
"scenario1.feature",
"scenario2.feature",
},
},
{
name: "matches multiple tags with negation",
files: files,
tagExpression: "@act3 and not @credits",
wantMatched: []string{
"scenario2.feature",
},
wantUnmatched: []string{
"scenario1.feature",
"scenario3.feature",
},
},
{
name: "no matches with negation",
files: files,
tagExpression: "not @nomatch",
wantMatched: []string(nil),
wantUnmatched: []string{
"scenario1.feature",
"scenario2.feature",
"scenario3.feature",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matched, unmatched := MatchFiles(mockFS, tt.files, tt.tagExpression)
if diff := cmp.Diff(tt.wantMatched, matched); diff != "" {
t.Errorf("MatchFiles() returned unexpected matched files (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantUnmatched, unmatched); diff != "" {
t.Errorf("MatchFiles() returned unexpected unmatched files (-want +got):\n%s", diff)
}
})
}
}