// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json;
using Xunit;
namespace Infrastructure.Tests.TestTriggerMap;
/// <summary>
/// Tests for the SelectTests CLI wiring (<see cref="Selection.Run"/>) — the argument handling and
/// side-channel outputs (<c>$GITHUB_OUTPUT</c> / <c>$GITHUB_STEP_SUMMARY</c>) that surround the
/// engine, as opposed to <see cref="Aspire.SelectTests.TestSelector"/> itself (covered by
/// <see cref="SelectTestsAcceptanceTests"/>). This boundary is the sole gate in enforce mode, so the
/// <c>run_*</c> job booleans, the audit-vs-enforce matrix contract, change resolution, and the
/// degenerate "select nothing" path are the failure modes worth pinning before flipping the
/// <c>select-tests</c> action's <c>enforce</c> input in <c>tests.yml</c>.
/// </summary>
// Shares the collection with the other classes that mutate the process-wide GITHUB_OUTPUT /
// GITHUB_STEP_SUMMARY env vars (and the MSBuildLocator registration), so they never run concurrently
// and clobber each other's side-channel files.
[Collection("GraphAffectedProjects")]
public sealed class SelectTestsCliTests
{
// A hermetic Aspire.slnx: two test projects (the universe) plus a fixture project that must be
// excluded (no .Tests suffix). Only the text is parsed; the .csproj files need not exist.
private const string Slnx = """
<Solution>
<Project Path="tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj" />
<Project Path="tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj" />
<Project Path="tests/Aspire.TestUtilities/Aspire.TestUtilities.csproj" />
</Solution>
""";
// A synthetic map that carries job: targets in three shapes — referenced directly by a path
// rule, only via a group, and only via a derived rule — so the run_* contract can be exercised
// without coupling to the real eng/github-ci/test-trigger-map.yml. Token -> run_* name:
// job:extension-e2e -> run_extension_e2e (direct; also pins the '-' -> '_' mapping)
// job:group-job -> run_group_job (reachable ONLY through GROUP_ONLY_JOB)
// job:derived-only-job -> run_derived_only_job (reachable ONLY through a derived_targets rule)
private const string Map = """
version: 1
groups:
GROUP_ONLY_JOB: [job:group-job]
path_rules:
- paths: [trigger.txt]
targets: ["test:Aspire.Hosting.Tests", "job:extension-e2e"]
- paths: [other.txt]
targets: ["test:Aspire.Cli.Tests"]
- paths: [all.txt]
targets: [ALL]
- paths: [grp.txt]
targets: [GROUP_ONLY_JOB]
- paths: [prod.txt]
targets: ["test:Aspire.Hosting", "test:Aspire.Hosting.Tests"]
derived_targets:
- tests: [test:Aspire.Cli.Tests]
targets: [job:derived-only-job]
""";
// Regression: --force-all means "run everything regardless of the diff", so it must NOT require a
// --from/--changed-files input. Run() previously resolved changed files *before* the force-all
// short-circuit and threw "Provide either --changed-files or --from"; the CI step then swallowed
// that non-zero exit, silently masking a broken selector. This drives the exact path the workflow
// takes on the run-full-ci label kill switch (force-all, no diff base). Because the selection is ALL, no
// restriction props are written even under --enforce, so enumerate-tests runs the full matrix.
[Fact]
public void ForceAllWithoutDiffInputsWritesNoRestrictionProps()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var exitCode = Selection.Run(Options(repoRoot, propsPath, forceAll: true, enforce: true));
Assert.Equal(0, exitCode);
Assert.False(File.Exists(propsPath));
Assert.Equal("", output()["project_override_props"]);
});
}
// The --slnx option points the selector at a solution outside the default <repo-root>/Aspire.slnx.
// Failure mode: if SlnxPath were ignored and the tool fell back to <repo-root>/Aspire.slnx, the
// universe would be read from the wrong (here: absent) file and LoadTestProjects would throw, so a
// change that maps to a test project would never reach the enforce props. Pins that a custom
// solution path is honored end-to-end (so the select-tests action's `slnx` input is real).
[Fact]
public void CustomSlnxPathIsHonored()
{
var dir = Directory.CreateTempSubdirectory("selecttests-slnx");
try
{
// Deliberately NOT at <repo-root>/Aspire.slnx -- the default lookup would mask the option.
var slnxPath = Path.Combine(dir.FullName, "custom", "MyApp.slnx");
Directory.CreateDirectory(Path.GetDirectoryName(slnxPath)!);
File.WriteAllText(slnxPath, Slnx);
File.WriteAllText(Path.Combine(dir.FullName, "map.yml"), Map);
WithGitHubEnv(dir.FullName, _ =>
{
var propsPath = Path.Combine(dir.FullName, "BeforeBuildProps.props");
var changed = WriteChangedFiles(dir.FullName, "trigger.txt");
var exit = Selection.Run(Options(
dir.FullName, propsPath, changedFilesPath: changed,
skipLayer1: true, enforce: true, slnxPath: slnxPath));
Assert.Equal(0, exit);
Assert.Contains("Aspire.Hosting.Tests", File.ReadAllText(propsPath));
});
}
finally
{
Directory.Delete(dir.FullName, recursive: true);
}
}
// Enforce + a non-ALL selection writes the OverrideProjectToBuild props for exactly the selected
// test projects (mapped to their Aspire.slnx paths), and reports the props path so the workflow
// can pass /p:BeforeBuildPropsPath to enumerate-tests.
[Fact]
public void EnforceWritesOverridePropsForSelectedSubset()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
var exitCode = Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
Assert.Equal(0, exitCode);
Assert.Equal(propsPath, output()["project_override_props"]);
// A non-empty selection enumerates its subset, so the .NET matrix is built.
Assert.Equal("true", output()["has_dotnet_tests"]);
var props = File.ReadAllText(propsPath);
Assert.Contains("<OverrideProjectToBuild Include=\"$(RepoRoot)tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj\" />", props);
Assert.DoesNotContain("Aspire.Cli.Tests", props);
});
}
// The PR comment (SELECT_TESTS_COMMENT_FILE) is the terse, scannable view: a "## Tests
// selector" heading, the selected test projects, and the selected jobs -- and none of the
// step-summary audit detail (options, changed files, would-have-skipped). Pin that so the comment
// stays reader-friendly, and that enforcing mode omits the "(audit mode)" qualifier.
[Fact]
public void WritesConciseSelectionComment()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var commentPath = Path.Combine(repoRoot, "comment.md");
var previous = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
try
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var comment = File.ReadAllText(commentPath);
Assert.StartsWith("## Tests selector", comment);
Assert.DoesNotContain("audit mode", comment);
Assert.Contains("### Selected test projects (1 / 2)", comment);
Assert.Contains("`Aspire.Hosting.Tests`", comment);
Assert.Contains("### Selected jobs (1)", comment);
Assert.Contains("`extension-e2e`", comment);
// Test projects are the primary signal, so their section must come BEFORE the jobs
// section. Falsifies a revert to the old jobs-first ordering.
Assert.True(
comment.IndexOf("### Selected test projects", StringComparison.Ordinal)
< comment.IndexOf("### Selected jobs", StringComparison.Ordinal),
"Selected test projects must be listed before Selected jobs.");
// The rationale is collapsed by default behind a <details> so the comment leads with
// what runs; the heading is the <summary>. Falsifies a revert to a plain "### How these
// were chosen" heading that is always expanded.
Assert.Contains("<summary>How these were chosen — grouped by what changed</summary>", comment);
Assert.DoesNotContain("### How these were chosen", comment);
// The job-reasons table attributes each selected job to what triggered it
// (extension-e2e <- trigger.txt). Falsifies a revert that drops the table.
Assert.Contains("| Job | Triggered by |", comment);
Assert.Contains("| `extension-e2e` | `trigger.txt` |", comment);
Assert.DoesNotContain("### Options", comment);
Assert.DoesNotContain("Changed files", comment);
Assert.DoesNotContain("Would have been", comment);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previous);
}
});
}
// The PR comment attributes EVERY cause that selected an item — no truncation, no "(+N more)"
// tail. A reviewer must see exactly which changed files / edges pulled each test in. Here both
// trigger.txt and prod.txt route to Aspire.Hosting.Tests, so the grouped "how chosen" section must
// show both files as triggers (the project appears under each). Failure mode: dropping a trigger
// hides why a test was selected.
[Fact]
public void CommentListsEveryCauseWithoutTruncation()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var commentPath = Path.Combine(repoRoot, "comment.md");
var previous = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
try
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt", "prod.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var comment = File.ReadAllText(commentPath);
// Each changed file is its own group heading, and the "directly" bucket under it lists
// exactly the projects that file pulled in. Asserting the grouped (by-trigger) shape --
// file heading + "→ **N** directly: ..." -- falsifies a revert to the flat per-project
// rendering, which had no such per-file buckets. prod.txt selects both projects;
// trigger.txt selects only the test project.
Assert.Contains("`prod.txt`** *(changed)*", comment);
Assert.Contains("→ **2** directly: `Aspire.Hosting`, `Aspire.Hosting.Tests`", comment);
Assert.Contains("`trigger.txt`** *(changed)*", comment);
Assert.Contains("→ **1** directly: `Aspire.Hosting.Tests`", comment);
Assert.DoesNotContain("more)", comment);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previous);
}
});
}
// A job pulled in by SEVERAL independent triggers must render each as its OWN bulleted line, not
// comma-joined: a comma between e.g. "affected project X" and a selected-test reason reads as a
// single causal chain when they are unrelated. Here job:multi is hit by a path rule (a.txt) AND a
// derived_targets pull from the selected test Aspire.Cli.Tests; the cell must bullet the two and
// name the trigger as "selected test" (a noun, parallel to "affected project"). Failure mode:
// regressing to comma-joining makes "affected project X, selected test Y" look like one chain.
[Fact]
public void JobWithIndependentCausesRendersEachReasonOnItsOwnLine()
{
const string slnx = """
<Solution>
<Project Path="tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj" />
</Solution>
""";
const string map = """
version: 1
path_rules:
- paths: [a.txt]
targets: ["job:multi"]
- paths: [b.txt]
targets: ["test:Aspire.Cli.Tests"]
derived_targets:
- tests: [test:Aspire.Cli.Tests]
targets: [job:multi]
""";
RunInTempRepo((repoRoot, propsPath, _) =>
{
var commentPath = Path.Combine(repoRoot, "comment.md");
var previous = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
try
{
var changed = WriteChangedFiles(repoRoot, "a.txt", "b.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var comment = File.ReadAllText(commentPath);
// The two independent triggers are bulleted on separate lines (<br>), and the derived
// pull reads "selected test" -- pinning the bullets, the separation, and the wording in
// one exact cell match.
Assert.Contains("| `multi` | • `a.txt`<br>• selected test `Aspire.Cli.Tests` |", comment);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previous);
}
}, slnx: slnx, map: map);
}
// The headline call-out (⚠️ "N of the M ... come from a single change") and the <details> collapse
// in RenderProjectList are both threshold-gated (headline: tests >= 10 && largest group >= 5;
// collapse: inline limit of 12). A single change that fans out to many projects must trip both.
// Failure mode: a refactor silently drops the headline or stops collapsing large buckets, making
// big selections unreadable again — the very problem this comment layout exists to solve.
[Fact]
public void LargeFanOutEmitsHeadlineAndCollapsesProjectList()
{
var projects = Enumerable.Range(1, 14).Select(i => $"Aspire.Pkg{i:00}.Tests").ToList();
var slnx = "<Solution>\n"
+ string.Join("\n", projects.Select(p => $" <Project Path=\"tests/{p}/{p}.csproj\" />"))
+ "\n</Solution>";
// One path rule maps a single changed file to all 14 test projects, so they land in one group.
var map = "version: 1\npath_rules:\n - paths: [big.txt]\n targets: ["
+ string.Join(", ", projects.Select(p => $"\"test:{p}\""))
+ "]\n";
RunInTempRepo((repoRoot, propsPath, _) =>
{
var commentPath = Path.Combine(repoRoot, "comment.md");
var previous = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
try
{
var changed = WriteChangedFiles(repoRoot, "big.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var comment = File.ReadAllText(commentPath);
// Headline names the count and the single change that drove it.
Assert.Contains("⚠️ 14 of the 14 selected test projects come from a single change — `big.txt`", comment);
// The 14-project bucket (over the inline limit of 12) collapses into a <details>.
Assert.Contains("<details><summary>show 14</summary>", comment);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previous);
}
}, slnx: slnx, map: map);
}
// In audit mode the comment is advisory: the full matrix and all jobs still run, and the lists
// describe what selective CI WOULD run under enforcement. Pin the "(audit mode)" title qualifier
// and the explanatory line so the advisory framing can't silently disappear — without it a reader
// could mistake the selected subset for what actually ran.
[Fact]
public void AuditCommentMarksSelectionAsAdvisory()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var commentPath = Path.Combine(repoRoot, "comment.md");
var previous = Environment.GetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", commentPath);
try
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: false));
var comment = File.ReadAllText(commentPath);
Assert.StartsWith("## Tests selector (audit mode)", comment);
Assert.Contains("**would**", comment);
Assert.Contains("under enforcement", comment);
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previous);
}
});
}
// The JSON selection artifact (SELECT_TESTS_JSON_FILE) is the durable, machine-readable record of a
// selection: mode, inputs (to reproduce), and EVERY selected test/job with its per-item causes. It's
// what a maintainer downloads weeks later to see why something ran, without re-running CI. Pin the
// schema essentials and that per-item causes (with the triggering file) survive serialization.
[Fact]
public void WritesSelectionJsonArtifactWithPerItemCauses()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var jsonPath = Path.Combine(repoRoot, "selection.json");
var previous = Environment.GetEnvironmentVariable("SELECT_TESTS_JSON_FILE");
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", jsonPath);
try
{
// trigger.txt -> test:Aspire.Hosting.Tests + job:extension-e2e (both via the same path rule).
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
using var doc = JsonDocument.Parse(File.ReadAllText(jsonPath));
var root = doc.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("enforcing", root.GetProperty("mode").GetString());
Assert.False(root.GetProperty("selectsAll").GetBoolean());
Assert.Equal($"changed-files {changed}", root.GetProperty("inputs").GetProperty("changeSource").GetString());
var test = root.GetProperty("testProjects").EnumerateArray()
.Single(t => t.GetProperty("name").GetString() == "Aspire.Hosting.Tests");
var testCause = test.GetProperty("causes").EnumerateArray().Single();
Assert.Equal("PathRule", testCause.GetProperty("kind").GetString());
Assert.Equal("trigger.txt", testCause.GetProperty("trigger").GetString());
var job = root.GetProperty("jobs").EnumerateArray()
.Single(j => j.GetProperty("name").GetString() == "job:extension-e2e");
Assert.Equal("PathRule", job.GetProperty("causes").EnumerateArray().Single().GetProperty("kind").GetString());
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", previous);
}
});
}
// Crash traceability hardening: the diagnostics writer must be best-effort. If the step summary
// path is unwritable (the exact scenario where diagnostics matter), writing the block must NOT throw
// a NEW exception that masks the ORIGINAL failure. The original (FileNotFoundException from the
// missing --changed-files) must still propagate. Failure mode (before the fix): File.AppendAllText
// throws DirectoryNotFoundException from the catch handler, replacing the real root cause.
[Fact]
public void FailureDiagnosticsWriteFailureDoesNotMaskOriginalException()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var missing = Path.Combine(repoRoot, "does-not-exist.txt");
// A summary path under a directory that does not exist -> File.AppendAllText throws.
var unwritableSummary = Path.Combine(repoRoot, "no", "such", "dir", "summary.md");
var previousSummary = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
Environment.SetEnvironmentVariable("GITHUB_STEP_SUMMARY", unwritableSummary);
try
{
// The ORIGINAL failure (missing changed-files) must surface, not a DirectoryNotFoundException
// about the summary path. FileNotFoundException : IOException; DirectoryNotFoundException
// also : IOException, so assert the concrete original type.
Assert.Throws<FileNotFoundException>(() =>
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: missing, skipLayer1: true, enforce: true)));
}
finally
{
Environment.SetEnvironmentVariable("GITHUB_STEP_SUMMARY", previousSummary);
}
});
}
// Traceability: when the Layer 1 graph computation crashes, the wrapper must PRESERVE the original
// exception as InnerException so the diagnostics' stack trace points at where MSBuild actually failed,
// not at Layer1Failed. The hermetic slnx references .csproj files that don't exist on disk, so
// building the ProjectGraph (skipLayer1:false) fails deterministically. Failure mode (before the fix):
// Layer1Failed(ex.Message) discards the original exception, so InnerException is null and the real
// crash location is lost.
[Fact]
public void Layer1FailurePreservesOriginalExceptionAsInner()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var changed = WriteChangedFiles(repoRoot, "src/whatever/File.cs");
var ex = Assert.Throws<InvalidOperationException>(() =>
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: false, enforce: true)));
Assert.Contains("Layer 1", ex.Message, StringComparison.Ordinal);
Assert.NotNull(ex.InnerException);
});
}
// Audit mode (no --enforce) writes the run_* booleans and the summary but no restriction props,
// so enumerate-tests enumerates the full matrix unchanged even when a subset was selected.
[Fact]
public void AuditWritesNoRestrictionProps()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
var exitCode = Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: false));
Assert.Equal(0, exitCode);
Assert.False(File.Exists(propsPath));
Assert.Equal("", output()["project_override_props"]);
});
}
// P0-1. Audit mode forces every run_* boolean to true even when the computed selection is a
// strict subset, because enumerate-tests still runs the FULL matrix in audit — gating a non-.NET
// job off while running every .NET test would be an inconsistent, partial audit run.
[Fact]
public void AuditForcesEveryJobBooleanTrue()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
// trigger.txt selects only Aspire.Hosting.Tests + job:extension-e2e; group-job and
// derived-only-job are NOT selected. Audit must still report them all true.
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: false));
var o = output();
Assert.Equal("true", o["run_extension_e2e"]);
Assert.Equal("true", o["run_group_job"]);
Assert.Equal("true", o["run_derived_only_job"]);
});
}
// P0-2. Enforce emits the real per-job value for each job, and maps the job: token to its run_*
// name (strip "job:", '-' -> '_'). A mistranslated name never matches its if: in tests.yml (the
// job silently never runs); an unselected job must be 'false', not unset.
[Fact]
public void EnforceEmitsPerJobBooleansWithNameMapping()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
// trigger.txt selects job:extension-e2e only.
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var o = output();
Assert.Equal("true", o["run_extension_e2e"]);
Assert.Equal("false", o["run_group_job"]);
Assert.Equal("false", o["run_derived_only_job"]);
});
}
// P0-3. Every job the map can ever emit appears as a run_* key, regardless of selection — even a
// job reachable ONLY through a group (group-job) or ONLY through a derived rule (derived-only-job).
// A job omitted from AllJobTokens() would have its if: read an empty string and silently never run.
// (output() flattens the `selection` JSON back to run_* keys; see ReadOutput.)
[Fact]
public void EveryMapJobAppearsAsRunBoolean()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var keys = output().Keys;
Assert.Contains("run_extension_e2e", keys);
Assert.Contains("run_group_job", keys);
Assert.Contains("run_derived_only_job", keys);
});
}
// P0-3b. The job gates ship as ONE `selection` output holding a JSON object of real booleans, not
// one output per job. tests.yml consumes it via fromJSON(...).run_<job>, so a regression to flat
// run_* outputs (or to string values) would break every non-.NET job's if:. Asserts the raw output
// shape directly rather than through the flattening helper.
[Fact]
public void JobGatesEmittedAsSingleSelectionJsonObject()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var changed = WriteChangedFiles(repoRoot, "trigger.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
// Assert on the raw `selection` output directly (not the run_* keys the helper also
// flattens out of it): the on-the-wire contract is a single `selection` key whose value
// parses as a JSON object of booleans.
var raw = output();
Assert.Contains("selection", raw.Keys);
using var doc = JsonDocument.Parse(raw["selection"]);
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
// trigger.txt selects job:extension-e2e only -> true; group/derived jobs -> false.
Assert.True(doc.RootElement.GetProperty("run_extension_e2e").GetBoolean());
Assert.False(doc.RootElement.GetProperty("run_group_job").GetBoolean());
});
}
// P0-4. An ALL selection from a path rule (not just --force-all) must escalate to the full matrix:
// no restriction props, empty project_override_props, and every run_* true — even under --enforce.
// The failure mode is an ALL escalation being filtered down to whatever was otherwise selected,
// under-running on a run-everything trigger.
[Fact]
public void EnforceWithAllPathRuleRunsEverything()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var changed = WriteChangedFiles(repoRoot, "all.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var o = output();
Assert.False(File.Exists(propsPath));
Assert.Equal("", o["project_override_props"]);
Assert.Equal("true", o["run_extension_e2e"]);
Assert.Equal("true", o["run_group_job"]);
Assert.Equal("true", o["run_derived_only_job"]);
});
}
// P1-5. With neither --from nor --changed-files and not --force-all, there is no way to know what
// changed, so Run must throw rather than silently selecting nothing. This is the non-force-all
// sibling of the regression ForceAllWithoutDiffInputs... guards: the guard must not be reordered
// after Layer 1 so that this input combination quietly under-selects.
[Fact]
public void NoDiffInputsAndNoForceAllThrows()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var ex = Assert.Throws<InvalidOperationException>(() =>
Selection.Run(Options(repoRoot, propsPath, skipLayer1: true, enforce: true)));
Assert.Contains("--changed-files", ex.Message, StringComparison.Ordinal);
});
}
// Crash traceability: when the selector throws mid-run, the CI step must still fail loudly (so a
// crash never silently under-selects), AND the failure must be debuggable -- a diagnostics block in
// the step summary naming the stage it died in and the inputs needed to reproduce. A non-existent
// --changed-files path fails deterministically in the "resolve changed files" stage. Failure mode:
// a bare stack trace with no record of WHAT it was processing or HOW to re-run it.
[Fact]
public void FailureEmitsDiagnosticsNamingStageAndInputsThenRethrows()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var missing = Path.Combine(repoRoot, "does-not-exist.txt");
// The original failure must still surface (FileNotFoundException : IOException), not be
// swallowed -- the diagnostics augment it, they don't replace the non-zero exit.
Assert.ThrowsAny<IOException>(() =>
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: missing, skipLayer1: true, enforce: true)));
var summary = File.ReadAllText(Path.Combine(repoRoot, "summary"));
Assert.Contains("SelectTests FAILED", summary);
Assert.Contains("resolve changed files", summary);
// The exact input that reproduces the crash.
Assert.Contains(missing, summary);
});
}
// P1-6. --from/--to is a merge-base (three-dot) diff: the change set is taken from the common
// ancestor of base..head, NOT from the base tip. The repo below diverges so the two differ: feature
// adds trigger.txt off a base commit, then the base advances by editing other.txt. A base-tip..head
// diff would report BOTH files; the merge-base diff reports only trigger.txt. other.txt was changed
// on the advanced base AFTER the branch point, so it is NOT the PR's change and must not be selected.
// This is the real-world failure that motivated the merge-base switch: a file changed on main after a
// PR branched (tools/ExtractTestPartitions/Program.cs) tripped the run-all fallback under the old
// base-tip..head diff -- https://github.com/microsoft/aspire/pull/18377#issuecomment-4782187184.
[Fact]
public void FromToUsesMergeBaseDiffSemantics()
{
WithGitRepo((repoRoot, output) =>
{
WriteFile(repoRoot, "Aspire.slnx", Slnx);
WriteFile(repoRoot, "map.yml", Map);
WriteFile(repoRoot, "other.txt", "v0");
GitCommitAll(repoRoot, "base");
var baseSha = RunGit(repoRoot, "rev-parse", "HEAD");
RunGit(repoRoot, "checkout", "-q", "-b", "feature");
WriteFile(repoRoot, "trigger.txt", "x");
GitCommitAll(repoRoot, "feature change");
var featureSha = RunGit(repoRoot, "rev-parse", "HEAD");
// Advance the base after the branch point so base-tip..head and merge-base..head diverge.
RunGit(repoRoot, "checkout", "-q", "-b", "advanced-base", baseSha);
WriteFile(repoRoot, "other.txt", "v1");
GitCommitAll(repoRoot, "base advances");
var advancedBaseSha = RunGit(repoRoot, "rev-parse", "HEAD");
var propsPath = Path.Combine(repoRoot, "BeforeBuildProps.props");
Selection.Run(Options(repoRoot, propsPath, from: advancedBaseSha, to: featureSha, skipLayer1: true, enforce: true));
var props = File.ReadAllText(propsPath);
// trigger.txt (added on feature, the PR's own change) -> Aspire.Hosting.Tests. other.txt was
// changed on the advanced base AFTER the branch point, so the merge-base diff excludes it and
// Aspire.Cli.Tests (its target) is NOT selected. A regression to base-tip..head would add it.
Assert.Contains("Aspire.Hosting.Tests", props);
Assert.DoesNotContain("Aspire.Cli.Tests", props);
});
}
// P1-6a. An unresolved merge-base must NOT block the PR: when base and head share no common ancestor
// (history not deep enough, or genuinely divergent branches), the selector degrades to a fail-SAFE
// run-ALL rather than throwing -- over-selecting is safe, blocking every affected PR is not. Two
// unrelated root histories (an --orphan branch) make `git merge-base` find nothing. The run must
// succeed (exit 0), select ALL (so enforce writes NO restriction props -> the full matrix runs), and
// record the fallback reason in the summary so a systemic regression is visible, not hidden behind a
// green-but-full-matrix run. Failure mode: a regression that re-throws here turns every PR whose
// checkout can't reach the branch point red.
[Fact]
public void MergeBaseWithNoCommonAncestorFallsBackToAll()
{
WithGitRepo((repoRoot, output) =>
{
WriteFile(repoRoot, "Aspire.slnx", Slnx);
WriteFile(repoRoot, "map.yml", Map);
WriteFile(repoRoot, "other.txt", "v0");
GitCommitAll(repoRoot, "base");
var baseSha = RunGit(repoRoot, "rev-parse", "HEAD");
// --orphan starts a branch with no parent, so its first commit is a second root that shares
// no history with main; merge-base(unrelated, main) is therefore empty.
RunGit(repoRoot, "checkout", "-q", "--orphan", "unrelated");
WriteFile(repoRoot, "unrelated.txt", "v0");
GitCommitAll(repoRoot, "unrelated root");
var unrelatedSha = RunGit(repoRoot, "rev-parse", "HEAD");
var propsPath = Path.Combine(repoRoot, "BeforeBuildProps.props");
var exit = Selection.Run(Options(repoRoot, propsPath, from: unrelatedSha, to: baseSha, skipLayer1: true, enforce: true));
Assert.Equal(0, exit);
// ALL selected -> enforce writes no restriction props, so enumerate-tests runs everything.
Assert.False(File.Exists(propsPath));
// The fallback is recorded in the run summary (the durable record the weekly audit reads),
// naming the merge-base as the cause.
var summary = File.ReadAllText(Path.Combine(repoRoot, "summary"));
Assert.Contains("fail-safe run-all because", summary);
Assert.Contains("merge-base", summary);
});
}
// P1-6d. The CI action's OWN merge-base fallback enters the tool with --force-all already set (the
// shell deepen loop gave up), so RunCore's merge-base block is skipped and the reason can't be derived
// here -- the action passes it via --force-all-reason. ALL THREE output surfaces must name THAT reason
// ("fail-safe run-all because ...") rather than the run-full-ci kill switch, so a systemic
// shallow-history regression can't hide behind a green-but-full-matrix run no matter which surface a
// reader consults:
// - the step summary (the weekly audit reads it, not the raw logs),
// - the PR comment (what a contributor sees on the PR), and
// - the JSON artifact (the durable, machine-readable record).
// The reason flows through SelectorOptions.ForceAllReason into SelectionResult.EscalationReason, which
// the comment and JSON both render. Failure mode: carrying the reason only into the summary (not the
// selector) relabels the comment and JSON as the run-full-ci kill switch, contradicting the summary.
[Fact]
public void ForceAllReasonIsRecordedAcrossAllSurfacesDistinctFromKillSwitch()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var commentPath = Path.Combine(repoRoot, "comment.md");
var jsonPath = Path.Combine(repoRoot, "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 changed = WriteChangedFiles(repoRoot, "src/Aspire.Hosting/Foo.cs");
const string reason = "git merge-base of base abc123 and head def456 was unreachable within 4096 commits of CI checkout history";
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true, forceAll: true, forceAllReason: reason));
// The action-supplied reason is surfaced verbatim, flagged as fail-safe rather than kill switch.
var summary = File.ReadAllText(Path.Combine(repoRoot, "summary"));
Assert.Contains($"force-all: True — fail-safe run-all because {reason}", summary);
Assert.DoesNotContain("force-all: True (kill switch)", summary);
Assert.Contains($"**selects ALL** — {reason}", summary);
// The PR comment's ALL banner names the same reason, not the run-full-ci kill switch.
var comment = File.ReadAllText(commentPath);
Assert.Contains($"**Runs the full test matrix + all jobs (ALL)** — {reason}", comment);
Assert.DoesNotContain("the run-full-ci label forces the full matrix", comment);
// The durable JSON artifact records the same reason under escalationReason.
using var doc = JsonDocument.Parse(File.ReadAllText(jsonPath));
var root = doc.RootElement;
Assert.True(root.GetProperty("selectsAll").GetBoolean());
Assert.Equal(reason, root.GetProperty("escalationReason").GetString());
}
finally
{
Environment.SetEnvironmentVariable("SELECT_TESTS_COMMENT_FILE", previousComment);
Environment.SetEnvironmentVariable("SELECT_TESTS_JSON_FILE", previousJson);
}
});
}
// P1-6b. --from with no --to diffs the base ref against the WORKING TREE, so an uncommitted edit is
// picked up. Failure mode: requiring --to (or diffing against HEAD instead of the work tree) would
// miss locally-changed files when the workflow runs the selector against the checked-out tree.
[Fact]
public void FromWithoutToDiffsAgainstWorkingTree()
{
WithGitRepo((repoRoot, output) =>
{
WriteFile(repoRoot, "Aspire.slnx", Slnx);
WriteFile(repoRoot, "map.yml", Map);
WriteFile(repoRoot, "other.txt", "v0");
GitCommitAll(repoRoot, "base");
var baseSha = RunGit(repoRoot, "rev-parse", "HEAD");
// Uncommitted working-tree edit.
WriteFile(repoRoot, "other.txt", "v1");
var propsPath = Path.Combine(repoRoot, "BeforeBuildProps.props");
Selection.Run(Options(repoRoot, propsPath, from: baseSha, to: null, skipLayer1: true, enforce: true));
var props = File.ReadAllText(propsPath);
Assert.Contains("Aspire.Cli.Tests", props);
Assert.DoesNotContain("Aspire.Hosting.Tests", props);
});
}
// P1-6c. A rename must attribute BOTH sides so a file moved OUT of a mapped directory still runs
// that directory's tests. git's default rename detection reports only the destination, hiding the
// old path; the selector passes --no-renames so the diff decomposes into delete(old)+add(new).
// other.txt (-> Aspire.Cli.Tests) is renamed to renamed.txt, which the map ignores so the new side
// adds nothing and does not trip the run-all fallback -- isolating the assertion to the old side:
// the deletion of other.txt must still select Aspire.Cli.Tests. Failure mode: dropping --no-renames
// makes git hide other.txt, so only the ignored renamed.txt is seen, the rule never fires, and the
// move silently skips its tests.
[Fact]
public void RenameOutOfMappedPathStillSelectsItsTests()
{
const string map = """
version: 1
path_rules:
- paths: [other.txt]
targets: ["test:Aspire.Cli.Tests"]
ignore:
- renamed.txt
""";
WithGitRepo((repoRoot, output) =>
{
WriteFile(repoRoot, "Aspire.slnx", Slnx);
WriteFile(repoRoot, "map.yml", map);
WriteFile(repoRoot, "other.txt", "v0");
GitCommitAll(repoRoot, "base");
var baseSha = RunGit(repoRoot, "rev-parse", "HEAD");
RunGit(repoRoot, "mv", "other.txt", "renamed.txt");
GitCommitAll(repoRoot, "rename other.txt out of its mapped path");
var headSha = RunGit(repoRoot, "rev-parse", "HEAD");
var propsPath = Path.Combine(repoRoot, "BeforeBuildProps.props");
Selection.Run(Options(repoRoot, propsPath, from: baseSha, to: headSha, skipLayer1: true, enforce: true));
Assert.Contains("Aspire.Cli.Tests", File.ReadAllText(propsPath));
});
}
// Regression: a changed file whose repo-relative path contains non-ASCII bytes must still be
// attributed. git's default core.quotePath=true octal-escapes and double-quotes such paths
// (e.g. "eng/\343\203\206.../trigger.cs"), which does not glob-equal the real path, so the rule
// never fires. The selector passes -c core.quotePath=false so git emits the literal UTF-8 path and
// the rule matches, putting Aspire.Hosting.Tests in the enforce props (asserted below). If the flag
// regressed, the mangled path would match no rule and fall to the run-all fallback, which writes NO
// restriction props -- so the assertion below would fail (props missing/empty) and catch it. A CJK
// dir name is used deliberately (no NFC/NFD decomposition) so the test is stable on case/normalizing
// filesystems while still exercising the non-ASCII path.
[Fact]
public void NonAsciiChangedPathUnderAMappedDirStillSelectsItsTests()
{
const string unicodeMap = """
version: 1
path_rules:
- paths: ["eng/テスト/**"]
targets: ["test:Aspire.Hosting.Tests"]
""";
WithGitRepo((repoRoot, output) =>
{
WriteFile(repoRoot, "Aspire.slnx", Slnx);
WriteFile(repoRoot, "map.yml", unicodeMap);
WriteFile(repoRoot, "placeholder.txt", "v0");
GitCommitAll(repoRoot, "base");
var baseSha = RunGit(repoRoot, "rev-parse", "HEAD");
WriteFile(repoRoot, "eng/テスト/trigger.cs", "// changed");
GitCommitAll(repoRoot, "add a non-ASCII path under a mapped directory");
var headSha = RunGit(repoRoot, "rev-parse", "HEAD");
var propsPath = Path.Combine(repoRoot, "BeforeBuildProps.props");
Selection.Run(Options(repoRoot, propsPath, from: baseSha, to: headSha, skipLayer1: true, enforce: true));
Assert.Contains("Aspire.Hosting.Tests", File.ReadAllText(propsPath));
});
}
// P1-7. --changed-files trims surrounding whitespace and drops blank lines before glob matching.
// A regression that fed padded/blank paths to the globber would match nothing — " trigger.txt "
// does not glob-equal "trigger.txt" — so the surrounding-whitespace line below must still select
// Aspire.Hosting.Tests.
[Fact]
public void ChangedFilesTrimsWhitespaceAndBlankLines()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var changed = Path.Combine(repoRoot, "changed.txt");
File.WriteAllText(changed, "\n trigger.txt \n\n\t\n");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
Assert.Contains("Aspire.Hosting.Tests", File.ReadAllText(propsPath));
});
}
// P1-8. A selection that resolves only to non-.NET jobs (here grp.txt -> GROUP_ONLY_JOB ->
// job:group-job: a job target, no test: target, not ALL) selects no buildable .NET test project.
// Under --enforce that is the "no .NET tests" case: SelectTests writes no restriction props and
// signals has_dotnet_tests=false, so tests.yml skips enumerate-tests and emits an empty matrix.
// (An empty OverrideProjectToBuild would instead make the build fall back to the whole solution and
// fail.) Pin it so a future change can't turn "select nothing buildable" into "build everything",
// and so the non-.NET-job-only path keeps skipping the .NET matrix. (A genuinely unmapped file is a
// different case -- the run-all fallback -- and is covered separately.)
[Fact]
public void EnforceJobOnlySelectionSignalsNoDotnetTests()
{
RunInTempRepo((repoRoot, propsPath, output) =>
{
var changed = WriteChangedFiles(repoRoot, "grp.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
Assert.Equal("false", output()["has_dotnet_tests"]);
Assert.Equal("", output()["project_override_props"]);
Assert.False(File.Exists(propsPath));
});
}
// P1-9. A selected name that is not a buildable test project in Aspire.slnx (e.g. a production
// project name pulled in by a rule) contributes NO OverrideProjectToBuild item — only real
// tests/<Name>/<Name>.csproj projects do. Failure mode: a non-test project name leaking into the
// -test build list. prod.txt selects both "Aspire.Hosting" (production, not in the slnx test set)
// and "Aspire.Hosting.Tests"; only the latter must become an item.
[Fact]
public void EnforceSkipsNonTestProjectNamesInOverride()
{
RunInTempRepo((repoRoot, propsPath, _) =>
{
var changed = WriteChangedFiles(repoRoot, "prod.txt");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
var props = File.ReadAllText(propsPath);
var itemCount = props.Split("OverrideProjectToBuild Include=").Length - 1;
Assert.Equal(1, itemCount);
Assert.Contains("tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj", props);
});
}
// P1-10. The --skip-layer1 footgun: with Layer 1 disabled there is no graph attribution, so a
// src/** file under a real solution project dir must still fall to the run-all fallback rather
// than be treated as "Layer-1-owned" and silently select nothing. Failure mode (before the fix):
// project dirs were loaded even under --skip-layer1, so the file looked owned, no rule matched,
// and --enforce reported has_dotnet_tests=false -- a real source change skipping all .NET tests.
[Fact]
public void EnforceSkipLayer1SrcFileUnderProjectStillForcesRunAll()
{
const string slnx = """
<Solution>
<Project Path="tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj" />
<Project Path="src/Aspire.Managed/Aspire.Managed.csproj" />
</Solution>
""";
RunInTempRepo((repoRoot, propsPath, output) =>
{
// Matched by no map rule and under a solution project dir.
var changed = WriteChangedFiles(repoRoot, "src/Aspire.Managed/Program.cs");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
// Run-all: the full matrix is enumerated and no restriction props are written.
Assert.Equal("true", output()["has_dotnet_tests"]);
Assert.Equal("", output()["project_override_props"]);
Assert.False(File.Exists(propsPath));
}, slnx: slnx);
}
// Pre-filter: changed files matching a pattern in the (runtime-read) skip-gate patterns file are
// dropped BEFORE both layers, so a docs-only change selects nothing even when a path rule would
// otherwise route it to ALL. keep_routed carve-outs (files the selector routes) are never dropped.
[Fact]
public void PrefilterDropsPatternFileMatchesButHonorsKeepRouted()
{
const string mapWithPrefilter = """
version: 1
prefilter:
patterns_file: skip-patterns.txt
keep_routed:
- .github/workflows/**
path_rules:
- paths: [docs/**]
targets: [ALL]
- paths: [.github/workflows/**]
targets: ["test:Aspire.Hosting.Tests"]
""";
// A docs-only .md change is dropped (patterns file has **.md) -> empty selection (NOT ALL), even
// though docs/** -> ALL would have matched it.
RunInTempRepo((repoRoot, propsPath, output) =>
{
File.WriteAllText(Path.Combine(repoRoot, "skip-patterns.txt"), "# docs\n**.md\n");
var changed = WriteChangedFiles(repoRoot, "docs/guide.md");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
Assert.Equal("false", output()["has_dotnet_tests"]);
var summary = File.ReadAllText(Path.Combine(repoRoot, "summary"));
Assert.Contains("Pre-filtered (excluded) files (1)", summary);
Assert.Contains("docs/guide.md", summary);
}, map: mapWithPrefilter);
// keep_routed carve-out: a workflow file the patterns file lists is NOT dropped, so its rule fires.
RunInTempRepo((repoRoot, propsPath, _) =>
{
File.WriteAllText(Path.Combine(repoRoot, "skip-patterns.txt"), ".github/workflows/**\n");
var changed = WriteChangedFiles(repoRoot, ".github/workflows/ci.yml");
Selection.Run(Options(repoRoot, propsPath, changedFilesPath: changed, skipLayer1: true, enforce: true));
Assert.Contains("Aspire.Hosting.Tests", File.ReadAllText(propsPath));
}, map: mapWithPrefilter);
}
private static RunOptions Options(
string repoRoot,
string propsPath,
string? from = null,
string? to = null,
string? changedFilesPath = null,
bool skipLayer1 = false,
bool forceAll = false,
bool enforce = false,
string? slnxPath = null,
string? forceAllReason = null) =>
new(
RepoRoot: repoRoot,
MapPath: Path.Combine(repoRoot, "map.yml"),
SlnxPath: slnxPath ?? Path.Combine(repoRoot, "Aspire.slnx"),
From: from,
To: to,
ChangedFilesPath: changedFilesPath,
SkipLayer1: skipLayer1,
ForceAll: forceAll,
Enforce: enforce,
BeforeBuildProps: propsPath,
ForceAllReason: forceAllReason);
private static string WriteChangedFiles(string repoRoot, params string[] paths)
{
var changed = Path.Combine(repoRoot, "changed.txt");
File.WriteAllLines(changed, paths);
return changed;
}
// Sets up a hermetic repo (Aspire.slnx + map.yml) and redirects the GitHub Actions side-channel
// files into the temp dir, then runs the body. The third argument re-reads $GITHUB_OUTPUT into a
// key/value map on demand.
private static void RunInTempRepo(
Action<string, string, Func<IReadOnlyDictionary<string, string>>> body,
string slnx = Slnx,
string map = Map)
{
var dir = Directory.CreateTempSubdirectory("selecttests-cli");
try
{
File.WriteAllText(Path.Combine(dir.FullName, "Aspire.slnx"), slnx);
File.WriteAllText(Path.Combine(dir.FullName, "map.yml"), map);
WithGitHubEnv(dir.FullName, output =>
body(dir.FullName, Path.Combine(dir.FullName, "BeforeBuildProps.props"), output));
}
finally
{
Directory.Delete(dir.FullName, recursive: true);
}
}
// A temp git repo (no slnx/map written for you — the body sets up exactly the history it needs),
// with the GitHub Actions side channels redirected.
private static void WithGitRepo(Action<string, Func<IReadOnlyDictionary<string, string>>> body)
{
var dir = Directory.CreateTempSubdirectory("selecttests-git");
try
{
RunGit(dir.FullName, "init", "-q", "-b", "main");
RunGit(dir.FullName, "config", "user.email", "test@example.com");
RunGit(dir.FullName, "config", "user.name", "Test");
RunGit(dir.FullName, "config", "commit.gpgsign", "false");
WithGitHubEnv(dir.FullName, output => body(dir.FullName, output));
}
finally
{
Directory.Delete(dir.FullName, recursive: true);
}
}
private static void WithGitHubEnv(string dir, Action<Func<IReadOnlyDictionary<string, string>>> body)
{
var prevOutput = Environment.GetEnvironmentVariable("GITHUB_OUTPUT");
var prevSummary = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
try
{
var outputPath = Path.Combine(dir, "output");
Environment.SetEnvironmentVariable("GITHUB_OUTPUT", outputPath);
Environment.SetEnvironmentVariable("GITHUB_STEP_SUMMARY", Path.Combine(dir, "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)..];
}
}
}
// The run_<job> gates are emitted as ONE JSON object under `selection` (tests.yml reads
// it via fromJSON). Expand it back to flat run_* string entries so the per-job assertions
// keep the shape the tool used to emit one-key-per-job; deserializing as bool also pins
// that every gate is present and a real JSON boolean.
if (map.TryGetValue("selection", out var selectionJson))
{
foreach (var (name, value) in JsonSerializer.Deserialize<Dictionary<string, bool>>(selectionJson)!)
{
map[name] = value ? "true" : "false";
}
}
return map;
}
body(ReadOutput);
}
finally
{
Environment.SetEnvironmentVariable("GITHUB_OUTPUT", prevOutput);
Environment.SetEnvironmentVariable("GITHUB_STEP_SUMMARY", prevSummary);
}
}
private static void WriteFile(string repoRoot, string relativePath, string contents)
{
var fullPath = Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, contents);
}
private static void GitCommitAll(string repoRoot, string message)
{
RunGit(repoRoot, "add", "-A");
RunGit(repoRoot, "commit", "-q", "-m", message);
}
private static string RunGit(string repoRoot, params string[] args) => GitCli.Run(repoRoot, args);
}