Skip to content

Commit 473eda2

Browse files
radicalCopilot
andcommitted
[EXPERIMENT] enumerate partition-mode split tests from source (skip closure compile)
The enumerate-tests step (~67s baseline, the second-biggest cost in setup_for_tests after restore) is dominated by compiling the dependency closures of the 4 SplitTestsOnCI projects, just to reflect their split metadata off the built assemblies. Aspire.Hosting.Tests is the worst: it references the hosting "god-edge" closure (~40 integration projects), so its closure compile is the bulk of the step. This recurs for any PR whose selection includes Aspire.Hosting.Tests, not only the full-matrix case. Partition-mode discovery only needs the [Trait("Partition","<n>")] values, which are compile-time string literals on the test classes. This change reads them straight from source via eng/scripts/scan-test-partitions-from-source.ps1, so the partition list is produced with no compile. build-test-matrix.ps1 already appends an "uncollected:*" entry (--filter-not-trait "Partition=*"), so any class the scan misses (or one with no trait) still runs. The scan writes .tests-partitions.json only when it finds partitions; class-mode projects (Aspire.Cli.EndToEnd.Tests, Aspire.Templates.Tests — no Partition traits in source) leave the file absent and fall back to the original Build + --list-tests path. GenerateTestPartitionsForCI is untouched, so the AzDO/Helix prepare path that calls it directly is unaffected. Verified locally: the scan emits collection:1..6 + uncollected:* for Aspire.Hosting.Tests (identical to the compiled enumeration's 7 entries) and writes nothing for the class-mode projects. Caveat (experiment): a partition value written as a non-literal (const/computed) would be missed by the source scan and, because it still carries a Partition trait, would not fall under uncollected:* — those tests would be dropped. All current Aspire.Hosting.Tests partitions are string literals, so this does not occur today; productionizing should add a CI parity check vs the compiled list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f05c3fd commit 473eda2

2 files changed

Lines changed: 146 additions & 2 deletions

File tree

eng/TestEnumerationRunsheetBuilder/TestEnumerationRunsheetBuilder.targets

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,39 @@
4242
<Target Name="RunTests"
4343
Outputs="%(TestToRun.ResultsStdOutPath)"
4444
Condition="'$(_ShouldSkipProject)' != 'true' and '$(IsGitHubActionsRunner)' == 'true' and '$(RunOnGithubActions)' == 'true'">
45-
<!-- Build and generate test partitions for split test projects -->
45+
<!-- [EXPERIMENT] Split-test partition discovery without compiling the closure.
46+
47+
For SplitTestsOnCI projects we first try to read [Trait("Partition","<n>")]
48+
literals straight from source (no build). Partition-mode projects such as
49+
Aspire.Hosting.Tests reference the whole hosting "god-edge" closure (~40
50+
integrations); building that closure just to reflect partition traits off
51+
the test DLL is the bulk of the enumerate-tests step. The trait values are
52+
compile-time string literals, so a source scan yields the identical set,
53+
and build-test-matrix.ps1 always appends an "uncollected:*" entry (which
54+
maps to filter-not-trait Partition=*) so any class the scan misses runs.
55+
56+
The scan writes the .tests-partitions.json only when it finds partitions.
57+
If none are found (class-mode projects like Aspire.Cli.EndToEnd.Tests /
58+
Aspire.Templates.Tests, which enumerate classes against the built assembly),
59+
the file is absent and we fall back to the original Build + class discovery.
60+
GenerateTestPartitionsForCI itself is untouched, so the AzDO/Helix prepare
61+
path that calls it directly is unaffected. -->
62+
<PropertyGroup Condition="'$(SplitTestsOnCI)' == 'true'">
63+
<_SourcePartitionScanScript>$(MSBuildThisFileDirectory)../scripts/scan-test-partitions-from-source.ps1</_SourcePartitionScanScript>
64+
<_SourcePartitionsJsonPath>$([MSBuild]::NormalizePath('$(RepoRoot)', '$(TestArchiveTestsDir)$(MSBuildProjectName).tests-partitions.json'))</_SourcePartitionsJsonPath>
65+
</PropertyGroup>
66+
67+
<Exec Condition="'$(SplitTestsOnCI)' == 'true'"
68+
Command="pwsh -NoProfile -ExecutionPolicy Bypass -File &quot;$(_SourcePartitionScanScript)&quot; -ProjectDirectory &quot;$(MSBuildProjectDirectory)&quot; -TestPartitionsJsonFile &quot;$(_SourcePartitionsJsonPath)&quot;"
69+
IgnoreExitCode="false"
70+
WorkingDirectory="$(RepoRoot)" />
71+
72+
<!-- Class-mode fallback (and any partition-mode project where the scan found nothing):
73+
build the project + its closure and discover via the original DLL-based path. -->
4674
<MSBuild Projects="$(MSBuildProjectFullPath)"
4775
Properties="GenerateCIPartitions=true;BuildOs=$(BuildOs)"
4876
Targets="Build;GenerateTestPartitionsForCI"
49-
Condition="'$(SplitTestsOnCI)' == 'true'" />
77+
Condition="'$(SplitTestsOnCI)' == 'true' and !Exists('$(_SourcePartitionsJsonPath)')" />
5078

5179
<!-- Write .tests-metadata.json file -->
5280
<PropertyGroup>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<#
2+
.SYNOPSIS
3+
Discovers test partitions for CI splitting by scanning SOURCE (.cs) files,
4+
without building the test project or its dependency closure.
5+
6+
.DESCRIPTION
7+
The compiled enumeration path (split-test-projects-for-ci.ps1 +
8+
ExtractTestPartitions) reads [Trait("Partition", "<name>")] attributes from
9+
the built test assembly. Building a partition-mode project such as
10+
Aspire.Hosting.Tests forces a compile of its entire ProjectReference closure
11+
(the hosting "god-edge": ~40 integrations), which dominates the
12+
enumerate-tests step on CI.
13+
14+
Partition trait values are plain string literals on the test classes, e.g.:
15+
16+
[Trait("Partition", "5")]
17+
public class WithUrlsTests { ... }
18+
19+
so they can be read directly from source with no compile. This script scans
20+
the project directory for those literals and emits the same
21+
.tests-partitions.json shape the compiled path produces:
22+
23+
{ "testPartitions": ["collection:1", ..., "collection:6", "uncollected:*"] }
24+
25+
build-test-matrix.ps1 turns each "collection:N" into
26+
--filter-trait "Partition=N" and "uncollected:*" into
27+
--filter-not-trait "Partition=*", so the "uncollected" entry runs every test
28+
that lacks a Partition trait. That backstop means a class missed by the scan
29+
(or one with no trait) is never dropped — it still runs under "uncollected".
30+
31+
Class-mode projects (no Partition traits in source) are NOT handled here: the
32+
caller falls back to the build + --list-tests path, because class lists have
33+
no equivalent always-runs backstop and must match the runtime exactly.
34+
35+
.PARAMETER ProjectDirectory
36+
Directory of the test project to scan (recursively) for partition traits.
37+
38+
.PARAMETER TestPartitionsJsonFile
39+
Path to write the .tests-partitions.json output file. Only written when at
40+
least one partition trait is found (so the caller can detect class mode by
41+
the file's absence).
42+
43+
.NOTES
44+
PowerShell 7+. Exit code is always 0; "no partitions found" is signalled by
45+
not writing the output file, not by failure.
46+
#>
47+
48+
[CmdletBinding()]
49+
param(
50+
[Parameter(Mandatory = $true)]
51+
[string]$ProjectDirectory,
52+
53+
[Parameter(Mandatory = $true)]
54+
[string]$TestPartitionsJsonFile
55+
)
56+
57+
$ErrorActionPreference = 'Stop'
58+
Set-StrictMode -Version Latest
59+
60+
if (-not (Test-Path $ProjectDirectory)) {
61+
Write-Error "ProjectDirectory not found: $ProjectDirectory"
62+
}
63+
64+
# Matches [Trait("Partition", "<value>")] allowing arbitrary inner whitespace.
65+
# Examples matched:
66+
# [Trait("Partition", "5")]
67+
# [Trait( "Partition" , "BasicTests" )]
68+
# The value is a string literal (xunit trait values are compile-time constants),
69+
# so a source scan captures every partition the compiled assembly would expose.
70+
$partitionRegex = [regex]'\[\s*Trait\s*\(\s*"Partition"\s*,\s*"([^"]+)"\s*\)\s*\]'
71+
72+
$partitions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
73+
74+
# Skip obj/bin so generated/copied sources don't introduce phantom partitions.
75+
$sourceFiles = Get-ChildItem -Path $ProjectDirectory -Recurse -File -Filter '*.cs' |
76+
Where-Object { $_.FullName -notmatch '[\\/](obj|bin)[\\/]' }
77+
78+
foreach ($file in $sourceFiles) {
79+
$content = Get-Content -Raw -LiteralPath $file.FullName
80+
foreach ($m in $partitionRegex.Matches($content)) {
81+
$value = $m.Groups[1].Value.Trim()
82+
if (-not [string]::IsNullOrWhiteSpace($value)) {
83+
[void]$partitions.Add($value)
84+
}
85+
}
86+
}
87+
88+
if ($partitions.Count -eq 0) {
89+
Write-Host "No [Trait(`"Partition`", ...)] attributes found in source under '$ProjectDirectory'. Falling back to class-mode (build + --list-tests)."
90+
# Remove any stale output so the caller's !Exists() class-mode fallback is not
91+
# fooled by a partitions file left over from a previous build.
92+
if (Test-Path $TestPartitionsJsonFile) {
93+
Remove-Item -LiteralPath $TestPartitionsJsonFile -Force -ErrorAction SilentlyContinue
94+
}
95+
exit 0
96+
}
97+
98+
$lines = [System.Collections.Generic.List[string]]::new()
99+
foreach ($p in ($partitions | Sort-Object)) {
100+
$lines.Add("collection:$p")
101+
}
102+
# Always include the uncollected backstop so tests without a Partition trait still run.
103+
$lines.Add("uncollected:*")
104+
105+
$outputDir = Split-Path -Parent $TestPartitionsJsonFile
106+
if (-not [string]::IsNullOrEmpty($outputDir) -and -not (Test-Path $outputDir)) {
107+
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
108+
}
109+
110+
$testPartitionsJson = @{}
111+
$testPartitionsJson | Add-Member -Force -MemberType NoteProperty -Name 'testPartitions' -Value @($lines)
112+
$testPartitionsJson | ConvertTo-Json -Depth 20 | Set-Content -Path $TestPartitionsJsonFile -Encoding UTF8
113+
114+
Write-Host "Source partition scan found $($partitions.Count) partition(s): $(( $partitions | Sort-Object ) -join ', ')"
115+
Write-Host "Wrote: $TestPartitionsJsonFile"
116+
exit 0

0 commit comments

Comments
 (0)