From 29847d1c7c69a7c3168a03695776f5803b1f02d0 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Feb 2026 10:00:06 +1100 Subject: [PATCH 1/2] Add async-local override for BuildServerDetector Introduce an AsyncLocal-backed override for BuildServerDetector.Detected so tests (and other async-scoped code) can set the detected value without leaking to other threads. The static initialization now stores the computed detection in a private 'detected' field, and the Detected property reads from overrideDetected.Value ?? detected and sets overrideDetected.Value. Added unit tests to verify the override persists in the async context and doesn't leak, and updated readme/source docs with a snippet explaining how to override Detected in tests. --- readme.md | 45 +++++++++++++++++++ readme.source.md | 7 +++ .../BuildServerDetectorTest.cs | 37 +++++++++++++++ src/DiffEngine/BuildServerDetector.cs | 11 ++++- 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 3204f2c9..404cbc1f 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,7 @@ DiffEngine manages launching and cleanup of diff tools. It is designed to be use * [Closing a tool](#closing-a-tool) * [File type detection](#file-type-detection) * [BuildServerDetector](#buildserverdetector) + * [Override in tests](#override-in-tests) * [AiCliDetector](#aiclidetector) * [Disable for a machine/process](#disable-for-a-machineprocess) * [Disable in code](#disable-in-code) @@ -162,6 +163,50 @@ var isAppVeyor = BuildServerDetector.IsAppVeyor; +### Override in tests + +`BuildServerDetector.Detected` can be set at test time. The value is stored in an `AsyncLocal`, so it is scoped to the current async context and does not leak to other threads or tests running in parallel. + + + +```cs +[Fact] +public async Task SetDetectedPersistsInAsyncContext() +{ + var original = BuildServerDetector.Detected; + try + { + BuildServerDetector.Detected = true; + Assert.True(BuildServerDetector.Detected); + + await Task.Delay(1); + + Assert.True(BuildServerDetector.Detected); + } + finally + { + BuildServerDetector.Detected = original; + } +} + +[Fact] +public async Task SetDetectedDoesNotLeakToOtherContexts() +{ + var parentValue = BuildServerDetector.Detected; + + await Task.Run(() => + { + BuildServerDetector.Detected = true; + Assert.True(BuildServerDetector.Detected); + }); + + Assert.Equal(parentValue, BuildServerDetector.Detected); +} +``` +snippet source | anchor + + + ## AiCliDetector `AiCliDetector.Detected` returns true if the current code is running in an AI-powered CLI environment. diff --git a/readme.source.md b/readme.source.md index 0f8979a6..fd77687d 100644 --- a/readme.source.md +++ b/readme.source.md @@ -84,6 +84,13 @@ There are also individual properties to check for each specific build system snippet: BuildServerDetectorProps +### Override in tests + +`BuildServerDetector.Detected` can be set at test time. The value is stored in an `AsyncLocal`, so it is scoped to the current async context and does not leak to other threads or tests running in parallel. + +snippet: BuildServerDetectorDetectedOverride + + ## AiCliDetector `AiCliDetector.Detected` returns true if the current code is running in an AI-powered CLI environment. diff --git a/src/DiffEngine.Tests/BuildServerDetectorTest.cs b/src/DiffEngine.Tests/BuildServerDetectorTest.cs index 64f83eb3..3e2f96e5 100644 --- a/src/DiffEngine.Tests/BuildServerDetectorTest.cs +++ b/src/DiffEngine.Tests/BuildServerDetectorTest.cs @@ -24,4 +24,41 @@ public void Props() // ReSharper restore UnusedVariable } + + #region BuildServerDetectorDetectedOverride + + [Fact] + public async Task SetDetectedPersistsInAsyncContext() + { + var original = BuildServerDetector.Detected; + try + { + BuildServerDetector.Detected = true; + Assert.True(BuildServerDetector.Detected); + + await Task.Delay(1); + + Assert.True(BuildServerDetector.Detected); + } + finally + { + BuildServerDetector.Detected = original; + } + } + + [Fact] + public async Task SetDetectedDoesNotLeakToOtherContexts() + { + var parentValue = BuildServerDetector.Detected; + + await Task.Run(() => + { + BuildServerDetector.Detected = true; + Assert.True(BuildServerDetector.Detected); + }); + + Assert.Equal(parentValue, BuildServerDetector.Detected); + } + + #endregion } \ No newline at end of file diff --git a/src/DiffEngine/BuildServerDetector.cs b/src/DiffEngine/BuildServerDetector.cs index 1e5bcf34..980deae1 100644 --- a/src/DiffEngine/BuildServerDetector.cs +++ b/src/DiffEngine/BuildServerDetector.cs @@ -52,7 +52,7 @@ static BuildServerDetector() // https://docs.microsoft.com/en-us/azure/devops/pipelines/process/variables?view=azure-devops&tabs=yaml%2Cbatch#access-variables-through-the-environment IsAzureDevops = ValueEquals(variables, "TF_BUILD", "True"); - Detected = IsTravis || + detected = IsTravis || IsJenkins || IsGithubAction || IsAzureDevops || @@ -65,6 +65,9 @@ static BuildServerDetector() IsAppVeyor; } + static bool detected; + static AsyncLocal overrideDetected = new(); + static bool ValueEquals(IDictionary variables, string key, string value) { var variable = variables[key]; @@ -98,5 +101,9 @@ static bool ValueEquals(IDictionary variables, string key, string value) public static bool IsJenkins { get; } - public static bool Detected { get; set; } + public static bool Detected + { + get => overrideDetected.Value ?? detected; + set => overrideDetected.Value = value; + } } \ No newline at end of file From 93409492ca896c5d8ab23be06b2f0aa08ff3facc Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 5 Feb 2026 10:00:20 +1100 Subject: [PATCH 2/2] Update Directory.Build.props --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 958f0b6d..6e729e13 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;NU1608;NU1109 - 18.3.0 + 18.4.0 1.0.0 Testing, Snapshot, Diff, Compare Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries.