Add ExecutableConditionAttribute to conditionally run tests based on tool availability#9369
Conversation
…tool availability Adds a generic, tool-agnostic condition attribute that includes/excludes a test class or method based on whether an executable is discoverable on PATH (when no arguments are given) or runs successfully with exit code 0 within a configurable timeout (when arguments are given). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new MSTest condition attribute (ExecutableConditionAttribute) to gate test execution based on whether a tool is discoverable on PATH and (optionally) whether a command succeeds when executed, with result caching and multi-targeting support.
Changes:
- Introduces
ExecutableConditionAttribute(public API) with presence-check and run-check modes plus configurable timeout. - Records the new public surface in
PublicAPI.Unshipped.txt. - Adds unit tests for constructor behavior, grouping/message formatting, PATH probing, caching, and run/timeout behavior.
Show a summary per file
| File | Description |
|---|---|
src/TestFramework/TestFramework/Attributes/TestMethod/ExecutableConditionAttribute.cs |
Implements the new conditional attribute, including PATH probing, optional process execution, timeout handling, and caching. |
src/TestFramework/TestFramework/PublicAPI/PublicAPI.Unshipped.txt |
Registers the new public API members for API tracking. |
test/UnitTests/TestFramework.UnitTests/Attributes/ExecutableConditionAttributeTests.cs |
Adds unit tests covering the new attribute’s behavior across scenarios. |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 7
This comment has been minimized.
This comment has been minimized.
Evangelink
left a comment
There was a problem hiding this comment.
Note
🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.
Review Summary
The feature itself is well-structured: clean constructor hierarchy, good #if guarding across TFMs, correct async-drain pattern to avoid pipe-buffer deadlock, and solid process-kill handling. Two correctness bugs in the caching/grouping design need to be fixed before merge.
🔴 BLOCKING (2)
1 — GroupName / cache key is ambiguous (line 160)
The space separator used to join the executable and its arguments is the same character that can legally appear in an executable name. This means:
ExecutableConditionAttribute("foo bar") // path-check for binary named "foo bar"
ExecutableConditionAttribute("foo", "bar") // run "foo" with argument "bar"
Both produce GroupName = "ExecutableCondition:foo bar". Consequences:
- Cache collision: the static
ResultCacheis keyed byGroupName; whichever call runs first permanently determines the outcome for the other — despite them being fundamentally different operations (aFile.Existsprobe vs. a process run). - Wrong OR-grouping: MSTest uses
GroupNameto cluster attributes for OR evaluation. These two attributes would be placed in the same group when they should be independent.
Suggested fix: use a mode prefix and a null-byte separator, e.g.:
$"ExecutableCondition:presence\0{Executable}"
$"ExecutableCondition:run\0{Executable}\0{string.Join("\0", _arguments)}"2 — TimeoutSeconds is mutable but is not part of the cache key (line 151)
IsConditionMet reads ResultCache.GetOrAdd(GroupName, _ => Evaluate()), and Evaluate() uses TimeoutSeconds. But TimeoutSeconds is not reflected in GroupName (the cache key). Once a result is cached for a given executable+arguments combination, TimeoutSeconds is permanently ignored — on the same instance and on any other instance with the same command.
Concrete scenario on a slow CI machine where docker version takes ~8 s:
[ExecutableCondition("docker", "version")](30 s) runs first → cachestrue[ExecutableCondition("docker", "version", TimeoutSeconds = 5)]→ same key → returns cachedtrue, silently ignoring the 5 s constraint
The reverse is equally wrong: a 5 s timeout that times out poisons the cache for the 30 s use.
Suggested fix: split "cache key" from GroupName. Keep GroupName for OR-grouping (no timeout); add a private CacheKey property that appends TimeoutSeconds:
private string CacheKey => _arguments.Length == 0
? $"ExecutableCondition:presence\0{Executable}"
: $"ExecutableCondition:run\0{Executable}\0{string.Join("\0", _arguments)}\0{TimeoutSeconds}";
public override bool IsConditionMet
=> ResultCache.GetOrAdd(CacheKey, _ => Evaluate());🟡 Minor (1)
Redundant null-forgiving operator on line 316 — pathExt! after string.IsNullOrEmpty(pathExt) has already narrowed the type to non-null. Per the repo convention of trusting C# null annotations, the ! should be dropped.
- Cache key now includes TimeoutSeconds and is separate from GroupName, so a stricter timeout no longer reuses a looser timeout's cached result (BLOCKING). - GroupName/CacheKey use '\0' separators and presence/run prefixes so a presence check for an executable named 'foo bar' no longer collides with running 'foo' with arg 'bar' (BLOCKING). - GroupName includes Mode (like MemberConditionAttribute) so Include/Exclude for the same command are AND-ed instead of silently cancelling out. - Clone the params arguments array and expose Arguments via a read-only wrapper for immutability. - Clamp TimeoutSeconds * 1000 against int overflow. - Best-effort WaitForExit after Kill so a timed-out process doesn't linger. - Narrow the generic catches to specific operational exceptions. - MatchesAnyExtension uses LINQ Any; drop the redundant null-forgiving operator on pathExt. - Doc: qualify process-tree termination as 'where the runtime supports it'. - Tests: timeout test invokes ping/sleep directly (no shell wrapper) so the root-process kill reliably stops it across TFMs; add Mode/collision/immutability tests; update GroupName assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🧪 Test quality grade — PR #936924 new tests graded across 1 file (
This advisory comment was generated automatically. Grades are heuristic
|
What
Adds a new public
ExecutableConditionAttribute(inMicrosoft.VisualStudio.TestTools.UnitTesting) that conditionally includes or excludes a test class or method based on whether a given executable/tool is available.Two modes:
PATH(and, on Windows, resolvable throughPATHEXT). It does not run the executable. E.g.[ExecutableCondition("docker")].executable argumentsand is met only when the process exits with code0withinTimeoutSeconds(default 30s). Output is redirected/discarded and the process tree is terminated on timeout. E.g.[ExecutableCondition("docker", "version")].Each distinct executable+arguments combination forms its own condition group, so multiple attributes with different commands AND together, while repeats of the same command OR together.
Details
ConditionMode(Include/Exclude) overloads provided.ProcessStartInfo.ArgumentListon modern .NET and the well-known PasteArguments quoting onnetstandard/net462.PublicAPI.Unshipped.txt; attribute issealedand uses noinitaccessors.Tests
Adds
ExecutableConditionAttributeTests(TestFramework.ForTestingMSTest). All 21 cases pass on net8.0; project builds clean (0 warnings/0 errors) across all TFMs.