diff --git a/.gitignore b/.gitignore
index 3bb49919..e0f9b3b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -349,3 +349,6 @@ MigrationBackup/
# dotnet tool local install directory
.store/
+
+# File built by MacOS
+.DS_Store
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0893b308..e9a82c73 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,6 +33,9 @@ The easiest way to restore packages may be to run `init.ps1` which automatically
to the feeds that packages for this repo come from, if any.
`dotnet restore` or `nuget restore` also work but may require extra steps to authenticate to any applicable feeds.
+MacOS specific code that was added in #68 requires that `init.ps1` be run in the repo within a terminal *and devenv.exe must be spawned from that same window* in order for Visual Studio for Windows to be able to find the required macos workload.
+Note that `init.ps1 -InstallLocality machine` in broken due to dotnet workloads problems such as https://github.com/dotnet/sdk/issues/30230.
+
## Building
Building, testing, and packing this repository can be done by using the standard dotnet CLI commands (e.g. `dotnet build`, `dotnet test`, `dotnet pack`, etc.).
diff --git a/Directory.Build.props b/Directory.Build.props
index 2ca46b18..17232cc4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,6 +14,7 @@
true
true
true
+ true
true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 12841338..94385735 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,7 @@
+
diff --git a/Xunit.StaFact.sln b/Xunit.StaFact.sln
index 95fc7dea..c8fbed0c 100644
--- a/Xunit.StaFact.sln
+++ b/Xunit.StaFact.sln
@@ -21,6 +21,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xunit.StaFact.Tests", "test\Xunit.StaFact.Tests\Xunit.StaFact.Tests.csproj", "{9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xunit.StaFact.Tests.Mac", "test\Xunit.StaFact.Tests.Mac\Xunit.StaFact.Tests.Mac.csproj", "{077DFB77-0E63-4399-BD0B-4F9D9AC80495}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B9635B8C-19E8-4D04-ACA4-A391D558D80A}"
ProjectSection(SolutionItems) = preProject
src\.editorconfig = src\.editorconfig
@@ -38,17 +40,33 @@ EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ DebugMac|Any CPU = DebugMac|Any CPU
Release|Any CPU = Release|Any CPU
+ ReleaseMac|Any CPU = ReleaseMac|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.DebugMac|Any CPU.ActiveCfg = Debug|Any CPU
+ {8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.DebugMac|Any CPU.Build.0 = Debug|Any CPU
{8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.ReleaseMac|Any CPU.ActiveCfg = Release|Any CPU
+ {8C500C20-0CD6-4257-A9ED-C571DA6E68D6}.ReleaseMac|Any CPU.Build.0 = Release|Any CPU
{9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.DebugMac|Any CPU.ActiveCfg = Debug|Any CPU
+ {9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.DebugMac|Any CPU.Build.0 = Debug|Any CPU
{9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.ReleaseMac|Any CPU.ActiveCfg = Release|Any CPU
+ {9F99EC1E-7FF7-419D-84F7-3CE96D653C2E}.ReleaseMac|Any CPU.Build.0 = Release|Any CPU
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495}.DebugMac|Any CPU.ActiveCfg = Debug|Any CPU
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495}.DebugMac|Any CPU.Build.0 = Debug|Any CPU
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495}.ReleaseMac|Any CPU.ActiveCfg = Release|Any CPU
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495}.ReleaseMac|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -56,6 +74,7 @@ Global
GlobalSection(NestedProjects) = preSolution
{8C500C20-0CD6-4257-A9ED-C571DA6E68D6} = {B9635B8C-19E8-4D04-ACA4-A391D558D80A}
{9F99EC1E-7FF7-419D-84F7-3CE96D653C2E} = {1E94DC16-537C-4D14-A201-ED3140A994EB}
+ {077DFB77-0E63-4399-BD0B-4F9D9AC80495} = {1E94DC16-537C-4D14-A201-ED3140A994EB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DA3510A0-C12F-4470-B8A6-197F0D32CB7C}
diff --git a/azure-pipelines/build.yml b/azure-pipelines/build.yml
index 56169012..0c86ec34 100644
--- a/azure-pipelines/build.yml
+++ b/azure-pipelines/build.yml
@@ -48,6 +48,7 @@ jobs:
- template: dotnet.yml
parameters:
RunTests: ${{ parameters.RunTests }}
+ RunMacTests: true
- job: WrapUp
dependsOn:
diff --git a/azure-pipelines/dotnet.yml b/azure-pipelines/dotnet.yml
index 79babd4d..9e283b6d 100644
--- a/azure-pipelines/dotnet.yml
+++ b/azure-pipelines/dotnet.yml
@@ -1,5 +1,8 @@
parameters:
- RunTests:
+- name: RunTests
+- name: RunMacTests
+ type: boolean
+ default: false
steps:
@@ -10,6 +13,19 @@ steps:
displayName: 🧪 dotnet test
condition: and(succeeded(), ${{ parameters.RunTests }})
+- ${{ if parameters.RunMacTests }}:
+ - script: dotnet build --no-restore -c $(BuildConfiguration) ./test/Xunit.StaFact.Tests.Mac/Xunit.StaFact.Tests.Mac.csproj -t:Test -p:BuildProjectReferences=false
+ displayName: 🧪 Mac-specific tests
+
+ - task: PublishTestResults@2
+ inputs:
+ testResultsFormat: XUnit
+ testResultsFiles: ./test_logs/Xunit.StaFact.Tests.Mac.xml
+ testRunTitle: Xunit.StaFact.Tests.Mac (net6.0-macos, MacOS)
+ displayName: 📢 Publish Mac-specific tests results
+ condition: succeededOrFailed()
+ continueOnError: true
+
- powershell: azure-pipelines/variables/_pipelines.ps1
failOnStderr: true
displayName: âš™ Update pipeline variables based on build outputs
diff --git a/init.ps1 b/init.ps1
index 774ead58..2bb7fb94 100755
--- a/init.ps1
+++ b/init.ps1
@@ -65,6 +65,9 @@ if (!$NoPrerequisites) {
if ($env:OS -eq 'Windows_NT') {
$EnvVars['PROCDUMP_PATH'] = & "$PSScriptRoot\azure-pipelines\Get-ProcDump.ps1"
}
+
+ # Install specific macOS workload version in order to support usage on older .NET and macOS
+ dotnet workload install macos --from-rollback-file $PSScriptRoot/workloads.json
}
# Workaround nuget credential provider bug that causes very unreliable package restores on Azure Pipelines
@@ -77,7 +80,10 @@ try {
if (!$NoRestore -and $PSCmdlet.ShouldProcess("NuGet packages", "Restore")) {
Write-Host "Restoring NuGet packages" -ForegroundColor $HeaderColor
- dotnet restore
+ if ($IsMacOS) {
+ $restoreArgs = "/p:Configuration=DebugMac"
+ }
+ dotnet restore $restoreArgs
if ($lastexitcode -ne 0) {
throw "Failure while restoring packages."
}
diff --git a/src/Xunit.StaFact/Mac/CocoaFactAttribute.cs b/src/Xunit.StaFact/Mac/CocoaFactAttribute.cs
new file mode 100644
index 00000000..32f34f5b
--- /dev/null
+++ b/src/Xunit.StaFact/Mac/CocoaFactAttribute.cs
@@ -0,0 +1,16 @@
+// 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 Xunit.Sdk;
+
+namespace Xunit;
+
+///
+/// Identifies an xunit test that starts on with a
+/// running on .
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+[XunitTestCaseDiscoverer("Xunit.Sdk.CocoaFactDiscoverer", ThisAssembly.AssemblyName)]
+public class CocoaFactAttribute : FactAttribute
+{
+}
diff --git a/src/Xunit.StaFact/Mac/CocoaTheoryAttribute.cs b/src/Xunit.StaFact/Mac/CocoaTheoryAttribute.cs
new file mode 100644
index 00000000..7a2ec440
--- /dev/null
+++ b/src/Xunit.StaFact/Mac/CocoaTheoryAttribute.cs
@@ -0,0 +1,16 @@
+// 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 Xunit.Sdk;
+
+namespace Xunit;
+
+///
+/// Identifies an xunit theory that starts on with a
+/// running on .
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+[XunitTestCaseDiscoverer("Xunit.Sdk.CocoaTheoryDiscoverer", ThisAssembly.AssemblyName)]
+public class CocoaTheoryAttribute : TheoryAttribute
+{
+}
diff --git a/src/Xunit.StaFact/Sdk.Mac/CocoaFactDiscoverer.cs b/src/Xunit.StaFact/Sdk.Mac/CocoaFactDiscoverer.cs
new file mode 100644
index 00000000..121dded5
--- /dev/null
+++ b/src/Xunit.StaFact/Sdk.Mac/CocoaFactDiscoverer.cs
@@ -0,0 +1,35 @@
+// 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.Runtime.CompilerServices;
+
+namespace Xunit.Sdk;
+
+///
+/// The discovery class for .
+///
+public class CocoaFactDiscoverer : FactDiscoverer
+{
+ private readonly IMessageSink diagnosticMessageSink;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The diagnostic message sink.
+ public CocoaFactDiscoverer(IMessageSink diagnosticMessageSink)
+ : base(diagnosticMessageSink)
+ {
+ this.diagnosticMessageSink = diagnosticMessageSink;
+ }
+
+ protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute)
+ {
+ if (testMethod.Method.ReturnType.Name == "System.Void" &&
+ testMethod.Method.GetCustomAttributes(typeof(AsyncStateMachineAttribute)).Any())
+ {
+ return new ExecutionErrorTestCase(this.diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod, "Async void methods are not supported.");
+ }
+
+ return new UITestCase(UITestCase.SyncContextType.Cocoa, this.diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod);
+ }
+}
diff --git a/src/Xunit.StaFact/Sdk.Mac/CocoaSynchronizationContext.cs b/src/Xunit.StaFact/Sdk.Mac/CocoaSynchronizationContext.cs
new file mode 100644
index 00000000..85290a59
--- /dev/null
+++ b/src/Xunit.StaFact/Sdk.Mac/CocoaSynchronizationContext.cs
@@ -0,0 +1,190 @@
+// Copyright (c) Andrew Arnott. All rights reserved.
+// Licensed under the Ms-PL license. See LICENSE file in the project root for full license information.
+
+namespace Xunit.Sdk;
+
+internal sealed class CocoaSynchronizationContext : SynchronizationContext
+{
+ private readonly Queue> messageQueue = new();
+ private readonly int mainThread = Environment.CurrentManagedThreadId;
+ private readonly AsyncAutoResetEvent workItemDone = new();
+ private readonly string name;
+ private readonly bool shouldSetAsCurrent;
+ private int activeOperations;
+ private bool pumping;
+ private ExceptionAggregator? aggregator;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public CocoaSynchronizationContext(string name, bool shouldSetAsCurrent)
+ {
+ this.name = name;
+ this.shouldSetAsCurrent = shouldSetAsCurrent;
+ }
+
+ internal bool IsInContext => this.mainThread == Environment.CurrentManagedThreadId;
+
+ private bool AnyMessagesInQueue
+ {
+ get
+ {
+ lock (this.messageQueue)
+ {
+ return this.messageQueue.Count > 0;
+ }
+ }
+ }
+
+ private bool AnyPendingOperations => Volatile.Read(ref this.activeOperations) > 0;
+
+ public override SynchronizationContext CreateCopy()
+ => new CocoaSynchronizationContext(this.name, this.shouldSetAsCurrent);
+
+ public override void Post(SendOrPostCallback d, object? state)
+ => NSRunLoop.Main.BeginInvokeOnMainThread(() => d(state));
+
+ public override void Send(SendOrPostCallback d, object? state)
+ => NSRunLoop.Main.InvokeOnMainThread(() => d(state));
+
+ ///
+ /// Blocks the calling thread to pump messages until a task has completed.
+ ///
+ /// The task that must complete to break out of the message loop.
+ public void PumpMessages(Task untilCompleted)
+ {
+ this.VerifyState();
+
+ this.pumping = true;
+ try
+ {
+ // Arrange to wake up immediately when the task completes.
+ untilCompleted.ContinueWith(
+ _ =>
+ {
+ lock (this.messageQueue)
+ {
+ Monitor.Pulse(this.messageQueue);
+ }
+ },
+ TaskScheduler.Default);
+
+ // Now run the message loop until the task completes.
+ while (!untilCompleted.IsCompleted)
+ {
+ this.TryOneWorkItem();
+ }
+ }
+ finally
+ {
+ this.pumping = false;
+ }
+ }
+
+ public async Task WaitForOperationCompletionAsync()
+ {
+ while (this.AnyPendingOperations || this.AnyMessagesInQueue)
+ {
+ await this.workItemDone.WaitAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Pump messages until all pending operations have completed
+ /// and the message queue is empty.
+ ///
+ public void CompleteOperations()
+ {
+ this.VerifyState();
+ this.pumping = true;
+ try
+ {
+ while (this.AnyPendingOperations || this.AnyMessagesInQueue)
+ {
+ this.TryOneWorkItem();
+ }
+ }
+ finally
+ {
+ this.pumping = false;
+ }
+ }
+
+ ///
+ public override void OperationStarted()
+ {
+ Interlocked.Increment(ref this.activeOperations);
+ }
+
+ ///
+ public override void OperationCompleted()
+ {
+ int result = Interlocked.Decrement(ref this.activeOperations);
+ if (result == 0)
+ {
+ // Give any message waiter a heads up that the operation count has reached zero,
+ // in case the queue is empty at the same time the operation count is, which
+ // is usually a sign to return to its caller.
+ lock (this.messageQueue)
+ {
+ Monitor.Pulse(this.messageQueue);
+ }
+ }
+ }
+
+ internal void SetExceptionAggregator(ExceptionAggregator aggregator)
+ {
+ this.aggregator = aggregator;
+ }
+
+ private void VerifyState()
+ {
+ if (Environment.CurrentManagedThreadId != this.mainThread)
+ {
+ throw new InvalidOperationException("Wrong thread");
+ }
+
+ if (Current != this && this.shouldSetAsCurrent)
+ {
+ throw new InvalidOperationException("Wrong sync context");
+ }
+
+ if (this.pumping)
+ {
+ throw new InvalidOperationException("Already pumping");
+ }
+ }
+
+ private bool TryOneWorkItem()
+ {
+ KeyValuePair work = default;
+ lock (this.messageQueue)
+ {
+ if (this.messageQueue.Count == 0)
+ {
+ Monitor.Wait(this.messageQueue);
+ return false;
+ }
+
+ work = this.messageQueue.Dequeue();
+ }
+
+ try
+ {
+ if (this.aggregator is object)
+ {
+ this.aggregator.Run(() => work.Key(work.Value));
+ }
+ else
+ {
+ work.Key(work.Value);
+ }
+
+ return true;
+ }
+ finally
+ {
+ this.workItemDone.Set();
+ }
+ }
+}
diff --git a/src/Xunit.StaFact/Sdk.Mac/CocoaSynchronizationContextAdapter.cs b/src/Xunit.StaFact/Sdk.Mac/CocoaSynchronizationContextAdapter.cs
new file mode 100644
index 00000000..b474c46f
--- /dev/null
+++ b/src/Xunit.StaFact/Sdk.Mac/CocoaSynchronizationContextAdapter.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Andrew Arnott. All rights reserved.
+// Licensed under the Ms-PL license. See LICENSE file in the project root for full license information.
+
+namespace Xunit.Sdk;
+
+internal class CocoaSynchronizationContextAdapter : SyncContextAdapter
+{
+ internal static readonly SyncContextAdapter Default = new CocoaSynchronizationContextAdapter();
+
+ private CocoaSynchronizationContextAdapter()
+ {
+ }
+
+ internal override bool CanCompleteOperations => true;
+
+ internal override SynchronizationContext Create(string name) => new CocoaSynchronizationContext(name, this.ShouldSetAsCurrent);
+
+ internal override Task WaitForOperationCompletionAsync(SynchronizationContext syncContext) => ((CocoaSynchronizationContext)syncContext).WaitForOperationCompletionAsync();
+
+ internal override void PumpTill(SynchronizationContext synchronizationContext, Task task)
+ {
+ ((CocoaSynchronizationContext)synchronizationContext).PumpMessages(task);
+ }
+}
diff --git a/src/Xunit.StaFact/Sdk.Mac/CocoaTheoryDiscoverer.cs b/src/Xunit.StaFact/Sdk.Mac/CocoaTheoryDiscoverer.cs
new file mode 100644
index 00000000..7ce9e4cf
--- /dev/null
+++ b/src/Xunit.StaFact/Sdk.Mac/CocoaTheoryDiscoverer.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Andrew Arnott. All rights reserved.
+// Licensed under the Ms-PL license. See LICENSE file in the project root for full license information.
+
+namespace Xunit.Sdk;
+
+///
+/// The discovery class for .
+///
+public class CocoaTheoryDiscoverer : TheoryDiscoverer
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The diagnostic message sink.
+ public CocoaTheoryDiscoverer(IMessageSink diagnosticMessageSink)
+ : base(diagnosticMessageSink)
+ {
+ }
+
+ protected override IEnumerable CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow)
+ {
+ yield return new UITestCase(UITestCase.SyncContextType.Cocoa, this.DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod, dataRow);
+ }
+
+ protected override IEnumerable CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute)
+ {
+ yield return new UITheoryTestCase(UITestCase.SyncContextType.Cocoa, this.DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod);
+ }
+}
diff --git a/src/Xunit.StaFact/Sdk/UITestCase.cs b/src/Xunit.StaFact/Sdk/UITestCase.cs
index d6796661..f0ed6fe9 100644
--- a/src/Xunit.StaFact/Sdk/UITestCase.cs
+++ b/src/Xunit.StaFact/Sdk/UITestCase.cs
@@ -55,6 +55,13 @@ public enum SyncContextType
///
Portable,
+#if MACOS
+ ///
+ /// Use a running on , which is only available on macOS.
+ ///
+ Cocoa,
+#endif
+
#if NETFRAMEWORK || WINDOWS
///
/// Use the , which is only available on Desktop.
@@ -115,6 +122,10 @@ internal static SyncContextAdapter GetAdapter(SyncContextType syncContextType)
case SyncContextType.Portable:
return UISynchronizationContext.Adapter.Default;
+#if MACOS
+ case SyncContextType.Cocoa:
+ return CocoaSynchronizationContextAdapter.Default;
+#endif
#if NETFRAMEWORK || WINDOWS
case SyncContextType.WPF:
return DispatcherSynchronizationContextAdapter.Default;
diff --git a/src/Xunit.StaFact/Xunit.StaFact.csproj b/src/Xunit.StaFact/Xunit.StaFact.csproj
index 839526d7..0d788046 100644
--- a/src/Xunit.StaFact/Xunit.StaFact.csproj
+++ b/src/Xunit.StaFact/Xunit.StaFact.csproj
@@ -1,6 +1,10 @@

- net472;netstandard2.0;net6.0;net6.0-windows
+
+ net6.0-macos10.15
+ $(TargetFrameworks);net472;netstandard2.0;net6.0;net6.0-windows
Xunit
Run your xunit-based tests on an STA thread with the WPF Dispatcher, a WinForms SynchronizationContext, or even a cross-platform generic UI thread emulation with a SynchronizationContext that keeps code running on a "main thread" for that test.
@@ -14,6 +18,10 @@ Theory variants of these attributes allow for parameterized testing. Check out t
+
+
+
+
diff --git a/src/Xunit.StaFact/net6.0-macos10.15/PublicAPI.Shipped.txt b/src/Xunit.StaFact/net6.0-macos10.15/PublicAPI.Shipped.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Xunit.StaFact/net6.0-macos10.15/PublicAPI.Unshipped.txt b/src/Xunit.StaFact/net6.0-macos10.15/PublicAPI.Unshipped.txt
new file mode 100644
index 00000000..5ce3afb1
--- /dev/null
+++ b/src/Xunit.StaFact/net6.0-macos10.15/PublicAPI.Unshipped.txt
@@ -0,0 +1,55 @@
+#nullable enable
+override Xunit.Sdk.CocoaFactDiscoverer.CreateTestCase(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! factAttribute) -> Xunit.Sdk.IXunitTestCase!
+override Xunit.Sdk.CocoaTheoryDiscoverer.CreateTestCasesForDataRow(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! theoryAttribute, object![]! dataRow) -> System.Collections.Generic.IEnumerable!
+override Xunit.Sdk.CocoaTheoryDiscoverer.CreateTestCasesForTheory(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! theoryAttribute) -> System.Collections.Generic.IEnumerable!
+override Xunit.Sdk.StaFactDiscoverer.CreateTestCase(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! factAttribute) -> Xunit.Sdk.IXunitTestCase!
+override Xunit.Sdk.StaTheoryDiscoverer.CreateTestCasesForDataRow(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! theoryAttribute, object![]! dataRow) -> System.Collections.Generic.IEnumerable!
+override Xunit.Sdk.StaTheoryDiscoverer.CreateTestCasesForTheory(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! theoryAttribute) -> System.Collections.Generic.IEnumerable!
+override Xunit.Sdk.UIFactDiscoverer.CreateTestCase(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! factAttribute) -> Xunit.Sdk.IXunitTestCase!
+override Xunit.Sdk.UITestCase.Deserialize(Xunit.Abstractions.IXunitSerializationInfo! data) -> void
+override Xunit.Sdk.UITestCase.RunAsync(Xunit.Abstractions.IMessageSink! diagnosticMessageSink, Xunit.Sdk.IMessageBus! messageBus, object![]! constructorArguments, Xunit.Sdk.ExceptionAggregator! aggregator, System.Threading.CancellationTokenSource! cancellationTokenSource) -> System.Threading.Tasks.Task!
+override Xunit.Sdk.UITestCase.Serialize(Xunit.Abstractions.IXunitSerializationInfo! data) -> void
+override Xunit.Sdk.UITheoryDiscoverer.CreateTestCasesForDataRow(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! theoryAttribute, object![]! dataRow) -> System.Collections.Generic.IEnumerable!
+override Xunit.Sdk.UITheoryDiscoverer.CreateTestCasesForTheory(Xunit.Abstractions.ITestFrameworkDiscoveryOptions! discoveryOptions, Xunit.Abstractions.ITestMethod! testMethod, Xunit.Abstractions.IAttributeInfo! theoryAttribute) -> System.Collections.Generic.IEnumerable!
+override Xunit.Sdk.UITheoryTestCase.Deserialize(Xunit.Abstractions.IXunitSerializationInfo! data) -> void
+override Xunit.Sdk.UITheoryTestCase.RunAsync(Xunit.Abstractions.IMessageSink! diagnosticMessageSink, Xunit.Sdk.IMessageBus! messageBus, object![]! constructorArguments, Xunit.Sdk.ExceptionAggregator! aggregator, System.Threading.CancellationTokenSource! cancellationTokenSource) -> System.Threading.Tasks.Task!
+override Xunit.Sdk.UITheoryTestCase.Serialize(Xunit.Abstractions.IXunitSerializationInfo! data) -> void
+Xunit.CocoaFactAttribute
+Xunit.CocoaFactAttribute.CocoaFactAttribute() -> void
+Xunit.CocoaTheoryAttribute
+Xunit.CocoaTheoryAttribute.CocoaTheoryAttribute() -> void
+Xunit.Sdk.CocoaFactDiscoverer
+Xunit.Sdk.CocoaFactDiscoverer.CocoaFactDiscoverer(Xunit.Abstractions.IMessageSink! diagnosticMessageSink) -> void
+Xunit.Sdk.CocoaTheoryDiscoverer
+Xunit.Sdk.CocoaTheoryDiscoverer.CocoaTheoryDiscoverer(Xunit.Abstractions.IMessageSink! diagnosticMessageSink) -> void
+Xunit.Sdk.StaFactDiscoverer
+Xunit.Sdk.StaFactDiscoverer.StaFactDiscoverer(Xunit.Abstractions.IMessageSink! diagnosticMessageSink) -> void
+Xunit.Sdk.StaTheoryDiscoverer
+Xunit.Sdk.StaTheoryDiscoverer.StaTheoryDiscoverer(Xunit.Abstractions.IMessageSink! diagnosticMessageSink) -> void
+Xunit.Sdk.UIFactDiscoverer
+Xunit.Sdk.UIFactDiscoverer.UIFactDiscoverer(Xunit.Abstractions.IMessageSink! diagnosticMessageSink) -> void
+Xunit.Sdk.UITestCase
+Xunit.Sdk.UITestCase.SyncContextType
+Xunit.Sdk.UITestCase.SyncContextType.Cocoa = 2 -> Xunit.Sdk.UITestCase.SyncContextType
+Xunit.Sdk.UITestCase.SyncContextType.None = 0 -> Xunit.Sdk.UITestCase.SyncContextType
+Xunit.Sdk.UITestCase.SyncContextType.Portable = 1 -> Xunit.Sdk.UITestCase.SyncContextType
+Xunit.Sdk.UITestCase.UITestCase() -> void
+Xunit.Sdk.UITestCase.UITestCase(Xunit.Sdk.UITestCase.SyncContextType synchronizationContextType, Xunit.Abstractions.IMessageSink! diagnosticMessageSink, Xunit.Sdk.TestMethodDisplay defaultMethodDisplay, Xunit.Abstractions.ITestMethod! testMethod, object?[]? testMethodArguments = null) -> void
+Xunit.Sdk.UITestCaseRunner
+Xunit.Sdk.UITestInvoker
+Xunit.Sdk.UITestInvoker.RunAsync() -> System.Threading.Tasks.Task!
+Xunit.Sdk.UITestRunner
+Xunit.Sdk.UITheoryDiscoverer
+Xunit.Sdk.UITheoryDiscoverer.UITheoryDiscoverer(Xunit.Abstractions.IMessageSink! diagnosticMessageSink) -> void
+Xunit.Sdk.UITheoryTestCase
+Xunit.Sdk.UITheoryTestCase.UITheoryTestCase() -> void
+Xunit.Sdk.UITheoryTestCase.UITheoryTestCase(Xunit.Sdk.UITestCase.SyncContextType synchronizationContextType, Xunit.Abstractions.IMessageSink! diagnosticMessageSink, Xunit.Sdk.TestMethodDisplay defaultMethodDisplay, Xunit.Sdk.TestMethodDisplayOptions defaultMethodDisplayOptions, Xunit.Abstractions.ITestMethod! testMethod) -> void
+Xunit.Sdk.UITheoryTestCaseRunner
+Xunit.StaFactAttribute
+Xunit.StaFactAttribute.StaFactAttribute() -> void
+Xunit.StaTheoryAttribute
+Xunit.StaTheoryAttribute.StaTheoryAttribute() -> void
+Xunit.UIFactAttribute
+Xunit.UIFactAttribute.UIFactAttribute() -> void
+Xunit.UITheoryAttribute
+Xunit.UITheoryAttribute.UITheoryAttribute() -> void
diff --git a/test/Xunit.StaFact.Tests.Mac/AppDelegate.cs b/test/Xunit.StaFact.Tests.Mac/AppDelegate.cs
new file mode 100644
index 00000000..8ab08124
--- /dev/null
+++ b/test/Xunit.StaFact.Tests.Mac/AppDelegate.cs
@@ -0,0 +1,48 @@
+// 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.Runtime.InteropServices;
+
+namespace Xunit.StaFact.Tests.Mac;
+
+[Register("AppDelegate")]
+public class AppDelegate : NSApplicationDelegate
+{
+ private readonly List unitTestDriverArguments = new List();
+
+ public AppDelegate()
+ {
+ var args = NSProcessInfo.ProcessInfo.Arguments;
+ for (int i = 1; i < args.Length; i++)
+ {
+ this.unitTestDriverArguments.Add(args[i]);
+ }
+ }
+
+ public override void DidFinishLaunching(NSNotification notification)
+ {
+ // Disable AppDomain support since we need all tests to run
+ // in the main app domain to be able to run on or dispatch to
+ // the main thread.
+ this.unitTestDriverArguments.Add("-appdomains");
+ this.unitTestDriverArguments.Add("denied");
+
+ // We also cannot run tests in parallel since our tests depend
+ // on ordered execution on the main thread.
+ this.unitTestDriverArguments.Add("-parallel");
+ this.unitTestDriverArguments.Add("none");
+
+ var args = this.unitTestDriverArguments.ToArray();
+
+ ThreadPool.QueueUserWorkItem(o =>
+ {
+ var exitCode = Xunit.ConsoleClient.Program.Main(args);
+ _exit(exitCode);
+ });
+ }
+
+ [DllImport(ObjCRuntime.Constants.libcLibrary)]
+#pragma warning disable SA1300 // Element should begin with upper-case letter
+ private static extern void _exit(int exitCode);
+#pragma warning restore SA1300 // Element should begin with upper-case letter
+}
diff --git a/test/Xunit.StaFact.Tests.Mac/CocoaFactTests.cs b/test/Xunit.StaFact.Tests.Mac/CocoaFactTests.cs
new file mode 100644
index 00000000..4c8e41f4
--- /dev/null
+++ b/test/Xunit.StaFact.Tests.Mac/CocoaFactTests.cs
@@ -0,0 +1,30 @@
+// 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 Xunit;
+
+public class CocoaFactTests
+{
+ [CocoaFact]
+ public void AssertMainThreadSync()
+ => NSApplication.EnsureUIThread();
+
+ [Fact]
+ public void AssertMainThreadSyncAndFail()
+ => Assert.Throws(NSApplication.EnsureUIThread);
+
+ [CocoaFact]
+ public Task AssertMainThreadAsyncReturnCompletedTask()
+ {
+ NSApplication.EnsureUIThread();
+ return Task.CompletedTask;
+ }
+
+ [CocoaFact]
+ public async Task AssertMainThreadAsyncWithAwait()
+ {
+ await Task.Yield();
+ NSApplication.EnsureUIThread();
+ await Task.Delay(100);
+ }
+}
diff --git a/test/Xunit.StaFact.Tests.Mac/Info.plist b/test/Xunit.StaFact.Tests.Mac/Info.plist
new file mode 100644
index 00000000..65c1f660
--- /dev/null
+++ b/test/Xunit.StaFact.Tests.Mac/Info.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleName
+ Xunit.StaFact.Tests.Mac
+ CFBundleIdentifier
+ com.companyname.Xunit.StaFact.Tests.Mac
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ CFBundleDevelopmentRegion
+ en
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundlePackageType
+ APPL
+ CFBundleSignature
+ ????
+ NSHumanReadableCopyright
+ ${AuthorCopyright:HtmlEncode}
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/test/Xunit.StaFact.Tests.Mac/Main.cs b/test/Xunit.StaFact.Tests.Mac/Main.cs
new file mode 100644
index 00000000..8f217142
--- /dev/null
+++ b/test/Xunit.StaFact.Tests.Mac/Main.cs
@@ -0,0 +1,11 @@
+// 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 Xunit.StaFact.Tests.Mac;
+
+NSApplication.Init();
+
+// Need to manually set up AppDelegate due to lack of UI
+NSApplication.SharedApplication.Delegate = new AppDelegate();
+
+NSApplication.Main(args);
diff --git a/test/Xunit.StaFact.Tests.Mac/Xunit.StaFact.Tests.Mac.csproj b/test/Xunit.StaFact.Tests.Mac/Xunit.StaFact.Tests.Mac.csproj
new file mode 100644
index 00000000..510d4d79
--- /dev/null
+++ b/test/Xunit.StaFact.Tests.Mac/Xunit.StaFact.Tests.Mac.csproj
@@ -0,0 +1,39 @@
+
+
+ net6.0-macos
+ Exe
+ 10.15
+ true
+ $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
+ $(HostArchitecture.ToLower())
+ $(RepoRootPath)\bin\Xunit.StaFact.Tests.Mac\$(Configuration)\$(TargetFramework)\osx-$(HostArchitecture)
+ $(PartialTargetPath)\Xunit.StaFact.Tests.Mac.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_AppExePath>$(PartialTargetPath)\Xunit.StaFact.Tests.Mac.app\Contents\MacOS\Xunit.StaFact.Tests.Mac
+
+
+
+
+
+
+
+
+
+
+
diff --git a/workloads.json b/workloads.json
new file mode 100644
index 00000000..1a354fb5
--- /dev/null
+++ b/workloads.json
@@ -0,0 +1,3 @@
+{
+ "microsoft.net.sdk.macos": "12.3.2372/7.0.100"
+}