From d51897bbd6c0ccbc1af6d94d9d9169f4ff6fb105 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:04:24 +0000 Subject: [PATCH 1/5] Initial plan From eb60a654e9bfc50dc1bf8a57c1792f96c668cc8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:09:25 +0000 Subject: [PATCH 2/5] Initial exploration and plan for artifact naming service Co-authored-by: nohwnd <5735905+nohwnd@users.noreply.github.com> --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 23153405ba..4196622d30 100644 --- a/global.json +++ b/global.json @@ -23,7 +23,7 @@ } }, "sdk": { - "version": "10.0.100-rc.2.25464.104", + "version": "8.0.119", "paths": [ ".dotnet", "$host$" From 32a34244862424207d701fe0cb6ce7a6de511d52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:15:20 +0000 Subject: [PATCH 3/5] Add artifact naming service implementation with backward compatibility Co-authored-by: nohwnd <5735905+nohwnd@users.noreply.github.com> --- .../HangDumpProcessLifetimeHandler.cs | 17 +- .../Hosts/TestHostBuilder.cs | 4 + .../Services/ArtifactNamingService.cs | 232 ++++++++++++++++++ .../Services/IArtifactNamingService.cs | 28 +++ .../Services/ServiceProviderExtensions.cs | 3 + .../Services/ArtifactNamingServiceTests.cs | 222 +++++++++++++++++ 6 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/Services/IArtifactNamingService.cs create mode 100644 test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingServiceTests.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs index 03130bdd14..5bc2dbf131 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs @@ -45,6 +45,7 @@ internal sealed class HangDumpProcessLifetimeHandler : ITestHostProcessLifetimeH private readonly ILogger _logger; private readonly ManualResetEventSlim _mutexNameReceived = new(false); private readonly ManualResetEventSlim _waitConsumerPipeName = new(false); + private readonly IArtifactNamingService _artifactNamingService; private TimeSpan _activityTimerValue = TimeSpan.FromMinutes(30); private Task? _waitConnectionTask; @@ -85,6 +86,7 @@ public HangDumpProcessLifetimeHandler( _processHandler = processHandler; _clock = clock; _testApplicationCancellationTokenSource = serviceProvider.GetTestApplicationCancellationTokenSource(); + _artifactNamingService = serviceProvider.GetArtifactNamingService(); _dumpFileNamePattern = $"{Path.GetFileNameWithoutExtension(testApplicationModuleInfo.GetCurrentTestApplicationFullPath())}_%p_hang.dmp"; } @@ -352,7 +354,20 @@ private async Task TakeDumpAsync() await _logger.LogInformationAsync($"Hang dump timeout({_activityTimerValue}) expired.").ConfigureAwait(false); await _outputDisplay.DisplayAsync(this, new ErrorMessageOutputDeviceData(string.Format(CultureInfo.InvariantCulture, ExtensionResources.HangDumpTimeoutExpired, _activityTimerValue))).ConfigureAwait(false); - string finalDumpFileName = _dumpFileNamePattern.Replace("%p", _testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture)); + // Create custom replacements for the dumped process + var customReplacements = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["process-name"] = _testHostProcessInformation.ProcessName, + ["pid"] = _testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture) + }; + + // Create legacy replacements for backward compatibility + var legacyReplacements = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["%p"] = _testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture) + }; + + string finalDumpFileName = _artifactNamingService.ResolveTemplateWithLegacySupport(_dumpFileNamePattern, customReplacements, legacyReplacements); finalDumpFileName = Path.Combine(_configuration.GetTestResultDirectory(), finalDumpFileName); ApplicationStateGuard.Ensure(_namedPipeClient is not null); diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs index 2a88a5c5e1..223779b5d3 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs @@ -101,6 +101,10 @@ public async Task BuildAsync( serviceProvider.TryAddService(systemMonitor); SystemMonitorAsyncFactory systemMonitorAsyncFactory = new(); serviceProvider.TryAddService(systemMonitorAsyncFactory); + + // Add artifact naming service + ArtifactNamingService artifactNamingService = new(_testApplicationModuleInfo, systemEnvironment, systemClock, processHandler); + serviceProvider.TryAddService(artifactNamingService); PlatformInformation platformInformation = new(); serviceProvider.AddService(platformInformation); diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs new file mode 100644 index 0000000000..2f5851d902 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs @@ -0,0 +1,232 @@ +// 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.Runtime.InteropServices; +using System.Text.RegularExpressions; + +using Microsoft.Testing.Platform.Helpers; + +namespace Microsoft.Testing.Platform.Services; + +internal sealed class ArtifactNamingService : IArtifactNamingService +{ + private readonly ITestApplicationModuleInfo _testApplicationModuleInfo; + private readonly IEnvironment _environment; + private readonly IClock _clock; + private readonly IProcessHandler _processHandler; + + private static readonly Regex TemplateFieldRegex = new(@"<([^>]+)>", RegexOptions.Compiled); + + public ArtifactNamingService( + ITestApplicationModuleInfo testApplicationModuleInfo, + IEnvironment environment, + IClock clock, + IProcessHandler processHandler) + { + _testApplicationModuleInfo = testApplicationModuleInfo; + _environment = environment; + _clock = clock; + _processHandler = processHandler; + } + + public string ResolveTemplate(string template, IDictionary? customReplacements = null) + { + ArgumentGuard.IsNotNullOrEmpty(template); + + var defaultReplacements = GetDefaultReplacements(); + var allReplacements = MergeReplacements(defaultReplacements, customReplacements); + + return TemplateFieldRegex.Replace(template, match => + { + string fieldName = match.Groups[1].Value; + return allReplacements.TryGetValue(fieldName, out string? value) ? value : match.Value; + }); + } + + public string ResolveTemplateWithLegacySupport(string template, IDictionary? customReplacements = null, IDictionary? legacyReplacements = null) + { + ArgumentGuard.IsNotNullOrEmpty(template); + + // First apply legacy replacements + string processedTemplate = template; + if (legacyReplacements is not null) + { + foreach (var (legacyPattern, replacement) in legacyReplacements) + { + processedTemplate = processedTemplate.Replace(legacyPattern, replacement); + } + } + + // Then apply modern template resolution + return ResolveTemplate(processedTemplate, customReplacements); + } + + private Dictionary GetDefaultReplacements() + { + var replacements = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Assembly info + string? assemblyName = _testApplicationModuleInfo.TryGetAssemblyName(); + if (!RoslynString.IsNullOrEmpty(assemblyName)) + { + replacements["assembly"] = assemblyName; + } + + // Process info + using var currentProcess = _processHandler.GetCurrentProcess(); + replacements["pid"] = currentProcess.Id.ToString(CultureInfo.InvariantCulture); + replacements["process-name"] = currentProcess.ProcessName; + + // OS info + replacements["os"] = GetOperatingSystemName(); + + // Target framework info + string tfm = GetTargetFrameworkMoniker(); + if (!RoslynString.IsNullOrEmpty(tfm)) + { + replacements["tfm"] = tfm; + } + + // Time info (precision to 1 second) + replacements["time"] = _clock.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture); + + // Random ID for uniqueness + replacements["id"] = GenerateShortId(); + + // Root directory + replacements["root"] = GetRootDirectory(); + + return replacements; + } + + private static Dictionary MergeReplacements(Dictionary defaultReplacements, IDictionary? customReplacements) + { + if (customReplacements is null || customReplacements.Count == 0) + { + return defaultReplacements; + } + + var merged = new Dictionary(defaultReplacements, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in customReplacements) + { + merged[key] = value; + } + + return merged; + } + + private static string GetOperatingSystemName() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "windows"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "linux"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "macos"; + } + + return "unknown"; + } + + private static string GetTargetFrameworkMoniker() + { + // Extract TFM from current runtime + string frameworkDescription = RuntimeInformation.FrameworkDescription; + + if (frameworkDescription.Contains(".NET Core")) + { + // Try to extract version from .NET Core description + var match = Regex.Match(frameworkDescription, @"\.NET Core (\d+\.\d+)"); + if (match.Success) + { + return $"netcoreapp{match.Groups[1].Value}"; + } + } + else if (frameworkDescription.Contains(".NET ")) + { + // Try to extract version from .NET 5+ description + var match = Regex.Match(frameworkDescription, @"\.NET (\d+\.\d+)"); + if (match.Success) + { + string version = match.Groups[1].Value; + return version switch + { + "5.0" => "net5.0", + "6.0" => "net6.0", + "7.0" => "net7.0", + "8.0" => "net8.0", + "9.0" => "net9.0", + "10.0" => "net10.0", + _ => $"net{version}" + }; + } + } + else if (frameworkDescription.Contains(".NET Framework")) + { + // Try to extract version from .NET Framework description + var match = Regex.Match(frameworkDescription, @"\.NET Framework (\d+\.\d+)"); + if (match.Success) + { + return $"net{match.Groups[1].Value.Replace(".", "")}"; + } + } + + return Environment.Version.ToString(); + } + + private static string GenerateShortId() + { + return Guid.NewGuid().ToString("N")[..8]; + } + + private string GetRootDirectory() + { + string currentDirectory = _testApplicationModuleInfo.GetCurrentTestApplicationDirectory(); + + // Try to find solution root, git root, or working directory + string? rootDirectory = FindSolutionRoot(currentDirectory) + ?? FindGitRoot(currentDirectory) + ?? currentDirectory; + + return rootDirectory; + } + + private static string? FindSolutionRoot(string startDirectory) + { + string? directory = startDirectory; + while (directory is not null) + { + if (Directory.GetFiles(directory, "*.sln").Length > 0) + { + return directory; + } + + directory = Directory.GetParent(directory)?.FullName; + } + + return null; + } + + private static string? FindGitRoot(string startDirectory) + { + string? directory = startDirectory; + while (directory is not null) + { + if (Directory.Exists(Path.Combine(directory, ".git"))) + { + return directory; + } + + directory = Directory.GetParent(directory)?.FullName; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Platform/Services/IArtifactNamingService.cs b/src/Platform/Microsoft.Testing.Platform/Services/IArtifactNamingService.cs new file mode 100644 index 0000000000..aa2ac0bcf4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Services/IArtifactNamingService.cs @@ -0,0 +1,28 @@ +// 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.Testing.Platform.Services; + +/// +/// Service for generating consistent artifact names and paths using template patterns. +/// Supports placeholders like <process-name>, <pid>, <id>, <os>, <assembly>, <tfm>, <time>, <root>. +/// +internal interface IArtifactNamingService +{ + /// + /// Resolves a template pattern with available field replacements. + /// + /// Template pattern with placeholders like '<process-name>_<pid>_hang.dmp'. + /// Optional custom field replacements to override default values. + /// Resolved string with placeholders replaced by actual values. + string ResolveTemplate(string template, IDictionary? customReplacements = null); + + /// + /// Resolves a template pattern with backward compatibility for legacy patterns. + /// + /// Template pattern that may contain legacy patterns like '%p'. + /// Optional custom field replacements to override default values. + /// Legacy pattern replacements for backward compatibility. + /// Resolved string with both new and legacy placeholders replaced. + string ResolveTemplateWithLegacySupport(string template, IDictionary? customReplacements = null, IDictionary? legacyReplacements = null); +} \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs b/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs index 1c74ea6a2d..d806c26832 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs @@ -206,4 +206,7 @@ internal static IPlatformInformation GetPlatformInformation(this IServiceProvide internal static IFileLoggerInformation? GetFileLoggerInformation(this IServiceProvider serviceProvider) => serviceProvider.GetServiceInternal(); + + internal static IArtifactNamingService GetArtifactNamingService(this IServiceProvider serviceProvider) + => serviceProvider.GetRequiredServiceInternal(); } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingServiceTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingServiceTests.cs new file mode 100644 index 0000000000..92df36a88a --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingServiceTests.cs @@ -0,0 +1,222 @@ +// 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.Runtime.InteropServices; + +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Services; + +using Moq; + +namespace Microsoft.Testing.Platform.UnitTests.Services; + +[TestClass] +public sealed class ArtifactNamingServiceTests +{ + private readonly Mock _testApplicationModuleInfo = new(); + private readonly Mock _environment = new(); + private readonly Mock _clock = new(); + private readonly Mock _processHandler = new(); + private readonly Mock _process = new(); + + [TestInitialize] + public void TestInitialize() + { + _testApplicationModuleInfo.Setup(x => x.TryGetAssemblyName()).Returns("TestAssembly"); + _testApplicationModuleInfo.Setup(x => x.GetCurrentTestApplicationDirectory()).Returns("/test/directory"); + + _clock.Setup(x => x.UtcNow).Returns(new DateTimeOffset(2025, 9, 22, 13, 49, 34, TimeSpan.Zero)); + + _process.Setup(x => x.Id).Returns(12345); + _process.Setup(x => x.ProcessName).Returns("test-process"); + _processHandler.Setup(x => x.GetCurrentProcess()).Returns(_process.Object); + } + + [TestMethod] + public void ResolveTemplate_WithBasicPlaceholders_ReplacesCorrectly() + { + // Arrange + var service = new ArtifactNamingService(_testApplicationModuleInfo.Object, _environment.Object, _clock.Object, _processHandler.Object); + string template = "__.dmp"; + + // Act + string result = service.ResolveTemplate(template); + + // Assert + result.Should().Contain("test-process"); + result.Should().Contain("12345"); + result.Should().Contain("TestAssembly"); + result.Should().MatchRegex(@"test-process_12345_TestAssembly\.dmp"); + } + + [TestMethod] + public void ResolveTemplate_WithTimeAndIdPlaceholders_ReplacesCorrectly() + { + // Arrange + var service = new ArtifactNamingService(_testApplicationModuleInfo.Object, _environment.Object, _clock.Object, _processHandler.Object); + string template = "