diff --git a/README.md b/README.md index ff7cc1b..a4f0b0e 100644 --- a/README.md +++ b/README.md @@ -38,4 +38,23 @@ public void TestFunctionalityWhichIsNotSupportedOnSomePlatforms() } ``` +## The `[SupportedOSPlatform]` attribute + +Since version 1.5, `Xunit.SkippableFact` understands the `SupportedOSPlatform` attribute to skip tests on unsupported platforms. + +```csharp +[SkippableFact, SupportedOSPlatform("Windows")] +public void TestCngKey() +{ + var key = CngKey.Create(CngAlgorithm.Sha256); + Assert.NotNull(key); +} +``` + +Without `[SupportedOSPlatform("Windows")]` the [CA1416][CA1416] code analysis warning would trigger: +> This call site is reachable on all platforms. 'CngKey. Create(CngAlgorithm)' is only supported on: 'windows'. + +Adding `[SupportedOSPlatform("Windows")]` both suppresses this platform compatibility warning and skips the test when running on Linux or macOS. + [NuPkg]: https://www.nuget.org/packages/Xunit.SkippableFact +[CA1416]: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416 diff --git a/src/Xunit.SkippableFact/Sdk/SkippableFactTestCase.cs b/src/Xunit.SkippableFact/Sdk/SkippableFactTestCase.cs index feb5e23..f81b9f5 100644 --- a/src/Xunit.SkippableFact/Sdk/SkippableFactTestCase.cs +++ b/src/Xunit.SkippableFact/Sdk/SkippableFactTestCase.cs @@ -88,4 +88,10 @@ public override void Deserialize(IXunitSerializationInfo data) base.Deserialize(data); this.SkippingExceptionNames = data.GetValue(nameof(this.SkippingExceptionNames)); } + + /// + protected override string GetSkipReason(IAttributeInfo factAttribute) + { + return this.TestMethod.GetPlatformSkipReason() ?? base.GetSkipReason(factAttribute); + } } diff --git a/src/Xunit.SkippableFact/Sdk/SkippableTheoryTestCase.cs b/src/Xunit.SkippableFact/Sdk/SkippableTheoryTestCase.cs index 09d5903..800ebeb 100644 --- a/src/Xunit.SkippableFact/Sdk/SkippableTheoryTestCase.cs +++ b/src/Xunit.SkippableFact/Sdk/SkippableTheoryTestCase.cs @@ -86,4 +86,10 @@ public override void Deserialize(IXunitSerializationInfo data) base.Deserialize(data); this.SkippingExceptionNames = data.GetValue(nameof(this.SkippingExceptionNames)); } + + /// + protected override string GetSkipReason(IAttributeInfo factAttribute) + { + return this.TestMethod.GetPlatformSkipReason() ?? base.GetSkipReason(factAttribute); + } } diff --git a/src/Xunit.SkippableFact/Sdk/TestMethodExtensions.cs b/src/Xunit.SkippableFact/Sdk/TestMethodExtensions.cs new file mode 100644 index 0000000..e3d0151 --- /dev/null +++ b/src/Xunit.SkippableFact/Sdk/TestMethodExtensions.cs @@ -0,0 +1,129 @@ +// Copyright (c) Andrew Arnott. All rights reserved. +// Licensed under the Microsoft Public License (Ms-PL). See LICENSE.txt file in the project root for full license information. + +using System.Runtime.InteropServices; +using Xunit.Abstractions; + +namespace Xunit.Sdk; + +/// +/// Extensions methods on . +/// +internal static class TestMethodExtensions +{ + /// + /// Assesses whether the test method can run on the current platform by looking at the [SupportedOSPlatform] attributes on the test method and on the test class. + /// + /// The . + /// A description of the supported platforms if the test can not run on the current platform or if the test can run on the current platform. + internal static string? GetPlatformSkipReason(this ITestMethod testMethod) + { +#if NET462 + return null; +#else + HashSet unsupportedPlatforms = GetPlatforms(testMethod, "UnsupportedOSPlatform"); + string? unsupportedPlatform = unsupportedPlatforms.FirstOrDefault(MatchesCurrentPlatform); + if (unsupportedPlatform is not null) + { + return $"Unsupported on {unsupportedPlatform}"; + } + + HashSet supportedPlatforms = GetPlatforms(testMethod, "SupportedOSPlatform"); + if (supportedPlatforms.Count == 0 || supportedPlatforms.Any(MatchesCurrentPlatform)) + { + return null; + } + + string platformsDescription = supportedPlatforms.Count == 1 ? supportedPlatforms.First() : string.Join(", ", supportedPlatforms.Reverse().Skip(1).Reverse()) + " and " + supportedPlatforms.Last(); + return $"Only supported on {platformsDescription}"; +#endif + } + +#if !NET462 + private static bool MatchesCurrentPlatform(string platform) + { + int versionIndex = platform.IndexOfAny(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); + bool matchesVersion; + if (versionIndex >= 0 && Version.TryParse(platform[versionIndex..], out Version version)) + { + platform = platform[..versionIndex]; + matchesVersion = MatchesCurrentVersion(version.Major, version.Minor, version.Build, version.Revision); + } + else + { + matchesVersion = true; + } + + return matchesVersion && RuntimeInformation.IsOSPlatform(OSPlatform.Create(platform)); + } + + // Adapted from OperatingSystem.IsOSVersionAtLeast() which is private, see https://github.com/dotnet/runtime/blob/d6eb35426ebdb09ee5c754aa9afb9ad6e96a3dec/src/libraries/System.Private.CoreLib/src/System/OperatingSystem.cs#L326-L351 + private static bool MatchesCurrentVersion(int major, int minor, int build, int revision) + { + Version current = Environment.OSVersion.Version; + + if (current.Major != major) + { + return current.Major > major; + } + + if (current.Minor != minor) + { + return current.Minor > minor; + } + + // Unspecified build component is to be treated as zero + int currentBuild = current.Build < 0 ? 0 : current.Build; + build = build < 0 ? 0 : build; + if (currentBuild != build) + { + return currentBuild > build; + } + + // Unspecified revision component is to be treated as zero + int currentRevision = current.Revision < 0 ? 0 : current.Revision; + revision = revision < 0 ? 0 : revision; + + return currentRevision >= revision; + } + + /// + /// Returns the collection of platforms defined by the specified that decorate the test method and the test class. + /// + /// The . + /// Either SupportedOSPlatform or UnsupportedOSPlatform. + /// + /// + /// Calling GetPlatforms(testMethod, "SupportedOSPlatform") where represents MyTest returns ["Linux", "macOS"]. + /// + /// + /// [SupportedOSPlatform("macOS")] + /// public class MyTests + /// { + /// [SkippableFact] + /// [SupportedOSPlatform("Linux")] + /// public void MyTest() + /// { + /// } + /// } + /// + /// + /// The collection of platforms defined by the specified that decorate the test method and the test class. + private static HashSet GetPlatforms(ITestMethod testMethod, string platformAttributeName) + { + string platformAttribute = $"System.Runtime.Versioning.{platformAttributeName}Attribute"; + var platforms = new HashSet(StringComparer.OrdinalIgnoreCase); + AddPlatforms(platforms, testMethod.Method.GetCustomAttributes(platformAttribute)); + AddPlatforms(platforms, testMethod.Method.Type.GetCustomAttributes(platformAttribute)); + return platforms; + } + + private static void AddPlatforms(HashSet platforms, IEnumerable supportedPlatformAttributes) + { + foreach (IAttributeInfo supportedPlatformAttribute in supportedPlatformAttributes) + { + platforms.Add(supportedPlatformAttribute.GetNamedArgument("PlatformName")); + } + } +#endif +} diff --git a/test/Xunit.SkippableFact.Tests/SampleTests.cs b/test/Xunit.SkippableFact.Tests/SampleTests.cs index fd1aa0d..8b91925 100644 --- a/test/Xunit.SkippableFact.Tests/SampleTests.cs +++ b/test/Xunit.SkippableFact.Tests/SampleTests.cs @@ -1,7 +1,7 @@ // Copyright (c) Andrew Arnott. All rights reserved. // Licensed under the Microsoft Public License (Ms-PL). See LICENSE.txt file in the project root for full license information. -using System; +using System.Runtime.Versioning; namespace Xunit.SkippableFact.Tests; @@ -76,4 +76,77 @@ public void SkipInsideAssertThrows() throw new Exception(); })); } + +#if NET5_0_OR_GREATER + [SkippableFact, SupportedOSPlatform("Linux")] + public void LinuxOnly() + { + Assert.True(OperatingSystem.IsLinux(), "This should only run on Linux"); + } + + [SkippableFact, SupportedOSPlatform("macOS")] + public void MacOsOnly() + { + Assert.True(OperatingSystem.IsMacOS(), "This should only run on macOS"); + } + + [SkippableFact, SupportedOSPlatform("macOS10.6")] + public void MacOs10_6Minimum() + { + Assert.True(OperatingSystem.IsMacOSVersionAtLeast(10, 6), "This should only run on macOS 10.6 onwards"); + } + + [SkippableFact, SupportedOSPlatform("macOS77.7")] + public void MacOs77_7Minimum() + { + Assert.True(OperatingSystem.IsMacOSVersionAtLeast(77, 7), "This should only run on macOS 77.7 onwards"); + } + + [SkippableFact, SupportedOSPlatform("Windows")] + public void WindowsOnly() + { + Assert.True(OperatingSystem.IsWindows(), "This should only run on Windows"); + } + + [SkippableFact, SupportedOSPlatform("Windows10.0")] + public void Windows10Minimum() + { + Assert.True(OperatingSystem.IsWindowsVersionAtLeast(10), "This should only run on Windows 10.0 onwards"); + } + + [SkippableFact, SupportedOSPlatform("Windows77.7")] + public void Windows77_7Minimum() + { + Assert.True(OperatingSystem.IsWindowsVersionAtLeast(77, 7), "This should only run on Windows 77.7 onwards"); + } + + [SkippableFact, SupportedOSPlatform("Android"), SupportedOSPlatform("Browser")] + public void AndroidAndBrowserFact() + { + Assert.True(OperatingSystem.IsAndroid() || OperatingSystem.IsBrowser(), "This should only run on Android and Browser"); + } + + [SkippableTheory, SupportedOSPlatform("Android"), SupportedOSPlatform("Browser")] + [InlineData(1)] + [InlineData(2)] + public void AndroidAndBrowserTheory(int number) + { + _ = number; + Assert.True(OperatingSystem.IsAndroid() || OperatingSystem.IsBrowser(), "This should only run on Android and Browser"); + } + + [SkippableFact, SupportedOSPlatform("Android"), SupportedOSPlatform("Browser"), SupportedOSPlatform("Wasi")] + public void AndroidAndBrowserAndWasiOnly() + { + Assert.True(OperatingSystem.IsAndroid() || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi(), "This should only run on Android, Browser and Wasi"); + } + + [SkippableFact, UnsupportedOSPlatform("Linux"), UnsupportedOSPlatform("macOS"), UnsupportedOSPlatform("Windows")] + public void UnsupportedPlatforms() + { + Assert.False(OperatingSystem.IsLinux()); + Assert.False(OperatingSystem.IsMacOS()); + Assert.False(OperatingSystem.IsWindows()); + } +#endif }