Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -316,15 +316,15 @@ public async Task RunTestsAsync_ShouldCallClientRunTests()
var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);
listener.Complete();

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(listener);

using var server = CreateServer();
await server.StartAsync();
var result = await server.RunTestsAsync(null);

result.ShouldNotBeNull();
_clientMock.Verify(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null), Times.Once);
_clientMock.Verify(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()), Times.Once);
}

[TestMethod]
Expand All @@ -336,14 +336,14 @@ public async Task RunTestsAsync_ShouldPassTestNodesToClient()
var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);
listener.Complete();

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), testNodes))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), testNodes, It.IsAny<CancellationToken>()))
.ReturnsAsync(listener);

using var server = CreateServer();
await server.StartAsync();
await server.RunTestsAsync(testNodes);

_clientMock.Verify(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), testNodes), Times.Once);
_clientMock.Verify(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), testNodes, It.IsAny<CancellationToken>()), Times.Once);
}

[TestMethod]
Expand All @@ -359,8 +359,8 @@ public async Task RunTestsAsync_ShouldCollectTestResults()
new TestNodeUpdate(failedNode, "parent")
};

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
.Returns<Guid, Func<TestNodeUpdate[], Task>, TestNode[]?>(async (id, callback, _) =>
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.Returns<Guid, Func<TestNodeUpdate[], Task>, TestNode[]?, CancellationToken>(async (id, callback, _, _) =>
{
await callback(updates);
var listener = new TestNodeUpdatesResponseListener(id, _ => Task.CompletedTask);
Expand All @@ -383,7 +383,7 @@ public async Task RunTestsAsync_WithTimeout_ShouldReturnTimedOutFalse_WhenComple
var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);
listener.Complete();

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(listener);

using var server = CreateServer();
Expand All @@ -401,7 +401,7 @@ public async Task RunTestsAsync_WithTimeout_ShouldReturnTimedOutTrue_WhenTimesOu
// Listener that never completes
var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(listener);

using var server = CreateServer();
Expand All @@ -418,7 +418,7 @@ public async Task RunTestsAsync_WithTimeout_ShouldThrowTestHostCrashed_WhenProce

// Listener that never completes (a crashed host never sends a completion signal)
var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(listener);

using var server = CreateServer();
Expand All @@ -439,7 +439,7 @@ public async Task RunTestsAsync_WithoutTimeout_ShouldThrowTestHostCrashed_WhenPr
SetupSuccessfulConnection();

var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(listener);

using var server = CreateServer();
Expand All @@ -452,13 +452,68 @@ public async Task RunTestsAsync_WithoutTimeout_ShouldThrowTestHostCrashed_WhenPr
async () => await server.RunTestsAsync(null));
}

[TestMethod]
public async Task RunTestsAsync_ShouldInvokeBailCallback_AndReturnCollectedResults()
{
SetupSuccessfulConnection();

var failedNode = new TestNode("uid-1", "Test1", "action", "failed");
var updates = new[] { new TestNodeUpdate(failedNode, "parent") };

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.Returns<Guid, Func<TestNodeUpdate[], Task>, TestNode[]?, CancellationToken>(async (id, callback, _, _) =>
{
await callback(updates);
var listener = new TestNodeUpdatesResponseListener(id, _ => Task.CompletedTask);
listener.Complete();
return listener;
});

IReadOnlyList<TestNodeUpdate>? observed = null;
using var server = CreateServer();
await server.StartAsync();

var (results, timedOut) = await server.RunTestsAsync(null, TimeSpan.FromSeconds(10), batch =>
{
observed = batch;
return true; // request bail on first batch
});

// The streamed results are surfaced to the bail callback and still returned to the caller.
observed.ShouldNotBeNull();
observed!.Single().Node.Uid.ShouldBe("uid-1");
results.Count.ShouldBe(1);
timedOut.ShouldBeFalse();
}

[TestMethod]
public async Task RunTestsAsync_ShouldPassCancellableToken_ToClient_ForBail()
{
SetupSuccessfulConnection();

CancellationToken capturedToken = default;
var listener = new TestNodeUpdatesResponseListener(Guid.NewGuid(), _ => Task.CompletedTask);
listener.Complete();

_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.Callback<Guid, Func<TestNodeUpdate[], Task>, TestNode[]?, CancellationToken>((_, _, _, token) => capturedToken = token)
.ReturnsAsync(listener);

using var server = CreateServer();
await server.StartAsync();
await server.RunTestsAsync(null, TimeSpan.FromSeconds(10), _ => false);

// A real, cancellable token is handed to the client so a bail can propagate $/cancelRequest.
capturedToken.CanBeCanceled.ShouldBeTrue();
}

[TestMethod]
public async Task RunTestsAsync_WithTimeout_ShouldReturnTimedOutTrue_WhenRpcCallBlocks()
{
SetupSuccessfulConnection();

// RPC call that never returns (simulates server stuck in infinite loop)
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null))
_clientMock.Setup(c => c.RunTestsAsync(It.IsAny<Guid>(), It.IsAny<Func<TestNodeUpdate[], Task>>(), null, It.IsAny<CancellationToken>()))
.Returns(new TaskCompletionSource<ResponseListener>().Task);

using var server = CreateServer();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,133 @@ bool Update(IReadOnlyList<IMutant> _, ITestIdentifiers failed, ITestIdentifiers
result.ResultMessage.ShouldContain("crash"); // failure reason is surfaced, not swallowed
}

// --- Bail-on-first-failure tests ---
//
// The MTP runner now mirrors the VsTest runner: streamed results are fed to the mutation update
// handler as they arrive, and once every tested mutant's fate is decided the remaining tests/assemblies
// are skipped. The update handler returns true while mutants are pending and always returns true when
// --disable-bail is set, so honouring its return value is all that is needed to honour that option.

[TestMethod, Timeout(1000)]
public async Task TestMultipleMutantsAsync_BailsOut_SkippingRemainingAssemblies_OnceMutantIsKilled()
{
const string assemblyA = "/a.dll";
const string assemblyB = "/b.dll";
_testsByAssembly[assemblyA] = [new TestNode("a-uid", "TestA", "test", TestNodeStates.Failed)];
_testsByAssembly[assemblyB] = [new TestNode("b-uid", "TestB", "test", TestNodeStates.Passed)];

var project = new Mock<IProjectAndTests>();
project.Setup(x => x.GetTestAssemblies()).Returns([assemblyA, assemblyB]);

var mutant = new Mock<IMutant>();
mutant.Setup(m => m.Id).Returns(1);

var runAssemblies = new List<string>();
using var runner = new StreamingAssemblyRunner(
_testsByAssembly, _testDescriptions, _testSet, _discoveryLock, runAssemblies);

// Bail (return false) as soon as any test has failed; continue otherwise.
bool Update(IReadOnlyList<IMutant> _, ITestIdentifiers failed, ITestIdentifiers __, ITestIdentifiers ___)
=> !failed.GetIdentifiers().Any();

await runner.TestMultipleMutantsAsync(project.Object, null, [mutant.Object], Update);

// Assembly A streamed a failing test, so the run bailed and never touched assembly B.
runAssemblies.ShouldBe([assemblyA]);
}

[TestMethod, Timeout(1000)]
public async Task TestMultipleMutantsAsync_DoesNotBail_WhenUpdateHandlerKeepsRunning()
{
// Mirrors --disable-bail: the handler always returns true, so every assembly must run even
// after a test fails.
const string assemblyA = "/a.dll";
const string assemblyB = "/b.dll";
_testsByAssembly[assemblyA] = [new TestNode("a-uid", "TestA", "test", TestNodeStates.Failed)];
_testsByAssembly[assemblyB] = [new TestNode("b-uid", "TestB", "test", TestNodeStates.Passed)];

var project = new Mock<IProjectAndTests>();
project.Setup(x => x.GetTestAssemblies()).Returns([assemblyA, assemblyB]);

var mutant = new Mock<IMutant>();
mutant.Setup(m => m.Id).Returns(1);

var runAssemblies = new List<string>();
using var runner = new StreamingAssemblyRunner(
_testsByAssembly, _testDescriptions, _testSet, _discoveryLock, runAssemblies);

bool Update(IReadOnlyList<IMutant> _, ITestIdentifiers __, ITestIdentifiers ___, ITestIdentifiers ____) => true;

await runner.TestMultipleMutantsAsync(project.Object, null, [mutant.Object], Update);

runAssemblies.ShouldBe([assemblyA, assemblyB]);
}

[TestMethod, Timeout(1000)]
public async Task TestMultipleMutantsAsync_FeedsStreamedResultsToUpdateHandler_BeforeRunCompletes()
{
const string assembly = "/a.dll";
_testsByAssembly[assembly] = [new TestNode("a-uid", "TestA", "test", TestNodeStates.Failed)];

var project = new Mock<IProjectAndTests>();
project.Setup(x => x.GetTestAssemblies()).Returns([assembly]);

var mutant = new Mock<IMutant>();
mutant.Setup(m => m.Id).Returns(1);

var runAssemblies = new List<string>();
using var runner = new StreamingAssemblyRunner(
_testsByAssembly, _testDescriptions, _testSet, _discoveryLock, runAssemblies);

ITestIdentifiers? capturedFailed = null;
bool Update(IReadOnlyList<IMutant> _, ITestIdentifiers failed, ITestIdentifiers __, ITestIdentifiers ___)
{
capturedFailed = failed;
return false;
}

await runner.TestMultipleMutantsAsync(project.Object, null, [mutant.Object], Update);

// The failing test was surfaced to the handler via the incremental streaming path.
capturedFailed.ShouldNotBeNull();
capturedFailed!.GetIdentifiers().ShouldContain("a-uid");
}

/// <summary>
/// Simulates the server streaming each discovered test's result to the bail callback during an assembly
/// run, without starting a real test host. Records the assemblies that were actually run so a test can
/// assert which ones were skipped after a bail.
/// </summary>
private sealed class StreamingAssemblyRunner : SingleMicrosoftTestPlatformRunner
{
private readonly List<string> _runAssemblies;

public StreamingAssemblyRunner(
Dictionary<string, List<TestNode>> testsByAssembly,
Dictionary<string, MtpTestDescription> testDescriptions,
TestSet testSet,
object discoveryLock,
List<string> runAssemblies)
: base(0, testsByAssembly, testDescriptions, testSet, discoveryLock, NullLogger.Instance)
=> _runAssemblies = runAssemblies;

internal override Task<(TestRunResult? Result, bool TimedOut, List<TestNode>? DiscoveredTests)> RunAssemblyTestsAsync(
string assembly, ITimeoutValueCalculator? timeoutCalc, Func<IReadOnlyList<TestNodeUpdate>, bool>? shouldBail = null)
{
_runAssemblies.Add(assembly);
var discovered = GetDiscoveredTests(assembly) ?? [];

// Simulate the server streaming the discovered tests' results to the bail callback.
shouldBail?.Invoke(discovered.Select(t => new TestNodeUpdate(t, "root")).ToList());

var result = BuildTestRunResult(
discovered.Select(t => new TestNodeUpdate(t, "root")).ToList(),
discovered.Count,
TimeSpan.Zero);
return Task.FromResult<(TestRunResult?, bool, List<TestNode>?)>((result, false, discovered));
}
}

/// <summary>
/// Simulates an assembly whose test host crashes: <see cref="RunAssemblyTestsAsync"/> returns the
/// failure sentinel produced by the real exception path, without starting any server process.
Expand All @@ -709,7 +836,7 @@ public CrashingAssemblyRunner(
=> _discovered = discovered;

internal override Task<(TestRunResult? Result, bool TimedOut, List<TestNode>? DiscoveredTests)> RunAssemblyTestsAsync(
string assembly, ITimeoutValueCalculator? timeoutCalc)
string assembly, ITimeoutValueCalculator? timeoutCalc, Func<IReadOnlyList<TestNodeUpdate>, bool>? shouldBail = null)
=> Task.FromResult<(TestRunResult?, bool, List<TestNode>?)>(
(new TestRunResult(false, "simulated test host crash"), false, _discovered));
}
Expand Down Expand Up @@ -1448,7 +1575,7 @@ public TimeoutSimulatingRunner(
: base(id, testsByAssembly, testDescriptions, testSet, discoveryLock, logger) { }

internal override Task<(TestRunResult? Result, bool TimedOut, List<TestNode>? DiscoveredTests)> RunAssemblyTestsAsync(
string assembly, ITimeoutValueCalculator? timeoutCalc)
string assembly, ITimeoutValueCalculator? timeoutCalc, Func<IReadOnlyList<TestNodeUpdate>, bool>? shouldBail = null)
{
var discoveredTests = GetDiscoveredTests(assembly);
var result = new TestRunResult(
Expand All @@ -1475,7 +1602,7 @@ public NoTimeoutSimulatingRunner(
: base(id, testsByAssembly, testDescriptions, testSet, discoveryLock, logger) { }

internal override Task<(TestRunResult? Result, bool TimedOut, List<TestNode>? DiscoveredTests)> RunAssemblyTestsAsync(
string assembly, ITimeoutValueCalculator? timeoutCalc)
string assembly, ITimeoutValueCalculator? timeoutCalc, Func<IReadOnlyList<TestNodeUpdate>, bool>? shouldBail = null)
{
var discoveredTests = GetDiscoveredTests(assembly);
var result = new TestRunResult(
Expand Down
Loading
Loading