diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 58629d5eca..9ea123433b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,4 +10,4 @@ "rollForward": false } } -} +} \ No newline at end of file diff --git a/docs/microsoft.testing.platform/ArtifactNamingHelper.md b/docs/microsoft.testing.platform/ArtifactNamingHelper.md new file mode 100644 index 0000000000..8e9f8957a7 --- /dev/null +++ b/docs/microsoft.testing.platform/ArtifactNamingHelper.md @@ -0,0 +1,54 @@ +# Artifact Naming Helper + +The `ArtifactNamingHelper` is a shared static helper that provides a standardized way to generate consistent names and paths for test artifacts. It is compiled into each extension that needs it via file linking (no service registration or IVT required). + +## Template-Based Naming + +Use placeholders in curly braces to create dynamic file names. Placeholder matching is **case-sensitive** — use lowercase placeholder names (e.g., `{pname}`, not `{PName}`). + +```text +{pname}_{pid}_{time}_hang.dmp +``` + +Resolves to: `MyTests_12345_2025-09-22_13-49-34.0000000_hang.dmp` + +## Available Placeholders + +| Placeholder | Description | Example | +|-------------|-------------|---------| +| `{pname}` | Name of the process | `MyTests` | +| `{pid}` | Process ID | `12345` | +| `{asm}` | Assembly name (entry assembly, or `unknown` if unavailable) | `MyTests` | +| `{tfm}` | Target framework moniker (best-effort, detected at runtime — may differ from the build-time TFM, e.g. a `net462` assembly running on .NET Framework 4.8 reports `net481`) | `net9.0`, `net8.0` | +| `{time}` | Timestamp (high precision) | `2025-09-22_13-49-34.0000000` | + +## Backward Compatibility + +Legacy patterns like `%p` continue to work in the hang dump extension. + +## Custom Replacements + +Override default values for specific scenarios: + +```csharp +var replacements = new Dictionary +{ + ["pname"] = "Notepad", + ["pid"] = "1111" +}; + +string result = ArtifactNamingHelper.ResolveTemplate("{pname}_{pid}.dmp", replacements); +// Result: "Notepad_1111.dmp" +``` + +## Hang Dump Integration + +The hang dump extension uses the artifact naming helper and supports both legacy and modern patterns: + +```text +# Legacy pattern (still works) +--hangdump-filename "mydump_%p.dmp" + +# New template pattern +--hangdump-filename "{pname}_{pid}_{time}_hang.dmp" +``` diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs index 14d7196a1a..a3ea2ee55f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs @@ -16,6 +16,7 @@ using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.Messages; using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Services; #if NETCOREAPP using Microsoft.Diagnostics.NETCore.Client; @@ -118,10 +119,10 @@ public async Task BeforeTestHostProcessStartAsync(CancellationToken cancellation _waitConnectionTask = _task.Run( async () => - { - await _logger.LogDebugAsync($"Waiting for connection to {_singleConnectionNamedPipeServer.PipeName.Name}").ConfigureAwait(false); - await _singleConnectionNamedPipeServer.WaitConnectionAsync(cancellationToken).TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, cancellationToken).ConfigureAwait(false); - }, cancellationToken); + { + await _logger.LogDebugAsync($"Waiting for connection to {_singleConnectionNamedPipeServer.PipeName.Name}").ConfigureAwait(false); + await _singleConnectionNamedPipeServer.WaitConnectionAsync(cancellationToken).TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout, cancellationToken).ConfigureAwait(false); + }, cancellationToken); } private async Task CallbackAsync(IRequest request) @@ -307,14 +308,40 @@ private async Task TakeDumpAsync(IProcess process, CancellationToken cancellatio ApplicationStateGuard.Ensure(_testHostProcessInformation is not null); ApplicationStateGuard.Ensure(_dumpType is not null); - string finalDumpFileName = (_dumpFileNamePattern ?? $"{process.Name}_%p_hang.dmp").Replace("%p", process.Id.ToString(CultureInfo.InvariantCulture)); - finalDumpFileName = Path.Combine(_configuration.GetTestResultDirectory(), finalDumpFileName); + string processId = process.Id.ToString(CultureInfo.InvariantCulture); + Dictionary replacements = ArtifactNamingHelper.GetStandardReplacements(process.Name, processId, _clock.UtcNow); + + string pattern = _dumpFileNamePattern ?? $"{process.Name}_%p_hang.dmp"; + + // First resolve {placeholder} templates, then handle legacy %p pattern for backward compatibility. + string finalDumpFileName = ArtifactNamingHelper.ResolveTemplate(pattern, replacements) + .Replace("%p", processId); + string resultsDirectory = Path.GetFullPath(_configuration.GetTestResultDirectory()); + finalDumpFileName = Path.GetFullPath(Path.Combine(resultsDirectory, finalDumpFileName)); + + // Reject resolved paths that escape the results directory (e.g. rooted paths or ".." segments). + // Append a trailing separator to prevent sibling-directory bypass (e.g. "/tmp/results" vs "/tmp/results-evil"). + // Use case-insensitive comparison on Windows where paths are case-insensitive. + StringComparison pathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string separatorStr = Path.DirectorySeparatorChar.ToString(); + string resultsDirectoryGuard = resultsDirectory.EndsWith(separatorStr, StringComparison.Ordinal) + ? resultsDirectory + : resultsDirectory + separatorStr; + if (!finalDumpFileName.StartsWith(resultsDirectoryGuard, pathComparison)) + { + throw new InvalidOperationException($"The resolved dump file path '{finalDumpFileName}' is outside the results directory '{resultsDirectory}'. Ensure --hangdump-filename is a relative path without '..' segments."); + } + + // Ensure the destination directory exists (templates may include directory separators, e.g. {asm}/{pname}). + Directory.CreateDirectory(Path.GetDirectoryName(finalDumpFileName)!); ApplicationStateGuard.Ensure(_namedPipeClient is not null); GetInProgressTestsResponse tests = await _namedPipeClient.RequestReplyAsync(new GetInProgressTestsRequest(), cancellationToken).ConfigureAwait(false); if (tests.Tests.Length > 0) { - string hangTestsFileName = Path.Combine(_configuration.GetTestResultDirectory(), Path.ChangeExtension(Path.GetFileName(finalDumpFileName), ".log")); + string hangTestsFileName = Path.ChangeExtension(finalDumpFileName, ".log"); using (FileStream fs = File.OpenWrite(hangTestsFileName)) using (StreamWriter sw = new(fs)) { diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj b/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj index 7965c8bfa1..f8686a26b9 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/Microsoft.Testing.Extensions.HangDump.csproj @@ -40,6 +40,8 @@ This package extends Microsoft Testing Platform to provide an implementation of + + diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingHelper.cs b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingHelper.cs new file mode 100644 index 0000000000..61923521b9 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingHelper.cs @@ -0,0 +1,74 @@ +// 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.CodeAnalysis; +using Microsoft.Testing.Platform.OutputDevice; + +namespace Microsoft.Testing.Platform.Services; + +[Embedded] +internal static partial class ArtifactNamingHelper +{ +#if NET + private static readonly Regex TemplateFieldRegex = GetTemplateFieldRegex(); + + [GeneratedRegex(@"\{([^}]+)\}")] + private static partial Regex GetTemplateFieldRegex(); +#else + private static readonly Regex TemplateFieldRegex = new(@"\{([^}]+)\}", RegexOptions.Compiled); +#endif + + /// + /// Builds the standard set of placeholder replacements for artifact naming. + /// Consumers pass process-specific values; the helper resolves the rest (asm, tfm). + /// + /// The name of the process (resolves {pname}). + /// The process ID (resolves {pid}). + /// The timestamp to use (resolves {time}). + public static Dictionary GetStandardReplacements(string processName, string processId, DateTimeOffset timestamp) + { + var replacements = new Dictionary(StringComparer.Ordinal) + { + ["pname"] = processName, + ["pid"] = processId, + ["time"] = timestamp.ToString("yyyy-MM-dd_HH-mm-ss.fffffff", CultureInfo.InvariantCulture), + }; + + string? asmName = Assembly.GetEntryAssembly()?.GetName().Name; + replacements["asm"] = asmName ?? "unknown"; + + string? tfm = TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) + ?? TargetFrameworkParser.GetShortTargetFramework(RuntimeInformation.FrameworkDescription); + replacements["tfm"] = tfm ?? "unknown"; + + return replacements; + } + + /// + /// Resolves a template pattern by replacing {placeholder} tokens with values from the provided dictionary. + /// Unknown placeholders are preserved as-is. Placeholder matching is always case-sensitive (ordinal). + /// + public static string ResolveTemplate(string template, IDictionary? replacements = null) + { + if (RoslynString.IsNullOrWhiteSpace(template)) + { + throw new ArgumentException("Template cannot be null, empty, or whitespace.", nameof(template)); + } + + if (replacements is null || replacements.Count == 0) + { + return template; + } + + // Ensure ordinal (case-sensitive) comparison regardless of the caller-provided dictionary comparer. + Dictionary ordinalReplacements = replacements is Dictionary dict && dict.Comparer == StringComparer.Ordinal + ? dict + : new Dictionary(replacements, StringComparer.Ordinal); + + return TemplateFieldRegex.Replace(template, match => + { + string fieldName = match.Groups[1].Value; + return ordinalReplacements.TryGetValue(fieldName, out string? value) ? value : match.Value; + }); + } +} diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs index 81137e9daf..810845381e 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs @@ -16,8 +16,8 @@ public async Task HangDump_DefaultSetting_CreateDump(string tfm) $"--hangdump --hangdump-timeout 8s --results-directory {resultDirectory}", new Dictionary { - { "SLEEPTIMEMS1", "4000" }, - { "SLEEPTIMEMS2", "600000" }, + { "SLEEPTIMEMS1", "4000" }, + { "SLEEPTIMEMS2", "600000" }, }, cancellationToken: TestContext.CancellationToken); testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); @@ -104,6 +104,88 @@ public async Task HangDump_PathWithSpaces_CreateDump() Assert.IsNotNull(dumpFile, $"Dump file not found '{TargetFrameworks.NetCurrent}'\n{testHostResult}'"); } + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task HangDump_TemplateFileName_CreateDump(string tfm) + { + string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N"), tfm); + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, "HangDump", tfm); + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--hangdump --hangdump-timeout 8s --hangdump-filename {{pname}}_{{pid}}_{{tfm}}_{{time}}_hang.dmp --results-directory {resultDirectory}", + new Dictionary + { + ["SLEEPTIMEMS1"] = "4000", + ["SLEEPTIMEMS2"] = "20000", + }, + cancellationToken: TestContext.CancellationToken); + testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); + + // Verify the dump file uses the template format + string[] dumpFiles = Directory.GetFiles(resultDirectory, "*_hang.dmp", SearchOption.AllDirectories); + string dumpFile = Assert.ContainsSingle( + dumpFiles, + $"Expected single dump file in '{resultDirectory}'\n{testHostResult}'"); + string fileName = Path.GetFileNameWithoutExtension(dumpFile); + + // File should match pattern: {pname}_{pid}_{tfm}_{time}_hang + // where {tfm} is e.g. net10.0, net8.0, net462 + // and {time} is yyyy-MM-dd_HH-mm-ss.fffffff + Assert.MatchesRegex(@"^.+_\d+_net[\w.]+_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d{7}_hang$", fileName, + $"File name should match '{{pname}}_{{pid}}_{{tfm}}_{{time}}_hang' pattern. Actual: {fileName}"); + + // Verify the TFM segment matches the expected target framework + Assert.Contains($"_{tfm}_", fileName, $"File name should contain the TFM '{tfm}'. Actual: {fileName}"); + } + + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + [TestMethod] + public async Task HangDump_TemplateFileNameWithSubdirectory_CreateDump(string tfm) + { + string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N"), tfm); + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, "HangDump", tfm); + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--hangdump --hangdump-timeout 8s --hangdump-filename {{asm}}/{{pname}}_{{pid}}_hang.dmp --results-directory {resultDirectory}", + new Dictionary + { + ["SLEEPTIMEMS1"] = "4000", + ["SLEEPTIMEMS2"] = "20000", + }, + cancellationToken: TestContext.CancellationToken); + testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); + + // Verify the dump file was created inside a subdirectory named after the assembly + string[] dumpFiles = Directory.GetFiles(resultDirectory, "*_hang.dmp", SearchOption.AllDirectories); + string dumpFile = Assert.ContainsSingle( + dumpFiles, + $"Expected single dump file in '{resultDirectory}'\n{testHostResult}'"); + + // The dump file should be in a subdirectory named after the assembly + string? parentDir = Path.GetDirectoryName(dumpFile); + Assert.IsNotNull(parentDir); + Assert.AreNotEqual(resultDirectory, parentDir, "Dump file should be in a subdirectory created from the {asm} placeholder"); + Assert.AreEqual("HangDump", Path.GetFileName(parentDir), + $"Subdirectory should be named after the assembly ('HangDump'). Actual: {Path.GetFileName(parentDir)}"); + } + + [TestMethod] + public async Task HangDump_TemplateWithPathTraversal_RejectsAndFails() + { + string resultDirectory = Path.Combine(AssetFixture.TargetAssetPath, Guid.NewGuid().ToString("N"), TargetFrameworks.NetCurrent); + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, "HangDump", TargetFrameworks.NetCurrent); + TestHostResult testHostResult = await testHost.ExecuteAsync( + $"--hangdump --hangdump-timeout 8s --hangdump-filename ../../outside/{{pname}}_hang.dmp --results-directory {resultDirectory}", + new Dictionary + { + ["SLEEPTIMEMS1"] = "4000", + ["SLEEPTIMEMS2"] = "20000", + }, + cancellationToken: TestContext.CancellationToken); + + // The path-traversal guard should cause a non-graceful exit and no dump file should be created outside the results directory. + testHostResult.AssertExitCodeIs(ExitCode.TestHostProcessExitedNonGracefully); + Assert.Contains("outside the results directory", testHostResult.StandardOutput); + } + [DataRow("Mini")] [DataRow("Heap")] [DataRow("Triage")] diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj index 64c5b1d23a..2723a4181b 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Microsoft.Testing.Platform.UnitTests.csproj @@ -32,6 +32,7 @@ + diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingHelperTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingHelperTests.cs new file mode 100644 index 0000000000..ddefd6fcec --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingHelperTests.cs @@ -0,0 +1,214 @@ +// 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.Services; + +namespace Microsoft.Testing.Platform.UnitTests.Services; + +[TestClass] +public sealed class ArtifactNamingHelperTests +{ + [TestMethod] + public void ResolveTemplate_WithReplacements_ReplacesCorrectly() + { + string template = "{pname}_{pid}_{asm}.dmp"; + var replacements = new Dictionary + { + ["pname"] = "test-process", + ["pid"] = "12345", + ["asm"] = "TestAssembly", + }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("test-process_12345_TestAssembly.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_LiteralTextAndPlaceholders_ReplacesOnlyPlaceholders() + { + string template = "{pname}_{pid}_custom.dmp"; + var replacements = new Dictionary + { + ["pname"] = "custom-process", + ["pid"] = "99999", + }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("custom-process_99999_custom.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_EmptyReplacementValue_ReplacesWithEmptyString() + { + string template = "{asm}_{pname}.dmp"; + var replacements = new Dictionary + { + ["asm"] = string.Empty, + ["pname"] = "test-process", + }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("_test-process.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_WithUnknownPlaceholder_KeepsPlaceholderAsIs() + { + string template = "{unknown-field}_{pname}"; + var replacements = new Dictionary + { + ["pname"] = "test-process", + }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("{unknown-field}_test-process", result); + } + + [TestMethod] + public void ResolveTemplate_ComplexTemplate_ReplacesAllKnownPlaceholders() + { + string template = "{asm}/{pname}_{pid}_{tfm}_{time}.dmp"; + var replacements = new Dictionary + { + ["asm"] = "TestAssembly", + ["pname"] = "test-process", + ["pid"] = "12345", + ["tfm"] = "net9.0", + ["time"] = "2025-09-22_13-49-34.0000000", + }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("TestAssembly/test-process_12345_net9.0_2025-09-22_13-49-34.0000000.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_CaseSensitive_DoesNotMatchDifferentCase() + { + string template = "{PName}_{PID}"; + var replacements = new Dictionary + { + ["pname"] = "test-process", + ["pid"] = "12345", + }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + // Case-sensitive: {PName} and {PID} don't match lowercase keys, so they are preserved as-is. + Assert.AreEqual("{PName}_{PID}", result); + } + + [TestMethod] + public void ResolveTemplate_NullReplacements_ReturnsTemplateUnchanged() + { + string template = "{pname}_{pid}.dmp"; + + string result = ArtifactNamingHelper.ResolveTemplate(template, null); + + Assert.AreEqual("{pname}_{pid}.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_EmptyReplacements_ReturnsTemplateUnchanged() + { + string template = "{pname}_{pid}.dmp"; + var replacements = new Dictionary(); + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("{pname}_{pid}.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_RepeatedPlaceholder_ReplacesAllOccurrences() + { + string template = "{pname}_{pname}.dmp"; + var replacements = new Dictionary { ["pname"] = "test-process" }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("test-process_test-process.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_NoPlaceholders_ReturnsTemplateUnchanged() + { + string template = "simple.dmp"; + var replacements = new Dictionary { ["pname"] = "test-process" }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("simple.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_CaseInsensitiveDictionary_EnforcesOrdinalComparison() + { + var ciDict = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["PNAME"] = "process", + }; + + // After ordinal normalization, key is stored as "PNAME". + // {pname} looks up "pname" which does not match "PNAME" ordinally, so placeholder is preserved. + string result = ArtifactNamingHelper.ResolveTemplate("{pname}", ciDict); + + Assert.AreEqual("{pname}", result); + } + + [TestMethod] + public void ResolveTemplate_CaseInsensitiveDictionaryWithMatchingKey_Resolves() + { + var ciDict = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["pname"] = "process", + }; + + string result = ArtifactNamingHelper.ResolveTemplate("{pname}", ciDict); + + Assert.AreEqual("process", result); + } + + [TestMethod] + public void ResolveTemplate_EmptyPlaceholder_IsPreservedAsIs() + { + string template = "{}_{pname}.dmp"; + var replacements = new Dictionary { ["pname"] = "myproc" }; + + string result = ArtifactNamingHelper.ResolveTemplate(template, replacements); + + Assert.AreEqual("{}_myproc.dmp", result); + } + + [TestMethod] + public void ResolveTemplate_NullTemplate_ThrowsArgumentException() + => Assert.ThrowsExactly(() => ArtifactNamingHelper.ResolveTemplate(null!)); + + [TestMethod] + public void ResolveTemplate_EmptyTemplate_ThrowsArgumentException() + => Assert.ThrowsExactly(() => ArtifactNamingHelper.ResolveTemplate(string.Empty)); + + [TestMethod] + public void ResolveTemplate_WhitespaceOnlyTemplate_ThrowsArgumentException() + => Assert.ThrowsExactly(() => ArtifactNamingHelper.ResolveTemplate(" ")); + + [TestMethod] + public void GetStandardReplacements_ReturnsExpectedKeys() + { + Dictionary replacements = ArtifactNamingHelper.GetStandardReplacements("myproc", "42", new DateTimeOffset(2025, 9, 22, 13, 49, 34, TimeSpan.Zero)); + + Assert.AreEqual("myproc", replacements["pname"]); + Assert.AreEqual("42", replacements["pid"]); + Assert.AreEqual("2025-09-22_13-49-34.0000000", replacements["time"]); + + // asm and tfm are runtime-dependent, just verify they're present and non-empty. + Assert.IsTrue(replacements.ContainsKey("asm"), "Expected 'asm' key in standard replacements"); + Assert.IsTrue(replacements.ContainsKey("tfm"), "Expected 'tfm' key in standard replacements"); + Assert.AreNotEqual(string.Empty, replacements["asm"], "Expected 'asm' to have a non-empty value"); + Assert.AreNotEqual(string.Empty, replacements["tfm"], "Expected 'tfm' to have a non-empty value"); + } +}