// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.SelectTests;
using Xunit;
namespace Infrastructure.Tests.TestTriggerMap;
/// <summary>
/// End-to-end integration of <see cref="Selection.Run"/> with Layer 1 (the in-process
/// <see cref="GraphAffectedProjects"/> graph) enabled — the wiring the CLI tests deliberately skip
/// via <c>--skip-layer1</c>. Builds a real MSBuild graph (a production project + a test project that
/// references it) and asserts that a change to the production source flows through the graph closure,
/// is intersected with the slnx test-project universe, and lands in the enforce-mode
/// OverrideProjectToBuild props.
/// </summary>
[Collection("GraphAffectedProjects")] // MSBuildLocator registers process-wide; keep these serialized.
public sealed class SelectTestsLayer1IntegrationTests
{
private readonly ITestOutputHelper _outputHelper;
public SelectTestsLayer1IntegrationTests(ITestOutputHelper outputHelper)
{
_outputHelper = outputHelper;
GraphAffectedProjects.EnsureMSBuildRegistered();
}
// Failure mode: the selector wires Layer 1's affected set into the result incorrectly (drops it,
// fails to intersect with the matrix, or never reaches the props), so a production-only change
// silently runs no tests in enforce mode. A change to src/Core/Core.cs must reach Core.Tests
// (which references Core) via the reverse-dependency closure and be written to the props.
[Fact]
public void ProductionChangeFlowsThroughLayer1IntoEnforceProps()
{
using var workspace = TemporaryWorkspace.Create(_outputHelper);
using var fixture = new GraphRepoFixture(workspace);
var changed = fixture.WriteChangedFiles("src/Core/Core.cs");
var propsPath = System.IO.Path.Combine(fixture.Path, "BeforeBuildProps.props");
fixture.WithGitHubEnvRedirected(output =>
{
var exit = Selection.Run(new RunOptions(
RepoRoot: fixture.Path,
MapPath: System.IO.Path.Combine(fixture.Path, "map.yml"),
SlnxPath: System.IO.Path.Combine(fixture.Path, "Aspire.slnx"),
From: null,
To: null,
ChangedFilesPath: changed,
SkipLayer1: false,
ForceAll: false,
Enforce: true,
BeforeBuildProps: propsPath));
Assert.Equal(0, exit);
Assert.Equal(propsPath, output()["project_override_props"]);
Assert.Contains("tests/Core.Tests/Core.Tests.csproj", File.ReadAllText(propsPath));
});
}
// The merge-base rebind feeds BOTH layers: Layer 1's graph closure must diff from the branch point
// too, not the base tip. The existing CLI-level merge-base test runs with --skip-layer1, so nothing
// pins that Layer 1 also honors it. Diverge the history so the two diff bases disagree: the PR edits
// Core's source on a feature branch, and the base separately edits an INDEPENDENT project's source
// (Other) after the branch point. The PR's own change set is Core only, so Layer 1 must select
// Core.Tests and NOT Other.Tests. Failure mode: a regression that fed Layer 1 the base tip (or
// rebased only Layer 2) would attribute Other.Tests from base-branch churn the PR never touched.
[Fact]
public void Layer1DiffsFromMergeBaseNotBaseTip()
{
using var workspace = TemporaryWorkspace.Create(_outputHelper);
using var fixture = new GraphRepoFixture(workspace, withSecondProject: true);
fixture.InitGit();
fixture.CommitAll("base");
var baseSha = fixture.Git("rev-parse", "HEAD");
// The PR's own change: edit Core's source on a feature branch off the base.
fixture.Git("checkout", "-q", "-b", "feature");
fixture.WriteFile("src/Core/Core.cs", "namespace Core; public class C(ITestOutputHelper outputHelper) { public int X; }");
fixture.CommitAll("feature: change Core");
var featureSha = fixture.Git("rev-parse", "HEAD");
// Base-branch churn AFTER the branch point: edit Other's source on the advanced base. A
// base-tip..head diff would surface Other.cs (it differs across the two tips) and select
// Other.Tests; the merge-base..head diff excludes it.
fixture.Git("checkout", "-q", "-b", "advanced-base", baseSha);
fixture.WriteFile("src/Other/Other.cs", "namespace Other; public class O(ITestOutputHelper outputHelper) { public int Y; }");
fixture.CommitAll("base advances: change Other");
var advancedBaseSha = fixture.Git("rev-parse", "HEAD");
var propsPath = System.IO.Path.Combine(fixture.Path, "BeforeBuildProps.props");
fixture.WithGitHubEnvRedirected(output =>
{
var exit = Selection.Run(new RunOptions(
RepoRoot: fixture.Path,
MapPath: System.IO.Path.Combine(fixture.Path, "map.yml"),
SlnxPath: System.IO.Path.Combine(fixture.Path, "Aspire.slnx"),
From: advancedBaseSha,
To: featureSha,
ChangedFilesPath: null,
SkipLayer1: false,
ForceAll: false,
Enforce: true,
BeforeBuildProps: propsPath));
Assert.Equal(0, exit);
var props = File.ReadAllText(propsPath);
// Core.cs is the PR's own change -> Core.Tests selected via the reverse-dependency graph.
Assert.Contains("tests/Core.Tests/Core.Tests.csproj", props);
// Other.cs changed only on the advanced base AFTER the branch point, so the merge-base diff
// excludes it and Other.Tests is NOT selected. A regression to base-tip..head would add it.
Assert.DoesNotContain("tests/Other.Tests/Other.Tests.csproj", props);
});
}
// Failure mode: the step summary reports THAT Core.Tests was selected but not HOW, so a reviewer
// can't see the decision path. The Layer 1 cause must render the full chain -- seed file then the
// reverse-dependency project chain -- so a change to src/Core/Core.cs shows as
// "src/Core/Core.cs -> Core -> Core.Tests" in the summary.
[Fact]
public void Layer1SelectionRendersFullDecisionPathInSummary()
{
using var workspace = TemporaryWorkspace.Create(_outputHelper);
using var fixture = new GraphRepoFixture(workspace);
var changed = fixture.WriteChangedFiles("src/Core/Core.cs");
var propsPath = System.IO.Path.Combine(fixture.Path, "BeforeBuildProps.props");
fixture.WithGitHubEnvRedirected(output =>
{
var commentPath = System.IO.Path.Combine(fixture.Path, "comment.md");
var jsonPath = System.IO.Path.Combine(fixture.Path, "selection.json");
var previousComment = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
var previousJson = Environment.GetEnvironmentVariable("SELECT_TESTS_JSON_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", jsonPath);
try
{
var exit = Selection.Run(new RunOptions(
RepoRoot: fixture.Path,
MapPath: System.IO.Path.Combine(fixture.Path, "map.yml"),
SlnxPath: System.IO.Path.Combine(fixture.Path, "Aspire.slnx"),
From: null,
To: null,
ChangedFilesPath: changed,
SkipLayer1: false,
ForceAll: false,
Enforce: true,
BeforeBuildProps: propsPath));
Assert.Equal(0, exit);
// Summary carries the full chain; the terse PR comment groups the graph fan-out under
// the seed file heading instead of repeating the path per project.
var summary = File.ReadAllText(System.IO.Path.Combine(fixture.Path, "summary"));
Assert.Contains("src/Core/Core.cs → Core → Core.Tests", summary);
var comment = File.ReadAllText(commentPath);
Assert.Contains("`src/Core/Core.cs`", comment);
Assert.Contains("via the project graph", comment);
Assert.Contains("`Core.Tests`", comment);
// The JSON artifact preserves the decision path as a structured array.
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(jsonPath));
var cause = doc.RootElement.GetProperty("testProjects").EnumerateArray()
.Single(t => t.GetProperty("name").GetString() == "Core.Tests")
.GetProperty("causes").EnumerateArray().Single();
Assert.Equal("Layer1Graph", cause.GetProperty("kind").GetString());
var path = cause.GetProperty("path").EnumerateArray().Select(e => e.GetString()).ToArray();
Assert.Equal(new[] { "src/Core/Core.cs", "Core", "Core.Tests" }, path);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previousComment);
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", previousJson);
}
});
}
// Failure mode: the "(N hops)" annotation in the PR comment (MemberWithHops, path.Count - 2) is
// dropped or its math regresses, so a reviewer loses the near-vs-far dependency signal for
// graph-selected tests. With Core.Tests -> Mid -> Core, a change to src/Core/Core.cs reaches
// Core.Tests through two project edges, which must render as "(2 hops)".
[Fact]
public void MultiHopGraphSelectionAnnotatesHopCountInComment()
{
using var workspace = TemporaryWorkspace.Create(_outputHelper);
using var fixture = new GraphRepoFixture(workspace, withIntermediateProject: true);
var changed = fixture.WriteChangedFiles("src/Core/Core.cs");
var propsPath = System.IO.Path.Combine(fixture.Path, "BeforeBuildProps.props");
fixture.WithGitHubEnvRedirected(_ =>
{
var commentPath = System.IO.Path.Combine(fixture.Path, "comment.md");
var jsonPath = System.IO.Path.Combine(fixture.Path, "selection.json");
var previousComment = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
var previousJson = Environment.GetEnvironmentVariable("SELECT_TESTS_JSON_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", jsonPath);
try
{
var exit = Selection.Run(new RunOptions(
RepoRoot: fixture.Path,
MapPath: System.IO.Path.Combine(fixture.Path, "map.yml"),
SlnxPath: System.IO.Path.Combine(fixture.Path, "Aspire.slnx"),
From: null,
To: null,
ChangedFilesPath: changed,
SkipLayer1: false,
ForceAll: false,
Enforce: true,
BeforeBuildProps: propsPath));
Assert.Equal(0, exit);
// The structured path confirms the two-edge chain before we assert on the rendered text.
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(jsonPath));
var cause = doc.RootElement.GetProperty("testProjects").EnumerateArray()
.Single(t => t.GetProperty("name").GetString() == "Core.Tests")
.GetProperty("causes").EnumerateArray().Single();
var path = cause.GetProperty("path").EnumerateArray().Select(e => e.GetString()).ToArray();
Assert.Equal(new[] { "src/Core/Core.cs", "Core", "Mid", "Core.Tests" }, path);
var comment = File.ReadAllText(commentPath);
Assert.Contains("via the project graph", comment);
Assert.Contains("`Core.Tests` (2 hops)", comment);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previousComment);
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", previousJson);
}
});
}
/// <summary>
/// A temp repo with a real, buildable MSBuild graph: <c>src/Core</c> (production) and
/// <c>tests/Core.Tests</c> (a test project referencing it), plus an <c>Aspire.slnx</c> and an
/// empty <c>map.yml</c> (Layer 1 alone does the work here).
/// </summary>
private sealed class GraphRepoFixture : IDisposable
{
private readonly TemporaryWorkspace _workspace;
public string Path => _workspace.Path;
public GraphRepoFixture(TemporaryWorkspace workspace, bool withIntermediateProject = false, bool withSecondProject = false)
{
_workspace = workspace;
Write("Directory.Build.props", "<Project />");
Write("Directory.Build.targets", "<Project />");
Write("map.yml", "version: 1\n");
Write("src/Core/Core.cs", "namespace Core; public class C(ITestOutputHelper outputHelper) { }");
WriteProject("src/Core/Core.csproj", compiles: ["Core.cs"], references: []);
if (withIntermediateProject)
{
// A two-edge chain: Core.Tests -> Mid -> Core. A change to src/Core/Core.cs reaches
// Core.Tests through two project edges, so its Layer 1 path is
// [src/Core/Core.cs, Core, Mid, Core.Tests] (hops == 2) -- exactly what makes the PR
// comment render the "(N hops)" annotation. Only Core.Tests (under tests/) is in the
// test matrix; Mid is a production project that just lengthens the dependency path.
Write("src/Mid/Mid.cs", "namespace Mid; public class M(ITestOutputHelper outputHelper) { }");
WriteProject("src/Mid/Mid.csproj", compiles: ["Mid.cs"], references: [@"..\..\src\Core\Core.csproj"]);
Write("tests/Core.Tests/Core.Tests.cs", "namespace Core.Tests; public class T(ITestOutputHelper outputHelper) { }");
WriteProject("tests/Core.Tests/Core.Tests.csproj", compiles: ["Core.Tests.cs"], references: [@"..\..\src\Mid\Mid.csproj"]);
Write("Aspire.slnx",
"""
<Solution>
<Project Path="src/Core/Core.csproj" />
<Project Path="src/Mid/Mid.csproj" />
<Project Path="tests/Core.Tests/Core.Tests.csproj" />
</Solution>
""");
return;
}
Write("tests/Core.Tests/Core.Tests.cs", "namespace Core.Tests; public class T(ITestOutputHelper outputHelper) { }");
WriteProject("tests/Core.Tests/Core.Tests.csproj", compiles: ["Core.Tests.cs"], references: [@"..\..\src\Core\Core.csproj"]);
if (withSecondProject)
{
// A second, INDEPENDENT graph (Other.Tests -> Other) with no edge to Core. It exists so a
// diff can change Other's source in isolation: a base-tip..head diff that picked up that
// base-branch churn would select Other.Tests, while a merge-base diff (the PR's own
// change set) leaves Other.Tests out -- the discriminator the merge-base test asserts.
Write("src/Other/Other.cs", "namespace Other; public class O(ITestOutputHelper outputHelper) { }");
WriteProject("src/Other/Other.csproj", compiles: ["Other.cs"], references: []);
Write("tests/Other.Tests/Other.Tests.cs", "namespace Other.Tests; public class T(ITestOutputHelper outputHelper) { }");
WriteProject("tests/Other.Tests/Other.Tests.csproj", compiles: ["Other.Tests.cs"], references: [@"..\..\src\Other\Other.csproj"]);
Write("Aspire.slnx",
"""
<Solution>
<Project Path="src/Core/Core.csproj" />
<Project Path="tests/Core.Tests/Core.Tests.csproj" />
<Project Path="src/Other/Other.csproj" />
<Project Path="tests/Other.Tests/Other.Tests.csproj" />
</Solution>
""");
return;
}
Write("Aspire.slnx",
"""
<Solution>
<Project Path="src/Core/Core.csproj" />
<Project Path="tests/Core.Tests/Core.Tests.csproj" />
</Solution>
""");
}
public string WriteChangedFiles(params string[] paths)
{
var changed = System.IO.Path.Combine(_workspace.Path, "changed.txt");
File.WriteAllLines(changed, paths);
return changed;
}
// Git scaffolding for the tests that need real history (the merge-base diff path). Tests that
// feed a literal --changed-files list don't init a repo at all.
public void InitGit()
{
Git("init", "-q", "-b", "main");
Git("config", "user.email", "test@example.com");
Git("config", "user.name", "Test");
Git("config", "commit.gpgsign", "false");
}
public void CommitAll(string message)
{
Git("add", "-A");
Git("commit", "-q", "-m", message);
}
public string Git(params string[] args) => GitCli.Run(_workspace.Path, args);
public void WriteFile(string relativePath, string contents) => Write(relativePath, contents);
public void WithGitHubEnvRedirected(Action<Func<IReadOnlyDictionary<string, string>>> body)
{
var prevOutput = Environment.GetEnvironmentVariable("GITHUB_OUTPUT");
var prevSummary = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
try
{
var outputPath = System.IO.Path.Combine(_workspace.Path, "output");
Environment.SetEnvironmentVariable("GITHUB_OUTPUT", outputPath);
Environment.SetEnvironmentVariable("GITHUB_STEP_SUMMARY", System.IO.Path.Combine(_workspace.Path, "summary"));
IReadOnlyDictionary<string, string> ReadOutput()
{
var map = new Dictionary<string, string>(StringComparer.Ordinal);
if (File.Exists(outputPath))
{
foreach (var line in File.ReadAllLines(outputPath))
{
var eq = line.IndexOf('=', StringComparison.Ordinal);
if (eq >= 0)
{
map[line[..eq]] = line[(eq + 1)..];
}
}
}
return map;
}
body(ReadOutput);
}
finally
{
Environment.SetEnvironmentVariable("GITHUB_OUTPUT", prevOutput);
Environment.SetEnvironmentVariable("GITHUB_STEP_SUMMARY", prevSummary);
}
}
private void Write(string relativePath, string contents)
{
var fullPath = System.IO.Path.Combine(_workspace.Path, relativePath.Replace('\\', System.IO.Path.DirectorySeparatorChar));
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, contents);
}
private void WriteProject(string relativePath, string[] compiles, string[] references)
{
var items = string.Join(Environment.NewLine,
compiles.Select(c => $""" <Compile Include="{c}" Link="{System.IO.Path.GetFileName(c)}" />""")
.Concat(references.Select(r => $""" <ProjectReference Include="{r}" />""")));
Write(relativePath,
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
{items}
</ItemGroup>
</Project>
""");
}
public void Dispose() => _workspace.Dispose();
}
}