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(); + } +}