From 3640da3362a96ff4ad75f2f2f19de9b42edba508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 9 Jul 2026 21:03:12 +0200 Subject: [PATCH] Add public injectable IArtifactNamingService to Microsoft.Testing.Platform Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Hosts/TestHostBuilder.CommonServices.cs | 1 + .../InternalAPI/InternalAPI.Unshipped.txt | 9 +- .../PublicAPI/PublicAPI.Unshipped.txt | 3 + .../Services/ArtifactFileNameSanitizer.cs | 55 +++++++++++ .../Services/ArtifactNamingService.cs | 39 ++++++++ .../Services/IArtifactNamingService.cs | 34 +++++++ .../Services/ServiceProviderExtensions.cs | 8 ++ .../Services/ArtifactNamingServiceTests.cs | 96 +++++++++++++++++++ 8 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/Services/ArtifactFileNameSanitizer.cs 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.Platform/Hosts/TestHostBuilder.CommonServices.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs index ed2953eb53..c1b612915d 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.CommonServices.cs @@ -64,6 +64,7 @@ private async Task SetupCommonServicesAsync( serviceProvider.TryAddService(_testApplicationModuleInfo); serviceProvider.TryAddService(testHostControllerInfo); serviceProvider.TryAddService(systemClock); + serviceProvider.TryAddService(new ArtifactNamingService(_testApplicationModuleInfo, systemEnvironment, systemClock)); SystemMonitor systemMonitor = new(); serviceProvider.TryAddService(systemMonitor); diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index 7676911637..b0b6652dd9 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -4,6 +4,10 @@ Microsoft.Testing.Platform.ServerMode.ClientCapabilities.ClientCapabilities(bool Microsoft.Testing.Platform.ServerMode.ClientCapabilities.Deconstruct(out bool DebuggerProvider, out bool IsStateful) -> void Microsoft.Testing.Platform.ServerMode.ClientCapabilities.IsStateful.get -> bool Microsoft.Testing.Platform.ServerMode.ClientCapabilities.IsStateful.init -> void +Microsoft.Testing.Platform.Services.ArtifactFileNameSanitizer +Microsoft.Testing.Platform.Services.ArtifactNamingService +Microsoft.Testing.Platform.Services.ArtifactNamingService.ArtifactNamingService(Microsoft.Testing.Platform.Services.ITestApplicationModuleInfo! testApplicationModuleInfo, Microsoft.Testing.Platform.Helpers.IEnvironment! environment, Microsoft.Testing.Platform.Helpers.IClock! clock) -> void +Microsoft.Testing.Platform.Services.ArtifactNamingService.ResolveFileName(string! template) -> string! Microsoft.Testing.Platform.Services.ClientCapabilitiesService Microsoft.Testing.Platform.Services.ClientCapabilitiesService.$() -> Microsoft.Testing.Platform.Services.ClientCapabilitiesService! Microsoft.Testing.Platform.Services.ClientCapabilitiesService.ClientCapabilitiesService(bool IsStateful) -> void @@ -18,11 +22,12 @@ Microsoft.Testing.Platform.Services.ClientInfoService.Deconstruct(out string! Id override Microsoft.Testing.Platform.Services.ClientCapabilitiesService.Equals(object? obj) -> bool override Microsoft.Testing.Platform.Services.ClientCapabilitiesService.GetHashCode() -> int override Microsoft.Testing.Platform.Services.ClientCapabilitiesService.ToString() -> string! +static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void +static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void +static Microsoft.Testing.Platform.Services.ArtifactFileNameSanitizer.ReplaceInvalidFileNameChars(string! fileName) -> string! static Microsoft.Testing.Platform.Services.ClientCapabilitiesService.operator !=(Microsoft.Testing.Platform.Services.ClientCapabilitiesService? left, Microsoft.Testing.Platform.Services.ClientCapabilitiesService? right) -> bool static Microsoft.Testing.Platform.Services.ClientCapabilitiesService.operator ==(Microsoft.Testing.Platform.Services.ClientCapabilitiesService? left, Microsoft.Testing.Platform.Services.ClientCapabilitiesService? right) -> bool *REMOVED*Microsoft.Testing.Platform.ServerMode.ClientCapabilities.ClientCapabilities(bool DebuggerProvider) -> void *REMOVED*Microsoft.Testing.Platform.ServerMode.ClientCapabilities.Deconstruct(out bool DebuggerProvider) -> void *REMOVED*Microsoft.Testing.Platform.Services.ClientInfoService.ClientInfoService(string! Id, string! Version) -> void *REMOVED*Microsoft.Testing.Platform.Services.ClientInfoService.Deconstruct(out string! Id, out string! Version) -> void -static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void -static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 01eaf70577..2e89dae49d 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -2,3 +2,6 @@ [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities.IsStateful.get -> bool [TPEXP]Microsoft.Testing.Platform.Services.IClientInfo.Capabilities.get -> Microsoft.Testing.Platform.Services.IClientCapabilities! +Microsoft.Testing.Platform.Services.IArtifactNamingService +Microsoft.Testing.Platform.Services.IArtifactNamingService.ResolveFileName(string! template) -> string! +static Microsoft.Testing.Platform.Services.ServiceProviderExtensions.GetArtifactNamingService(this System.IServiceProvider! serviceProvider) -> Microsoft.Testing.Platform.Services.IArtifactNamingService! diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ArtifactFileNameSanitizer.cs b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactFileNameSanitizer.cs new file mode 100644 index 0000000000..6d9361376e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactFileNameSanitizer.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Helpers; + +namespace Microsoft.Testing.Platform.Services; + +// Mirrors the extension-side Microsoft.Testing.Extensions.ReportFileNameSanitizer (linked source in +// src/Platform/SharedExtensionHelpers/ReportFileNameSanitizer.cs). The extension copy has SHIPPED +// InternalAPI entries in five extensions, so it cannot be moved without churn/risk. This core-internal +// copy exists pending future consolidation of the two. +internal static partial class ArtifactFileNameSanitizer +{ + private const char ReplacementChar = '_'; + private static readonly Regex ReservedFileNamesRegex = BuildReservedFileNameRegex(); + + internal static string ReplaceInvalidFileNameChars(string fileName) + { + char[] result = new char[fileName.Length]; + + for (int i = 0; i < fileName.Length; ++i) + { + result[i] = IsInvalidFileNameChar(fileName[i]) ? ReplacementChar : fileName[i]; + } + + string replaced = new string(result).TrimEnd(); + ArgumentGuard.Ensure(replaced.Length > 0, nameof(fileName), $"File name {fileName} is empty after sanitizing invalid characters."); + + if (IsReservedFileName(replaced)) + { + replaced = ReplacementChar + replaced; // Cannot add to the end because it can have extensions. + } + + return replaced; + } + + private static bool IsInvalidFileNameChar(char c) + => c is < ' ' or '"' or '<' or '>' or '|' or ':' or '*' or '?' or '\\' or '/' or '@' or '(' or ')' or '^' or ' '; + + private static bool IsReservedFileName(string fileName) => + // CreateFile: + // The following reserved device names cannot be used as the name of a file: + // CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9, + // LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, and LPT9. + // Also avoid these names followed by an extension, for example, NUL.tx7. + // Windows NT: CLOCK$ is also a reserved device name. + ReservedFileNamesRegex.IsMatch(fileName); + +#if NET7_0_OR_GREATER + [GeneratedRegex(@"(?i:^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CLOCK\$)(\..*)?)$", RegexOptions.None, "en-150")] + private static partial Regex BuildReservedFileNameRegex(); +#else + private static Regex BuildReservedFileNameRegex() => new(@"(?i:^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CLOCK\$)(\..*)?)$"); +#endif +} 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..352e39ad0e --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +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; + + public ArtifactNamingService(ITestApplicationModuleInfo testApplicationModuleInfo, IEnvironment environment, IClock clock) + { + _testApplicationModuleInfo = testApplicationModuleInfo; + _environment = environment; + _clock = clock; + } + + public string ResolveFileName(string template) + { + string processName = Path.GetFileNameWithoutExtension(_testApplicationModuleInfo.GetCurrentTestApplicationFullPath()); + string processId = _environment.ProcessId.ToString(CultureInfo.InvariantCulture); + Dictionary replacements = ArtifactNamingHelper.GetStandardReplacements(processName, processId, _clock.UtcNow); + string resolved = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + string directoryPart = Path.GetDirectoryName(resolved) ?? string.Empty; + string leafName = Path.GetFileName(resolved); + if (leafName.Length == 0) + { + throw new ArgumentException("The resolved template must produce a file name, not just a directory path.", nameof(template)); + } + + string sanitized = ArtifactFileNameSanitizer.ReplaceInvalidFileNameChars(leafName); + + return directoryPart.Length == 0 ? sanitized : Path.Combine(directoryPart, sanitized); + } +} 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..863c47f78b --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Services/IArtifactNamingService.cs @@ -0,0 +1,34 @@ +// 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; + +/// +/// Provides file-name templating for artifacts produced by the test platform and its extensions. +/// +/// +/// The service expands the standard placeholders and sanitizes the resulting leaf file name so it is +/// valid on the current operating system. It is registered as a common service and can be resolved +/// through . +/// +public interface IArtifactNamingService +{ + /// + /// Resolves a file-name template into a concrete file name. + /// + /// + /// The template to resolve. The following placeholders are expanded: + /// + /// {pname} — the current test application process name. + /// {pid} — the current process id. + /// {asm} — the entry assembly name (or unknown if not available). + /// {tfm} — the short target framework moniker including platform (or unknown if not available). + /// {arch} — the process architecture. + /// {time} — the current UTC timestamp. + /// + /// Unknown placeholders are preserved as-is. Any directory portion of the template is preserved + /// while only the leaf file name is sanitized. + /// + /// The resolved file name, with the leaf sanitized to be valid for the current file system. + string ResolveFileName(string template); +} diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs b/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs index 6bea6de92d..fc1f4e2c13 100644 --- a/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs +++ b/src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs @@ -71,6 +71,14 @@ public static IMessageBus GetMessageBus(this IServiceProvider serviceProvider) public static IConfiguration GetConfiguration(this IServiceProvider serviceProvider) => serviceProvider.GetRequiredService(); + /// + /// Gets the artifact naming service from the . + /// + /// The service provider. + /// The artifact naming service. + public static IArtifactNamingService GetArtifactNamingService(this IServiceProvider serviceProvider) + => serviceProvider.GetRequiredService(); + /// /// Gets the command line options from the . /// 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..3640c3e0c4 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingServiceTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +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 _moduleInfoMock = new(); + private readonly Mock _environmentMock = new(); + private readonly Mock _clockMock = new(); + + private ArtifactNamingService CreateService(string modulePath = "MyApp.dll", int processId = 4242) + { + _ = _moduleInfoMock.Setup(x => x.GetCurrentTestApplicationFullPath()).Returns(modulePath); + _ = _environmentMock.SetupGet(x => x.ProcessId).Returns(processId); + _ = _clockMock.SetupGet(x => x.UtcNow).Returns(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero)); + return new ArtifactNamingService(_moduleInfoMock.Object, _environmentMock.Object, _clockMock.Object); + } + + [TestMethod] + public void ResolveFileName_ExpandsStandardPlaceholders() + { + ArtifactNamingService service = CreateService(modulePath: Path.Combine("dir", "MyApp.dll"), processId: 4242); + + string result = service.ResolveFileName("{pname}_{pid}_{time}.json"); + + Assert.StartsWith("MyApp_4242_", result); + Assert.Contains("2026-01-02_03-04-05", result); + Assert.EndsWith(".json", result); + Assert.DoesNotContain("{pname}", result); + Assert.DoesNotContain("{pid}", result); + Assert.DoesNotContain("{time}", result); + } + + [TestMethod] + public void ResolveFileName_SanitizesInvalidCharactersInExpandedName() + { + ArtifactNamingService service = CreateService(modulePath: Path.Combine("dir", "My*App.dll")); + + string result = service.ResolveFileName("{pname}.json"); + + Assert.AreEqual("My_App.json", result); + } + + [TestMethod] + public void ResolveFileName_PrefixesReservedDeviceName() + { + // A {pname} that resolves to a reserved Windows device name (CON/PRN/NUL/COM1/...) + // must be prefixed with '_' by the sanitizer so it is a valid file name. + ArtifactNamingService service = CreateService(modulePath: Path.Combine("dir", "CON.dll")); + + string result = service.ResolveFileName("{pname}.json"); + + Assert.AreEqual("_CON.json", result); + } + + [TestMethod] + public void ResolveFileName_PreservesDirectoryPortionAndSanitizesLeaf() + { + ArtifactNamingService service = CreateService(modulePath: Path.Combine("dir", "My*App.dll")); + + string template = Path.Combine("sub", "dir", "{pname}.json"); + string result = service.ResolveFileName(template); + + Assert.AreEqual(Path.Combine("sub", "dir", "My_App.json"), result); + } + + [TestMethod] + public void ResolveFileName_LeavesUnknownPlaceholderAsIs() + { + ArtifactNamingService service = CreateService(); + + string result = service.ResolveFileName("{unknown}_{pname}.json"); + + Assert.Contains("{unknown}", result); + Assert.Contains("MyApp", result); + } + + [TestMethod] + public void ResolveFileName_ThrowsWhenTemplateHasNoLeafFileName() + { + // A template that resolves to a directory path only (no leaf file name, e.g. ending in a + // directory separator) is rejected with a clear ArgumentException at the service boundary. + ArtifactNamingService service = CreateService(); + + string template = "outdir" + Path.DirectorySeparatorChar; + + Assert.ThrowsExactly(() => service.ResolveFileName(template)); + } +}