diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs index 19862a65ad..aa216a90ea 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/TerminalOutputDevice.cs @@ -165,13 +165,18 @@ await _policiesService.RegisterOnAbortCallbackAsync( // When --ansi auto is explicitly specified, it overrides --no-ansi too. bool effectiveNoAnsi = noAnsi && ansiOverride == AnsiOverride.None; + // Honor the de-facto cross-tool NO_COLOR convention (https://no-color.org): when present with any + // non-empty value, suppress ANSI color/cursor codes unless the user explicitly opted in via + // `--ansi on` (which still wins, matching the precedence documented in --help). + bool noColorEnv = !RoslynString.IsNullOrEmpty(_environment.GetEnvironmentVariable("NO_COLOR")); + bool inCI = new CIEnvironmentDetector(_environment).IsCIEnvironment(); bool isLLMEnvironment = new LLMEnvironmentDetector(_environment).IsLLMEnvironment(); AnsiMode ansiMode = ansiOverride switch { - // User explicitly forced ANSI on (e.g. `--ansi on`). Bypass CI / LLM / redirection detection - // so colors and cursor movement are emitted even when stdout is redirected. + // User explicitly forced ANSI on (e.g. `--ansi on`). Bypass CI / LLM / NO_COLOR / redirection + // detection so colors and cursor movement are emitted even when stdout is redirected. AnsiOverride.ForceOn => AnsiMode.ForceAnsi, // User explicitly disabled ANSI (`--ansi off`). @@ -181,7 +186,8 @@ await _policiesService.RegisterOnAbortCallbackAsync( // No --ansi argument was provided, or `--ansi auto` was provided. // Fall back to environment-based detection. // In LLM environments, prefer simple text output so that the LLM can parse it easily. - _ when effectiveNoAnsi || isLLMEnvironment => AnsiMode.NoAnsi, + // NO_COLOR (https://no-color.org) is honored as well: any non-empty value suppresses ANSI. + _ when effectiveNoAnsi || noColorEnv || isLLMEnvironment => AnsiMode.NoAnsi, _ when inCI => AnsiMode.SimpleAnsi, _ => AnsiMode.AnsiIfPossible, }; diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AnsiOptionTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AnsiOptionTests.cs index 2a188b86d0..3e0ced1d4f 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AnsiOptionTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/AnsiOptionTests.cs @@ -111,6 +111,77 @@ public async Task AnsiOption_MissingArgument_FailsCommandLineValidation() result.AssertOutputContains("Option '--ansi' from provider 'Terminal test reporter' (UID: TerminalTestReporterCommandLineOptionsProvider) expects at least 1 arguments"); } + [TestMethod] + public async Task NoColorEnvVar_NonEmpty_DisablesAnsiOutput() + { + // The de-facto NO_COLOR convention (https://no-color.org): when present with any non-empty + // value, color (and in our implementation, all ANSI escape codes) must be suppressed. + // TestHost.ExecuteAsync auto-injects `--no-ansi --no-progress` which would already suppress ESC, + // so we use `--ansi auto` together with the CI env (which would otherwise produce SimpleAnsi + // ESC) to deterministically prove NO_COLOR alone forces NoAnsi. With NO_COLOR set the output + // must contain no ESC even though `--ansi auto` is opted in. + Dictionary environmentVariables = new(InCIEnvironmentVariablesWithLLMCleared) + { + ["NO_COLOR"] = "1", + }; + + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult result = await testHost.ExecuteAsync( + "--ansi auto", + environmentVariables: environmentVariables, + cancellationToken: TestContext.CancellationToken); + + result.AssertExitCodeIs(ExitCode.Success); + Assert.IsFalse( + result.StandardOutput.Contains(EscapeCharacter, StringComparison.Ordinal), + $"Expected output to NOT contain ANSI escape characters when NO_COLOR is set, but got:\n{result.StandardOutput}"); + } + + [TestMethod] + public async Task NoColorEnvVar_Empty_IsIgnored() + { + // Per the NO_COLOR spec, ONLY a non-empty value disables color. An empty NO_COLOR must be a no-op. + // With `--ansi auto` in a CI environment (and LLM detection cleared), MTP picks SimpleAnsi which + // does emit color ESC sequences. Asserting ESC presence proves empty NO_COLOR did not force NoAnsi. + Dictionary environmentVariables = new(InCIEnvironmentVariablesWithLLMCleared) + { + ["NO_COLOR"] = string.Empty, + }; + + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult result = await testHost.ExecuteAsync( + "--ansi auto", + environmentVariables: environmentVariables, + cancellationToken: TestContext.CancellationToken); + + result.AssertExitCodeIs(ExitCode.Success); + Assert.IsTrue( + result.StandardOutput.Contains(EscapeCharacter, StringComparison.Ordinal), + $"Expected output to contain ANSI escape characters when NO_COLOR is empty (spec: only non-empty disables color), but got:\n{result.StandardOutput}"); + } + + [TestMethod] + public async Task NoColorEnvVar_OverriddenByExplicitAnsiOn() + { + // Precedence rule documented in TerminalOutputDevice: `--ansi on` short-circuits CI / LLM / + // NO_COLOR detection. NO_COLOR is honored "unless the user explicitly opted in via --ansi on". + Dictionary environmentVariables = new(InCIEnvironmentVariablesWithLLMCleared) + { + ["NO_COLOR"] = "1", + }; + + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + TestHostResult result = await testHost.ExecuteAsync( + "--ansi on", + environmentVariables: environmentVariables, + cancellationToken: TestContext.CancellationToken); + + result.AssertExitCodeIs(ExitCode.Success); + Assert.IsTrue( + result.StandardOutput.Contains(EscapeCharacter, StringComparison.Ordinal), + $"Expected output to contain ANSI escape characters because `--ansi on` overrides NO_COLOR, but got:\n{result.StandardOutput}"); + } + public TestContext TestContext { get; set; } = null!; // Env vars that force the platform to consider it to be in CI while clearing the known LLM env @@ -119,6 +190,7 @@ public async Task AnsiOption_MissingArgument_FailsCommandLineValidation() private static Dictionary InCIEnvironmentVariablesWithLLMCleared => new() { ["GITHUB_ACTIONS"] = "true", + ["NO_COLOR"] = string.Empty, ["CLAUDECODE"] = string.Empty, ["CLAUDE_CODE_ENTRYPOINT"] = string.Empty, ["CURSOR_EDITOR"] = string.Empty, diff --git a/test/Utilities/Microsoft.Testing.TestInfrastructure/WellKnownEnvironmentVariables.cs b/test/Utilities/Microsoft.Testing.TestInfrastructure/WellKnownEnvironmentVariables.cs index 4d46abc46b..e52cb3b88b 100644 --- a/test/Utilities/Microsoft.Testing.TestInfrastructure/WellKnownEnvironmentVariables.cs +++ b/test/Utilities/Microsoft.Testing.TestInfrastructure/WellKnownEnvironmentVariables.cs @@ -86,6 +86,12 @@ public static class WellKnownEnvironmentVariables // Isolate from the skip banner in case of parent, children tests "TESTINGPLATFORM_CONSOLEOUTPUTDEVICE_SKIP_BANNER", + // The de-facto cross-tool NO_COLOR convention (https://no-color.org). MTP honors any non-empty + // value as "disable ANSI". We filter it out so acceptance tests that rely on ANSI output are not + // affected when contributors / CI happen to have NO_COLOR set in their shell. Tests that need + // to exercise NO_COLOR behavior must set the variable explicitly. + "NO_COLOR", + // LLM / AI agent CLI environment variables - keep in sync with // src/Platform/Microsoft.Testing.Platform/Helpers/LLMEnvironmentDetector.cs. // We filter these out so acceptance tests are not affected by the ambient