Skip to content
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
96 changes: 77 additions & 19 deletions internal/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ const (

var excludedDirs = []string{"node_modules"}

type Excluder struct {
pattern string
type TestFileSetMatcher struct {
includeMatcher utils.PathMatcher
excludeMatcher utils.PathMatcher
explicitFiles map[string]struct{}
matchAll bool
}

type TestFileSet struct {
Expand All @@ -43,12 +46,16 @@ type TestFileSet struct {

func ResolveTestFiles(pattern, excludePattern string) (TestFileSet, error) {
testFiles := TestFileSet{Pattern: pattern}
if utils.NormalizePattern(excludePattern) == "" {
testFileMatcher, err := NewTestFileSetMatcher(TestFileSet{}, excludePattern)
if err != nil {
return TestFileSet{}, err
}
if testFileMatcher.excludeEmpty() {
slog.Info("Using test discovery pattern", "pattern", pattern)
return testFiles, nil
}

filteredFiles, err := DiscoverTestFiles(pattern, excludePattern)
filteredFiles, err := discoverTestFiles(pattern, testFileMatcher)
if err != nil {
return TestFileSet{}, err
}
Expand Down Expand Up @@ -80,36 +87,87 @@ func (t TestFileSet) Empty() bool {
return t.UseExplicitFiles() && len(t.ExplicitFiles) == 0
}

func NewExcluder(pattern string) (Excluder, error) {
normalized := utils.NormalizePattern(pattern)
if normalized == "" {
return Excluder{}, nil
func NewTestFileSetMatcher(testFiles TestFileSet, excludePattern string) (TestFileSetMatcher, error) {
excludeMatcher, err := utils.NewPathMatcher(excludePattern)
if err != nil {
return TestFileSetMatcher{}, fmt.Errorf("invalid tests exclude pattern %q: %w", excludePattern, err)
}

if testFiles.UseExplicitFiles() {
explicitFiles := make(map[string]struct{}, len(testFiles.ExplicitFiles))
for _, testFile := range testFiles.ExplicitFiles {
normalized := utils.NormalizePath(testFile)
if normalized != "" {
explicitFiles[normalized] = struct{}{}
}
}
return TestFileSetMatcher{
excludeMatcher: excludeMatcher,
explicitFiles: explicitFiles,
}, nil
}

matcher, err := utils.NewPathMatcher(testFiles.Pattern)
if err != nil {
return TestFileSetMatcher{}, fmt.Errorf("invalid tests location pattern %q: %w", testFiles.Pattern, err)
}
if !doublestar.ValidatePattern(normalized) {
return Excluder{}, fmt.Errorf("invalid tests exclude pattern %q", pattern)
if matcher.Empty() {
return TestFileSetMatcher{
excludeMatcher: excludeMatcher,
matchAll: true,
}, nil
}
return Excluder{pattern: normalized}, nil
return TestFileSetMatcher{
includeMatcher: matcher,
excludeMatcher: excludeMatcher,
}, nil
}

func (e Excluder) Match(path string) bool {
if e.pattern == "" {
func (m TestFileSetMatcher) Match(path string) bool {
return m.MatchNormalizedPath(utils.NormalizePath(path))
}

func (m TestFileSetMatcher) MatchNormalizedPath(normalizedPath string) bool {
if normalizedPath == "" {
return false
}
if m.excludesNormalizedPath(normalizedPath) {
return false
}
return doublestar.MatchUnvalidated(e.pattern, utils.NormalizePath(path))
if m.matchAll {
return true
}
if m.explicitFiles != nil {
_, ok := m.explicitFiles[normalizedPath]
return ok
}
return m.includeMatcher.MatchNormalizedPath(normalizedPath)
}

func (m TestFileSetMatcher) excludeEmpty() bool {
return m.excludeMatcher.Empty()
}

func (m TestFileSetMatcher) excludesNormalizedPath(normalizedPath string) bool {
return m.excludeMatcher.MatchNormalizedPath(normalizedPath)
}

func DiscoverTestFiles(includePattern, excludePattern string) ([]string, error) {
excluder, err := NewExcluder(excludePattern)
testFileMatcher, err := NewTestFileSetMatcher(TestFileSet{}, excludePattern)
if err != nil {
return nil, err
}
return discoverTestFiles(includePattern, testFileMatcher)
}

func discoverTestFiles(includePattern string, testFileMatcher TestFileSetMatcher) ([]string, error) {
normalizedIncludePattern := normalizeDiscoveryPattern(includePattern)
if normalizedIncludePattern == "" {
return []string{}, nil
}
if !doublestar.ValidatePattern(normalizedIncludePattern) {
return nil, fmt.Errorf("failed to discover test files with pattern %q: %w", includePattern, doublestar.ErrBadPattern)
includeMatcher, err := utils.NewNormalizedPathMatcher(normalizedIncludePattern)
if err != nil {
return nil, fmt.Errorf("failed to discover test files with pattern %q: %w", includePattern, err)
}

walkRoot := discoveryWalkRoot(normalizedIncludePattern)
Expand All @@ -136,7 +194,7 @@ func DiscoverTestFiles(includePattern, excludePattern string) ([]string, error)
return nil
}

if excluder.Match(normalizedPath) {
if testFileMatcher.excludesNormalizedPath(normalizedPath) {
if entry.IsDir() {
return filepath.SkipDir
}
Expand All @@ -147,7 +205,7 @@ func DiscoverTestFiles(includePattern, excludePattern string) ([]string, error)
return nil
}

if doublestar.MatchUnvalidated(normalizedIncludePattern, normalizedPath) {
if includeMatcher.MatchNormalizedPath(normalizedPath) {
testFiles = append(testFiles, normalizedPath)
}
return nil
Expand Down
67 changes: 67 additions & 0 deletions internal/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,73 @@ func TestResolveTestFilesWithoutExcludePattern(t *testing.T) {
}
}

func TestTestFileSetMatcherWithPattern(t *testing.T) {
matcher, err := NewTestFileSetMatcher(TestFileSet{Pattern: " ./spec/**/*_spec.rb "}, "")
if err != nil {
t.Fatalf("NewTestFileSetMatcher() returned error: %v", err)
}

if !matcher.Match("./spec/models/user_spec.rb") {
t.Fatal("expected matcher to match normalized path")
}
if !matcher.MatchNormalizedPath("spec/models/user_spec.rb") {
t.Fatal("expected matcher to match already-normalized path")
}
if matcher.MatchNormalizedPath("gems/lint_rules/spec/rule_spec.rb") {
t.Fatal("expected matcher not to match outside path")
}
}

func TestTestFileSetMatcherWithExplicitFiles(t *testing.T) {
matcher, err := NewTestFileSetMatcher(TestFileSet{
ExplicitFiles: []string{
"./spec/models/user_spec.rb",
"./spec/system/checkout_spec.rb",
},
}, "spec/system/**/*_spec.rb")
if err != nil {
t.Fatalf("NewTestFileSetMatcher() returned error: %v", err)
}

if !matcher.MatchNormalizedPath("spec/models/user_spec.rb") {
t.Fatal("expected matcher to match explicit file")
}
if matcher.MatchNormalizedPath("spec/system/checkout_spec.rb") {
t.Fatal("expected matcher not to match excluded explicit file")
}
}

func TestTestFileSetMatcherWithEmptyPatternMatchesAll(t *testing.T) {
matcher, err := NewTestFileSetMatcher(TestFileSet{}, "")
if err != nil {
t.Fatalf("NewTestFileSetMatcher() returned error: %v", err)
}

if !matcher.MatchNormalizedPath("spec/models/user_spec.rb") {
t.Fatal("expected empty pattern matcher to match all paths")
}
}

func TestTestFileSetMatcherWithExcludePattern(t *testing.T) {
matcher, err := NewTestFileSetMatcher(
TestFileSet{Pattern: "spec/**/*_spec.rb"},
"spec/system/**/*_spec.rb",
)
if err != nil {
t.Fatalf("NewTestFileSetMatcher() returned error: %v", err)
}

if !matcher.MatchNormalizedPath("spec/models/user_spec.rb") {
t.Fatal("expected matcher to match included path")
}
if matcher.MatchNormalizedPath("spec/system/checkout_spec.rb") {
t.Fatal("expected matcher not to match excluded path")
}
if matcher.MatchNormalizedPath("gems/lint_rules/spec/rule_spec.rb") {
t.Fatal("expected matcher not to match outside path")
}
}

func TestResolveTestFilesWithExcludePattern(t *testing.T) {
root := createDiscoveryFixture(t)
t.Chdir(root)
Expand Down
15 changes: 8 additions & 7 deletions internal/planner/discovered_tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ func (tp *TestPlanner) resetDiscoveryResults() {

func (tp *TestPlanner) recordFullDiscoveryResults(
discoveredTests []testoptimization.Test,
resolvedTestFiles discovery.TestFileSet,
skippableMatcher skippableMatcher,
) error {
excluder, err := discovery.NewExcluder(settings.GetTestsExcludePattern())
testFileMatcher, err := discovery.NewTestFileSetMatcher(resolvedTestFiles, settings.GetTestsExcludePattern())
if err != nil {
return err
}
Expand Down Expand Up @@ -54,10 +55,10 @@ func (tp *TestPlanner) recordFullDiscoveryResults(
for _, test := range discoveredTests {
normalizedSourceFile := utils.StripCwdSubdirPrefix(test.SuiteSourceFile)
normalizedSourceFile = utils.NormalizePath(normalizedSourceFile)
// Full discovery should already receive filtered files when exclude is configured.
// Keep this planner-side guard so normalized tracer-reported paths cannot re-enter
// the runnable file set or suite aggregates.
if normalizedSourceFile != "" && excluder.Match(normalizedSourceFile) {
// Full discovery receives the resolved test selection, but frameworks can still
// report extra tests loaded by process startup. Keep this planner-side guard so
// out-of-selection paths cannot enter the runnable file set or suite aggregates.
if normalizedSourceFile != "" && !testFileMatcher.MatchNormalizedPath(normalizedSourceFile) {
excludedTestsCount++
continue
}
Expand Down Expand Up @@ -161,14 +162,14 @@ func (tp *TestPlanner) recordAppliedSkippable(match skippableMatch) {
}

func (tp *TestPlanner) recordFastDiscoveryFallbackFiles(discoveredTestFiles []string) error {
excluder, err := discovery.NewExcluder(settings.GetTestsExcludePattern())
testFileMatcher, err := discovery.NewTestFileSetMatcher(discovery.TestFileSet{}, settings.GetTestsExcludePattern())
if err != nil {
return err
}

for _, testFile := range discoveredTestFiles {
normalizedTestFile := utils.NormalizePath(testFile)
if normalizedTestFile != "" && !excluder.Match(normalizedTestFile) {
if normalizedTestFile != "" && testFileMatcher.MatchNormalizedPath(normalizedTestFile) {
tp.testFiles[normalizedTestFile] = struct{}{}
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/planner/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ func (tp *TestPlanner) PreparePlanningData(ctx context.Context) error {
// backend data cancels it, use it even when TIA has no skips: full discovery
// is more precise than fast file discovery.
if fullDiscoverySucceeded {
if err := tp.recordFullDiscoveryResults(discoveredTests, skipMatcher); err != nil {
if err := tp.recordFullDiscoveryResults(discoveredTests, resolvedTestFiles, skipMatcher); err != nil {
return err
}
selectedDiscoveryMode = discoveryModeFull
Expand Down
Loading
Loading