diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs index a16e0dad4f..6b3471f7ba 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportEngine.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Extensions.HtmlReport.Resources; +using Microsoft.Testing.Platform; using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Configurations; using Microsoft.Testing.Platform.Extensions.TestFramework; @@ -64,11 +65,19 @@ public HtmlReportEngine( out string[]? providedFileName); string fileName = fileNameExplicitlyProvided - ? providedFileName![0] + ? ResolveHtmlFileName(GetProvidedFileName(providedFileName)) : BuildDefaultFileName(finishTime); string outputDirectory = _configuration.GetTestResultDirectory(); + // Path.Combine short-circuits when the second argument is rooted, so an absolute + // user-provided file name overrides the test results directory while validated + // relative paths stay nested under it. string finalPath = Path.Combine(outputDirectory, fileName); + string? finalDirectory = Path.GetDirectoryName(finalPath); + if (!RoslynString.IsNullOrEmpty(finalDirectory)) + { + _fileSystem.CreateDirectory(finalDirectory); + } string template = LoadTemplate(); string json = BuildJson(results, finishTime); @@ -82,6 +91,11 @@ public HtmlReportEngine( return await WriteWithRetryAsync(finalPath, bytes, fileNameExplicitlyProvided).ConfigureAwait(false); } + private static string GetProvidedFileName(string[]? providedFileName) + => providedFileName is { Length: > 0 } + ? providedFileName[0] + : throw ApplicationStateGuard.Unreachable(); + private async Task<(string FileName, string? Warning)> WriteWithRetryAsync(string finalPath, byte[] bytes, bool fileNameExplicitlyProvided) { // Explicit file names: use FileMode.Create (overwrite). Default-generated file @@ -154,6 +168,19 @@ private string BuildDefaultFileName(DateTimeOffset finishTime) return ReplaceInvalidFileNameChars(raw); } + private string ResolveHtmlFileName(string template) + { + string processName = Path.GetFileNameWithoutExtension(_testApplicationModuleInfo.GetCurrentTestApplicationFullPath()); + string processId = _environment.ProcessId.ToString(CultureInfo.InvariantCulture); + Dictionary replacements = ArtifactNamingHelper.GetStandardReplacements(processName, processId, _clock.UtcNow); + string resolved = ArtifactNamingHelper.ResolveTemplate(template, replacements); + string directoryPart = Path.GetDirectoryName(resolved) ?? string.Empty; + string sanitizedFileName = ReplaceInvalidFileNameChars(Path.GetFileName(resolved)); + return directoryPart.Length == 0 + ? sanitizedFileName + : Path.Combine(directoryPart, sanitizedFileName); + } + private static string GetTargetFrameworkMoniker() => TargetFrameworkParser.GetShortTargetFramework( Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) @@ -163,13 +190,46 @@ private static string GetTargetFrameworkMoniker() private static string ReplaceInvalidFileNameChars(string fileName) { var sb = new StringBuilder(fileName.Length); - char[] invalid = Path.GetInvalidFileNameChars(); foreach (char c in fileName) { - sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c); + sb.Append(IsInvalidFileNameChar(c) ? '_' : c); } - return sb.ToString(); + string replaced = sb.ToString().TrimEnd(); + if (IsReservedFileName(replaced)) + { + replaced = '_' + replaced; + } + + return replaced; + } + + private static bool IsInvalidFileNameChar(char c) + // Keep the explicit file-name sanitization aligned with TRX report naming so + // placeholders and cross-platform reserved characters produce compatible names. + => c is < ' ' or '"' or '<' or '>' or '|' or ':' or '*' or '?' or '\\' or '/' or '@' or '(' or ')' or '^' or ' '; + + private static bool IsReservedFileName(string fileName) + { + string bareName = fileName; + int dot = bareName.IndexOf('.'); + if (dot >= 0) + { + bareName = bareName.Substring(0, dot); + } + + return bareName.Equals("CON", StringComparison.OrdinalIgnoreCase) + || bareName.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || bareName.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || bareName.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || bareName.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase) + || IsReservedNameWithNumber(bareName, "COM") + || IsReservedNameWithNumber(bareName, "LPT"); + + static bool IsReservedNameWithNumber(string bareName, string prefix) + => bareName.Length == 4 + && bareName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && bareName[3] is >= '1' and <= '9'; } private static string LoadTemplate() diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGeneratorCommandLine.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGeneratorCommandLine.cs index 5c798c1101..3635b156ee 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGeneratorCommandLine.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGeneratorCommandLine.cs @@ -14,6 +14,8 @@ internal sealed class HtmlReportGeneratorCommandLine : ICommandLineOptionsProvid public const string HtmlReportOptionName = "report-html"; public const string HtmlReportFileNameOptionName = "report-html-filename"; + private static readonly char[] DirectorySeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + /// public string Uid => nameof(HtmlReportGeneratorCommandLine); @@ -40,19 +42,28 @@ public Task ValidateOptionArgumentsAsync(CommandLineOption com { if (commandOption.Name == HtmlReportFileNameOptionName) { - string fileName = arguments[0]; + if (arguments.Length is 0) + { + return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameMustNotBeEmpty); + } - // Validate "pure file name" first. We don't want any path component, drive letter, - // parent directory traversal, leading/trailing whitespace or invalid file name char. - if (!IsValidPureFileName(fileName)) + string argument = arguments[0]; + + string fileNamePart = Path.GetFileName(argument); + if (RoslynString.IsNullOrWhiteSpace(fileNamePart)) { - return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameShouldNotContainPath); + return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameMustNotBeEmpty); } - if (!fileName.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + if (!fileNamePart.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) { return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameExtensionIsNotHtml); } + + if (EscapesResultsDirectory(argument)) + { + return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameRelativePathMustStayUnderResultsDirectory); + } } return ValidationResult.ValidTask; @@ -65,75 +76,64 @@ public Task ValidateCommandLineOptionsAsync(ICommandLineOption ? ValidationResult.InvalidTask(ExtensionResources.HtmlReportIsNotValidForDiscovery) : ValidationResult.ValidTask; - // We are intentionally strict here so that we cannot be tricked across platforms. - // The argument must be a "pure" file name: no directory separator, no drive letter, - // no parent directory traversal, no invalid file name character, no leading/trailing - // whitespace, no Windows reserved device name. We use a hard-coded list of invalid - // characters (a superset of Path.GetInvalidFileNameChars() on Linux + Windows) so - // the same input is rejected regardless of the host OS. - private static readonly char[] InvalidFileNameChars = - [ - '\0', '/', '\\', ':', '*', '?', '"', '<', '>', '|', - '\u0001', '\u0002', '\u0003', '\u0004', '\u0005', '\u0006', '\u0007', - '\b', '\t', '\n', '\u000b', '\u000c', '\r', - '\u000e', '\u000f', '\u0010', '\u0011', '\u0012', '\u0013', '\u0014', - '\u0015', '\u0016', '\u0017', '\u0018', '\u0019', '\u001a', '\u001b', - '\u001c', '\u001d', '\u001e', '\u001f', - ]; - - // Windows reserved device names. CreateFile on Windows will redirect a file - // named e.g. CON.html to the actual device. Rejecting them up-front means the - // option doesn't pass validation but then explode later in WriteAsync. - private static readonly string[] WindowsReservedNames = - [ - "CON", "PRN", "AUX", "NUL", - "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - ]; - - private static bool IsValidPureFileName(string fileName) + private static bool EscapesResultsDirectory(string path) { - if (RoslynString.IsNullOrWhiteSpace(fileName)) + // Fully-qualified paths (e.g. "C:\foo.html", "\\server\share\foo.html" or "/foo.html") are + // accepted as-is and validated by the OS when we open the file - the user explicitly opted + // out of writing under the test results directory. + if (IsPathFullyQualified(path)) { return false; } - if (fileName != fileName.Trim()) + // Drive-relative paths on Windows such as "C:foo.html" are "rooted" but not fully qualified - + // they resolve against the current directory of the drive, which is unpredictable and would + // silently escape the test results directory. Reject them. On non-Windows OSes + // Path.IsPathRooted only returns true for paths starting with "/", which are already handled + // above, so this check is effectively Windows-only and matches the TRX option behavior. + if (Path.IsPathRooted(path)) { - return false; + return true; } - if (fileName == "." || fileName == ".." || fileName.Contains("..")) + // Any remaining ".." segment in a relative path would escape the test results directory. + return path.Split(DirectorySeparators, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."); + } + + private static bool IsPathFullyQualified(string path) + { +#if NETCOREAPP + return Path.IsPathFullyQualified(path); +#else + // Mirrors the runtime implementation that is missing on .NET Framework and netstandard2.0. + if (path.Length < 2) { return false; } - foreach (char c in fileName) + // UNC paths like "\\server\share" (or with forward slashes). + if (IsDirectorySeparator(path[0]) && IsDirectorySeparator(path[1])) { - if (Array.IndexOf(InvalidFileNameChars, c) >= 0) - { - return false; - } + return true; } - // Disallow Windows device names independent of host OS so the option is - // consistently rejected. We compare against the bare name (without extension) - // because e.g. "CON.html" maps to the CON device. - string bareName = fileName; - int dot = bareName.IndexOf('.'); - if (dot >= 0) + // On Unix, only paths starting with "/" are fully qualified. + if (Path.DirectorySeparatorChar == '/') { - bareName = bareName.Substring(0, dot); + return path[0] == '/'; } - foreach (string reserved in WindowsReservedNames) - { - if (string.Equals(bareName, reserved, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - } + // On Windows, fully qualified drive paths must be "X:\" or "X:/". + return path.Length >= 3 + && IsValidDriveLetter(path[0]) + && path[1] == ':' + && IsDirectorySeparator(path[2]); + + static bool IsDirectorySeparator(char c) + => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; - return true; + static bool IsValidDriveLetter(char c) + => c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z'); +#endif } } diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj index 83a09753a0..f3d2d5f0da 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Microsoft.Testing.Extensions.HtmlReport.csproj @@ -44,6 +44,7 @@ This package extends Microsoft Testing Platform to produce self-contained HTML t + diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/ExtensionResources.resx b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/ExtensionResources.resx index 3e5d997cf1..c00a8d5937 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/ExtensionResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/ExtensionResources.resx @@ -70,15 +70,20 @@ '--report-html-filename' file name argument must end with '.html' (e.g. --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + - The name of the generated HTML report + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) '--report-html-filename' requires '--report-html' to be enabled - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - Produce a self-contained HTML report for the current test session diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.cs.xlf index db2ff9b5fc..a17c5265e5 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.cs.xlf @@ -22,9 +22,21 @@ Argument názvu souboru „--report-html-filename“ musí končit na .html (např. --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Název vygenerované sestavy HTML + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Název vygenerované sestavy HTML + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ „--report-html-filename“ vyžaduje povolení „--report-html“ - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - Argument názvu souboru nesmí obsahovat cestu nebo neplatné znaky (např. --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Vytvoření samostatné sestavy HTML pro aktuální testovací relaci diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.de.xlf index fe2c7404f8..432d8694fc 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.de.xlf @@ -22,9 +22,21 @@ Das Dateinamenargument „--report-html-filename“ muss mit „.html“ enden (z. B. --report-html-filename myreport.html). + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Der Name des generierten HTML-Berichts. + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Der Name des generierten HTML-Berichts. + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ „--report-html-filename“ erfordert, dass „--report-html“ aktiviert ist. - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - Das Dateinamenargument darf keinen Pfad und keine ungültigen Zeichen enthalten (z. B. --report-html-filename myreport.html). - - Produce a self-contained HTML report for the current test session Erstellt einen eigenständigen HTML-Bericht für die aktuelle Testsitzung diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.es.xlf index 75b6592d33..0246dbc161 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.es.xlf @@ -22,9 +22,21 @@ El argumento del nombre de archivo '--report-html-filename' debe terminar en '.html' (por ejemplo, --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Nombre del informe HTML generado + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Nombre del informe HTML generado + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ '--report-html-filename' requiere que '--report-html' esté habilitado - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - el argumento de nombre de archivo no debe contener una ruta de acceso ni caracteres no válidos (por ejemplo, --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Generar un informe HTML independiente para la sesión de prueba actual diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.fr.xlf index 3b28815b6e..da6612001d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.fr.xlf @@ -22,9 +22,21 @@ L’argument de nom de fichier de « --report-html-filename » doit se terminer par « .html » (par exemple, --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Nom du rapport HTML généré + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Nom du rapport HTML généré + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ « --report-html-filename » nécessite l’activation de « --report-html » - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - L’argument de nom de fichier ne doit pas contenir de chemin d’accès ni de caractères non valides (par exemple, --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Générer un rapport HTML autonome pour la session de test actuelle diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.it.xlf index 8d2ec09e31..a52ef541ea 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.it.xlf @@ -22,9 +22,21 @@ L'argomento del nome file "--report-html-filename" deve terminare con ".html" (ad esempio --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Nome del report HTML generato + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Nome del report HTML generato + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ "--report-html-filename" richiede che "--report-html" sia abilitato - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - l'argomento del nome file non deve contenere un percorso o caratteri non validi (ad esempio --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Generare un report HTML autonomo per la sessione di test corrente diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ja.xlf index 97338ab96a..7f2a4136e7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ja.xlf @@ -22,9 +22,21 @@ '--report-html-filename' のファイル名引数の末尾は '.html' にする必要があります (例: --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - 生成された HTML レポートの名前 + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + 生成された HTML レポートの名前 + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ '--report-html-filename' を使用するには、'--report-html' を有効にする必要があります - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - ファイル名引数にパスや無効な文字を含めることはできません (例: --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session 現在のテスト セッションの自己完結型 HTML レポートを生成する diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ko.xlf index 38ad8285c6..497d49067e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ko.xlf @@ -22,9 +22,21 @@ '--report-html-filename' 파일 이름 인수는 '.html'로 끝나야 합니다. 예: --report-html-filename myreport.html + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - 생성된 HTML 보고서의 이름 + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + 생성된 HTML 보고서의 이름 + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ '--report-html-filename'을 사용하려면 '--report-html'을 사용하도록 설정해야 합니다. - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - 파일 이름 인수에는 경로나 잘못된 문자가 포함되면 안 됩니다. 예: --report-html-filename myreport.html - - Produce a self-contained HTML report for the current test session 현재 테스트 세션에 대한 독립 실행형 HTML 보고서 생성 diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pl.xlf index 7514281a8e..f7ff23bceb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pl.xlf @@ -22,9 +22,21 @@ Argument nazwy pliku „--report-html-filename” musi kończyć się ciągiem „.html” (np. --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Nazwa wygenerowanego raportu HTML + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Nazwa wygenerowanego raportu HTML + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ Element „--report-html-filename” wymaga włączenia parametru „--report-html” - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - argument nazwy pliku nie może zawierać ścieżki ani nieprawidłowych znaków (np. --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Tworzenie samodzielnego raportu HTML dla bieżącej sesji testowej diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pt-BR.xlf index 4f8d2b4c8f..0034bb5adb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.pt-BR.xlf @@ -22,9 +22,21 @@ O argumento de nome de arquivo "--report-html-filename" deve terminar com ".html" (por exemplo, --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - O nome do relatório HTML gerado + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + O nome do relatório HTML gerado + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ "--report-html-filename" requer que "--report-html" esteja habilitado - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - o argumento de nome de arquivo não deve conter um caminho ou caracteres inválidos (por exemplo, --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Produzir um relatório HTML autossuficiente para a sessão de teste atual diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ru.xlf index 42a1d588ed..456b2115d8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.ru.xlf @@ -22,9 +22,21 @@ Аргумент имени файла "--report-html-filename" должен оканчиваться на ".html" (например: --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Имя сгенерированного отчета в формате HTML + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Имя сгенерированного отчета в формате HTML + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ Для "--report-html-filename" требуется включить "--report-html" - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - аргумент с именем файла не должен содержать путь или недопустимые символы (например: --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Создать автономный отчет в формате HTML для текущего тестового сеанса diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.tr.xlf index f6271ce7d2..dae3cea660 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.tr.xlf @@ -22,9 +22,21 @@ '--report-html-filename' dosya adı bağımsız değişkeni '.html' ile bitmelidir (ör. --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - Oluşturulan HTML raporunun adı + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + Oluşturulan HTML raporunun adı + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ '--report-html-filename', '--report-html' seçeneğinin etkinleştirilmesini gerektirir - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - Dosya adı bağımsız değişkeni yol veya geçersiz karakterler içermemelidir (ör. --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session Geçerli test oturumu için bağımsız bir HTML raporu oluşturun diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hans.xlf index 1d7e4bbf9f..9b52b1ac9b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hans.xlf @@ -22,9 +22,21 @@ "--report-html-filename" 文件名参数必须以 ".html" 结尾(例如 --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - 生成的 HTML 报表的名称 + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + 生成的 HTML 报表的名称 + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ "--report-html-filename" 需要启用 "--report-html" - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - 文件名参数不得包含路径或无效字符(例如 --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session 为当前测试会话生成独立的 HTML 报表 diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hant.xlf index 1401b444dc..8d0ba6c172 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/Resources/xlf/ExtensionResources.zh-Hant.xlf @@ -22,9 +22,21 @@ '--report-html-filename' 檔名引數的結尾必須是 '.html' (例如 --report-html-filename myreport.html) + + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + '--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html) + + - The name of the generated HTML report - 產生的 TRX 報告名稱 + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. +Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). +Example: MyReport_{tfm}.html + 產生的 HTML 報告名稱 + + + + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) + '--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html) @@ -32,11 +44,6 @@ '--report-html-filename' 需要啟用 '--report-html' - - file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html) - 檔案名稱引數不能包含路徑或無效字元 (例如 --report-html-filename myreport.html) - - Produce a self-contained HTML report for the current test session 為目前的測試工作階段產生獨立式 HTML 報告 diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs index 16ec65ffd8..7947d7bdc7 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs @@ -135,7 +135,9 @@ Override the Azure DevOps artifact container name. Defaults to 'TestResults_{ass --report-html Enable generating an HTML report --report-html-filename - The name of the generated HTML report + The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. + Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). + Example: MyReport_{tfm}.html --report-trx Enable generating TRX report --report-trx-filename @@ -457,7 +459,9 @@ Default type is 'Full' --report-html-filename Arity: 1 Hidden: False - Description: The name of the generated HTML report + Description: The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created. + Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp). + Example: MyReport_{tfm}.html MSBuildCommandLineProvider Name: MSBuildCommandLineProvider Version: * diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HtmlReportTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HtmlReportTests.cs index b0aa16bc58..2beb8c0858 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HtmlReportTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HtmlReportTests.cs @@ -82,15 +82,29 @@ public async Task Html_WhenReportHtmlFilenameIsSpecified_HtmlReportIsGeneratedWi [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] [TestMethod] - public async Task Html_WhenReportHtmlFilenameContainsPath_ErrorIsDisplayed(string tfm) + public async Task Html_WhenReportHtmlFilenameContainsPath_HtmlReportIsGeneratedInThatPath(string tfm) { + string customFileName = Path.Combine("subdir", "report.html"); + string testResultsPath = Path.Combine(AssetFixture.TargetAssetPath, "bin", "Release", tfm, "TestResults"); + string customFilePath = Path.Combine(testResultsPath, customFileName); + string expectedFilePath = Regex.Escape(customFilePath); + var testHost = TestInfrastructure.TestHost.LocateFrom(AssetFixture.TargetAssetPath, TestAssetFixture.AssetName, tfm); TestHostResult testHostResult = await testHost.ExecuteAsync( - $"--report-html --report-html-filename {Path.Combine("subdir", "report.html")}", + $"--report-html --report-html-filename {customFileName}", cancellationToken: TestContext.CancellationToken); - testHostResult.AssertExitCodeIs(ExitCode.InvalidCommandLine); - testHostResult.AssertOutputContains("file name argument must not contain a path or invalid characters"); + testHostResult.AssertExitCodeIs(ExitCode.Success); + + string outputPattern = $""" + In process file artifacts produced: + - {expectedFilePath} +"""; + testHostResult.AssertOutputMatchesRegex(outputPattern); + + Assert.IsTrue( + File.Exists(customFilePath), + $"Expected custom HTML report file '{customFileName}' was not found in '{testResultsPath}'."); } [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs index 7fae073d0e..371a06c49e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs @@ -332,6 +332,117 @@ public async Task GenerateReportAsync_DefaultFileName_IncludesModuleNameAndTarge Assert.IsTrue(Regex.IsMatch(Path.GetFileName(finalPath), ExpectedFileNamePattern)); } + [TestMethod] + public async Task GenerateReportAsync_ExplicitRelativePath_IsResolvedUnderResultsDirectory() + { + string[]? htmlFileName = [Path.Combine("nested", "custom.html")]; + _ = _commandLineOptionsMock.Setup(_ => _.TryGetOptionArgumentList(HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName, out htmlFileName)).Returns(true); + + string? pathSeen = null; + var directories = new List(); + _ = _fileSystem.Setup(x => x.ExistFile(It.IsAny())).Returns(false); + _ = _fileSystem.Setup(x => x.CreateDirectory(It.IsAny())) + .Callback(directories.Add) + .Returns(path => path); + _ = _fileSystem.Setup(x => x.NewFileStream(It.IsAny(), FileMode.Create)) + .Returns((path, _) => + { + pathSeen = path; + return new MemoryFileStream(); + }); + + HtmlReportEngine engine = CreateEngine(); + _ = _configurationMock.SetupGet(_ => _[It.IsAny()]).Returns("out"); + + (string finalPath, _) = await engine.GenerateReportAsync([Captured("a", "A", "passed")]); + + string expectedPath = Path.Combine("out", "nested", "custom.html"); + Assert.AreEqual(expectedPath, finalPath); + Assert.AreEqual(expectedPath, pathSeen); + Assert.Contains(Path.Combine("out", "nested"), directories); + } + + [TestMethod] + public async Task GenerateReportAsync_ExplicitAbsolutePath_OverridesResultsDirectory() + { + string absolutePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".html"); + string[]? htmlFileName = [absolutePath]; + _ = _commandLineOptionsMock.Setup(_ => _.TryGetOptionArgumentList(HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName, out htmlFileName)).Returns(true); + + string? pathSeen = null; + _ = _fileSystem.Setup(x => x.ExistFile(It.IsAny())).Returns(false); + _ = _fileSystem.Setup(x => x.CreateDirectory(It.IsAny())).Returns(path => path); + _ = _fileSystem.Setup(x => x.NewFileStream(It.IsAny(), FileMode.Create)) + .Returns((path, _) => + { + pathSeen = path; + return new MemoryFileStream(); + }); + + HtmlReportEngine engine = CreateEngine(); + _ = _configurationMock.SetupGet(_ => _[It.IsAny()]).Returns("out"); + + (string finalPath, _) = await engine.GenerateReportAsync([Captured("a", "A", "passed")]); + + Assert.AreEqual(absolutePath, finalPath); + Assert.AreEqual(absolutePath, pathSeen); + } + + [TestMethod] + public async Task GenerateReportAsync_ExplicitFileName_ResolvesPlaceholdersAndSanitizesLeafName() + { + string[]? htmlFileName = [Path.Combine("nested", "report*_{pid}_{tfm}.html")]; + _ = _commandLineOptionsMock.Setup(_ => _.TryGetOptionArgumentList(HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName, out htmlFileName)).Returns(true); + + string? pathSeen = null; + _ = _fileSystem.Setup(x => x.ExistFile(It.IsAny())).Returns(false); + _ = _fileSystem.Setup(x => x.CreateDirectory(It.IsAny())).Returns(path => path); + _ = _fileSystem.Setup(x => x.NewFileStream(It.IsAny(), FileMode.Create)) + .Returns((path, _) => + { + pathSeen = path; + return new MemoryFileStream(); + }); + + HtmlReportEngine engine = CreateEngine(); + _ = _configurationMock.SetupGet(_ => _[It.IsAny()]).Returns("out"); + _ = _environmentMock.SetupGet(_ => _.ProcessId).Returns(1234); + + (string finalPath, _) = await engine.GenerateReportAsync([Captured("a", "A", "passed")]); + + Assert.AreEqual(pathSeen, finalPath); + string finalFileName = Path.GetFileName(finalPath); + Assert.StartsWith("report__", finalFileName); + Assert.Contains("1234", finalFileName); + Assert.Contains("_net", finalFileName); + Assert.EndsWith(".html", finalPath); + } + + [TestMethod] + public async Task GenerateReportAsync_ExplicitReservedFileName_SanitizesLeafName() + { + string[]? htmlFileName = [Path.Combine("nested", "CON.html")]; + _ = _commandLineOptionsMock.Setup(_ => _.TryGetOptionArgumentList(HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName, out htmlFileName)).Returns(true); + + string? pathSeen = null; + _ = _fileSystem.Setup(x => x.ExistFile(It.IsAny())).Returns(false); + _ = _fileSystem.Setup(x => x.CreateDirectory(It.IsAny())).Returns(path => path); + _ = _fileSystem.Setup(x => x.NewFileStream(It.IsAny(), FileMode.Create)) + .Returns((path, _) => + { + pathSeen = path; + return new MemoryFileStream(); + }); + + HtmlReportEngine engine = CreateEngine(); + _ = _configurationMock.SetupGet(_ => _[It.IsAny()]).Returns("out"); + + (string finalPath, _) = await engine.GenerateReportAsync([Captured("a", "A", "passed")]); + + Assert.AreEqual(pathSeen, finalPath); + Assert.AreEqual("_CON.html", Path.GetFileName(finalPath)); + } + [TestMethod] public async Task GenerateReportAsync_AppendsDisambiguatingSuffix_When_DefaultFileExists() { @@ -476,6 +587,11 @@ private HtmlReportEngine CreateEngine(MemoryFileStream stream) _ = _fileSystem.Setup(x => x.ExistFile(It.IsAny())).Returns(false); _ = _fileSystem.Setup(x => x.NewFileStream(It.IsAny(), It.IsAny())).Returns(stream); + return CreateEngine(); + } + + private HtmlReportEngine CreateEngine() + { _ = _configurationMock.SetupGet(_ => _[It.IsAny()]).Returns(string.Empty); _ = _environmentMock.SetupGet(_ => _.MachineName).Returns("MachineName"); _ = _environmentMock.Setup(_ => _.GetEnvironmentVariable(It.IsAny())).Returns("user"); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportGeneratorCommandLineTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportGeneratorCommandLineTests.cs index 1cace2d6f0..aa4fc14998 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportGeneratorCommandLineTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportGeneratorCommandLineTests.cs @@ -11,13 +11,45 @@ namespace Microsoft.Testing.Extensions.UnitTests; public sealed class HtmlReportGeneratorCommandLineTests { [TestMethod] - public async Task IsValid_If_PureHtmlFileName_Is_Provided() + [DataRow("report.html")] + [DataRow("sub/report.html")] + public async Task IsValid_If_HtmlFileNameOrNestedRelativePath_Is_Provided(string fileName) + { + var provider = new HtmlReportGeneratorCommandLine(); + Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() + .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); + + ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false); + + Assert.IsTrue(result.IsValid); + Assert.IsTrue(string.IsNullOrEmpty(result.ErrorMessage)); + } + + [TestMethod] + [OSCondition(OperatingSystems.Windows)] + public async Task IsValid_If_HtmlFileNameUsesBackslashSeparator_OnWindows() { + // The '\' character is only a directory separator on Windows; on Unix it would be treated + // as part of the leaf file name (and later sanitized at write time). var provider = new HtmlReportGeneratorCommandLine(); Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); - ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, ["report.html"]).ConfigureAwait(false); + ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, ["sub\\report.html"]).ConfigureAwait(false); + + Assert.IsTrue(result.IsValid); + Assert.IsTrue(string.IsNullOrEmpty(result.ErrorMessage)); + } + + [TestMethod] + public async Task IsValid_If_HtmlFile_Has_Absolute_Path() + { + var provider = new HtmlReportGeneratorCommandLine(); + Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() + .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); + string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".html"); + + ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false); Assert.IsTrue(result.IsValid); Assert.IsTrue(string.IsNullOrEmpty(result.ErrorMessage)); @@ -40,15 +72,43 @@ public async Task IsInvalid_If_FileName_Does_Not_End_With_Html(string fileName) } [TestMethod] - [DataRow("sub/report.html")] - [DataRow("sub\\report.html")] - [DataRow("..\\report.html")] [DataRow("../report.html")] - [DataRow("..report.html")] // contains ".." - [DataRow("C:report.html")] // drive letter - [DataRow(" report.html")] // leading whitespace - [DataRow("report.html ")] // trailing whitespace - public async Task IsInvalid_If_FileName_Contains_Path_Or_Invalid_Chars(string fileName) + [DataRow("nested/../report.html")] + public async Task IsInvalid_If_RelativePath_Contains_ParentDirectorySegment(string fileName) + { + var provider = new HtmlReportGeneratorCommandLine(); + Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() + .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); + + ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false); + + Assert.IsFalse(result.IsValid); + Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameRelativePathMustStayUnderResultsDirectory, result.ErrorMessage); + } + + [TestMethod] + [OSCondition(OperatingSystems.Windows)] + public async Task IsInvalid_If_HtmlFile_Uses_DriveRelativePath_OnWindows() + { + // Drive-relative paths such as "C:report.html" are "rooted" but not fully qualified, so they + // would silently escape the test results directory. Validate that they are rejected on Windows. + // On non-Windows OSes ':' is a valid file-name character, so this check is Windows-only and + // matches the TRX option behavior. + var provider = new HtmlReportGeneratorCommandLine(); + Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() + .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); + + ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, ["C:report.html"]).ConfigureAwait(false); + + Assert.IsFalse(result.IsValid); + Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameRelativePathMustStayUnderResultsDirectory, result.ErrorMessage); + } + + [TestMethod] + [DataRow(" ")] + [DataRow("sub/")] + [DataRow("sub/ ")] + public async Task IsInvalid_If_FileNamePart_Is_Empty_Or_Whitespace(string fileName) { var provider = new HtmlReportGeneratorCommandLine(); Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() @@ -57,7 +117,20 @@ public async Task IsInvalid_If_FileName_Contains_Path_Or_Invalid_Chars(string fi ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false); Assert.IsFalse(result.IsValid); - Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameShouldNotContainPath, result.ErrorMessage); + Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameMustNotBeEmpty, result.ErrorMessage); + } + + [TestMethod] + public async Task IsInvalid_If_No_Argument_Provided() + { + var provider = new HtmlReportGeneratorCommandLine(); + Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() + .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); + + ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, []).ConfigureAwait(false); + + Assert.IsFalse(result.IsValid); + Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameMustNotBeEmpty, result.ErrorMessage); } [TestMethod] @@ -106,33 +179,9 @@ public async Task IsValid_When_HtmlReport_Used_Alone() } [TestMethod] - [DataRow("report*.html")] // * is Windows-invalid even though Linux allows it - [DataRow("report?.html")] // ? same - [DataRow("report\".html")] - [DataRow("report<.html")] - [DataRow("report>.html")] - [DataRow("report|.html")] - public async Task IsInvalid_When_FileName_Contains_WindowsInvalidChars_OnAnyOS(string fileName) - { - var provider = new HtmlReportGeneratorCommandLine(); - Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() - .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); - - ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false); - - Assert.IsFalse(result.IsValid); - Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameShouldNotContainPath, result.ErrorMessage); - } - - [TestMethod] + [DataRow("report*.html")] [DataRow("CON.html")] - [DataRow("con.html")] // case insensitive - [DataRow("NUL.html")] - [DataRow("PRN.html")] - [DataRow("AUX.html")] - [DataRow("COM1.html")] - [DataRow("LPT9.html")] - public async Task IsInvalid_When_FileName_Is_Reserved_Windows_Device_Name(string fileName) + public async Task IsValid_When_FileName_WillBeSanitized_AtWriteTime(string fileName) { var provider = new HtmlReportGeneratorCommandLine(); Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() @@ -140,20 +189,6 @@ public async Task IsInvalid_When_FileName_Is_Reserved_Windows_Device_Name(string ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false); - Assert.IsFalse(result.IsValid); - Assert.AreEqual(HtmlReport.Resources.ExtensionResources.HtmlReportFileNameShouldNotContainPath, result.ErrorMessage); - } - - [TestMethod] - public async Task IsValid_When_FileName_Starts_With_Reserved_Name_But_Has_Extra_Chars() - { - // "CONfig.html" is not a reserved device name (only the bare "CON" base name is). - var provider = new HtmlReportGeneratorCommandLine(); - Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions() - .First(x => x.Name == HtmlReportGeneratorCommandLine.HtmlReportFileNameOptionName); - - ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, ["CONfig.html"]).ConfigureAwait(false); - Assert.IsTrue(result.IsValid); } }