diff --git a/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs b/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs index 57f0115ea7..4e365485af 100644 --- a/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs +++ b/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs @@ -41,35 +41,34 @@ public async Task> CollectTestsAsync(string testSessio // Extract hints from filter for pre-filtering test sources by type var filterHints = MetadataFilterMatcher.ExtractFilterHints(filter); - // Get test sources, optionally pre-filtered by type - IEnumerable>> testSourcesByType = Sources.TestSources; - - if (filterHints.HasHints) - { - // Pre-filter test sources by type based on filter hints - var matchingSources = testSourcesByType.Where(kvp => filterHints.CouldTypeMatch(kvp.Key)).ToList(); - - // Expand to include sources for dependency classes - testSourcesByType = ExpandSourcesForDependencies(matchingSources, Sources.TestSources); - } - - var testSourcesList = testSourcesByType.SelectMany(kvp => kvp.Value).ToList(); - - // Try two-phase discovery for sources that support it (with specific filter hints) - // This avoids creating full TestMetadata for tests that won't pass filtering + // Try two-phase discovery with single-pass filtering when all sources support descriptors. + // This avoids double-enumeration: previously ExpandSourcesForDependencies enumerated + // descriptors to find dependencies, then CollectTestsWithTwoPhaseDiscoveryAsync enumerated + // them again. Now we pass ALL sources and do type-level + descriptor-level filtering + // in a single pass, while indexing everything for dependency resolution. IEnumerable standardTestMetadatas; - if (filterHints.HasHints && testSourcesList.All(static s => s is ITestDescriptorSource)) + if (filterHints.HasHints && Sources.TestSources.All(static kvp => kvp.Value.All(static s => s is ITestDescriptorSource))) { - // Two-phase discovery: enumerate descriptors, filter, then materialize only matching + // Single-pass two-phase discovery: enumerate all descriptors once, + // apply type and descriptor filters during enumeration, expand dependencies from index standardTestMetadatas = await CollectTestsWithTwoPhaseDiscoveryAsync( - testSourcesList.Cast(), + Sources.TestSources, testSessionId, filterHints).ConfigureAwait(false); } else { // Fallback: Use traditional collection (for legacy sources or no filter hints) + // Apply type-level pre-filtering when hints are available + IEnumerable>> testSourcesByType = Sources.TestSources; + + if (filterHints.HasHints) + { + testSourcesByType = testSourcesByType.Where(kvp => filterHints.CouldTypeMatch(kvp.Key)); + } + + var testSourcesList = testSourcesByType.SelectMany(kvp => kvp.Value).ToList(); standardTestMetadatas = await CollectTestsTraditionalAsync(testSourcesList, testSessionId).ConfigureAwait(false); } @@ -84,149 +83,70 @@ public async Task> CollectTestsAsync(string testSessio } /// - /// Expands the pre-filtered sources to include sources for dependency classes. - /// This ensures cross-class dependencies are included in two-phase discovery. - /// - private static IEnumerable>> ExpandSourcesForDependencies( - List>> matchingSources, - ConcurrentDictionary> allSources) - { - // Build index of all sources by class name for dependency lookup - var sourcesByClassName = new Dictionary>>(); - foreach (var kvp in allSources) - { - sourcesByClassName[kvp.Key.Name] = kvp; - // Also index without generic suffix (e.g., "MyClass`1" -> "MyClass") - var backtickIndex = kvp.Key.Name.IndexOf('`'); - if (backtickIndex > 0) - { - sourcesByClassName[kvp.Key.Name.Substring(0, backtickIndex)] = kvp; - } - } - - // Collect all dependency class names from matching sources - var dependencyClassNames = new HashSet(); - foreach (var kvp in matchingSources) - { - foreach (var source in kvp.Value) - { - if (source is ITestDescriptorSource descriptorSource) - { - foreach (var descriptor in descriptorSource.EnumerateTestDescriptors()) - { - foreach (var dependency in descriptor.DependsOn) - { - // Parse dependency format: "ClassName:MethodName" - var separatorIndex = dependency.IndexOf(':'); - if (separatorIndex > 0) // Cross-class dependency (not same-class ":MethodName") - { - var depClassName = dependency.Substring(0, separatorIndex); - dependencyClassNames.Add(depClassName); - } - } - } - } - } - } - - // Build result set starting with matching sources - var resultSet = new Dictionary>>(); - foreach (var kvp in matchingSources) - { - resultSet[kvp.Key] = kvp; - } - - // Expand dependencies transitively - var queue = new Queue(dependencyClassNames); - var processedClasses = new HashSet(); - - while (queue.Count > 0) - { - var className = queue.Dequeue(); - if (!processedClasses.Add(className)) - { - continue; - } - - if (sourcesByClassName.TryGetValue(className, out var depSource) && !resultSet.ContainsKey(depSource.Key)) - { - resultSet[depSource.Key] = depSource; - - // Check for transitive dependencies - foreach (var source in depSource.Value) - { - if (source is ITestDescriptorSource descriptorSource) - { - foreach (var descriptor in descriptorSource.EnumerateTestDescriptors()) - { - foreach (var dependency in descriptor.DependsOn) - { - var separatorIndex = dependency.IndexOf(':'); - if (separatorIndex > 0) - { - var transDepClassName = dependency.Substring(0, separatorIndex); - if (!processedClasses.Contains(transDepClassName)) - { - queue.Enqueue(transDepClassName); - } - } - } - } - } - } - } - } - - return resultSet.Values; - } - - /// - /// Two-phase discovery: enumerate lightweight descriptors, apply filter hints, materialize only matching. - /// This is more efficient when filters are present as it avoids creating full TestMetadata for non-matching tests. + /// Two-phase discovery with single-pass filtering and dependency resolution. + /// Accepts ALL test sources (with their associated types) and performs type-level + /// and descriptor-level filtering in a single enumeration pass, while indexing + /// all descriptors for dependency resolution. + /// + /// This avoids the previous double-enumeration where ExpandSourcesForDependencies + /// enumerated descriptors to find dependencies, and then this method enumerated + /// them again for filtering and materialization. /// private async Task> CollectTestsWithTwoPhaseDiscoveryAsync( - IEnumerable descriptorSources, + IEnumerable>> allSourcesByType, string testSessionId, FilterHints filterHints) { - // Phase 1: Single-pass enumeration with filtering - // - Index all descriptors for dependency resolution - // - Immediately identify matching descriptors (no separate iteration) + // Phase 1: Single-pass enumeration over ALL sources with combined filtering + // - Index ALL descriptors (from all types) for dependency resolution + // - Apply type-level filter (assembly, namespace, class) per source group + // - Apply descriptor-level filter (class name, method name) per descriptor + // - Only descriptors passing BOTH filters are added to matchingDescriptors // - Track if any matching descriptor has dependencies var descriptorsByClassAndMethod = new Dictionary<(string ClassName, string MethodName), TestDescriptor>(); var descriptorsByClass = new Dictionary>(); var matchingDescriptors = new List(); var hasDependencies = false; - foreach (var source in descriptorSources) + foreach (var kvp in allSourcesByType) { - foreach (var descriptor in source.EnumerateTestDescriptors()) + // Check type-level filter once per source group (covers assembly, namespace, class name) + var typeMatches = filterHints.CouldTypeMatch(kvp.Key); + + foreach (var source in kvp.Value) { - // Index by class + method for specific dependency lookups - var key = (descriptor.ClassName, descriptor.MethodName); - descriptorsByClassAndMethod[key] = descriptor; + var descriptorSource = (ITestDescriptorSource)source; - // Index by class for class-level dependency lookups - if (!descriptorsByClass.TryGetValue(descriptor.ClassName, out var classDescriptors)) + foreach (var descriptor in descriptorSource.EnumerateTestDescriptors()) { - classDescriptors = []; - descriptorsByClass[descriptor.ClassName] = classDescriptors; - } - classDescriptors.Add(descriptor); + // Always index for dependency resolution regardless of filter match + var key = (descriptor.ClassName, descriptor.MethodName); + descriptorsByClassAndMethod[key] = descriptor; - // Filter during enumeration - no separate pass needed - if (filterHints.CouldDescriptorMatch(descriptor)) - { - matchingDescriptors.Add(descriptor); - if (descriptor.DependsOn.Length > 0) + if (!descriptorsByClass.TryGetValue(descriptor.ClassName, out var classDescriptors)) + { + classDescriptors = []; + descriptorsByClass[descriptor.ClassName] = classDescriptors; + } + classDescriptors.Add(descriptor); + + // Only add to matching set if both type-level and descriptor-level filters pass + if (typeMatches && filterHints.CouldDescriptorMatch(descriptor)) { - hasDependencies = true; + matchingDescriptors.Add(descriptor); + if (descriptor.DependsOn.Length > 0) + { + hasDependencies = true; + } } } } } - // Phase 2: Expand dependencies only if any matching descriptor has them + // Phase 2: Expand dependencies only if any matching descriptor has them. + // Because all descriptors are indexed (not just filtered ones), cross-class + // and transitive dependencies are resolved correctly even when the dependency + // target was filtered out by type/descriptor hints. HashSet? expandedSet = null; if (hasDependencies) {