Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
98f0c0d
Initial plan
Copilot Jul 25, 2025
180cea5
Fix escape character handling in test display names
Copilot Jul 25, 2025
132d475
Fixes
Evangelink Jul 25, 2025
1b305a4
Parameterize tests to cover all C0 control characters (U+0000-U+001F)
Copilot Jul 25, 2025
e2994cb
Fix diagnostic
Evangelink Jul 25, 2025
1913168
Fix broken tests
Evangelink Jul 30, 2025
39969d7
Merge branch 'main' into copilot/fix-5133
Evangelink Jul 30, 2025
0ebce8c
Simplfied
Evangelink Jul 30, 2025
b58187d
Missed replace
Evangelink Jul 30, 2025
0869b81
Apply suggestion from @nohwnd
Evangelink Jul 31, 2025
d5afd2e
Apply suggestion from @nohwnd
Evangelink Jul 31, 2025
3ac28ca
Merge branch 'main' into copilot/fix-5133
Evangelink Jul 31, 2025
a5d57df
Optimize NormalizeSpecialCharacters method for better performance
Copilot Jul 31, 2025
303e533
Improve test clarity and optimize NormalizeSpecialCharacters method
Copilot Jul 31, 2025
e55b2bb
Add multi-targeting support for NormalizeSpecialCharacters optimization
Copilot Jul 31, 2025
d5e8b2e
Remove unnecessary using directive for System.Buffers
Copilot Jul 31, 2025
975f931
Fix IDE0300 warnings by simplifying collection initialization syntax
Copilot Jul 31, 2025
93513d8
Fix
Evangelink Aug 1, 2025
954bd5c
Fix diagnostic
Evangelink Aug 1, 2025
f52a77d
Merge branch 'main' into copilot/fix-5133
Evangelink Aug 4, 2025
3f995c8
Remove trailing spaces
Evangelink Aug 4, 2025
aca5b88
Rename NormalizeSpecialCharacters to MakeControlCharactersVisible and…
Copilot Aug 4, 2025
cd21778
Merge branch 'main' into copilot/fix-5133
Evangelink Aug 13, 2025
a923d0e
Fix diagnostics
Evangelink Aug 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ private void RenderTestCompleted(
terminal.Append(outcomeText);
terminal.ResetColor();
terminal.Append(' ');
terminal.Append(displayName);
terminal.Append(MakeControlCharactersVisible(displayName, true));
terminal.SetColor(TerminalColor.DarkGray);
terminal.Append(' ');
AppendLongDuration(terminal, duration);
Expand Down Expand Up @@ -536,11 +536,11 @@ private static void FormatStandardAndErrorOutput(ITerminal terminal, string? sta
terminal.SetColor(TerminalColor.DarkGray);
terminal.Append(SingleIndentation);
terminal.AppendLine(PlatformResources.StandardOutput);
string? standardOutputWithoutSpecialChars = NormalizeSpecialCharacters(standardOutput);
string? standardOutputWithoutSpecialChars = MakeControlCharactersVisible(standardOutput, normalizeWhitespaceCharacters: false);
AppendIndentedLine(terminal, standardOutputWithoutSpecialChars, DoubleIndentation);
terminal.Append(SingleIndentation);
terminal.AppendLine(PlatformResources.StandardError);
string? standardErrorWithoutSpecialChars = NormalizeSpecialCharacters(standardError);
string? standardErrorWithoutSpecialChars = MakeControlCharactersVisible(standardError, normalizeWhitespaceCharacters: false);
AppendIndentedLine(terminal, standardErrorWithoutSpecialChars, DoubleIndentation);
terminal.ResetColor();
}
Expand Down Expand Up @@ -651,10 +651,101 @@ internal void AssemblyRunCompleted()
_terminalWithProgress.RemoveWorker(assemblyRun.SlotIndex);
}

private static string? NormalizeSpecialCharacters(string? text)
=> text?.Replace('\0', '\x2400')
// escape char
.Replace('\x001b', '\x241b');
// SearchValues for efficient detection of control characters
#if NET8_0_OR_GREATER
private static readonly System.Buffers.SearchValues<char> AllControlChars = CreateControlCharSearchValues(includeWhitespace: true);
private static readonly System.Buffers.SearchValues<char> NonWhitespaceControlChars = CreateControlCharSearchValues(includeWhitespace: false);

private static System.Buffers.SearchValues<char> CreateControlCharSearchValues(bool includeWhitespace)
{
var controlChars = new List<char>();
for (char c = '\0'; c <= '\u00FF'; c++) // Check first 256 characters for performance
{
if (char.IsControl(c))
{
if (includeWhitespace || (c != '\t' && c != '\n' && c != '\r'))
{
controlChars.Add(c);
}
}
}

return System.Buffers.SearchValues.Create(controlChars.ToArray());
}
#else
private static readonly char[] AllControlChars = CreateControlCharArray(includeWhitespace: true);
private static readonly char[] NonWhitespaceControlChars = CreateControlCharArray(includeWhitespace: false);

private static char[] CreateControlCharArray(bool includeWhitespace)
{
var controlChars = new List<char>();
for (char c = '\0'; c <= '\u00FF'; c++) // Check first 256 characters for performance
{
if (char.IsControl(c))
{
if (includeWhitespace || (c != '\t' && c != '\n' && c != '\r'))
{
controlChars.Add(c);
}
}
}

return [.. controlChars];
}
#endif

[return: NotNullIfNotNull(nameof(text))]
private static string? MakeControlCharactersVisible(string? text, bool normalizeWhitespaceCharacters)
{
if (text is null)
{
return null;
}

#if NET8_0_OR_GREATER
// Use SearchValues to efficiently check if we need to do any work
System.Buffers.SearchValues<char> searchValues = normalizeWhitespaceCharacters ? AllControlChars : NonWhitespaceControlChars;
if (text.AsSpan().IndexOfAny(searchValues) == -1)
{
return text; // No control characters found, return original string
}
#else
// Use IndexOfAny to check if we need to do any work
char[] searchChars = normalizeWhitespaceCharacters ? AllControlChars : NonWhitespaceControlChars;
if (text.IndexOfAny(searchChars) == -1)
{
return text; // No control characters found, return original string
}
#endif

// Pre-allocate StringBuilder with known capacity
var sb = new StringBuilder(text.Length);

foreach (char c in text)
{
if (char.IsControl(c))
{
// Skip normalization for whitespace characters when not requested
if (!normalizeWhitespaceCharacters && (c == '\t' || c == '\n' || c == '\r'))
Comment thread
nohwnd marked this conversation as resolved.
{
sb.Append(c);
}
else
{
// Convert to Unicode control picture using bit manipulation (0x2400 + char value)
// For C0 control characters (0x00-0x1F), this produces proper Unicode control pictures (U+2400-U+241F)
// For other control characters, this produces printable characters that won't break console formatting
sb.Append((char)(0x2400 + c));
Comment thread
Youssef1313 marked this conversation as resolved.
}
}
else
{
sb.Append(c);
}
}

return sb.ToString();
}

/// <summary>
/// Appends a long duration in human readable format such as 1h 23m 500ms.
Expand Down Expand Up @@ -752,7 +843,7 @@ internal void TestDiscovered(string displayName)
asm.TotalTests++;
}

asm.DiscoveredTestDisplayNames.Add(displayName);
asm.DiscoveredTestDisplayNames.Add(MakeControlCharactersVisible(displayName, true));

_terminalWithProgress.UpdateWorker(asm.SlotIndex);
}
Expand Down Expand Up @@ -836,7 +927,7 @@ public void TestInProgress(
{
asm.TestNodeResultsState ??= new(Interlocked.Increment(ref _counter));
asm.TestNodeResultsState.AddRunningTestNode(
Interlocked.Increment(ref _counter), testNodeUid, displayName, CreateStopwatch());
Interlocked.Increment(ref _counter), testNodeUid, MakeControlCharactersVisible(displayName, true), CreateStopwatch());
}

_terminalWithProgress.UpdateWorker(asm.SlotIndex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -596,4 +596,163 @@ private class StackTraceException : Exception

public override string? StackTrace { get; }
}

// Test data for all C0 control characters (U+0000-U+001F) that are normalized
[DataRow('\x0000', '\x2400', "NULL")]
[DataRow('\x0001', '\x2401', "START OF HEADING")]
[DataRow('\x0002', '\x2402', "START OF TEXT")]
[DataRow('\x0003', '\x2403', "END OF TEXT")]
[DataRow('\x0004', '\x2404', "END OF TRANSMISSION")]
[DataRow('\x0005', '\x2405', "ENQUIRY")]
[DataRow('\x0006', '\x2406', "ACKNOWLEDGE")]
[DataRow('\x0007', '\x2407', "BELL")]
[DataRow('\x0008', '\x2408', "BACKSPACE")]
[DataRow('\t', '\x2409', "TAB")]
[DataRow('\n', '\x240A', "LINE FEED")]
[DataRow('\x000B', '\x240B', "VERTICAL TAB")]
[DataRow('\x000C', '\x240C', "FORM FEED")]
[DataRow('\r', '\x240D', "CARRIAGE RETURN")]
[DataRow('\x000E', '\x240E', "SHIFT OUT")]
[DataRow('\x000F', '\x240F', "SHIFT IN")]
[DataRow('\x0010', '\x2410', "DATA LINK ESCAPE")]
[DataRow('\x0011', '\x2411', "DEVICE CONTROL ONE")]
[DataRow('\x0012', '\x2412', "DEVICE CONTROL TWO")]
[DataRow('\x0013', '\x2413', "DEVICE CONTROL THREE")]
[DataRow('\x0014', '\x2414', "DEVICE CONTROL FOUR")]
[DataRow('\x0015', '\x2415', "NEGATIVE ACKNOWLEDGE")]
[DataRow('\x0016', '\x2416', "SYNCHRONOUS IDLE")]
[DataRow('\x0017', '\x2417', "END OF TRANSMISSION BLOCK")]
[DataRow('\x0018', '\x2418', "CANCEL")]
[DataRow('\x0019', '\x2419', "END OF MEDIUM")]
[DataRow('\x001A', '\x241A', "SUBSTITUTE")]
[DataRow('\x001B', '\x241B', "ESCAPE")]
[DataRow('\x001C', '\x241C', "FILE SEPARATOR")]
[DataRow('\x001D', '\x241D', "GROUP SEPARATOR")]
[DataRow('\x001E', '\x241E', "RECORD SEPARATOR")]
[DataRow('\x001F', '\x241F', "UNIT SEPARATOR")]
[TestMethod]
public void TestDisplayNames_WithControlCharacters_AreNormalized(char controlChar, char expectedChar, string charName)
{
string targetFramework = "net8.0";
string architecture = "x64";
string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\work\assembly.dll" : "/mnt/work/assembly.dll";

var stringBuilderConsole = new StringBuilderConsole();
var terminalReporter = new TerminalTestReporter(assembly, targetFramework, architecture, stringBuilderConsole, new CTRLPlusCCancellationTokenSource(), new TerminalTestReporterOptions
{
ShowPassedTests = () => true,
UseAnsi = false,
ShowProgress = () => false,
});

DateTimeOffset startTime = DateTimeOffset.MinValue;
DateTimeOffset endTime = DateTimeOffset.MaxValue;
terminalReporter.TestExecutionStarted(startTime, 1, isDiscovery: false);

terminalReporter.AssemblyRunStarted();

// Test display name with the specific control character
string testDisplayName = $"Test{controlChar}Name";
terminalReporter.TestCompleted(testNodeUid: "Test1", testDisplayName, TestOutcome.Passed, TimeSpan.FromSeconds(1),
informativeMessage: null, errorMessage: null, exception: null, expected: null, actual: null, standardOutput: null, errorOutput: null);

terminalReporter.AssemblyRunCompleted();
terminalReporter.TestExecutionCompleted(endTime);

string output = stringBuilderConsole.Output;

// Verify that the control character is replaced with its Unicode control picture
string normalizedDisplayName = $"Test{expectedChar}Name";
Assert.Contains(normalizedDisplayName, output, $"{charName} should be replaced with {expectedChar}");

// Verify that the literal control character is not present in the test display name
// Note: We skip this assertion for whitespace characters (\t, \n, \r) because these
// characters naturally appear in console output formatting (e.g., line breaks between tests)
// and asserting their complete absence would cause false positives
string literalDisplayName = $"Test{controlChar}Name";
bool isWhitespaceChar = controlChar is '\t' or '\n' or '\r';
if (!isWhitespaceChar)
{
Assert.DoesNotContain(literalDisplayName, output, $"Literal {charName} should not be present in test display name");
}
}

// Test data for all C0 control characters (U+0000-U+001F) that are normalized
[DataRow('\x0000', '\x2400', "NULL")]
[DataRow('\x0001', '\x2401', "START OF HEADING")]
[DataRow('\x0002', '\x2402', "START OF TEXT")]
[DataRow('\x0003', '\x2403', "END OF TEXT")]
[DataRow('\x0004', '\x2404', "END OF TRANSMISSION")]
[DataRow('\x0005', '\x2405', "ENQUIRY")]
[DataRow('\x0006', '\x2406', "ACKNOWLEDGE")]
[DataRow('\x0007', '\x2407', "BELL")]
[DataRow('\x0008', '\x2408', "BACKSPACE")]
[DataRow('\t', '\x2409', "TAB")]
[DataRow('\n', '\x240A', "LINE FEED")]
[DataRow('\x000B', '\x240B', "VERTICAL TAB")]
[DataRow('\x000C', '\x240C', "FORM FEED")]
[DataRow('\r', '\x240D', "CARRIAGE RETURN")]
[DataRow('\x000E', '\x240E', "SHIFT OUT")]
[DataRow('\x000F', '\x240F', "SHIFT IN")]
[DataRow('\x0010', '\x2410', "DATA LINK ESCAPE")]
[DataRow('\x0011', '\x2411', "DEVICE CONTROL ONE")]
[DataRow('\x0012', '\x2412', "DEVICE CONTROL TWO")]
[DataRow('\x0013', '\x2413', "DEVICE CONTROL THREE")]
[DataRow('\x0014', '\x2414', "DEVICE CONTROL FOUR")]
[DataRow('\x0015', '\x2415', "NEGATIVE ACKNOWLEDGE")]
[DataRow('\x0016', '\x2416', "SYNCHRONOUS IDLE")]
[DataRow('\x0017', '\x2417', "END OF TRANSMISSION BLOCK")]
[DataRow('\x0018', '\x2418', "CANCEL")]
[DataRow('\x0019', '\x2419', "END OF MEDIUM")]
[DataRow('\x001A', '\x241A', "SUBSTITUTE")]
[DataRow('\x001B', '\x241B', "ESCAPE")]
[DataRow('\x001C', '\x241C', "FILE SEPARATOR")]
[DataRow('\x001D', '\x241D', "GROUP SEPARATOR")]
[DataRow('\x001E', '\x241E', "RECORD SEPARATOR")]
[DataRow('\x001F', '\x241F', "UNIT SEPARATOR")]
[TestMethod]
public void TestDiscovery_WithControlCharacters_AreNormalized(char controlChar, char expectedChar, string charName)
{
string targetFramework = "net8.0";
string architecture = "x64";
string assembly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\work\assembly.dll" : "/mnt/work/assembly.dll";

var stringBuilderConsole = new StringBuilderConsole();
var terminalReporter = new TerminalTestReporter(assembly, targetFramework, architecture, stringBuilderConsole, new CTRLPlusCCancellationTokenSource(), new TerminalTestReporterOptions
{
ShowPassedTests = () => true,
UseAnsi = false,
ShowProgress = () => false,
});

DateTimeOffset startTime = DateTimeOffset.MinValue;
DateTimeOffset endTime = DateTimeOffset.MaxValue;
terminalReporter.TestExecutionStarted(startTime, 1, isDiscovery: true);

terminalReporter.AssemblyRunStarted();

// Test discovery with the specific control character
string testDisplayName = $"Test{controlChar}Name";
terminalReporter.TestDiscovered(testDisplayName);

terminalReporter.AssemblyRunCompleted();
terminalReporter.TestExecutionCompleted(endTime);

string output = stringBuilderConsole.Output;

// Verify that the control character is replaced with its Unicode control picture
string normalizedDisplayName = $"Test{expectedChar}Name";
Assert.Contains(normalizedDisplayName, output, $"{charName} should be replaced with {expectedChar} in discovery");

// Verify that the literal control character is not present in the test display name
// Note: We skip this assertion for whitespace characters (\t, \n, \r) because these
// characters naturally appear in console output formatting (e.g., line breaks between tests)
// and asserting their complete absence would cause false positives
string literalDisplayName = $"Test{controlChar}Name";
bool isWhitespaceChar = controlChar is '\t' or '\n' or '\r';
if (!isWhitespaceChar)
{
Assert.DoesNotContain(literalDisplayName, output, $"Literal {charName} should not be present in test display name");
}
}
}