Skip to content

Commit

Permalink
feat: Tag aware sharding for cucumber-playwright (#933)
Browse files Browse the repository at this point in the history
* Add gherkin deps

* Add tag expression parser dep

* Add module for parsing cucumber feature files

* Enable shardGrepEnabled feature for cucumber playwright

* Use the tags already available in configuration

* Add another negation test case

* Operate on the pickle

* Readability

* Organization

* docs

* Formatting

* lint

* update schema

* rename

* Align property names

* Formatting for readability

* Use cmp.Diff instead of DeepEqual for testing

* Add warning if feature file could not be parsed

* Formatting

* efficiency
  • Loading branch information
mhan83 authored Aug 9, 2024
1 parent 218e222 commit 6a1d389
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 0 deletions.
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)
}
})
}
}

0 comments on commit 6a1d389

Please sign in to comment.