Skip to content

Commit 5e77a3d

Browse files
nohwndCopilot
andcommitted
Add regression tests for 8 previously untested bug fixes
Tests verify the specific behavior that was fixed — each test would fail if the corresponding fix were reverted. - GH-4461: Socket IOException not propagated when child exits (2 tests) - GH-2319: ErrorStackTrace settable before ErrorMessage (3 tests) - GH-5132: WarnOnFileOverwrite parameter parsed and applied (2 tests) - GH-4243: TestOutcome.Error is value 0, no Min/Max aliases (3 tests) - GH-5184: Stderr forwarded as Informational, not Error (2 tests) - GH-2479: ARM64 on Windows uses DLL hosting, not testhost.exe (1 test) - GH-2483: HtmlLogger.Initialize creates results directory (2 tests) - GH-3454: PortablePdbReader(MetadataReaderProvider) validates null (1 test) Co-authored-by: Copilot <[email protected]>
1 parent 0d67383 commit 5e77a3d

5 files changed

Lines changed: 557 additions & 0 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System;
5+
using System.IO;
6+
using System.Net;
7+
using System.Net.Sockets;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities;
12+
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Interfaces;
13+
using Microsoft.VisualStudio.TestTools.UnitTesting;
14+
15+
using Moq;
16+
17+
namespace Microsoft.TestPlatform.CommunicationUtilities.UnitTests;
18+
19+
/// <summary>
20+
/// Regression test for GH-4461:
21+
/// After a non-timeout IOException with a SocketException inner exception,
22+
/// the error output parameter must remain null (not the exception).
23+
/// Previously, the code set <c>error = ioException</c>, which propagated
24+
/// confusing socket errors to developers when testhost crashed.
25+
/// </summary>
26+
[TestClass]
27+
public class RegressionBugFixTests
28+
{
29+
public TestContext TestContext { get; set; } = null!;
30+
31+
private static (TcpClient client, TcpClient serverSide, TcpListener listener) CreateConnectedTcpPair()
32+
{
33+
var listener = new TcpListener(IPAddress.Loopback, 0);
34+
listener.Start();
35+
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
36+
37+
var client = new TcpClient();
38+
client.Connect(IPAddress.Loopback, port);
39+
var serverSide = listener.AcceptTcpClient();
40+
41+
return (client, serverSide, listener);
42+
}
43+
44+
[TestMethod]
45+
public async Task MessageLoopAsync_NonTimeoutSocketIOException_ErrorMustBeNull()
46+
{
47+
// GH-4461: When a non-timeout IOException wrapping a SocketException occurs,
48+
// the error handler must receive null (not the exception).
49+
// If the fix were reverted (error = ioException uncommented), capturedError would be non-null.
50+
var (client, serverSide, listener) = CreateConnectedTcpPair();
51+
try
52+
{
53+
var channel = new Mock<ICommunicationChannel>();
54+
var socketException = new SocketException((int)SocketError.ConnectionReset);
55+
var ioException = new IOException("Connection forcibly closed", socketException);
56+
57+
channel.Setup(c => c.NotifyDataAvailable(It.IsAny<CancellationToken>()))
58+
.Throws(ioException);
59+
60+
Exception? capturedError = null;
61+
62+
// Write data so Poll returns true and NotifyDataAvailable is invoked.
63+
var serverStream = serverSide.GetStream();
64+
await serverStream.WriteAsync(new byte[] { 0x1 }, 0, 1, TestContext.CancellationToken);
65+
await serverStream.FlushAsync(TestContext.CancellationToken);
66+
67+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken);
68+
cts.CancelAfter(TimeSpan.FromSeconds(5));
69+
70+
await client.MessageLoopAsync(channel.Object, error => capturedError = error, cts.Token);
71+
72+
// The fix commented out `error = ioException`, so capturedError must be null.
73+
Assert.IsNull(capturedError,
74+
"GH-4461: Non-timeout IOException with SocketException must NOT be propagated to the error handler.");
75+
}
76+
finally
77+
{
78+
client.Dispose();
79+
serverSide.Dispose();
80+
listener.Stop();
81+
}
82+
}
83+
84+
[TestMethod]
85+
public async Task MessageLoopAsync_NonIOException_ErrorMustBeSet()
86+
{
87+
// Contrast test: non-IOException exceptions must still be propagated.
88+
var (client, serverSide, listener) = CreateConnectedTcpPair();
89+
try
90+
{
91+
var channel = new Mock<ICommunicationChannel>();
92+
var expectedException = new InvalidOperationException("Non-IO failure");
93+
channel.Setup(c => c.NotifyDataAvailable(It.IsAny<CancellationToken>()))
94+
.Throws(expectedException);
95+
96+
Exception? capturedError = null;
97+
98+
serverSide.GetStream().Write(new byte[] { 1 }, 0, 1);
99+
100+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken);
101+
cts.CancelAfter(TimeSpan.FromSeconds(5));
102+
103+
await client.MessageLoopAsync(channel.Object, error => capturedError = error, cts.Token);
104+
105+
Assert.AreSame(expectedException, capturedError,
106+
"Non-IOException must still be propagated to the error handler.");
107+
}
108+
finally
109+
{
110+
client.Dispose();
111+
serverSide.Dispose();
112+
listener.Stop();
113+
}
114+
}
115+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System;
5+
using System.IO;
6+
using System.Runtime.Serialization;
7+
8+
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
9+
using Microsoft.VisualStudio.TestPlatform.Utilities.Helpers.Interfaces;
10+
using Microsoft.VisualStudio.TestTools.UnitTesting;
11+
12+
using Moq;
13+
14+
using HtmlLoggerImpl = Microsoft.VisualStudio.TestPlatform.Extensions.HtmlLogger.HtmlLogger;
15+
using IHtmlTransformer = Microsoft.VisualStudio.TestPlatform.Extensions.HtmlLogger.IHtmlTransformer;
16+
17+
namespace Microsoft.TestPlatform.Extensions.HtmlLogger.UnitTests;
18+
19+
/// <summary>
20+
/// Regression test for GH-2483:
21+
/// HtmlLogger.Initialize must create the test results directory if it doesn't exist.
22+
/// Previously, Initialize would not create the directory, causing later file
23+
/// operations to fail with DirectoryNotFoundException.
24+
/// </summary>
25+
[TestClass]
26+
public class RegressionBugFixTests
27+
{
28+
[TestMethod]
29+
public void Initialize_NonExistentDirectory_MustCreateIt()
30+
{
31+
// GH-2483: The fix added Directory.CreateDirectory(testResultsDirPath) in Initialize.
32+
// If the fix were reverted, the directory would not exist after Initialize.
33+
var mockFileHelper = new Mock<IFileHelper>();
34+
var mockHtmlTransformer = new Mock<IHtmlTransformer>();
35+
var mockXmlSerializer = new Mock<XmlObjectSerializer>();
36+
var htmlLogger = new HtmlLoggerImpl(mockFileHelper.Object, mockHtmlTransformer.Object, mockXmlSerializer.Object);
37+
38+
var events = new Mock<TestLoggerEvents>();
39+
var nonExistentDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
40+
41+
try
42+
{
43+
// Act
44+
htmlLogger.Initialize(events.Object, nonExistentDir);
45+
46+
// Assert: directory must have been created
47+
Assert.IsTrue(Directory.Exists(nonExistentDir),
48+
"GH-2483: Initialize must call Directory.CreateDirectory for the test results directory.");
49+
Assert.AreEqual(nonExistentDir, htmlLogger.TestResultsDirPath);
50+
}
51+
finally
52+
{
53+
if (Directory.Exists(nonExistentDir))
54+
{
55+
Directory.Delete(nonExistentDir);
56+
}
57+
}
58+
}
59+
60+
[TestMethod]
61+
public void Initialize_ExistingDirectory_MustNotThrow()
62+
{
63+
// Complementary test: Initialize with an existing directory must succeed.
64+
var mockFileHelper = new Mock<IFileHelper>();
65+
var mockHtmlTransformer = new Mock<IHtmlTransformer>();
66+
var mockXmlSerializer = new Mock<XmlObjectSerializer>();
67+
var htmlLogger = new HtmlLoggerImpl(mockFileHelper.Object, mockHtmlTransformer.Object, mockXmlSerializer.Object);
68+
69+
var events = new Mock<TestLoggerEvents>();
70+
var existingDir = Path.GetTempPath();
71+
72+
htmlLogger.Initialize(events.Object, existingDir);
73+
74+
Assert.AreEqual(existingDir, htmlLogger.TestResultsDirPath);
75+
}
76+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Reflection;
8+
9+
using Microsoft.TestPlatform.Extensions.TrxLogger.Utility;
10+
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
11+
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging;
12+
using Microsoft.VisualStudio.TestTools.UnitTesting;
13+
14+
using Moq;
15+
16+
using TrxLoggerConstants = Microsoft.TestPlatform.Extensions.TrxLogger.Utility.Constants;
17+
using TrxLoggerObjectModel = Microsoft.TestPlatform.Extensions.TrxLogger.ObjectModel;
18+
19+
using DefaultLoggerParameterNames = Microsoft.VisualStudio.TestPlatform.ObjectModel.DefaultLoggerParameterNames;
20+
21+
namespace Microsoft.TestPlatform.Extensions.TrxLogger.UnitTests;
22+
23+
/// <summary>
24+
/// Regression tests for:
25+
/// - GH-2319: Setting ErrorStackTrace before ErrorMessage must not crash.
26+
/// - GH-5132: WarnOnFileOverwrite=false must suppress overwrite warning.
27+
/// - GH-4243: TestOutcome.Error must have value 0, must serialize as "Error" (not "Min").
28+
/// </summary>
29+
[TestClass]
30+
public class RegressionBugFixTests
31+
{
32+
private static readonly string DefaultTestRunDirectory = Path.GetTempPath();
33+
34+
#region GH-2319: ErrorStackTrace without ErrorMessage doesn't crash
35+
36+
[TestMethod]
37+
public void ErrorStackTrace_SetBeforeErrorMessage_MustNotThrow()
38+
{
39+
// GH-2319: Previously, setting ErrorStackTrace first would crash with Debug.Assert
40+
// because _errorInfo was null. The fix uses ??= to lazily initialize.
41+
var testResult = CreateTestResult();
42+
43+
// Act: set ErrorStackTrace FIRST, before any ErrorMessage
44+
testResult.ErrorStackTrace = "at SomeTest.Method() in file.cs:line 42";
45+
46+
// Assert: should not throw and ErrorMessage should return empty string (not null or crash)
47+
Assert.AreEqual("at SomeTest.Method() in file.cs:line 42", testResult.ErrorStackTrace);
48+
Assert.AreEqual(string.Empty, testResult.ErrorMessage,
49+
"GH-2319: ErrorMessage must return empty string when only ErrorStackTrace is set.");
50+
}
51+
52+
[TestMethod]
53+
public void ErrorMessage_ThenErrorStackTrace_BothAccessible()
54+
{
55+
// Setting ErrorMessage first, then ErrorStackTrace: both must be readable.
56+
var testResult = CreateTestResult();
57+
58+
testResult.ErrorMessage = "Assert.Fail hit";
59+
testResult.ErrorStackTrace = "at MyTest.Run()";
60+
61+
Assert.AreEqual("Assert.Fail hit", testResult.ErrorMessage);
62+
Assert.AreEqual("at MyTest.Run()", testResult.ErrorStackTrace);
63+
}
64+
65+
[TestMethod]
66+
public void ErrorMessage_NeverSet_ReturnsEmptyString()
67+
{
68+
// Before the fix, accessing getters on a fresh TestResult without _errorInfo
69+
// would behave unpredictably. The fix returns string.Empty via null-coalescing.
70+
var testResult = CreateTestResult();
71+
72+
Assert.AreEqual(string.Empty, testResult.ErrorMessage);
73+
Assert.AreEqual(string.Empty, testResult.ErrorStackTrace);
74+
}
75+
76+
#endregion
77+
78+
#region GH-5132: WarnOnFileOverwrite parameter
79+
80+
[TestMethod]
81+
public void Initialize_WarnOnFileOverwriteFalse_FieldMustBeFalse()
82+
{
83+
// GH-5132: When WarnOnFileOverwrite=false, the _warnOnFileOverwrite field must be false.
84+
// If the fix were reverted (field removed), this would fail.
85+
var logger = new TestableTrxLogger();
86+
var events = new Mock<TestLoggerEvents>();
87+
var parameters = CreateDefaultParameters();
88+
parameters[TrxLoggerConstants.WarnOnFileOverwrite] = "false";
89+
90+
logger.Initialize(events.Object, parameters);
91+
92+
var warnField = typeof(VisualStudio.TestPlatform.Extensions.TrxLogger.TrxLogger)
93+
.GetField("_warnOnFileOverwrite", BindingFlags.NonPublic | BindingFlags.Instance);
94+
Assert.IsNotNull(warnField, "_warnOnFileOverwrite field must exist.");
95+
Assert.IsFalse((bool)warnField.GetValue(logger)!,
96+
"GH-5132: _warnOnFileOverwrite must be false when parameter is 'false'.");
97+
}
98+
99+
[TestMethod]
100+
public void Initialize_WarnOnFileOverwriteNotSet_DefaultsToTrue()
101+
{
102+
// GH-5132: When WarnOnFileOverwrite parameter is not provided, default must be true
103+
// (preserving existing behavior for users who did not opt out).
104+
var logger = new TestableTrxLogger();
105+
var events = new Mock<TestLoggerEvents>();
106+
var parameters = CreateDefaultParameters();
107+
108+
logger.Initialize(events.Object, parameters);
109+
110+
var warnField = typeof(VisualStudio.TestPlatform.Extensions.TrxLogger.TrxLogger)
111+
.GetField("_warnOnFileOverwrite", BindingFlags.NonPublic | BindingFlags.Instance);
112+
Assert.IsNotNull(warnField, "_warnOnFileOverwrite field must exist.");
113+
Assert.IsTrue((bool)warnField.GetValue(logger)!,
114+
"GH-5132: _warnOnFileOverwrite must default to true when parameter is not provided.");
115+
}
116+
117+
#endregion
118+
119+
#region GH-4243: TestOutcome has no Min/Max aliases
120+
121+
[TestMethod]
122+
public void TestOutcome_Error_MustHaveIntValue0()
123+
{
124+
// GH-4243: Error must be the first enum member (value 0).
125+
// Previously Min=Error alias made Error.ToString() return "Min".
126+
// Box through object to avoid compile-time constant folding (MSTEST0032).
127+
object errorEnum = TrxLoggerObjectModel.TestOutcome.Error;
128+
Assert.AreEqual(0, (int)(TrxLoggerObjectModel.TestOutcome)errorEnum,
129+
"GH-4243: TestOutcome.Error must have integer value 0.");
130+
}
131+
132+
[TestMethod]
133+
public void TestOutcome_Error_MustSerializeAsError_NotMin()
134+
{
135+
// GH-4243: The fix removed Min=Error alias. Error.ToString() must return "Error".
136+
// If the fix were reverted and Min=Error added back, ToString() would return "Min".
137+
Assert.AreEqual("Error", TrxLoggerObjectModel.TestOutcome.Error.ToString(),
138+
"GH-4243: TestOutcome.Error.ToString() must be 'Error', not 'Min'.");
139+
}
140+
141+
[TestMethod]
142+
public void TestOutcome_NoMinOrMaxMember()
143+
{
144+
// GH-4243: The enum must NOT have a member named "Min" or "Max".
145+
// Verify via reflection.
146+
var names = Enum.GetNames(typeof(TrxLoggerObjectModel.TestOutcome));
147+
CollectionAssert.DoesNotContain(names, "Min",
148+
"GH-4243: TestOutcome enum must not have a 'Min' member.");
149+
CollectionAssert.DoesNotContain(names, "Max",
150+
"GH-4243: TestOutcome enum must not have a 'Max' member.");
151+
}
152+
153+
#endregion
154+
155+
#region Helpers
156+
157+
private static TrxLoggerObjectModel.TestResult CreateTestResult()
158+
{
159+
return new TrxLoggerObjectModel.TestResult(
160+
runId: Guid.NewGuid(),
161+
testId: Guid.NewGuid(),
162+
executionId: Guid.NewGuid(),
163+
parentExecutionId: Guid.Empty,
164+
resultName: "TestResult1",
165+
computerName: Environment.MachineName,
166+
outcome: TrxLoggerObjectModel.TestOutcome.Failed,
167+
testType: new TrxLoggerObjectModel.TestType(Guid.NewGuid()),
168+
testCategoryId: TrxLoggerObjectModel.TestListCategoryId.Uncategorized,
169+
trxFileHelper: new TrxFileHelper());
170+
}
171+
172+
private static Dictionary<string, string?> CreateDefaultParameters()
173+
{
174+
return new Dictionary<string, string?>
175+
{
176+
[DefaultLoggerParameterNames.TestRunDirectory] = DefaultTestRunDirectory,
177+
[TrxLoggerConstants.LogFileNameKey] = "test.trx"
178+
};
179+
}
180+
181+
#endregion
182+
}

0 commit comments

Comments
 (0)