Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docfx/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ The STA fixture intentionally does not install one, matching @Xunit.StaFactAttri
- Do not dispose a fixture directly when xUnit owns it. xUnit disposes it at the end of its class or collection lifetime.
- Put only state that is intentionally shared into the fixture. Test class instances are still created separately for each test.

UI fact and theory attributes support a per-test timeout in milliseconds,
covering construction, initialization, invocation, and disposal. A timeout fails the test and
cancels its test cancellation token without waiting for the UI thread to finish. Code that does
not observe cancellation may continue running after the failure is reported. Timeouts are
disabled while a debugger is attached.


[^1]: This is a private @System.Threading.SynchronizationContext that works cross-platform and effectively keeps code running on the test's starting thread the way a GUI application's main thread would do.

Expand Down
64 changes: 60 additions & 4 deletions src/Xunit.StaFact/Sdk/UITestCaseRunner.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Andrew Arnott. All rights reserved.
// Licensed under the Ms-PL license. See LICENSE file in the project root for full license information.

using System.Diagnostics;
using System.Globalization;
using System.Text;

Expand Down Expand Up @@ -112,7 +113,13 @@ internal static ValueTask<RunSummary> Run(
{
ThreadRental threadRental = sharedThreadFixture?.ThreadRental
?? (ownedThreadRental = await ThreadRental.CreateAsync(adapter, testCase.TestMethod));
await threadRental.SynchronizationContext;

// Keep timeout reporting independent of a blocked UI thread.
if (testCase.Timeout <= 0 || Debugger.IsAttached)
{
await threadRental.SynchronizationContext;
}

var runner = new UITestCaseRunner(settings, threadRental);
return await runner.Run(
testCase,
Expand All @@ -129,7 +136,42 @@ internal static ValueTask<RunSummary> Run(
}
finally
{
ownedThreadRental?.Dispose();
if (ownedThreadRental is not null)
{
if (testCase.Timeout > 0 && !Debugger.IsAttached)
{
// Cleanup must run on the UI thread without delaying timeout reporting.
ownedThreadRental.SynchronizationContext.Post(
_ =>
{
try
{
ownedThreadRental.Dispose();
}
catch (Exception ex)
{
IXunitTestMethod testMethod = testCase.TestMethod;
IXunitTestClass testClass = testMethod.TestClass;
IXunitTestCollection testCollection = testClass.TestCollection;
if (!messageBus.QueueMessage(TestCaseCleanupFailure.FromException(
ex,
testCollection.TestAssembly.UniqueID,
testCollection.UniqueID,
testClass.UniqueID,
testMethod.UniqueID,
testCase.UniqueID)))
{
cancellationTokenSource.Cancel();
}
}
},
null);
}
else
{
ownedThreadRental.Dispose();
}
}
}
},
cancellationTokenSource.Token);
Expand All @@ -146,6 +188,11 @@ protected async override ValueTask<RunSummary> RunTest(UITestCaseRunnerContext c
throw new ArgumentNullException(nameof(ctxt));
}

if (test is null)
{
throw new ArgumentNullException(nameof(test));
}

RunSummary result = default;
for (int i = 0; i < this.settings.MaxAttempts; i++)
{
Expand Down Expand Up @@ -181,7 +228,12 @@ protected async override ValueTask<RunSummary> RunTest(UITestCaseRunnerContext c

private async ValueTask<RunSummary> RunTestAttempt(UITestCaseRunnerContext ctxt, IXunitTest test, bool finalAttempt)
{
await ctxt.ThreadRental.SynchronizationContext;
bool useUIThread = test.Timeout <= 0 || Debugger.IsAttached;
if (useUIThread)
{
await ctxt.ThreadRental.SynchronizationContext;
}

CultureInfo? originalCulture = null;
CultureInfo? originalUICulture = null;
try
Expand Down Expand Up @@ -213,7 +265,11 @@ private async ValueTask<RunSummary> RunTestAttempt(UITestCaseRunnerContext ctxt,
{
if (originalCulture is not null && originalUICulture is not null)
{
await ctxt.ThreadRental.SynchronizationContext;
if (useUIThread)
{
await ctxt.ThreadRental.SynchronizationContext;
}

CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUICulture;
}
Expand Down
65 changes: 65 additions & 0 deletions src/Xunit.StaFact/Sdk/UITestRunner.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Andrew Arnott. All rights reserved.
// Licensed under the Ms-PL license. See LICENSE file in the project root for full license information.

using System.Diagnostics;

namespace Xunit.Sdk;

public class UITestRunner : XunitTestRunnerBase<UITestRunnerContext, IXunitTest>
Expand Down Expand Up @@ -72,6 +74,69 @@ protected async override ValueTask<TimeSpan> RunTest(UITestRunnerContext ctxt)
uiSyncContext.SetExceptionAggregator(ctxt.Aggregator);
}

int timeout = ctxt.Test.Timeout;
if (ctxt.Aggregator.HasExceptions || timeout <= 0 || Debugger.IsAttached)
{
return await this.RunTestLifecycle(ctxt);
}

Stopwatch stopwatch = Stopwatch.StartNew();
TaskCompletionSource<TimeSpan> finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
ExecutionContext? executionContext = ExecutionContext.Capture();
ctxt.ThreadRental.SynchronizationContext.Post(
_ =>
{
if (executionContext is not null)
{
ExecutionContext.Run(executionContext, RunLifecycle, null);
}
else
{
RunLifecycle(null);
}
},
null);

using CancellationTokenSource delayCancellation = new();
Task completedTask = await Task.WhenAny(finished.Task, Task.Delay(timeout, delayCancellation.Token)).ConfigureAwait(false);
delayCancellation.Cancel();
if (completedTask == finished.Task)
{
return await finished.Task.ConfigureAwait(false);
}

// Like xunit, report the timeout without waiting for the lifecycle to finish.
// The UI thread continues until the test completes or observes cancellation.
try
{
TestTimeoutException timeoutException = TestTimeoutException.ForTimedOutTest(timeout);
ctxt.Aggregator.Add(timeoutException);
this.UpdateTestContext(null, TestResultState.FromException((decimal)stopwatch.Elapsed.TotalSeconds, timeoutException));
}
finally
{
TestContext.Current.CancelCurrentTest();
}

return stopwatch.Elapsed;

async void RunLifecycle(object? state)
{
try
{
SynchronizationContext.SetSynchronizationContext(
ctxt.ThreadRental.SyncContextAdapter.ShouldSetAsCurrent ? ctxt.ThreadRental.SynchronizationContext : null);
finished.TrySetResult(await this.RunTestLifecycle(ctxt));
}
catch (Exception ex)
{
finished.TrySetException(ex);
}
}
}

private async ValueTask<TimeSpan> RunTestLifecycle(UITestRunnerContext ctxt)
{
object? testClassInstance = null;
TimeSpan elapsedTime = TimeSpan.Zero;

Expand Down
3 changes: 2 additions & 1 deletion src/Xunit.StaFact/Sdk/Utilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ internal static IReadOnlyCollection<IXunitTestCase> CreateTestCasesForFact(
details.SkipWhen,
GetCultureTraits(traits, culture),
sourceFilePath: details.SourceFilePath,
sourceLineNumber: details.SourceLineNumber));
sourceLineNumber: details.SourceLineNumber,
timeout: details.Timeout));
}

internal static IReadOnlyCollection<IXunitTestCase> CreateTestCasesForDataRow(
Expand Down
3 changes: 2 additions & 1 deletion test/Xunit.StaFact.Tests/SharedUIThreadFixtureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ public SharedUIThreadFixtureTests(TrackingUIThreadFixture fixture)
this.fixture = fixture;
}

[UIFact]
[UIFact(Timeout = 30_000)]
public void FactUsesFixtureThread()
{
TestContext.Current.CancellationToken.ThrowIfCancellationRequested();
Assert.True(this.fixture.Initialized);
Assert.Equal(this.fixture.ThreadId, Environment.CurrentManagedThreadId);
Assert.Same(this.fixture.Context, SynchronizationContext.Current);
Expand Down
Loading
Loading