diff --git a/api/saucectl.schema.json b/api/saucectl.schema.json index 0dd078656..63f5cbbc6 100644 --- a/api/saucectl.schema.json +++ b/api/saucectl.schema.json @@ -2468,11 +2468,12 @@ ] }, "shard": { - "description": "When sharding is configured, saucectl automatically splits the tests (e.g. by spec or concurrency) so that they can easily run in parallel.", + "description": "When sharding is configured, saucectl automatically splits the tests (e.g. by spec, concurrency or scenario) so that they can easily run in parallel.", "enum": [ "", "concurrency", - "spec" + "spec", + "scenario" ] }, "shardTagsEnabled": { diff --git a/api/v1alpha/framework/playwright-cucumberjs.schema.json b/api/v1alpha/framework/playwright-cucumberjs.schema.json index 4689de0d4..83caa9a56 100644 --- a/api/v1alpha/framework/playwright-cucumberjs.schema.json +++ b/api/v1alpha/framework/playwright-cucumberjs.schema.json @@ -142,11 +142,12 @@ ] }, "shard": { - "description": "When sharding is configured, saucectl automatically splits the tests (e.g. by spec or concurrency) so that they can easily run in parallel.", + "description": "When sharding is configured, saucectl automatically splits the tests (e.g. by spec, concurrency or scenario) so that they can easily run in parallel.", "enum": [ "", "concurrency", - "spec" + "spec", + "scenario" ] }, "shardTagsEnabled": { diff --git a/internal/cucumber/config.go b/internal/cucumber/config.go index 81aade284..0f2782279 100644 --- a/internal/cucumber/config.go +++ b/internal/cucumber/config.go @@ -12,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/scenario" "github.com/saucelabs/saucectl/internal/cucumber/tag" "github.com/saucelabs/saucectl/internal/fpath" "github.com/saucelabs/saucectl/internal/insights" @@ -231,7 +232,7 @@ func shardSuites(rootDir string, suites []Suite, ccy int) ([]Suite, error) { var shardedSuites []Suite for _, s := range suites { - if s.Shard != "spec" && s.Shard != "concurrency" { + if s.Shard == "" { shardedSuites = append(shardedSuites, s) continue } @@ -291,6 +292,15 @@ func shardSuites(rootDir string, suites []Suite, ccy int) ([]Suite, error) { shardedSuites = append(shardedSuites, replica) } } + if s.Shard == "scenario" { + scenarios := scenario.List(os.DirFS(rootDir), testFiles) + for _, name := range scenario.GetUniqueNames(scenarios) { + replica := s + replica.Name = fmt.Sprintf("%s - %s", s.Name, name) + replica.Options.Name = fmt.Sprintf("^%s$", name) + shardedSuites = append(shardedSuites, replica) + } + } } return shardedSuites, nil diff --git a/internal/cucumber/scenario/scenario.go b/internal/cucumber/scenario/scenario.go new file mode 100644 index 000000000..7f78e2ac7 --- /dev/null +++ b/internal/cucumber/scenario/scenario.go @@ -0,0 +1,55 @@ +package scenario + +import ( + "io/fs" + + gherkin "github.com/cucumber/gherkin/go/v28" + messages "github.com/cucumber/messages/go/v24" + "github.com/rs/zerolog/log" +) + +// List parses the provided files and returns a list of scenarios. +func List(sys fs.FS, files []string) []*messages.Pickle { + uuid := &messages.UUID{} + + var scenarios []*messages.Pickle + for _, filename := range files { + scenarios = append(scenarios, ReadFile(sys, filename, uuid)...) + } + return scenarios +} + +// ReadFile reads a feature file and returns the parsed list of scenarios. +func ReadFile(sys fs.FS, filename string, uuid *messages.UUID) []*messages.Pickle { + f, err := sys.Open(filename) + if err != nil { + log.Warn().Str("filename", filename).Msgf("Failed to open the file: %v", err) + return nil + } + 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.") + return nil + } + return gherkin.Pickles(*doc, filename, uuid.NewId) +} + +// GetUniqueNames extracts and returns unique scenario names. +func GetUniqueNames(scenarios []*messages.Pickle) []string { + uniqueMap := make(map[string]bool) + + for _, s := range scenarios { + uniqueMap[s.Name] = true + } + + var names []string + for name := range uniqueMap { + names = append(names, name) + } + + return names +} diff --git a/internal/cucumber/tag/matcher.go b/internal/cucumber/tag/matcher.go index 4eade9850..d4a96159d 100644 --- a/internal/cucumber/tag/matcher.go +++ b/internal/cucumber/tag/matcher.go @@ -4,10 +4,9 @@ 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" + "github.com/saucelabs/saucectl/internal/cucumber/scenario" ) // MatchFiles finds feature files that include scenarios with tags that match the given tag expression. @@ -23,21 +22,7 @@ func MatchFiles(sys fs.FS, files []string, tagExpression string) (matched []stri 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) - + scenarios := scenario.ReadFile(sys, filename, uuid) hasMatch := false for _, s := range scenarios { if match(s.Tags, tagMatcher) {