From 2b513c22b7217eb13e7e1ea9a55c6a5c1a85e2ae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Amaury=20Lev=C3=A9?= <amauryleve@microsoft.com>
Date: Tue, 9 Jun 2026 11:14:28 +0200
Subject: [PATCH 1/3] [CrashDump] Support {placeholder} tokens in
 --crashdump-filename via ArtifactNamingHelper

Wires ArtifactNamingHelper into CrashDumpEnvironmentVariableProvider so users can use the same {pname}/{pid}/{asm}/{tfm}/{time} template tokens as HangDump and the report extensions.

Because CrashDump only hands a filename pattern to the .NET runtime's createdump BEFORE the testhost launches, the testhost PID/exe name are not yet known. We resolve {asm}/{tfm}/{time} to literal values up-front and map {pid} -> %p and {pname} -> %e so the runtime expands them at crash-write time.

Also fixes the existing crash-report lookup which previously did only a single .Replace(%p, PID): when the dump pattern contains %e (now possible via {pname}), the literal expected path would not exist on disk. Switched to regex-based enumeration that reuses the existing testhostDumpRegex (which already turns %X into .* wildcards).

The sequence file name is now derived from the resolved user pattern (with {X} substituted) rather than the raw user input, keeping the sequence file aligned with the actual dump file name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 docs/glossary.md                              |  2 +-
 .../CrashDumpEnvironmentVariableProvider.cs   | 43 +++++++++++--
 .../CrashDumpExtensions.cs                    |  3 +-
 .../CrashDumpProcessLifetimeHandler.cs        | 34 ++++++++++-
 ...rosoft.Testing.Extensions.CrashDump.csproj |  2 +
 .../Resources/CrashDumpResources.resx         |  3 +-
 .../Resources/xlf/CrashDumpResources.cs.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.de.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.es.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.fr.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.it.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.ja.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.ko.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.pl.xlf   |  5 +-
 .../xlf/CrashDumpResources.pt-BR.xlf          |  5 +-
 .../Resources/xlf/CrashDumpResources.ru.xlf   |  5 +-
 .../Resources/xlf/CrashDumpResources.tr.xlf   |  5 +-
 .../xlf/CrashDumpResources.zh-Hans.xlf        |  5 +-
 .../xlf/CrashDumpResources.zh-Hant.xlf        |  5 +-
 .../HelpInfoAllExtensionsTests.cs             |  6 +-
 .../CrashDumpTests.cs                         | 61 +++++++++++++++++++
 21 files changed, 179 insertions(+), 40 deletions(-)

diff --git a/docs/glossary.md b/docs/glossary.md
index 12602e7f7d..877f149ccb 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -14,7 +14,7 @@ An MTP struct (`ArgumentArity.cs`) that defines the minimum and maximum number o
 
 ### ArtifactNamingHelper
 
-A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Legacy `%p` patterns from earlier hang-dump versions continue to work. Custom per-call overrides can replace default placeholder values via a `Dictionary<string, string>`. Used by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions.
+A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Legacy `%p` patterns from earlier hang-dump versions continue to work. Custom per-call overrides can replace default placeholder values via a `Dictionary<string, string>`. Used directly by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions, and indirectly by the report extensions ([HtmlReport](#htmlreport), [JUnitReport](#junitreport), and TrxReport) via the shared `ReportFileNameHelper`. The CrashDump consumer maps `{pid}` to the .NET runtime's `%p` and `{pname}` to `%e` so they expand at crash-write time (the testhost PID is not known when the environment variables are configured).
 
 ### AzureDevOpsReport
 
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs
index b65f597a50..d7993af2a7 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpEnvironmentVariableProvider.cs
@@ -8,6 +8,7 @@
 using Microsoft.Testing.Platform.Extensions.TestHostControllers;
 using Microsoft.Testing.Platform.Helpers;
 using Microsoft.Testing.Platform.Logging;
+using Microsoft.Testing.Platform.Services;
 
 namespace Microsoft.Testing.Extensions.Diagnostics;
 
@@ -27,6 +28,7 @@ internal sealed class CrashDumpEnvironmentVariableProvider : ITestHostEnvironmen
     private readonly ICommandLineOptions _commandLineOptions;
     private readonly CrashDumpConfiguration _crashDumpGeneratorConfiguration;
     private readonly ILogger<CrashDumpEnvironmentVariableProvider> _logger;
+    private readonly IClock _clock;
 
     private string? _miniDumpNameValue;
     private string? _sequenceFileValue;
@@ -35,12 +37,14 @@ public CrashDumpEnvironmentVariableProvider(
         IConfiguration configuration,
         ICommandLineOptions commandLineOptions,
         CrashDumpConfiguration crashDumpGeneratorConfiguration,
-        ILoggerFactory loggerFactory)
+        ILoggerFactory loggerFactory,
+        IClock clock)
     {
         _logger = loggerFactory.CreateLogger<CrashDumpEnvironmentVariableProvider>();
         _configuration = configuration;
         _commandLineOptions = commandLineOptions;
         _crashDumpGeneratorConfiguration = crashDumpGeneratorConfiguration;
+        _clock = clock;
     }
 
     /// <inheritdoc />
@@ -137,9 +141,12 @@ public Task UpdateAsync(IEnvironmentVariables environmentVariables)
         }
 
         string testAppName = Assembly.GetEntryAssembly()?.GetName().Name ?? throw ApplicationStateGuard.Unreachable();
-        _miniDumpNameValue = _commandLineOptions.TryGetOptionArgumentList(CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, out string[]? dumpFileName)
-            ? Path.Combine(_configuration.GetTestResultDirectory(), dumpFileName[0])
-            : Path.Combine(_configuration.GetTestResultDirectory(), $"{testAppName}_%p_crash.dmp");
+        string? resolvedUserPattern = _commandLineOptions.TryGetOptionArgumentList(CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, out string[]? dumpFileName)
+            ? ResolveUserDumpFileNameTemplate(dumpFileName[0])
+            : null;
+        _miniDumpNameValue = Path.Combine(
+            _configuration.GetTestResultDirectory(),
+            resolvedUserPattern ?? $"{testAppName}_%p_crash.dmp");
         _crashDumpGeneratorConfiguration.DumpFileNamePattern = _miniDumpNameValue;
         foreach (string prefix in Prefixes)
         {
@@ -157,9 +164,14 @@ public Task UpdateAsync(IEnvironmentVariables environmentVariables)
             // launches targeting the same results directory cannot stomp on each other's sequence
             // files (a write collision would otherwise be silently lost; a graceful exit of one host
             // would also delete the sibling host's sequence file before it could be published).
+            //
+            // We base the sequence file on the *resolved* user pattern (after substituting {asm}/{tfm}/
+            // {time}) so the sequence file name stays in sync with the dump file name; only the
+            // runtime placeholders that we deferred to "createdump" (e.g. {pid} -> %p, {pname} -> %e)
+            // are stripped here.
             string uniqueToken = Guid.NewGuid().ToString("N").Substring(0, 8);
-            string sequenceFileName = dumpFileName is not null
-                ? $"{StripRuntimePlaceholders(dumpFileName[0])}_{uniqueToken}.sequence.log"
+            string sequenceFileName = resolvedUserPattern is not null
+                ? $"{StripRuntimePlaceholders(resolvedUserPattern)}_{uniqueToken}.sequence.log"
                 : $"{testAppName}_{uniqueToken}_crash.sequence.log";
             _sequenceFileValue = Path.Combine(_configuration.GetTestResultDirectory(), sequenceFileName);
             _crashDumpGeneratorConfiguration.SequenceFileName = _sequenceFileValue;
@@ -190,6 +202,25 @@ private bool IsSequenceLoggingEnabled()
         return !CommandLineOptionArgumentValidator.IsOffValue(arguments[0]);
     }
 
+    private string ResolveUserDumpFileNameTemplate(string userTemplate)
+    {
+        // CrashDump's filename is consumed by the .NET runtime's "createdump" - not by our code -
+        // so any user-facing token whose value is only knowable at crash time (process id and
+        // executable name) must map to a runtime placeholder ("%p" / "%e") rather than a literal
+        // value resolved here. The remaining standard tokens (asm/tfm/time) are deterministic at
+        // configuration time and substituted straight away.
+        //
+        // Implementation note: GetStandardReplacements seeds the dictionary with pname=processName
+        // and pid=processId, so passing "%e" / "%p" as those arguments yields a dictionary whose
+        // {pname}/{pid} entries are the runtime placeholders themselves. This keeps the resolver
+        // generic and avoids a second post-processing pass.
+        Dictionary<string, string> replacements = ArtifactNamingHelper.GetStandardReplacements(
+            processName: "%e",
+            processId: "%p",
+            _clock.UtcNow);
+        return ArtifactNamingHelper.ResolveTemplate(userTemplate, replacements);
+    }
+
     private static string StripRuntimePlaceholders(string pattern)
     {
         // Drop the .NET runtime's "createdump" placeholders (%p, %e, %h, %t, ...) from a dump filename
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs
index c1b7dc50ec..b92e7b6f6a 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpExtensions.cs
@@ -34,7 +34,8 @@ public static void AddCrashDumpProvider(this ITestApplicationBuilder builder, bo
                 serviceProvider.GetConfiguration(),
                 serviceProvider.GetCommandLineOptions(),
                 crashDumpGeneratorConfiguration,
-                serviceProvider.GetLoggerFactory()));
+                serviceProvider.GetLoggerFactory(),
+                serviceProvider.GetClock()));
 
         builder.TestHostControllers.AddProcessLifetimeHandler(serviceProvider
             => new CrashDumpProcessLifetimeHandler(
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs
index e35bf29e79..332389ba3f 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs
@@ -274,7 +274,35 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
         // want the banner to reflect that the testhost dump is, technically, present on disk.
         testhostDumpProduced = generateDump && (testhostDumpProduced || File.Exists(expectedDumpFile));
         bool dumpArtifactProduced = generateDump && (testhostDumpProduced || publishedAnyDump);
-        bool crashReportArtifactProduced = generateCrashReport && File.Exists(expectedCrashReportFile);
+
+        // The crash report file is written as "<dump file name>.crashreport.json" beside the dump.
+        // The dump file name pattern can contain runtime placeholders besides "%p" (e.g. "%e" when
+        // the user picks the {pname} token, "%h" or "%t" when configured directly). A plain
+        // File.Exists check on `expectedCrashReportFile` would miss those reports, so we apply the
+        // same testhost-dump-name regex to the prefix of each "*.crashreport.json" file in the dump
+        // directory: this preserves the literal-`%p`-baked PID match while expanding any remaining
+        // placeholders as wildcards, mirroring the dump-publication logic above.
+        string? matchedCrashReportFile = null;
+        if (generateCrashReport && Directory.Exists(dumpDirectory))
+        {
+            foreach (string crashReportFile in Directory.EnumerateFiles(dumpDirectory, CrashReportFileSearchPattern))
+            {
+                string crashReportFileName = Path.GetFileName(crashReportFile);
+                if (!crashReportFileName.EndsWith(CrashReportFileExtension, StringComparison.OrdinalIgnoreCase))
+                {
+                    continue;
+                }
+
+                string dumpFileNamePart = crashReportFileName.Substring(0, crashReportFileName.Length - CrashReportFileExtension.Length);
+                if (testhostDumpRegex.IsMatch(dumpFileNamePart))
+                {
+                    matchedCrashReportFile = crashReportFile;
+                    break;
+                }
+            }
+        }
+
+        bool crashReportArtifactProduced = matchedCrashReportFile is not null;
 
         // Inspect the disk before emitting the crash banner so the message reflects
         // what was actually produced, not what was requested. The runtime may fail
@@ -314,9 +342,9 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
 
         if (generateCrashReport)
         {
-            if (crashReportArtifactProduced)
+            if (matchedCrashReportFile is not null)
             {
-                await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(expectedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false);
+                await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(matchedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false);
             }
             else
             {
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj
index 6b734a548b..61dd9ae8aa 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Microsoft.Testing.Extensions.CrashDump.csproj
@@ -12,6 +12,8 @@
     <Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Helpers\ApplicationStateGuard.cs" Link="Helpers\ApplicationStateGuard.cs" />
     <Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Helpers\RoslynString.cs" Link="Helpers\RoslynString.cs" />
     <Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Resources\PlatformResources.cs" Link="Resources\PlatformResources.cs" />
+    <Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Services\ArtifactNamingHelper.cs" Link="Services\ArtifactNamingHelper.cs" />
+    <Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\OutputDevice\TargetFrameworkParser.cs" Link="Helpers\TargetFrameworkParser.cs" />
   </ItemGroup>
 
   <!-- NuGet properties -->
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx
index e4cc8fbef1..f889afc556 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx
@@ -136,7 +136,8 @@
     <value>Crash dump</value>
   </data>
   <data name="CrashDumpFileNameOptionDescription" xml:space="preserve">
-    <value>Specify the name of the dump file</value>
+    <value>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</value>
   </data>
   <data name="CrashDumpInvalidEnvironmentVariableValueErrorMessage" xml:space="preserve">
     <value>Environment variable '{0}' should have been set to '{1}' but value is '{2}'</value>
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf
index 6f14fbaf78..d548c7638d 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.cs.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Zadejte název souboru výpisu paměti.</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Zadejte název souboru výpisu paměti.</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf
index fd4892bc5a..e624e8545e 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.de.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Namen der Speicherabbilddatei angeben</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Namen der Speicherabbilddatei angeben</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf
index bacad05dbf..38a284a436 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.es.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Especificar el nombre del archivo de volcado</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Especificar el nombre del archivo de volcado</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf
index f1fcde6c74..9e733cb717 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.fr.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Spécifier le nom du fichier de vidage</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Spécifier le nom du fichier de vidage</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf
index 27505d8d0f..124de436aa 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.it.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Specificare il nome del file di dump</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Specificare il nome del file di dump</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf
index 47cdeedbc3..3f4bfeff6d 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ja.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">ダンプ ファイルの名前を指定する</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">ダンプ ファイルの名前を指定する</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf
index 512a4dd712..07441bb264 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ko.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">덤프 파일의 이름 지정</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">덤프 파일의 이름 지정</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf
index 100d24b8b3..8c5a04160d 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pl.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Określ nazwę pliku zrzutu</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Określ nazwę pliku zrzutu</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf
index 31ca8e1585..ea99487068 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.pt-BR.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Especifique o nome do arquivo de despejo</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Especifique o nome do arquivo de despejo</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf
index 60efbeb7f0..8bd64191de 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.ru.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Укажите имя файла дампа зависания</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Укажите имя файла дампа зависания</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf
index 8c200f832e..29555b61e1 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.tr.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">Döküm dosyasının adını belirtin</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">Döküm dosyasının adını belirtin</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf
index 00704d47d5..e7f3f7575c 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hans.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">指定转储文件的名称</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">指定转储文件的名称</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf
index fd5ea0674c..d61d56c25c 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/xlf/CrashDumpResources.zh-Hant.xlf
@@ -33,8 +33,9 @@
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpFileNameOptionDescription">
-        <source>Specify the name of the dump file</source>
-        <target state="translated">指定傾印檔案的名稱</target>
+        <source>Specify the name of the dump file.
+Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</source>
+        <target state="needs-review-translation">指定傾印檔案的名稱</target>
         <note />
       </trans-unit>
       <trans-unit id="CrashDumpInvalidEnvironmentVariableValueErrorMessage">
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs
index d0b71aabf1..ed4f1aeec5 100644
--- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs
@@ -92,7 +92,8 @@ The file makes it possible to identify the tests that were running at the time o
     --crashdump
         [net6.0+ only] Generate a dump file if the test process crashes
     --crashdump-filename
-        Specify the name of the dump file
+        Specify the name of the dump file.
+        Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.
     --crashdump-type
         Specify the type of the dump.
         Valid values are 'Mini', 'Heap', 'Triage' or 'Full'. Default type is 'Full'.
@@ -448,7 +449,8 @@ The file makes it possible to identify the tests that were running at the time o
       --crashdump-filename
         Arity: 1
         Hidden: False
-        Description: Specify the name of the dump file
+        Description: Specify the name of the dump file.
+        Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.
       --crashdump-type
         Arity: 1
         Hidden: False
diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs
index 3b06be9cf5..2f3ec3be8c 100644
--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs
@@ -619,6 +619,67 @@ public async Task OnTestHostProcessExitedAsync_TesthostAndChildBothCrashWithMult
         }
     }
 
+    [TestMethod]
+    public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePlaceholders_PublishesMatchedReport()
+    {
+        // When the configured dump pattern relies on runtime placeholders other than `%p`
+        // (here `%e`, which the `{pname}` token maps to), the literal-`%p`-substituted crash
+        // report path never exists on disk. The handler must therefore identify the crash
+        // report via the testhost-dump regex applied to the report's "<dump file name>"
+        // prefix, and publish the actual file found on disk (not the unresolved path).
+        string dumpDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N"))).FullName;
+        try
+        {
+            string dumpPattern = Path.Combine(dumpDirectory, "Dump_%e_%p.dmp");
+            var configuration = new CrashDumpConfiguration { DumpFileNamePattern = dumpPattern };
+            var commandLineOptions = new TestCommandLineOptions(new Dictionary<string, string[]>
+            {
+                { CrashDumpCommandLineOptions.CrashDumpOptionName, [] },
+                { CrashDumpCommandLineOptions.CrashReportOptionName, [] },
+            });
+            var messageBus = new RecordingMessageBus();
+            var outputDevice = new CapturingOutputDevice();
+            var handler = new CrashDumpProcessLifetimeHandler(commandLineOptions, messageBus, outputDevice, configuration);
+
+            await handler.OnTestHostProcessStartedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None);
+
+            // Simulate the runtime writing the testhost dump and its companion crash report with
+            // `%e` expanded to "testhost". A naive File.Exists check on the literal-%p-substituted
+            // crash report path (`Dump_%e_123.dmp.crashreport.json`) would miss the actual file.
+            string testhostDump = Path.Combine(dumpDirectory, "Dump_testhost_123.dmp");
+            string testhostCrashReport = testhostDump + ".crashreport.json";
+            File.WriteAllText(testhostDump, "fresh");
+            File.WriteAllText(testhostCrashReport, "{}");
+
+            await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None);
+
+            string[] publishedFiles = messageBus.Published
+                .OfType<FileArtifact>()
+                .Select(static a => a.FileInfo.FullName)
+                .OrderBy(static p => p, StringComparer.Ordinal)
+                .ToArray();
+            string[] expected = new[] { testhostDump, testhostCrashReport }.OrderBy(static p => p, StringComparer.Ordinal).ToArray();
+            Assert.AreSequenceEqual(expected, publishedFiles);
+
+            // The "expected crash report not found" warning must NOT be emitted: the report was
+            // matched by the regex even though `expectedCrashReportFile` (literal `%p` substitution)
+            // would be `Dump_%e_123.dmp.crashreport.json`, which does not exist on disk.
+            string captured = string.Join(" | ", outputDevice.Displayed);
+            Assert.DoesNotContain("Dump_%e_123.dmp.crashreport.json", captured, "CannotFindExpectedCrashReportFile must not be displayed when the report was recognized via the regex.");
+        }
+        finally
+        {
+            try
+            {
+                Directory.Delete(dumpDirectory, recursive: true);
+            }
+            catch
+            {
+                // Best effort cleanup.
+            }
+        }
+    }
+
     private sealed class RecordingMessageBus : IMessageBus
     {
         public List<IData> Published { get; } = [];

From ee04d8af0af89fdca6caa09ae5e651bb269f4247 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Amaury=20Lev=C3=A9?= <amauryleve@microsoft.com>
Date: Thu, 11 Jun 2026 10:11:47 +0200
Subject: [PATCH 2/3] Address crashdump artifact naming review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 docs/glossary.md                              |  2 +-
 .../CrashDumpProcessLifetimeHandler.cs        | 23 +++++---
 .../CrashDumpTests.cs                         | 54 ++++++++++++++++++-
 3 files changed, 70 insertions(+), 9 deletions(-)

diff --git a/docs/glossary.md b/docs/glossary.md
index 877f149ccb..0c9946e90a 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -14,7 +14,7 @@ An MTP struct (`ArgumentArity.cs`) that defines the minimum and maximum number o
 
 ### ArtifactNamingHelper
 
-A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Legacy `%p` patterns from earlier hang-dump versions continue to work. Custom per-call overrides can replace default placeholder values via a `Dictionary<string, string>`. Used directly by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions, and indirectly by the report extensions ([HtmlReport](#htmlreport), [JUnitReport](#junitreport), and TrxReport) via the shared `ReportFileNameHelper`. The CrashDump consumer maps `{pid}` to the .NET runtime's `%p` and `{pname}` to `%e` so they expand at crash-write time (the testhost PID is not known when the environment variables are configured).
+A shared static helper compiled into MTP extensions via file linking (no NuGet service registration or InternalsVisibleTo required) that provides template-based naming for test artifact files (dump files, report files, etc.). Templates are strings containing `{placeholder}` tokens (case-sensitive, lowercase): `{pname}` (process name), `{pid}` (process ID), `{asm}` (entry-assembly name), `{tfm}` (target framework moniker, best-effort runtime detection), and `{time}` (high-precision UTC timestamp). Custom per-call overrides can replace default placeholder values via a `Dictionary<string, string>`. Used directly by the [HangDump](#hangdump) and [CrashDump](#crashdump) extensions, and indirectly by the report extensions ([HtmlReport](#htmlreport), [JUnitReport](#junitreport), and TrxReport) via the shared `ReportFileNameHelper`. The CrashDump consumer maps `{pid}` to the .NET runtime's `%p` and `{pname}` to `%e` so they expand at crash-write time (the testhost PID is not known when the environment variables are configured). HangDump preserves its legacy `%p` compatibility in its own option handling.
 
 ### AzureDevOpsReport
 
diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs
index 332389ba3f..4003fd04ac 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/CrashDumpProcessLifetimeHandler.cs
@@ -282,7 +282,8 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
         // same testhost-dump-name regex to the prefix of each "*.crashreport.json" file in the dump
         // directory: this preserves the literal-`%p`-baked PID match while expanding any remaining
         // placeholders as wildcards, mirroring the dump-publication logic above.
-        string? matchedCrashReportFile = null;
+        List<string>? crashReportFiles = null;
+        bool matchedCrashReportFile = false;
         if (generateCrashReport && Directory.Exists(dumpDirectory))
         {
             foreach (string crashReportFile in Directory.EnumerateFiles(dumpDirectory, CrashReportFileSearchPattern))
@@ -293,16 +294,19 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
                     continue;
                 }
 
+                crashReportFiles ??= [];
+                crashReportFiles.Add(crashReportFile);
+
                 string dumpFileNamePart = crashReportFileName.Substring(0, crashReportFileName.Length - CrashReportFileExtension.Length);
                 if (testhostDumpRegex.IsMatch(dumpFileNamePart))
                 {
-                    matchedCrashReportFile = crashReportFile;
-                    break;
+                    matchedCrashReportFile = true;
                 }
             }
         }
 
-        bool crashReportArtifactProduced = matchedCrashReportFile is not null;
+        bool expectedCrashReportFileExists = File.Exists(expectedCrashReportFile);
+        bool crashReportArtifactProduced = expectedCrashReportFileExists || matchedCrashReportFile;
 
         // Inspect the disk before emitting the crash banner so the message reflects
         // what was actually produced, not what was requested. The runtime may fail
@@ -342,9 +346,16 @@ public async Task OnTestHostProcessExitedAsync(ITestHostProcessInformation testH
 
         if (generateCrashReport)
         {
-            if (matchedCrashReportFile is not null)
+            if (expectedCrashReportFileExists)
+            {
+                await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(expectedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false);
+            }
+            else if (matchedCrashReportFile)
             {
-                await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(matchedCrashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false);
+                foreach (string crashReportFile in crashReportFiles!)
+                {
+                    await _messageBus.PublishAsync(this, new FileArtifact(new FileInfo(crashReportFile), CrashDumpResources.CrashReportArtifactDisplayName, CrashDumpResources.CrashReportArtifactDescription)).ConfigureAwait(false);
+                }
             }
             else
             {
diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs
index 2f3ec3be8c..a445e9db9e 100644
--- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CrashDumpTests.cs
@@ -4,13 +4,18 @@
 using Microsoft.Testing.Extensions.Diagnostics;
 using Microsoft.Testing.Extensions.Diagnostics.Resources;
 using Microsoft.Testing.Extensions.UnitTests.Helpers;
+using Microsoft.Testing.Platform.Configurations;
 using Microsoft.Testing.Platform.Extensions.CommandLine;
 using Microsoft.Testing.Platform.Extensions.Messages;
 using Microsoft.Testing.Platform.Extensions.OutputDevice;
 using Microsoft.Testing.Platform.Extensions.TestHostControllers;
+using Microsoft.Testing.Platform.Helpers;
+using Microsoft.Testing.Platform.Logging;
 using Microsoft.Testing.Platform.Messages;
 using Microsoft.Testing.Platform.OutputDevice;
 
+using Moq;
+
 namespace Microsoft.Testing.Extensions.UnitTests;
 
 [TestClass]
@@ -406,6 +411,47 @@ public void GetDumpDirectory_WhenPatternHasDirectoryComponent_ReturnsDirectory()
         Assert.AreEqual(directory, actual);
     }
 
+    [TestMethod]
+    [DataRow("{pname}_{pid}_crash.dmp", "%e_%p_crash.dmp")]
+    [DataRow("{asm}_{pid}.dmp", null)]
+    [DataRow("literal.dmp", "literal.dmp")]
+    public async Task UpdateAsync_UserFilenameTemplate_SetsCorrectDumpFileNamePattern(string userTemplate, string? expectedSuffix)
+    {
+        var commandLineOptions = new TestCommandLineOptions(new Dictionary<string, string[]>
+        {
+            { CrashDumpCommandLineOptions.CrashDumpOptionName, [] },
+            { CrashDumpCommandLineOptions.CrashDumpFileNameOptionName, [userTemplate] },
+        });
+        string resultsDirectory = Path.Combine(Path.GetTempPath(), "crashdump-tests-" + Guid.NewGuid().ToString("N"));
+        var configuration = new Mock<IConfiguration>();
+        configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns(resultsDirectory);
+        var crashDumpConfiguration = new CrashDumpConfiguration();
+        var loggerFactory = new Mock<ILoggerFactory>();
+        loggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(Mock.Of<ILogger>());
+        var clock = new Mock<IClock>();
+        clock.Setup(x => x.UtcNow).Returns(new DateTimeOffset(2026, 6, 11, 7, 0, 0, TimeSpan.Zero));
+        var environmentVariables = new Mock<IEnvironmentVariables>();
+        var provider = new CrashDumpEnvironmentVariableProvider(
+            configuration.Object,
+            commandLineOptions,
+            crashDumpConfiguration,
+            loggerFactory.Object,
+            clock.Object);
+
+        await provider.UpdateAsync(environmentVariables.Object);
+
+        Assert.IsNotNull(crashDumpConfiguration.DumpFileNamePattern);
+        if (expectedSuffix is not null)
+        {
+            Assert.IsTrue(crashDumpConfiguration.DumpFileNamePattern.EndsWith(expectedSuffix, StringComparison.Ordinal), $"Expected pattern ending '{expectedSuffix}', got '{crashDumpConfiguration.DumpFileNamePattern}'.");
+        }
+        else
+        {
+            Assert.DoesNotContain("{", crashDumpConfiguration.DumpFileNamePattern, "Unresolved placeholder left in pattern.");
+            Assert.IsTrue(crashDumpConfiguration.DumpFileNamePattern.EndsWith("_%p.dmp", StringComparison.Ordinal), $"Expected pattern ending '_%p.dmp', got '{crashDumpConfiguration.DumpFileNamePattern}'.");
+        }
+    }
+
     [TestMethod]
     public async Task OnTestHostProcessExitedAsync_OnlyPublishesDumpsThatAppearedDuringTheRun()
     {
@@ -648,8 +694,12 @@ public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePla
             // crash report path (`Dump_%e_123.dmp.crashreport.json`) would miss the actual file.
             string testhostDump = Path.Combine(dumpDirectory, "Dump_testhost_123.dmp");
             string testhostCrashReport = testhostDump + ".crashreport.json";
+            string childDump = Path.Combine(dumpDirectory, "Dump_child_456.dmp");
+            string childCrashReport = childDump + ".crashreport.json";
             File.WriteAllText(testhostDump, "fresh");
             File.WriteAllText(testhostCrashReport, "{}");
+            File.WriteAllText(childDump, "fresh");
+            File.WriteAllText(childCrashReport, "{}");
 
             await handler.OnTestHostProcessExitedAsync(new TestHostProcessInformation(pid: 123, exitCode: 1, hasExitedGracefully: false), CancellationToken.None);
 
@@ -658,7 +708,7 @@ public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePla
                 .Select(static a => a.FileInfo.FullName)
                 .OrderBy(static p => p, StringComparer.Ordinal)
                 .ToArray();
-            string[] expected = new[] { testhostDump, testhostCrashReport }.OrderBy(static p => p, StringComparer.Ordinal).ToArray();
+            string[] expected = new[] { testhostDump, testhostCrashReport, childDump, childCrashReport }.OrderBy(static p => p, StringComparer.Ordinal).ToArray();
             Assert.AreSequenceEqual(expected, publishedFiles);
 
             // The "expected crash report not found" warning must NOT be emitted: the report was
@@ -673,7 +723,7 @@ public async Task OnTestHostProcessExitedAsync_CrashReport_PatternWithRuntimePla
             {
                 Directory.Delete(dumpDirectory, recursive: true);
             }
-            catch
+            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
             {
                 // Best effort cleanup.
             }

From ca39702c20065a52a9bb922a86c0c3aa1a910441 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Amaury=20Lev=C3=A9?= <amauryleve@microsoft.com>
Date: Mon, 22 Jun 2026 10:59:38 +0200
Subject: [PATCH 3/3] Fix CI and address review: remove duplicate resx value,
 restore explicit type

- Remove the obsolete duplicate <value> node for CrashDumpFileNameOptionDescription
  in CrashDumpResources.resx (addresses review comment).
- Restore explicit 'TerminalTestReporter' type for the four CreateOrchestratorReporter
  locals in TerminalTestReporterTests.cs that a main merge had regressed to 'var',
  fixing the IDE0008 build failures across all CI legs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../Resources/CrashDumpResources.resx                     | 1 -
 .../OutputDevice/Terminal/TerminalTestReporterTests.cs    | 8 ++++----
 2 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx
index 79cfcd93d4..71b01b915e 100644
--- a/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx
+++ b/src/Platform/Microsoft.Testing.Extensions.CrashDump/Resources/CrashDumpResources.resx
@@ -141,7 +141,6 @@
   <data name="CrashDumpFileNameOptionDescription" xml:space="preserve">
     <value>Specify the name of the dump file.
 Supports the following placeholders: {pname} (process executable name, deferred to the .NET runtime as "%e"), {pid} (process ID, deferred to the .NET runtime as "%p"), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp when the crashdump environment is configured). The legacy %p / %e / %h / %t tokens consumed directly by the .NET runtime's createdump are also supported.</value>
-    <value>Specify the name of the dump file</value>
     <comment>Do not localize option name {Locked="--crashdump-filename"} or file extension {Locked=".dmp"}.</comment>
   </data>
   <data name="CrashDumpInvalidEnvironmentVariableValueErrorMessage" xml:space="preserve">
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs
index 07f2fc4ea8..2033c507f8 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/Terminal/TerminalTestReporterTests.cs
@@ -1465,7 +1465,7 @@ public void AssemblyRunCompleted_WhenTestsWereRetried_ShowsRetriedCount()
     public void AssemblyRunCompleted_WhenKnownAssemblyFails_PrintsExecutableSummary()
     {
         var stringBuilderConsole = new StringBuilderConsole();
-        var terminalReporter = CreateOrchestratorReporter(stringBuilderConsole);
+        TerminalTestReporter terminalReporter = CreateOrchestratorReporter(stringBuilderConsole);
         terminalReporter.TestExecutionStarted(DateTimeOffset.MinValue, workerCount: 1, isDiscovery: false, isHelp: false, isRetry: false);
 
         string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\repo\Failing.dll" : "/repo/Failing.dll";
@@ -1489,7 +1489,7 @@ public void AssemblyRunCompleted_WhenKnownAssemblySucceeds_DoesNotPrintExecutabl
         // Keep the default ShowAssemblyStartAndComplete: true so the reporter DOES print the per-assembly summary
         // line. Otherwise a zero-exit run writes nothing at all and the DoesNotContain assertion below passes
         // vacuously instead of verifying that the executable-summary block is specifically suppressed on success.
-        var terminalReporter = CreateOrchestratorReporter(stringBuilderConsole);
+        TerminalTestReporter terminalReporter = CreateOrchestratorReporter(stringBuilderConsole);
         terminalReporter.TestExecutionStarted(DateTimeOffset.MinValue, workerCount: 1, isDiscovery: false, isHelp: false, isRetry: false);
 
         string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\repo\Passing.dll" : "/repo/Passing.dll";
@@ -1510,7 +1510,7 @@ public void AssemblyRunCompleted_WhenKnownAssemblySucceeds_DoesNotPrintExecutabl
     public void TestExecutionCompleted_WhenHandshakeFailures_PrintsRecapAndFailsRun()
     {
         var stringBuilderConsole = new StringBuilderConsole();
-        var terminalReporter = CreateOrchestratorReporter(stringBuilderConsole, showAssemblyStartAndComplete: false);
+        TerminalTestReporter terminalReporter = CreateOrchestratorReporter(stringBuilderConsole, showAssemblyStartAndComplete: false);
         terminalReporter.TestExecutionStarted(DateTimeOffset.MinValue, workerCount: 2, isDiscovery: false, isHelp: false, isRetry: false);
 
         // Two assemblies fail to handshake (their execution ids were never registered). The assembly paths are not
@@ -1534,7 +1534,7 @@ public void TestExecutionCompleted_WhenHandshakeFailures_PrintsRecapAndFailsRun(
     public void AssemblyRunStarted_AfterRetry_RendersLatestAttemptCounts()
     {
         var stringBuilderConsole = new StringBuilderConsole();
-        var terminalReporter = CreateOrchestratorReporter(stringBuilderConsole);
+        TerminalTestReporter terminalReporter = CreateOrchestratorReporter(stringBuilderConsole);
         terminalReporter.TestExecutionStarted(DateTimeOffset.MinValue, workerCount: 1, isDiscovery: false, isHelp: false, isRetry: true);
 
         string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\repo\Flaky.dll" : "/repo/Flaky.dll";
