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
100 changes: 100 additions & 0 deletions docs/ArtifactNamingService.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Artifact Naming Service

The artifact naming service provides a standardized way to generate consistent names and paths for test artifacts across all extensions.

## Features

### Template-Based Naming
Use placeholders in angle brackets to create dynamic file names:

```
<process-name>_<pid>_<id>_hang.dmp
```
Resolves to: `MyTests_12345_a1b2c3d4_hang.dmp`

### Complex Path Templates
Create structured directory layouts:

```
<root>/artifacts/<os>/<assembly>/dumps/<process-name>_<pid>_<tfm>_<time>.dmp
```
Resolves to: `c:/myproject/artifacts/linux/MyTests/dumps/my-child-process_10001_net9.0_2025-09-22T13:49:34.dmp`

### Available Placeholders

| Placeholder | Description | Example |
|-------------|-------------|---------|
| `<process-name>` | Name of the process | `MyTests` |
| `<pid>` | Process ID | `12345` |
| `<id>` | Short random identifier (8 chars) | `a1b2c3d4` |
| `<os>` | Operating system | `windows`, `linux`, `macos` |
| `<assembly>` | Assembly name | `MyTests` |
| `<tfm>` | Target framework moniker | `net9.0`, `net8.0` |
| `<time>` | Timestamp (1-second precision) | `2025-09-22T13:49:34` |
| `<root>` | Project root directory | Found via solution/git/working dir |

### Backward Compatibility
Legacy patterns are still supported:

```csharp
// Old pattern
"myfile_%p.dmp"

// Works with legacy support
service.ResolveTemplateWithLegacySupport("myfile_%p.dmp",
legacyReplacements: new Dictionary<string, string> { ["%p"] = "12345" });
```

### Custom Replacements
Override default values for specific scenarios:

```csharp
// When dumping a different process than the test host
var customReplacements = new Dictionary<string, string>
{
["process-name"] = "Notepad",
["pid"] = "1111"
};

string result = service.ResolveTemplate("<process-name>_<pid>.dmp", customReplacements);
// Result: "Notepad_1111.dmp"
```

## Usage in Extensions

Extensions can use the service through dependency injection:

```csharp
public class MyExtension
{
private readonly IArtifactNamingService _artifactNamingService;

public MyExtension(IServiceProvider serviceProvider)
{
_artifactNamingService = serviceProvider.GetArtifactNamingService();
}

public void CreateArtifact(string template)
{
string fileName = _artifactNamingService.ResolveTemplate(template);
// Use fileName for artifact creation
}
}
```

## Hang Dump Integration

The hang dump extension now uses the artifact naming service and supports both legacy and modern patterns:

```bash
# Legacy pattern (still works)
--hangdump-filename "mydump_%p.dmp"

# New template pattern
--hangdump-filename "<process-name>_<pid>_<id>_hang.dmp"

# Complex path template
--hangdump-filename "<root>/dumps/<os>/<process-name>_<pid>_<time>.dmp"
```

This provides consistent artifact naming across all extensions while maintaining backward compatibility.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal sealed class HangDumpProcessLifetimeHandler : ITestHostProcessLifetimeH
private readonly ILogger<HangDumpProcessLifetimeHandler> _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;
Expand Down Expand Up @@ -85,6 +86,7 @@ public HangDumpProcessLifetimeHandler(
_processHandler = processHandler;
_clock = clock;
_testApplicationCancellationTokenSource = serviceProvider.GetTestApplicationCancellationTokenSource();
_artifactNamingService = serviceProvider.GetArtifactNamingService();
_dumpFileNamePattern = $"{Path.GetFileNameWithoutExtension(testApplicationModuleInfo.GetCurrentTestApplicationFullPath())}_%p_hang.dmp";
}

Expand Down Expand Up @@ -352,7 +354,32 @@ 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
string processName;
try
{
using var process = _processHandler.GetProcessById(_testHostProcessInformation.PID);
processName = process.ProcessName;
}
catch
{
// If we can't get the process name, use a default
processName = "testhost";
}

var customReplacements = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["process-name"] = processName,
["pid"] = _testHostProcessInformation.PID.ToString(CultureInfo.InvariantCulture)
};

// Create legacy replacements for backward compatibility
var legacyReplacements = new Dictionary<string, string>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@
serviceProvider.TryAddService(systemMonitor);
SystemMonitorAsyncFactory systemMonitorAsyncFactory = new();
serviceProvider.TryAddService(systemMonitorAsyncFactory);

Check failure on line 104 in src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build Linux Release)

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs#L104

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs(104,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)

Check failure on line 104 in src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build MacOS Release)

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs#L104

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs(104,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)

Check failure on line 104 in src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs#L104

src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs(104,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)
// Add artifact naming service
ArtifactNamingService artifactNamingService = new(_testApplicationModuleInfo, systemEnvironment, systemClock, processHandler);
serviceProvider.TryAddService(artifactNamingService);

PlatformInformation platformInformation = new();
serviceProvider.AddService(platformInformation);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// 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.Globalization;
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<string, string>? customReplacements = null)
{
ArgumentGuard.IsNotNullOrEmpty(template);

Check failure on line 35 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build Linux Debug)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L35

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(35,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 35 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build Linux Release)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L35

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(35,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 35 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build MacOS Release)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L35

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(35,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 35 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build MacOS Debug)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L35

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(35,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 35 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L35

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(35,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

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<string, string>? customReplacements = null, IDictionary<string, string>? legacyReplacements = null)
{
ArgumentGuard.IsNotNullOrEmpty(template);

Check failure on line 49 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build Linux Release)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L49

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(49,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 49 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build MacOS Release)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L49

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(49,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 49 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build MacOS Debug)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L49

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(49,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

Check failure on line 49 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L49

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(49,23): error CS0117: (NETCORE_ENGINEERING_TELEMETRY=Build) 'ArgumentGuard' does not contain a definition for 'IsNotNullOrEmpty'

// 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<string, string> GetDefaultReplacements()
{
var replacements = new Dictionary<string, string>(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;

Check failure on line 79 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build Linux Release)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L79

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(79,55): error CS1061: (NETCORE_ENGINEERING_TELEMETRY=Build) 'IProcess' does not contain a definition for 'ProcessName' and no accessible extension method 'ProcessName' accepting a first argument of type 'IProcess' could be found (are you missing a using directive or an assembly reference?)

Check failure on line 79 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx (Build MacOS Release)

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L79

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(79,55): error CS1061: (NETCORE_ENGINEERING_TELEMETRY=Build) 'IProcess' does not contain a definition for 'ProcessName' and no accessible extension method 'ProcessName' accepting a first argument of type 'IProcess' could be found (are you missing a using directive or an assembly reference?)

Check failure on line 79 in src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs

View check run for this annotation

Azure Pipelines / microsoft.testfx

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs#L79

src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs(79,55): error CS1061: (NETCORE_ENGINEERING_TELEMETRY=Build) 'IProcess' does not contain a definition for 'ProcessName' and no accessible extension method 'ProcessName' accepting a first argument of type 'IProcess' could be found (are you missing a using directive or an assembly reference?)

// 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<string, string> MergeReplacements(Dictionary<string, string> defaultReplacements, IDictionary<string, string>? customReplacements)
{
if (customReplacements is null || customReplacements.Count == 0)
{
return defaultReplacements;
}

var merged = new Dictionary<string, string>(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;
}
}
Loading
Loading