diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml
new file mode 100644
index 0000000000..5f1bafe7dd
--- /dev/null
+++ b/.github/workflows/platform-smoke.yml
@@ -0,0 +1,159 @@
+name: Platform smoke tests
+
+on:
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ smoke:
+ name: ${{ matrix.platform }} smoke test
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: Browser
+ os: ubuntu-latest
+ workload: wasm-tools
+ - platform: Android
+ os: ubuntu-latest
+ workload: android
+ - platform: iOS
+ os: macos-26
+ workload: ios
+ device: iPhone 17
+ simulator: iOS-26-5
+ - platform: tvOS
+ os: macos-26
+ workload: tvos
+ device: Apple TV
+ simulator: tvOS-26-5
+ - platform: MacCatalyst
+ os: macos-26
+ workload: maccatalyst
+ - platform: WASI
+ os: ubuntu-latest
+ workload: wasi-experimental
+ env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: 1
+ TESTINGPLATFORM_TELEMETRY_OPTOUT: 1
+ SmokePlatform: ${{ matrix.platform }}
+ steps:
+ - uses: actions/checkout@v7.0.1
+ - uses: actions/setup-dotnet@v6.0.0
+ with:
+ dotnet-version: 11.0.100-rc.1.26425.128
+ - uses: actions/cache@v6
+ with:
+ path: ~/.nuget/packages
+ key: platform-smoke-${{ runner.os }}-${{ runner.arch }}-${{ matrix.platform }}-${{ hashFiles('global.json', 'Directory.Packages.props', 'tests/TUnit.PlatformSmoke/TUnit.PlatformSmoke.csproj') }}
+ restore-keys: platform-smoke-${{ runner.os }}-${{ runner.arch }}-${{ matrix.platform }}-
+ - name: Select the installed Xcode
+ if: runner.os == 'macOS'
+ run: sudo xcode-select --switch /Applications/Xcode_26.6.app
+ - name: Start the installed Apple simulator
+ if: matrix.platform == 'iOS' || matrix.platform == 'tvOS'
+ run: |
+ device=$(xcrun simctl list devices available --json | jq -er '.devices["com.apple.CoreSimulator.SimRuntime.${{ matrix.simulator }}"] | map(select(.name == "${{ matrix.device }}")) | first | .udid // error("Required preinstalled simulator is missing")')
+ xcrun simctl boot "$device"
+ echo "Device=$device" >> "$GITHUB_ENV"
+ - run: dotnet workload install ${{ matrix.workload }}
+ - name: Install XHarness
+ if: matrix.platform == 'Browser'
+ run: >-
+ dotnet tool install Microsoft.DotNet.XHarness.CLI --global
+ --version 10.0.0-prerelease.26471.3
+ --add-source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json
+ - name: Publish the browser smoke test
+ if: matrix.platform == 'Browser'
+ run: dotnet publish tests/TUnit.PlatformSmoke -c Debug
+ - name: Build the smoke test
+ if: matrix.platform != 'Browser'
+ run: dotnet build tests/TUnit.PlatformSmoke -c Debug
+ - name: Execute the TUnit smoke test in Chrome
+ if: matrix.platform == 'Browser'
+ run: >-
+ xharness wasm test-browser
+ --app=tests/TUnit.PlatformSmoke/bin/Debug/net11.0/publish/wwwroot
+ --browser=chrome --expected-exit-code=0 --timeout=00:02:00
+ --output-directory=artifacts/platform-smoke/browser
+ - name: Enable KVM
+ if: matrix.platform == 'Android'
+ run: sudo chmod 666 /dev/kvm
+ - name: Execute the TUnit smoke test on Android
+ if: matrix.platform == 'Android'
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 35
+ arch: x86_64
+ target: google_apis
+ disable-animations: true
+ script: |
+ mkdir -p artifacts/platform-smoke/Android
+ bash -o pipefail -c 'dotnet test --project "$GITHUB_WORKSPACE/tests/TUnit.PlatformSmoke/TUnit.PlatformSmoke.csproj" --no-build 2>&1 | tee artifacts/platform-smoke/Android/test.log'
+ - name: Wait for the Apple simulator
+ if: matrix.platform == 'iOS' || matrix.platform == 'tvOS'
+ timeout-minutes: 10
+ run: |
+ xcrun simctl bootstatus "$Device" -b
+ - name: Execute the TUnit smoke test on Mac Catalyst
+ if: matrix.platform == 'MacCatalyst'
+ working-directory: tests/TUnit.PlatformSmoke
+ timeout-minutes: 10
+ shell: bash
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/artifacts/platform-smoke/${{ matrix.platform }}"
+ dotnet test --project "$GITHUB_WORKSPACE/tests/TUnit.PlatformSmoke/TUnit.PlatformSmoke.csproj" --no-build --minimum-expected-tests 1 \
+ 2>&1 | tee "$GITHUB_WORKSPACE/artifacts/platform-smoke/${{ matrix.platform }}/test.log"
+ - name: Execute the TUnit smoke test in the Apple simulator
+ if: matrix.platform == 'iOS' || matrix.platform == 'tvOS'
+ timeout-minutes: 5
+ shell: bash
+ run: |
+ mkdir -p "artifacts/platform-smoke/${{ matrix.platform }}"
+ platform=$(echo "$SmokePlatform" | tr '[:upper:]' '[:lower:]')
+ app="tests/TUnit.PlatformSmoke/bin/Debug/net11.0-$platform/${platform}simulator-arm64/TUnit.PlatformSmoke.app"
+ xcrun simctl install "$Device" "$app"
+ container=$(xcrun simctl get_app_container "$Device" net.dot.TUnitSmoke data)
+ xcrun simctl launch --console "$Device" net.dot.TUnitSmoke --connection-mode=none \
+ --app-arg=--minimum-expected-tests --app-arg=1 \
+ --app-arg=--report-trx --app-arg=--report-trx-filename --app-arg=smoke.trx \
+ --app-arg=--results-directory "--app-arg=$container/Documents/TestResults" \
+ 2>&1 | tee "artifacts/platform-smoke/${{ matrix.platform }}/test.log"
+ test "$(xmllint --xpath 'boolean(/*[local-name()="TestRun"]/*[local-name()="ResultSummary"]/*[local-name()="Counters"][@total="1" and @passed="1" and @failed="0"])' "$container/Documents/TestResults/smoke.trx")" = true
+ - name: Collect Apple simulator test reports
+ if: always() && (matrix.platform == 'iOS' || matrix.platform == 'tvOS')
+ run: |
+ if container=$(xcrun simctl get_app_container "$Device" net.dot.TUnitSmoke data 2>/dev/null); then
+ if [[ -d "$container/Documents/TestResults" ]]; then
+ mkdir -p "artifacts/platform-smoke/${{ matrix.platform }}"
+ cp -R "$container/Documents/TestResults" "artifacts/platform-smoke/${{ matrix.platform }}/"
+ fi
+ fi
+ - uses: bytecodealliance/actions/wasmtime/setup@v1.1.3
+ if: matrix.platform == 'WASI'
+ with:
+ version: v49.0.1
+ - name: Execute the TUnit smoke test on WASI
+ if: matrix.platform == 'WASI'
+ working-directory: tests/TUnit.PlatformSmoke/bin/Debug/net11.0/wasi-wasm
+ shell: bash
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/artifacts/platform-smoke/WASI"
+ wasmtime run -S http --dir . dotnet.wasm TUnit.PlatformSmoke \
+ 2>&1 | tee "$GITHUB_WORKSPACE/artifacts/platform-smoke/WASI/run.log"
+ - uses: actions/upload-artifact@v7.0.1
+ if: always()
+ with:
+ name: platform-smoke-${{ matrix.platform }}
+ path: artifacts/platform-smoke
+ if-no-files-found: ignore
diff --git a/src/TUnit.Core/EngineCancellationToken.cs b/src/TUnit.Core/EngineCancellationToken.cs
index 3b72aba7c9..b4715fdbf9 100644
--- a/src/TUnit.Core/EngineCancellationToken.cs
+++ b/src/TUnit.Core/EngineCancellationToken.cs
@@ -1,3 +1,4 @@
+using System.Runtime.Versioning;
using TUnit.Core.Settings;
namespace TUnit.Core;
@@ -20,6 +21,7 @@ public class EngineCancellationToken : IDisposable
private int _initialised;
private volatile bool _forcefulExitStarted;
private CancellationTokenRegistration _platformRegistration;
+ private bool _cancelKeyPressSubscribed;
public EngineCancellationToken()
{
@@ -60,13 +62,45 @@ internal void Initialise(CancellationToken platformCancellationToken = default)
if (!OperatingSystem.IsBrowser())
{
#endif
- Console.CancelKeyPress += OnCancelKeyPress;
+ _cancelKeyPressSubscribed = TrySubscribeCancelKeyPress();
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
#if NET5_0_OR_GREATER
}
#endif
}
+ ///
+ /// Subscribes to . Platforms without console signals
+ /// (such as Android, iOS and tvOS) throw ; there the
+ /// run is cancelled only through the platform token.
+ ///
+#if NET5_0_OR_GREATER
+ [UnsupportedOSPlatform("browser")]
+#endif
+ private bool TrySubscribeCancelKeyPress()
+ {
+ try
+ {
+ SubscribeCancelKeyPress();
+ return true;
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return false;
+ }
+ }
+
+ // Virtual so tests can simulate a platform without console signals.
+#if NET5_0_OR_GREATER
+ [UnsupportedOSPlatform("browser")]
+#endif
+ internal virtual void SubscribeCancelKeyPress() => Console.CancelKeyPress += OnCancelKeyPress;
+
+#if NET5_0_OR_GREATER
+ [UnsupportedOSPlatform("browser")]
+#endif
+ internal virtual void UnsubscribeCancelKeyPress() => Console.CancelKeyPress -= OnCancelKeyPress;
+
private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
{
Cancel();
@@ -137,7 +171,12 @@ public void Dispose()
if (!OperatingSystem.IsBrowser())
{
#endif
- Console.CancelKeyPress -= OnCancelKeyPress;
+ if (_cancelKeyPressSubscribed)
+ {
+ _cancelKeyPressSubscribed = false;
+ UnsubscribeCancelKeyPress();
+ }
+
AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
#if NET5_0_OR_GREATER
}
diff --git a/tests/TUnit.PlatformSmoke/AndroidInstrumentation.cs b/tests/TUnit.PlatformSmoke/AndroidInstrumentation.cs
new file mode 100644
index 0000000000..b0b4c9bee0
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/AndroidInstrumentation.cs
@@ -0,0 +1,46 @@
+#if ANDROID
+using Android.App;
+using Android.OS;
+using Android.Runtime;
+
+namespace TUnit.PlatformSmoke;
+
+[Instrumentation(Name = "net.dot.TUnitSmoke.TestInstrumentation")]
+public sealed class AndroidInstrumentation(IntPtr handle, JniHandleOwnership ownership)
+ : Instrumentation(handle, ownership)
+{
+ public override void OnCreate(Bundle? arguments)
+ {
+ base.OnCreate(arguments);
+ Start();
+ }
+
+ public override async void OnStart()
+ {
+ base.OnStart();
+ using var result = new Bundle();
+ try
+ {
+ var resultsDirectory = Path.Combine(Application.Context.GetExternalFilesDir(null)!.AbsolutePath, "TestResults");
+ // Use the same generated entry point and registered hooks as every other platform.
+ var exitCode = await MicrosoftTestingPlatformEntryPoint.Main([
+ "--minimum-expected-tests", "1",
+ "--report-trx", "--report-trx-filename", "smoke.trx",
+ "--results-directory", resultsDirectory
+ ]);
+ result.PutString("resultsPath", Path.Combine(resultsDirectory, "smoke.trx"));
+ if (exitCode != 0)
+ {
+ result.PutString("error", $"TUnit exited with code {exitCode}.");
+ }
+
+ Finish(exitCode == 0 ? Result.Ok : Result.Canceled, result);
+ }
+ catch (Exception exception)
+ {
+ result.PutString("error", exception.ToString());
+ Finish(Result.Canceled, result);
+ }
+ }
+}
+#endif
diff --git a/tests/TUnit.PlatformSmoke/AppleInitialization.cs b/tests/TUnit.PlatformSmoke/AppleInitialization.cs
new file mode 100644
index 0000000000..c50adeede6
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/AppleInitialization.cs
@@ -0,0 +1,16 @@
+#if IOS || TVOS || MACCATALYST
+using System.Runtime.CompilerServices;
+
+namespace TUnit.PlatformSmoke;
+
+internal static class AppleInitialization
+{
+ [ModuleInitializer]
+ internal static void Initialize()
+ {
+ // The Apple SDK installs a UIKit context before Main. This console-style
+ // test app has no UI event loop to execute captured async continuations.
+ SynchronizationContext.SetSynchronizationContext(null);
+ }
+}
+#endif
diff --git a/tests/TUnit.PlatformSmoke/SmokeTest.cs b/tests/TUnit.PlatformSmoke/SmokeTest.cs
new file mode 100644
index 0000000000..13abf26208
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/SmokeTest.cs
@@ -0,0 +1,33 @@
+using TUnit.Assertions;
+using TUnit.Core;
+
+namespace TUnit.PlatformSmoke;
+
+public class SmokeTest
+{
+ [Test]
+ public async Task RunsOnTheExpectedPlatform()
+ {
+ await Task.Yield();
+ await Assert.That(Environment.Version.Major).IsEqualTo(11);
+
+#if BROWSER
+ await Assert.That(OperatingSystem.IsBrowser()).IsTrue();
+#elif ANDROID
+ await Assert.That(OperatingSystem.IsAndroid()).IsTrue();
+#elif IOS
+ await Assert.That(OperatingSystem.IsIOS()).IsTrue();
+#elif TVOS
+ await Assert.That(OperatingSystem.IsTvOS()).IsTrue();
+#elif MACCATALYST
+ await Assert.That(OperatingSystem.IsMacCatalyst()).IsTrue();
+#elif WASI
+ await Assert.That(OperatingSystem.IsWasi()).IsTrue();
+#endif
+#if IOS || TVOS || MACCATALYST
+ await Assert.That(System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture)
+ .IsEqualTo(System.Runtime.InteropServices.Architecture.Arm64);
+#endif
+ await Assert.That(new List { 1, 2 }.Count).IsEqualTo(2);
+ }
+}
diff --git a/tests/TUnit.PlatformSmoke/TUnit.PlatformSmoke.csproj b/tests/TUnit.PlatformSmoke/TUnit.PlatformSmoke.csproj
new file mode 100644
index 0000000000..a373b0f15c
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/TUnit.PlatformSmoke.csproj
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+ net11.0
+ net11.0-android
+ net11.0-ios
+ net11.0-tvos
+ net11.0-maccatalyst
+ false
+ true
+
+
+
+ net.dot.TUnitSmoke
+ TUnit smoke test
+ 1
+ 1.0
+ android-x64
+ iossimulator-arm64
+ tvossimulator-arm64
+ maccatalyst-arm64
+ false
+ None
+
+
+
+ true
+ apk
+ false
+
+
+
+ 11.0.0-rc.1.26425.128
+ true
+ true
+ false
+
+
+ wasi-wasm
+ $(DefineConstants);WASI
+
+
+ browser-wasm
+ true
+ $(DefineConstants);BROWSER
+ true
+
+
+
+
+
+
+
+
+
diff --git a/tests/TUnit.PlatformSmoke/testconfig.json b/tests/TUnit.PlatformSmoke/testconfig.json
new file mode 100644
index 0000000000..85931e041a
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/testconfig.json
@@ -0,0 +1,7 @@
+{
+ "commandLineOptions": {
+ "minimum-expected-tests": 1,
+ "report-trx": true,
+ "results-directory": "TestResults"
+ }
+}
diff --git a/tests/TUnit.PlatformSmoke/wwwroot/index.html b/tests/TUnit.PlatformSmoke/wwwroot/index.html
new file mode 100644
index 0000000000..9eebb8b601
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/wwwroot/index.html
@@ -0,0 +1,5 @@
+
+
+TUnit platform smoke test
+
+
diff --git a/tests/TUnit.PlatformSmoke/wwwroot/main.js b/tests/TUnit.PlatformSmoke/wwwroot/main.js
new file mode 100644
index 0000000000..d2618549a6
--- /dev/null
+++ b/tests/TUnit.PlatformSmoke/wwwroot/main.js
@@ -0,0 +1,17 @@
+import { dotnet } from './_framework/dotnet.js';
+
+let exitCode = 1;
+try {
+ exitCode = await dotnet
+ .withApplicationArguments('--minimum-expected-tests', '1')
+ .run();
+} catch (error) {
+ console.error(error);
+}
+
+// XHarness reads this marker to verify the application's actual exit code.
+const completion = document.createElement('label');
+completion.id = 'tests_done';
+completion.textContent = String(exitCode);
+document.body.appendChild(completion);
+console.log(`WASM EXIT ${exitCode}`);
diff --git a/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs
new file mode 100644
index 0000000000..99b9cfbeb8
--- /dev/null
+++ b/tests/TUnit.UnitTests/EngineCancellationTokenTests.cs
@@ -0,0 +1,83 @@
+using TUnit.Core;
+
+namespace TUnit.UnitTests;
+
+public class EngineCancellationTokenTests
+{
+ [Test]
+ public async Task Initialise_DoesNotThrow_WhenCancelKeyPressIsUnsupported()
+ {
+ using var token = new UnsupportedCancelKeyPressToken();
+
+ await Assert.That(() => token.Initialise()).ThrowsNothing();
+ await Assert.That(token.SubscribeCalls).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task PlatformToken_StillCancels_WhenCancelKeyPressIsUnsupported()
+ {
+ using var platform = new CancellationTokenSource();
+ using var token = new UnsupportedCancelKeyPressToken();
+ token.Initialise(platform.Token);
+
+ platform.Cancel();
+
+ await Assert.That(token.Token.IsCancellationRequested).IsTrue();
+ }
+
+ [Test]
+ public async Task Dispose_DoesNotUnsubscribe_WhenCancelKeyPressIsUnsupported()
+ {
+ var token = new UnsupportedCancelKeyPressToken();
+ token.Initialise();
+
+ token.Dispose();
+
+ await Assert.That(token.UnsubscribeCalls).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task Dispose_Unsubscribes_WhenCancelKeyPressIsSupported()
+ {
+ var token = new RecordingCancelKeyPressToken();
+ token.Initialise();
+
+ token.Dispose();
+
+ await Assert.That(token.SubscribeCalls).IsEqualTo(1);
+ await Assert.That(token.UnsubscribeCalls).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Initialise_PropagatesOtherSubscriptionFailures()
+ {
+ using var token = new FailingCancelKeyPressToken();
+
+ await Assert.That(() => token.Initialise()).Throws();
+ }
+
+ private class RecordingCancelKeyPressToken : EngineCancellationToken
+ {
+ public int SubscribeCalls { get; private set; }
+
+ public int UnsubscribeCalls { get; private set; }
+
+ internal override void SubscribeCancelKeyPress() => SubscribeCalls++;
+
+ internal override void UnsubscribeCancelKeyPress() => UnsubscribeCalls++;
+ }
+
+ private sealed class UnsupportedCancelKeyPressToken : RecordingCancelKeyPressToken
+ {
+ internal override void SubscribeCancelKeyPress()
+ {
+ base.SubscribeCancelKeyPress();
+ throw new PlatformNotSupportedException();
+ }
+ }
+
+ private sealed class FailingCancelKeyPressToken : EngineCancellationToken
+ {
+ internal override void SubscribeCancelKeyPress() => throw new InvalidOperationException();
+ }
+}