Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ internal class TestLoggerManager : ITestLoggerManager
/// </summary>
private bool _treatNoTestsAsError;

/// <summary>
/// Target architecture (e.g. "x64", "x86", "ARM64").
/// </summary>
private string? _targetArchitecture;

/// <summary>
/// Shared test run timestamp for artifact naming.
/// </summary>
private string? _testRunTimestamp;

/// <summary>
/// Test Logger Events instance which will be passed to loggers when they are initialized.
/// </summary>
Expand All @@ -82,13 +92,18 @@ internal class TestLoggerManager : ITestLoggerManager
/// </summary>
private readonly IAssemblyLoadContext _assemblyLoadContext;

/// <summary>
/// Time provider for generating timestamps. Injected for testability.
/// </summary>
private readonly Func<DateTime> _timeProvider;

/// <summary>
/// Test logger manager.
/// </summary>
/// <param name="requestData">Request Data for Providing Common Services/Data for Discovery and Execution.</param>
/// <param name="messageLogger">Message Logger.</param>
/// <param name="loggerEvents">Logger events.</param>
public TestLoggerManager(IRequestData requestData, IMessageLogger messageLogger, InternalTestLoggerEvents loggerEvents) : this(requestData, messageLogger, loggerEvents, new PlatformAssemblyLoadContext())
public TestLoggerManager(IRequestData requestData, IMessageLogger messageLogger, InternalTestLoggerEvents loggerEvents) : this(requestData, messageLogger, loggerEvents, new PlatformAssemblyLoadContext(), () => DateTime.UtcNow)
{
}

Expand All @@ -99,14 +114,17 @@ internal class TestLoggerManager : ITestLoggerManager
/// <param name="messageLogger"></param>
/// <param name="loggerEvents"></param>
/// <param name="assemblyLoadContext"></param>
/// <param name="timeProvider">Provides current UTC time. Injected for testability.</param>
internal TestLoggerManager(IRequestData requestData, IMessageLogger messageLogger,
InternalTestLoggerEvents loggerEvents, IAssemblyLoadContext assemblyLoadContext)
InternalTestLoggerEvents loggerEvents, IAssemblyLoadContext assemblyLoadContext,
Func<DateTime> timeProvider)
{
_requestData = requestData;
_messageLogger = messageLogger;
_testLoggerExtensionManager = null;
_loggerEvents = loggerEvents;
_assemblyLoadContext = assemblyLoadContext;
_timeProvider = timeProvider;
}

/// <summary>
Expand Down Expand Up @@ -135,6 +153,8 @@ public void Initialize(string? runSettings)
// Store test run directory. This runsettings is the final runsettings merging CLI args and runsettings.
_testRunDirectory = GetResultsDirectory(runSettings);
_targetFramework = GetTargetFramework(runSettings)?.Name;
_targetArchitecture = GetTargetArchitecture(runSettings);
_testRunTimestamp = GetOrGenerateTestRunTimestamp(runSettings);
_treatNoTestsAsError = GetTreatNoTestsAsError(runSettings);

var loggers = XmlRunSettingsUtilities.GetLoggerRunSettings(runSettings);
Expand Down Expand Up @@ -478,6 +498,67 @@ internal static bool GetTreatNoTestsAsError(string? runSettings)
return RunSettingsUtilities.GetTreatNoTestsAsError(runSettings);
}

/// <summary>
/// Gets the target architecture (e.g. "x64", "x86", "ARM64") from RunSettings.
/// </summary>
/// <param name="runSettings">Test run settings.</param>
/// <returns>Architecture string, or null if not available.</returns>
internal static string? GetTargetArchitecture(string? runSettings)
{
if (runSettings != null)
{
try
{
RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);

// Only propagate architecture when explicitly set in RunSettings.
// Default is the OS architecture, but we don't want to surface that
// unless the user or engine has explicitly configured it.
if (runConfiguration.TargetPlatformSet)
{
return runConfiguration.TargetPlatform.ToString().ToLowerInvariant();
}
}
catch (SettingsException se)
{
EqtTrace.Error("TestLoggerManager.GetTargetArchitecture: Unable to get the target architecture: Error {0}", se);
}
}

return null;
}

/// <summary>
/// Gets the shared test run timestamp from RunSettings, or generates a new one.
/// The timestamp is in ISO 8601 compact format: yyyyMMddTHHmmss.fff
/// If <c>&lt;ArtifactRunTimestamp&gt;</c> is already set in RunSettings (e.g. by the
/// orchestrator for cross-process synchronization), that value is used.
/// Otherwise a new timestamp is generated from the current UTC time.
/// </summary>
/// <param name="runSettings">Test run settings.</param>
/// <returns>Timestamp string.</returns>
internal string GetOrGenerateTestRunTimestamp(string? runSettings)
{
if (runSettings != null)
{
try
{
RunConfiguration runConfiguration = XmlRunSettingsUtilities.GetRunConfigurationNode(runSettings);
string? existingTimestamp = runConfiguration.ArtifactRunTimestamp;
if (!StringUtils.IsNullOrEmpty(existingTimestamp))
{
return existingTimestamp;
}
}
catch (SettingsException se)
{
EqtTrace.Error("TestLoggerManager.GetOrGenerateTestRunTimestamp: Unable to read ArtifactRunTimestamp: Error {0}", se);
}
}

return _timeProvider().ToUniversalTime().ToString("yyyyMMdd'T'HHmmss.fff", CultureInfo.InvariantCulture);
}

/// <summary>
/// Enables sending of events to the loggers which are registered.
/// </summary>
Expand Down Expand Up @@ -636,6 +717,8 @@ private bool InitializeLogger(object? logger, string? extensionUri, Dictionary<s
// Add default logger parameters...
loggerParams[DefaultLoggerParameterNames.TestRunDirectory] = _testRunDirectory;
loggerParams[DefaultLoggerParameterNames.TargetFramework] = _targetFramework;
loggerParams[DefaultLoggerParameterNames.TargetArchitecture] = _targetArchitecture;
loggerParams[DefaultLoggerParameterNames.TestRunTimestamp] = _testRunTimestamp;

// Add custom logger parameters
if (_treatNoTestsAsError)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Generic;

namespace Microsoft.VisualStudio.TestPlatform.ObjectModel.ArtifactNaming;

/// <summary>
/// Describes a request to resolve an artifact file name from a template.
/// </summary>
public sealed class ArtifactNameRequest
{
/// <summary>
/// Gets or sets the file name template (without directory, without extension).
/// Uses <c>{TokenName}</c> placeholders for token expansion.
/// </summary>
/// <example><c>{AssemblyName}_{Tfm}_{Architecture}</c></example>
public string FileTemplate { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the file extension including the leading dot.
/// </summary>
/// <example><c>.trx</c>, <c>.xml</c>, <c>.coverage</c></example>
public string Extension { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the token values available for template expansion.
/// </summary>
public IReadOnlyDictionary<string, string> Context { get; set; } = new Dictionary<string, string>();

/// <summary>
/// Gets or sets the collision behavior when the resolved path already exists.
/// Defaults to <see cref="CollisionBehavior.AppendCounter"/>.
/// </summary>
public CollisionBehavior Collision { get; set; } = CollisionBehavior.AppendCounter;

/// <summary>
/// Gets or sets the optional directory template. When <see langword="null"/>, the value of
/// <see cref="ArtifactNameTokens.TestResultsDirectory"/> from <see cref="Context"/> is used.
/// </summary>
public string? DirectoryTemplate { get; set; }

/// <summary>
/// Gets or sets an optional artifact kind tag for diagnostics (e.g., "trx", "coverage", "blame").
/// </summary>
public string? ArtifactKind { get; set; }

/// <summary>
/// Gets or sets an optional producer name for diagnostics (e.g., "TrxLogger", "BlameCollector").
/// </summary>
public string? ProducerName { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestPlatform.ObjectModel.ArtifactNaming;

/// <summary>
/// Result of resolving an artifact name. Contains the resolved path and metadata
/// about the resolution process (e.g., whether an existing file was detected).
/// </summary>
public sealed class ArtifactNameResult
{
/// <summary>
/// Gets the fully resolved, sanitized file path.
/// </summary>
public string FilePath { get; }

/// <summary>
/// Gets a value indicating whether the resolved path already existed before resolution.
/// When <see langword="true"/> with <see cref="CollisionBehavior.Overwrite"/>, the caller
/// should log a warning before writing.
/// </summary>
public bool IsOverwrite { get; }

/// <summary>
/// Gets a value indicating whether this process created (and therefore owns) the
/// output directory. When <see langword="false"/>, the directory was already present
/// (possibly created by another test host in the same run, or a previous run).
/// </summary>
public bool IsDirectoryOwner { get; }

/// <summary>
/// Initializes a new instance of the <see cref="ArtifactNameResult"/> class.
/// </summary>
public ArtifactNameResult(string filePath, bool isOverwrite, bool isDirectoryOwner)
{
FilePath = filePath;
IsOverwrite = isOverwrite;
IsDirectoryOwner = isDirectoryOwner;
}

/// <summary>
/// Returns the resolved file path.
/// </summary>
public override string ToString() => FilePath;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestPlatform.ObjectModel.ArtifactNaming;

/// <summary>
/// Well-known token names for artifact name templates.
/// </summary>
public static class ArtifactNameTokens
{
/// <summary>The test results output directory.</summary>
public const string TestResultsDirectory = "TestResultsDirectory";

/// <summary>The target framework short name (e.g., "net8.0").</summary>
public const string Tfm = "Tfm";

/// <summary>UTC timestamp in sortable ISO 8601 compact format with milliseconds: 20260415T105100.123</summary>
public const string Timestamp = "Timestamp";

/// <summary>UTC date only: 2026-04-15</summary>
public const string Date = "Date";

/// <summary>The machine name.</summary>
public const string MachineName = "MachineName";

/// <summary>The current user name.</summary>
public const string UserName = "UserName";

/// <summary>The current process ID.</summary>
public const string Pid = "Pid";

/// <summary>Short 8-character hex prefix of the test run ID.</summary>
public const string RunId = "RunId";

/// <summary>Full test session GUID.</summary>
public const string SessionId = "SessionId";

/// <summary>The test assembly file name without extension.</summary>
public const string AssemblyName = "AssemblyName";

/// <summary>The runtime architecture (e.g., "x64", "x86", "arm64").</summary>
public const string Architecture = "Architecture";

/// <summary>The build configuration (e.g., "Debug", "Release").</summary>
public const string Configuration = "Configuration";

/// <summary>The project name.</summary>
public const string ProjectName = "ProjectName";

/// <summary>The test host index in parallel execution.</summary>
public const string HostId = "HostId";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Generic;

namespace Microsoft.VisualStudio.TestPlatform.ObjectModel.ArtifactNaming;

/// <summary>
/// Built-in artifact naming presets that define directory and file template pairs.
/// </summary>
public static class ArtifactNamingPresets
{
/// <summary>CI preset: flat output folder, deterministic names, overwrite on rerun.</summary>
public const string CI = "ci";

/// <summary>Local preset: per-run timestamped subfolder, deterministic file names.</summary>
public const string Local = "local";

/// <summary>Detailed preset: per-run folder with run ID in file names.</summary>
public const string Detailed = "detailed";

/// <summary>Flat preset: minimal file names, one per assembly.</summary>
public const string Flat = "flat";

/// <summary>
/// Gets the directory and file templates for a named preset.
/// </summary>
/// <param name="presetName">The preset name (case-insensitive).</param>
/// <param name="directoryTemplate">The directory template for the preset.</param>
/// <param name="fileTemplate">The file template for the preset.</param>
/// <param name="collision">The collision behavior for the preset.</param>
/// <returns><see langword="true"/> if the preset was found; otherwise <see langword="false"/>.</returns>
public static bool TryGetPreset(
string presetName,
out string directoryTemplate,
out string fileTemplate,
out CollisionBehavior collision)
{
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryGetPreset will throw a NullReferenceException when presetName is null because it calls ToLowerInvariant() unconditionally. Since this is a public API and follows a 'Try*' pattern, it should return false for null/empty input (and set outputs to safe defaults) rather than throwing.

Suggested change
{
{
if (string.IsNullOrEmpty(presetName))
{
directoryTemplate = string.Empty;
fileTemplate = string.Empty;
collision = CollisionBehavior.AppendCounter;
return false;
}

Copilot uses AI. Check for mistakes.
switch (presetName.ToLowerInvariant())
{
case CI:
directoryTemplate = "{" + ArtifactNameTokens.TestResultsDirectory + "}";
fileTemplate = "{" + ArtifactNameTokens.AssemblyName + "}_{" + ArtifactNameTokens.Tfm + "}_{" + ArtifactNameTokens.Architecture + "}";
collision = CollisionBehavior.Overwrite;
return true;

case Local:
directoryTemplate = "{" + ArtifactNameTokens.TestResultsDirectory + "}/{" + ArtifactNameTokens.Timestamp + "}";
fileTemplate = "{" + ArtifactNameTokens.AssemblyName + "}_{" + ArtifactNameTokens.Tfm + "}_{" + ArtifactNameTokens.Architecture + "}";
collision = CollisionBehavior.AppendCounter;
return true;

case Detailed:
directoryTemplate = "{" + ArtifactNameTokens.TestResultsDirectory + "}/{" + ArtifactNameTokens.Timestamp + "}";
fileTemplate = "{" + ArtifactNameTokens.AssemblyName + "}_{" + ArtifactNameTokens.Tfm + "}_{" + ArtifactNameTokens.Architecture + "}_{" + ArtifactNameTokens.RunId + "}";
collision = CollisionBehavior.AppendCounter;
return true;

case Flat:
directoryTemplate = "{" + ArtifactNameTokens.TestResultsDirectory + "}";
fileTemplate = "{" + ArtifactNameTokens.AssemblyName + "}";
collision = CollisionBehavior.AppendCounter;
return true;

default:
directoryTemplate = string.Empty;
fileTemplate = string.Empty;
collision = CollisionBehavior.AppendCounter;
return false;
}
}

/// <summary>
/// Gets all known preset names.
/// </summary>
public static IReadOnlyList<string> All { get; } = new[] { CI, Local, Detailed, Flat };
}
Loading
Loading