diff --git a/documentation/specs/dotnet-run-for-maui.md b/documentation/specs/dotnet-run-for-maui.md index 33fd374d8b67..d66d1d67f4a2 100644 --- a/documentation/specs/dotnet-run-for-maui.md +++ b/documentation/specs/dotnet-run-for-maui.md @@ -86,6 +86,7 @@ to subsequent build, deploy, and run steps._ * `build`: unchanged, but is passed `-p:Device` and optionally `-p:RuntimeIdentifier` if the selected device provided a `%(RuntimeIdentifier)` metadata value. + Environment variables from `-e` are passed as `@(RuntimeEnvironmentVariable)` items. * `deploy` @@ -96,12 +97,15 @@ to subsequent build, deploy, and run steps._ `-p:Device` global MSBuild property, and optionally `-p:RuntimeIdentifier` if the selected device provided a `%(RuntimeIdentifier)` metadata value. + * Environment variables from `-e` are passed as `@(RuntimeEnvironmentVariable)` items. + * This step needs to run, even with `--no-build`, as you may have selected a different device. -* `ComputeRunArguments`: unchanged, but is passed `-p:Device` and optionally - `-p:RuntimeIdentifier` if the selected device provided a `%(RuntimeIdentifier)` - metadata value. +* `ComputeRunArguments`: unchanged, but is passed `-p:Device` and + optionally `-p:RuntimeIdentifier` if the selected device provided a + `%(RuntimeIdentifier)` metadata value. Environment variables from + `-e` are passed as `@(RuntimeEnvironmentVariable)` items. * `run`: unchanged. `ComputeRunArguments` should have set a valid `$(RunCommand)` and `$(RunArguments)` using the value supplied by @@ -146,6 +150,89 @@ A new `--device` switch will: * The iOS and Android workloads will know how to interpret `$(Device)` to select an appropriate device, emulator, or simulator. +## Environment Variables + +The `dotnet run` command supports passing environment variables via the +`-e` or `--environment` option: + +```dotnetcli +dotnet run -e FOO=BAR -e ANOTHER=VALUE +``` + +These environment variables are: + +1. **Passed to the running application** - as process environment + variables when the app is launched. + +2. **Passed to MSBuild during build, deploy, and ComputeRunArguments** - + as `@(RuntimeEnvironmentVariable)` items that workloads can consume. + **This behavior is opt-in**: projects must declare the `RuntimeEnvironmentVariableSupport` + project capability to receive these items. + +```xml + + + + +``` + +This allows workloads (iOS, Android, etc.) to access environment +variables during the `build`, `DeployToDevice`, and `ComputeRunArguments` target execution. + +### Opting In + +To receive environment variables as MSBuild items, projects must opt in by declaring +the `RuntimeEnvironmentVariableSupport` project capability: + +```xml + + + +``` + +Mobile workloads (iOS, Android, etc.) should declare this capability in their SDK targets +so that all projects using those workloads automatically opt in. + +Workloads can consume these items in their MSBuild targets: + +```xml + + + + +``` + +### Implementation Details + +For the **build step**, which uses out-of-process MSBuild via `dotnet build`, +environment variables are injected by creating a temporary `.props` file. +The file is created in the project's `$(IntermediateOutputPath)` directory +(e.g., `obj/Debug/net11.0-android/dotnet-run-env.props`). The path is +obtained from the project evaluation performed during target framework and +device selection. If `IntermediateOutputPath` is not available, the file +falls back to the `obj/` directory. + +The file is passed to MSBuild via the `CustomBeforeMicrosoftCommonProps` property, +ensuring the items are available early in evaluation. +The temporary file is automatically deleted after the build completes. + +The generated props file looks like: + +```xml + + + + + + +``` + +For the **deploy step** (`DeployToDevice` target) and +**ComputeRunArguments target**, which use in-process MSBuild, +environment variables are added directly as +`@(RuntimeEnvironmentVariable)` items to the `ProjectInstance` before +invoking the target. + ## Binary Logs for Device Selection When using `-bl` with `dotnet run`, all MSBuild operations are logged to a single diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs index 598d1dfe3483..ee1f84a8a888 100644 --- a/src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs +++ b/src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs @@ -33,10 +33,26 @@ public static class Constants public const string DeployToDevice = nameof(DeployToDevice); public const string CoreCompile = nameof(CoreCompile); + // MSBuild items + internal const string RuntimeEnvironmentVariable = nameof(RuntimeEnvironmentVariable); + // MSBuild item metadata public const string Identity = nameof(Identity); public const string FullPath = nameof(FullPath); + // MSBuild properties + public const string CustomBeforeMicrosoftCommonProps = nameof(CustomBeforeMicrosoftCommonProps); + public const string IntermediateOutputPath = nameof(IntermediateOutputPath); + + // MSBuild items for project capabilities + public const string ProjectCapability = nameof(ProjectCapability); + + /// + /// Project capability that workloads declare to opt in to receiving environment variables as MSBuild items. + /// When present, 'dotnet run -e' will pass environment variables as @(RuntimeEnvironmentVariable) items. + /// + public const string RuntimeEnvironmentVariableSupport = nameof(RuntimeEnvironmentVariableSupport); + // MSBuild CLI flags /// diff --git a/src/Cli/dotnet/Commands/Run/EnvironmentVariablesToMSBuild.cs b/src/Cli/dotnet/Commands/Run/EnvironmentVariablesToMSBuild.cs new file mode 100644 index 000000000000..7c51ec53f605 --- /dev/null +++ b/src/Cli/dotnet/Commands/Run/EnvironmentVariablesToMSBuild.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using System.Xml; +using Microsoft.Build.Execution; +using Microsoft.DotNet.Cli.Utils; + +namespace Microsoft.DotNet.Cli.Commands.Run; + +/// +/// Provides utilities for passing environment variables to MSBuild as items. +/// Environment variables specified via dotnet run -e NAME=VALUE are passed +/// as <RuntimeEnvironmentVariable Include="NAME" Value="VALUE" /> items. +/// +internal static class EnvironmentVariablesToMSBuild +{ + private const string PropsFileName = "dotnet-run-env.props"; + + /// + /// Adds environment variables as MSBuild items to a ProjectInstance. + /// Use this for in-process MSBuild operations (e.g., DeployToDevice target). + /// + /// The MSBuild project instance to add items to. + /// The environment variables to add. + public static void AddAsItems(ProjectInstance projectInstance, IReadOnlyDictionary environmentVariables) + { + foreach (var (name, value) in environmentVariables) + { + projectInstance.AddItem(Constants.RuntimeEnvironmentVariable, name, new Dictionary + { + ["Value"] = value + }); + } + } + + /// + /// Creates a temporary .props file containing environment variables as MSBuild items. + /// Use this for out-of-process MSBuild operations where you need to inject items via + /// CustomBeforeMicrosoftCommonProps property. + /// + /// The full path to the project file. If null or empty, returns null. + /// The environment variables to include. + /// + /// Optional intermediate output path where the file will be created. + /// If null or empty, defaults to "obj" subdirectory of the project directory. + /// + /// The full path to the created props file, or null if no environment variables were specified or projectFilePath is null. + public static string? CreatePropsFile(string? projectFilePath, IReadOnlyDictionary environmentVariables, string? intermediateOutputPath = null) + { + if (string.IsNullOrEmpty(projectFilePath) || environmentVariables.Count == 0) + { + return null; + } + + string projectDirectory = Path.GetDirectoryName(projectFilePath) ?? ""; + + // Normalize path separators - MSBuild may return paths with backslashes on non-Windows + string normalized = intermediateOutputPath?.Replace('\\', Path.DirectorySeparatorChar) ?? ""; + string objDir = string.IsNullOrEmpty(normalized) + ? Path.Combine(projectDirectory, Constants.ObjDirectoryName) + : Path.IsPathRooted(normalized) + ? normalized + : Path.Combine(projectDirectory, normalized); + Directory.CreateDirectory(objDir); + + // Ensure we return a full path for MSBuild property usage + string propsFilePath = Path.GetFullPath(Path.Combine(objDir, PropsFileName)); + using (var stream = File.Create(propsFilePath)) + { + WritePropsFileContent(stream, environmentVariables); + } + + return propsFilePath; + } + + /// + /// Deletes the temporary environment variables props file if it exists. + /// + /// The path to the props file to delete. + public static void DeletePropsFile(string? propsFilePath) + { + if (propsFilePath is not null && File.Exists(propsFilePath)) + { + try + { + File.Delete(propsFilePath); + } + catch (Exception ex) + { + // Best effort cleanup - don't fail the build if we can't delete the temp file + Reporter.Verbose.WriteLine($"Failed to delete temporary props file '{propsFilePath}': {ex.Message}"); + } + } + } + + /// + /// Adds the props file property to the MSBuild arguments. + /// This uses CustomBeforeMicrosoftCommonProps to inject the props file early in evaluation. + /// + /// The base MSBuild arguments. + /// The path to the props file (from ). + /// The MSBuild arguments with the props file property added, or the original args if propsFilePath is null. + public static MSBuildArgs AddPropsFileToArgs(MSBuildArgs msbuildArgs, string? propsFilePath) + { + if (propsFilePath is null) + { + return msbuildArgs; + } + + // Add the props file via CustomBeforeMicrosoftCommonProps. + // This ensures the items are available early in evaluation, similar to how we add items + // directly to ProjectInstance for in-process target invocations. + var additionalProperties = new ReadOnlyDictionary(new Dictionary + { + [Constants.CustomBeforeMicrosoftCommonProps] = propsFilePath + }); + + return msbuildArgs.CloneWithAdditionalProperties(additionalProperties); + } + + /// + /// Writes the content of the .props file containing environment variables as items. + /// + private static void WritePropsFileContent(Stream stream, IReadOnlyDictionary environmentVariables) + { + using var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + OmitXmlDeclaration = true, + Indent = true + }); + + writer.WriteStartElement("Project"); + writer.WriteStartElement("ItemGroup"); + + foreach (var (name, value) in environmentVariables) + { + writer.WriteStartElement(Constants.RuntimeEnvironmentVariable); + writer.WriteAttributeString("Include", name); + writer.WriteAttributeString("Value", value); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); // ItemGroup + writer.WriteEndElement(); // Project + } +} diff --git a/src/Cli/dotnet/Commands/Run/RunCommand.cs b/src/Cli/dotnet/Commands/Run/RunCommand.cs index 66e3e05af9ae..92ce418923a1 100644 --- a/src/Cli/dotnet/Commands/Run/RunCommand.cs +++ b/src/Cli/dotnet/Commands/Run/RunCommand.cs @@ -156,7 +156,7 @@ public int Execute() { // Pre-run evaluation: Handle target framework and device selection for project-based scenarios using var selector = ProjectFileFullPath is not null - ? new RunCommandSelector(ProjectFileFullPath, Interactive, MSBuildArgs, logger) + ? new RunCommandSelector(ProjectFileFullPath, Interactive, MSBuildArgs, EnvironmentVariables, logger) : null; if (selector is not null && !TrySelectTargetFrameworkAndDeviceIfNeeded(selector)) { @@ -186,7 +186,7 @@ public int Execute() Reporter.Output.WriteLine(CliCommandStrings.RunCommandBuilding); } - EnsureProjectIsBuilt(out projectFactory, out cachedRunProperties, out projectBuilder); + EnsureProjectIsBuilt(out projectFactory, out cachedRunProperties, out projectBuilder, selector?.IntermediateOutputPath, selector?.HasRuntimeEnvironmentVariableSupport ?? false); } else if (EntryPointFileFullPath is not null && launchProfileParseResult.Profile is not ExecutableLaunchProfile) { @@ -472,7 +472,7 @@ internal LaunchProfileParseResult ReadLaunchProfileSettings() return LaunchSettings.ReadProfileSettingsFromFile(launchSettingsPath, LaunchProfile); } - private void EnsureProjectIsBuilt(out Func? projectFactory, out RunProperties? cachedRunProperties, out VirtualProjectBuildingCommand? projectBuilder) + private void EnsureProjectIsBuilt(out Func? projectFactory, out RunProperties? cachedRunProperties, out VirtualProjectBuildingCommand? projectBuilder, string? intermediateOutputPath, bool hasRuntimeEnvironmentVariableSupport) { int buildResult; if (EntryPointFileFullPath is not null) @@ -489,11 +489,28 @@ private void EnsureProjectIsBuilt(out Func? projectFactory = null; cachedRunProperties = null; projectBuilder = null; - buildResult = new RestoringCommand( - MSBuildArgs.CloneWithExplicitArgs([ProjectFileFullPath, .. MSBuildArgs.OtherMSBuildArgs]), - NoRestore || _restoreDoneForDeviceSelection, - advertiseWorkloadUpdates: false - ).Execute(); + + // Create temporary props file for environment variables only if the project has opted in. + // This avoids invalidating incremental builds for projects that don't consume the items. + // Use IntermediateOutputPath from earlier project evaluation (via RunCommandSelector), defaulting to "obj" if not available. + string? envPropsFile = hasRuntimeEnvironmentVariableSupport + ? EnvironmentVariablesToMSBuild.CreatePropsFile(ProjectFileFullPath, EnvironmentVariables, intermediateOutputPath) + : null; + try + { + var buildArgs = MSBuildArgs.CloneWithExplicitArgs([ProjectFileFullPath, .. MSBuildArgs.OtherMSBuildArgs]); + buildArgs = EnvironmentVariablesToMSBuild.AddPropsFileToArgs(buildArgs, envPropsFile); + buildResult = new RestoringCommand( + buildArgs, + NoRestore || _restoreDoneForDeviceSelection, + advertiseWorkloadUpdates: false + ).Execute(); + } + finally + { + // Clean up temporary props file + EnvironmentVariablesToMSBuild.DeletePropsFile(envPropsFile); + } } if (buildResult != 0) @@ -575,7 +592,7 @@ private ICommand GetTargetCommandForProject(ProjectLaunchProfile? launchSettings var project = EvaluateProject(ProjectFileFullPath, projectFactory, MSBuildArgs, logger); ValidatePreconditions(project); - InvokeRunArgumentsTarget(project, NoBuild, logger, MSBuildArgs); + InvokeRunArgumentsTarget(project, NoBuild, logger, MSBuildArgs, EnvironmentVariables); var runProperties = RunProperties.FromProject(project).WithApplicationArguments(ApplicationArgs); command = CreateCommandFromRunProperties(runProperties); @@ -663,8 +680,15 @@ static ICommand CreateCommandForCscBuiltProgram(string entryPointFileFullPath, s return command; } - static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, FacadeLogger? binaryLogger, MSBuildArgs buildArgs) + static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, FacadeLogger? binaryLogger, MSBuildArgs buildArgs, IReadOnlyDictionary environmentVariables) { + // Only add environment variables as MSBuild items if the project has opted in via capability + if (project.GetItems(Constants.ProjectCapability) + .Any(item => string.Equals(item.EvaluatedInclude, Constants.RuntimeEnvironmentVariableSupport, StringComparison.OrdinalIgnoreCase))) + { + EnvironmentVariablesToMSBuild.AddAsItems(project, environmentVariables); + } + List loggersForBuild = [ CommonRunHelpers.GetConsoleLogger( buildArgs.CloneWithExplicitArgs([$"--verbosity:{LoggerVerbosity.Quiet.ToString().ToLowerInvariant()}", ..buildArgs.OtherMSBuildArgs]) diff --git a/src/Cli/dotnet/Commands/Run/RunCommandSelector.cs b/src/Cli/dotnet/Commands/Run/RunCommandSelector.cs index b2016b765bf4..982fa2c65e93 100644 --- a/src/Cli/dotnet/Commands/Run/RunCommandSelector.cs +++ b/src/Cli/dotnet/Commands/Run/RunCommandSelector.cs @@ -28,6 +28,7 @@ internal sealed class RunCommandSelector : IDisposable private readonly FacadeLogger? _binaryLogger; private readonly bool _isInteractive; private readonly MSBuildArgs _msbuildArgs; + private readonly IReadOnlyDictionary _environmentVariables; private ProjectCollection? _collection; private Microsoft.Build.Evaluation.Project? _project; @@ -38,20 +39,58 @@ internal sealed class RunCommandSelector : IDisposable /// public bool HasValidProject { get; private set; } + /// + /// Gets the IntermediateOutputPath property from the evaluated project. + /// This will evaluate the project if it hasn't been evaluated yet. + /// Returns null if the project cannot be evaluated or the property is not set. + /// + public string? IntermediateOutputPath + { + get + { + if (OpenProjectIfNeeded(out var projectInstance)) + { + return projectInstance.GetPropertyValue(Constants.IntermediateOutputPath); + } + return null; + } + } + + /// + /// Gets whether the project has opted in to receiving environment variables as MSBuild items. + /// When true, 'dotnet run -e' will pass environment variables as @(RuntimeEnvironmentVariable) items + /// via CustomBeforeMicrosoftCommonProps. + /// + public bool HasRuntimeEnvironmentVariableSupport + { + get + { + if (OpenProjectIfNeeded(out var projectInstance)) + { + return projectInstance.GetItems(Constants.ProjectCapability) + .Any(item => string.Equals(item.EvaluatedInclude, Constants.RuntimeEnvironmentVariableSupport, StringComparison.OrdinalIgnoreCase)); + } + return false; + } + } + /// Path to the project file to evaluate /// Whether to prompt the user for selections /// MSBuild arguments containing properties and verbosity settings + /// Environment variables to pass to MSBuild targets as items /// Optional binary logger for MSBuild operations. The logger will not be disposed by this class. public RunCommandSelector( string projectFilePath, bool isInteractive, MSBuildArgs msbuildArgs, + IReadOnlyDictionary environmentVariables, FacadeLogger? binaryLogger = null) { _projectFilePath = projectFilePath; _globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(msbuildArgs); _isInteractive = isInteractive; _msbuildArgs = msbuildArgs; + _environmentVariables = environmentVariables; _binaryLogger = binaryLogger; } @@ -488,6 +527,12 @@ public bool TryDeployToDevice() return true; } + // Add environment variables as items before building the target, only if opted in + if (HasRuntimeEnvironmentVariableSupport) + { + EnvironmentVariablesToMSBuild.AddAsItems(projectInstance, _environmentVariables); + } + // Build the DeployToDevice target var buildResult = projectInstance.Build( targets: [Constants.DeployToDevice], diff --git a/test/TestAssets/TestProjects/DotnetRunDevices/DotnetRunDevices.csproj b/test/TestAssets/TestProjects/DotnetRunDevices/DotnetRunDevices.csproj index 04ce32c6db1d..8c8991768558 100644 --- a/test/TestAssets/TestProjects/DotnetRunDevices/DotnetRunDevices.csproj +++ b/test/TestAssets/TestProjects/DotnetRunDevices/DotnetRunDevices.csproj @@ -3,8 +3,22 @@ Exe net9.0;$(CurrentTargetFramework) + + false + + + + + + + + + @@ -49,9 +63,25 @@ + + + + + + + + + + - + + + diff --git a/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunSelectsDevice.cs b/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunSelectsDevice.cs index c2c1948b12a5..68ac24240d3e 100644 --- a/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunSelectsDevice.cs +++ b/test/dotnet.Tests/CommandTests/Run/GivenDotnetRunSelectsDevice.cs @@ -376,4 +376,135 @@ public void ItPassesRuntimeIdentifierToDeployToDeviceTarget() .And.HaveStdOutContaining($"Device: {deviceId}") .And.HaveStdOutContaining($"RuntimeIdentifier: {rid}"); } + + [Fact] + public void ItPassesEnvironmentVariablesToTargets() + { + var testInstance = _testAssetsManager.CopyTestAsset("DotnetRunDevices", identifier: "EnvVarTargets") + .WithSource(); + + string deviceId = "test-device-1"; + string buildBinlogPath = Path.Combine(testInstance.Path, "msbuild.binlog"); + string runBinlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-run.binlog"); + + var result = new DotnetCommand(Log, "run") + .WithWorkingDirectory(testInstance.Path) + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "--device", deviceId, + "-e", "FOO=BAR", "-e", "ANOTHER=VALUE", + "-bl"); + + result.Should().Pass(); + + // Verify the binlog files were created + File.Exists(buildBinlogPath).Should().BeTrue("the build binlog file should be created"); + File.Exists(runBinlogPath).Should().BeTrue("the run binlog file should be created"); + + // Verify environment variables were passed to Build target (out-of-process build) + AssertTargetInBinlog(buildBinlogPath, "_LogRuntimeEnvironmentVariableDuringBuild", + targets => + { + targets.Should().NotBeEmpty("_LogRuntimeEnvironmentVariableDuringBuild target should have executed"); + var messages = targets.First().FindChildrenRecursive(); + var envVarMessage = messages.FirstOrDefault(m => m.Text?.Contains("Build: RuntimeEnvironmentVariable=") == true); + envVarMessage.Should().NotBeNull("the Build target should have logged the environment variables"); + envVarMessage.Text.Should().Contain("FOO=BAR").And.Contain("ANOTHER=VALUE"); + }); + + // Verify environment variables were passed to ComputeRunArguments target (in-process) + AssertTargetInBinlog(runBinlogPath, "_LogRuntimeEnvironmentVariableDuringComputeRunArguments", + targets => + { + targets.Should().NotBeEmpty("_LogRuntimeEnvironmentVariableDuringComputeRunArguments target should have executed"); + var messages = targets.First().FindChildrenRecursive(); + var envVarMessage = messages.FirstOrDefault(m => m.Text?.Contains("ComputeRunArguments: RuntimeEnvironmentVariable=") == true); + envVarMessage.Should().NotBeNull("the ComputeRunArguments target should have logged the environment variables"); + envVarMessage.Text.Should().Contain("FOO=BAR").And.Contain("ANOTHER=VALUE"); + }); + + // Verify environment variables were passed to DeployToDevice target (in-process) + AssertTargetInBinlog(runBinlogPath, "DeployToDevice", + targets => + { + targets.Should().NotBeEmpty("DeployToDevice target should have executed"); + var messages = targets.First().FindChildrenRecursive(); + var envVarMessage = messages.FirstOrDefault(m => m.Text?.Contains("DeployToDevice: RuntimeEnvironmentVariable=") == true); + envVarMessage.Should().NotBeNull("the DeployToDevice target should have logged the environment variables"); + envVarMessage.Text.Should().Contain("FOO=BAR").And.Contain("ANOTHER=VALUE"); + }); + + // Verify the props file was created in the correct IntermediateOutputPath location + string tempPropsFile = Path.Combine(testInstance.Path, "obj", "Debug", ToolsetInfo.CurrentTargetFramework, "dotnet-run-env.props"); + var build = BinaryLog.ReadBuild(buildBinlogPath); + var propsFile = build.SourceFiles?.FirstOrDefault(f => f.FullPath.EndsWith("dotnet-run-env.props", StringComparison.OrdinalIgnoreCase)); + propsFile.Should().NotBeNull("dotnet-run-env.props should be embedded in the binlog"); + propsFile.FullPath.Should().Be(tempPropsFile, "the props file should be in the IntermediateOutputPath"); + File.Exists(tempPropsFile).Should().BeFalse("the temporary props file should be deleted after build"); + } + + [Fact] + public void ItDoesNotPassEnvironmentVariablesToTargetsWithoutOptIn() + { + var testInstance = _testAssetsManager.CopyTestAsset("DotnetRunDevices", identifier: "EnvVarNoOptIn") + .WithSource(); + + string deviceId = "test-device-1"; + string buildBinlogPath = Path.Combine(testInstance.Path, "msbuild.binlog"); + string runBinlogPath = Path.Combine(testInstance.Path, "msbuild-dotnet-run.binlog"); + + // Run with EnableRuntimeEnvironmentVariableSupport=false to opt out of the capability + var result = new DotnetCommand(Log, "run") + .WithWorkingDirectory(testInstance.Path) + .Execute("--framework", ToolsetInfo.CurrentTargetFramework, "--device", deviceId, + "-e", "FOO=BAR", "-e", "ANOTHER=VALUE", + "-p:EnableRuntimeEnvironmentVariableSupport=false", + "-bl"); + + result.Should().Pass(); + + // Verify the binlog files were created + File.Exists(buildBinlogPath).Should().BeTrue("the build binlog file should be created"); + File.Exists(runBinlogPath).Should().BeTrue("the run binlog file should be created"); + + // Verify _LogRuntimeEnvironmentVariableDuringBuild target did NOT execute (condition failed due to no items) + AssertTargetInBinlog(buildBinlogPath, "_LogRuntimeEnvironmentVariableDuringBuild", + targets => + { + // The target should either not execute, or execute with no environment variable message + if (targets.Any()) + { + var messages = targets.First().FindChildrenRecursive(); + var envVarMessage = messages.FirstOrDefault(m => m.Text?.Contains("Build: RuntimeEnvironmentVariable=") == true); + envVarMessage.Should().BeNull("the Build target should NOT have logged the environment variables when not opted in"); + } + }); + + // Verify _LogRuntimeEnvironmentVariableDuringComputeRunArguments target did NOT log env vars + AssertTargetInBinlog(runBinlogPath, "_LogRuntimeEnvironmentVariableDuringComputeRunArguments", + targets => + { + if (targets.Any()) + { + var messages = targets.First().FindChildrenRecursive(); + var envVarMessage = messages.FirstOrDefault(m => m.Text?.Contains("ComputeRunArguments: RuntimeEnvironmentVariable=") == true); + envVarMessage.Should().BeNull("the ComputeRunArguments target should NOT have logged the environment variables when not opted in"); + } + }); + + // Verify DeployToDevice target did NOT log actual env var values + AssertTargetInBinlog(runBinlogPath, "DeployToDevice", + targets => + { + targets.Should().NotBeEmpty("DeployToDevice target should have executed"); + var messages = targets.First().FindChildrenRecursive(); + // The message may appear (target has no condition) but should NOT contain actual env var values + var envVarMessage = messages.FirstOrDefault(m => m.Text?.Contains("FOO=BAR") == true || m.Text?.Contains("ANOTHER=VALUE") == true); + envVarMessage.Should().BeNull("the DeployToDevice target should NOT have logged the actual environment variable values when not opted in"); + }); + + // Verify no props file was created (since opt-in is false) + string tempPropsFile = Path.Combine(testInstance.Path, "obj", "Debug", ToolsetInfo.CurrentTargetFramework, "dotnet-run-env.props"); + var build = BinaryLog.ReadBuild(buildBinlogPath); + var propsFile = build.SourceFiles?.FirstOrDefault(f => f.FullPath.EndsWith("dotnet-run-env.props", StringComparison.OrdinalIgnoreCase)); + propsFile.Should().BeNull("dotnet-run-env.props should NOT be created when not opted in"); + } }