diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 982a056..6a7dbc8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -7,9 +7,9 @@ on: branches: [ "main" ] env: - DotNetVersion: '6.0.401' - UnoCheck_Version: '1.5.4' - UnoCheck_Manifest: 'https://raw.githubusercontent.com/unoplatform/uno.check/34b1a60f5c1c51604b47362781969dde46979fd5/manifests/uno.ui.manifest.json' + DotNetVersion: '7.0.401' + UnoCheck_Version: '1.16.0' + UnoCheck_Manifest: 'https://raw.githubusercontent.com/unoplatform/uno.check/519b147a80d92e35cac4b8f97855d9302c91b340/manifests/uno.ui.manifest.json' jobs: build: @@ -45,7 +45,7 @@ jobs: - run: | & dotnet tool update --global uno.check --version $env:UnoCheck_Version --add-source https://api.nuget.org/v3/index.json - & uno-check -v --ci --non-interactive --fix --skip xcode --skip gtk3 --skip vswin --skip vsmac --manifest $env:UnoCheck_Manifest + & uno-check -v --ci --non-interactive --fix --skip xcode --skip gtk3 --skip vswin --skip vswinworkloads --skip vsmac --manifest $env:UnoCheck_Manifest - name: Add msbuild to PATH uses: microsoft/setup-msbuild@v1.1 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4abf556..83e847e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,6 +15,7 @@ enable true + $(NoWarn);CS1998 diff --git a/src/TestApp/Directory.Build.props b/src/TestApp/Directory.Build.props new file mode 100644 index 0000000..54367bb --- /dev/null +++ b/src/TestApp/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + $(DefineConstants);IS_UNO_RUNTIMETEST_PROJECT + + + + + <_Parameter1>$(MSBuildProjectFullPath) + + + + <_Parameter1>$([System.IO.Path]::GetFileName(`$(PkgUno_UI_DevServer)`)) + + + <_Parameter1>$([System.IO.Path]::GetFileName(`$(PkgUno_WinUI_DevServer)`)) + + + \ No newline at end of file diff --git a/src/TestApp/Directory.Build.targets b/src/TestApp/Directory.Build.targets new file mode 100644 index 0000000..92f1890 --- /dev/null +++ b/src/TestApp/Directory.Build.targets @@ -0,0 +1,7 @@ + + + + + $(DefineConstants);HAS_UNO_DEVSERVER + + \ No newline at end of file diff --git a/src/TestApp/shared/App.xaml.cs b/src/TestApp/shared/App.xaml.cs index 49ab0dd..0801c20 100644 --- a/src/TestApp/shared/App.xaml.cs +++ b/src/TestApp/shared/App.xaml.cs @@ -165,6 +165,14 @@ private static void InitializeLogging() builder.AddFilter("Windows", LogLevel.Warning); builder.AddFilter("Microsoft", LogLevel.Warning); + builder.AddFilter("Uno.UI.RuntimeTests.HotReload", LogLevel.Debug); + + // Hot reload testing + builder.AddFilter("Uno.UI.RemoteControl", LogLevel.Debug); + builder.AddFilter("Uno.UI.RuntimeTests.Internal.Helpers", LogLevel.Debug); // DevServer and SecondaryApp + builder.AddFilter("Uno.UI.RuntimeTests.HotReloadHelper", LogLevel.Trace); // Helper used by tests in secondary app instances (non debuggable) + builder.AddFilter("Uno.UI.RuntimeTests.UnitTestsControl", LogLevel.Information); // Dumps the runner status + // Generic Xaml events // builder.AddFilter("Windows.UI.Xaml", LogLevel.Debug ); // builder.AddFilter("Windows.UI.Xaml.VisualStateGroup", LogLevel.Debug ); diff --git a/src/TestApp/shared/FilterTests.cs b/src/TestApp/shared/FilterTests.cs new file mode 100644 index 0000000..caee6b2 --- /dev/null +++ b/src/TestApp/shared/FilterTests.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Uno.UI.RuntimeTests.Engine; + +[TestClass] +public class FilterTests +{ + [TestMethod] + [DataRow("abc & ghi", "abc", false)] + [DataRow("abc & ghi", "ghi", false)] + [DataRow("abc & ghi", "abc.ghi", true)] + [DataRow("abc & (ghi)", "abc", false)] + [DataRow("abc & (ghi)", "ghi", false)] + [DataRow("abc & (ghi)", "abc.ghi", true)] + [DataRow("a.b.c & ghi", "a.b", false)] + [DataRow("a.b.c & ghi", "a.b.c.def.ghi", true)] + [DataRow("a.b.c & (ghi)", "a.b", false)] + [DataRow("a.b.c & (ghi)", "a.b.c.def.ghi", true)] + [DataRow("abc & g.h.i", "g.h", false)] + [DataRow("abc & g.h.i", "abc.def.g.h.i", true)] + + [DataRow("abc | ghi", "abc", true)] + [DataRow("abc | ghi", "ghi", true)] + [DataRow("abc | ghi", "abc.ghi", true)] + [DataRow("abc | (ghi)", "abc", true)] + [DataRow("abc | (ghi)", "ghi", true)] + [DataRow("abc | (ghi)", "abc.ghi", true)] + [DataRow("a.b.c | ghi", "a.b", false)] + [DataRow("a.b.c | ghi", "a.b.c", true)] + [DataRow("a.b.c | ghi", "a.b.c.def.ghi", true)] + [DataRow("a.b.c | (ghi)", "a.b", false)] + [DataRow("a.b.c | (ghi)", "a.b.c", true)] + [DataRow("a.b.c | (ghi)", "a.b.c.def.ghi", true)] + [DataRow("abc | g.h.i", "g.h", false)] + [DataRow("abc | g.h.i", "g.h.i", true)] + [DataRow("abc | g.h.i", "abc.def.g.h.i", true)] + + [DataRow("abc ; ghi", "abc", true)] + [DataRow("abc ; ghi", "ghi", true)] + [DataRow("abc ; ghi", "abc.ghi", true)] + [DataRow("abc ; (ghi)", "abc", true)] + [DataRow("abc ; (ghi)", "ghi", true)] + [DataRow("abc ; (ghi)", "abc.ghi", true)] + [DataRow("a.b.c ; ghi", "a.b", false)] + [DataRow("a.b.c ; ghi", "a.b.c", true)] + [DataRow("a.b.c ; ghi", "a.b.c.def.ghi", true)] + [DataRow("a.b.c ; (ghi)", "a.b", false)] + [DataRow("a.b.c ; (ghi)", "a.b.c", true)] + [DataRow("a.b.c ; (ghi)", "a.b.c.def.ghi", true)] + [DataRow("abc ; g.h.i", "g.h", false)] + [DataRow("abc ; g.h.i", "g.h.i", true)] + [DataRow("abc ; g.h.i", "abc.def.g.h.i", true)] + public void When_ParseAndMatch(string filter, string method, bool expectedResult) + { + UnitTestFilter sut = filter; + var result = sut.IsMatch(method); + + Assert.AreEqual(expectedResult, result); + } +} \ No newline at end of file diff --git a/src/TestApp/shared/HotReloadTest_SimpleSubject.cs b/src/TestApp/shared/HotReloadTest_SimpleSubject.cs new file mode 100644 index 0000000..58fd1e0 --- /dev/null +++ b/src/TestApp/shared/HotReloadTest_SimpleSubject.cs @@ -0,0 +1,12 @@ +#pragma warning disable + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Uno.UI.RuntimeTests.Engine; + +public class HotReloadTest_SimpleSubject +{ + public string Value => "42"; +} \ No newline at end of file diff --git a/src/TestApp/shared/HotReloadTests.cs b/src/TestApp/shared/HotReloadTests.cs new file mode 100644 index 0000000..1518df2 --- /dev/null +++ b/src/TestApp/shared/HotReloadTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Extensions; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +#endif + +namespace Uno.UI.RuntimeTests.Engine; + +[TestClass] +public class HotReloadSanity +{ + [TestMethod] + public void Is_HotReload_Supported() + { +#if __SKIA__ // DevServer should be referenced also in release + Assert.IsTrue(HotReloadHelper.IsSupported); +#else + Assert.IsFalse(HotReloadHelper.IsSupported); +#endif + } +} + +[TestClass] +[RunsInSecondaryApp] +public class HotReloadTests +{ + [TestMethod] +#if !__SKIA__ + [Ignore("This tests assume directs access to the file system which not possible on this platform.")] +#endif + public async Task Is_SourcesEditable(CancellationToken ct) + { + var sutPath = "../../shared/HotReloadTests_Subject.xaml"; + var dir = Path.GetDirectoryName(typeof(HotReloadHelper).Assembly.GetCustomAttribute()!.ProjectFullPath)!; + var file = Path.Combine(dir, sutPath); + + Assert.IsTrue(File.Exists(file)); + Assert.IsTrue(File.ReadAllText(file).Contains("Original text")); + + await using var _ = await HotReloadHelper.UpdateSourceFile(sutPath, "Original text", "Updated text from Can_Edit_File", waitForMetadataUpdate: false, ct); + + await TestHelper.WaitFor(() => File.ReadAllText(file).Contains("Updated text from Can_Edit_File"), ct); + + Assert.IsTrue(File.ReadAllText(file).Contains("Updated text from Can_Edit_File")); + } + + [TestMethod] + public async Task Is_CodeHotReload_Enabled(CancellationToken ct) + { + if (!HotReloadHelper.IsSupported) + { + Assert.Inconclusive("Hot reload testing is not supported on this platform."); + } + + var sut = new HotReloadTest_SimpleSubject(); + + Debug.Assert(sut.Value == "42"); + + await using var _ = await HotReloadHelper.UpdateSourceFile("../../shared/HotReloadTest_SimpleSubject.cs", "42", "43", ct); + + Debug.Assert(sut.Value == "43"); + } + + [TestMethod] + public async Task Is_UIHotReload_Enabled(CancellationToken ct) + { + if (!HotReloadHelper.IsSupported) + { + Assert.Inconclusive("Hot reload testing is not supported on this platform."); + } + + await UIHelper.Load(new HotReloadTests_Subject(), ct); + + Assert.AreEqual("Original text", UIHelper.GetChild().Text); + + await using var _ = await HotReloadHelper.UpdateSourceFile("Original text", "Updated text", ct); + + await AsyncAssert.AreEqual("Updated text", () => UIHelper.GetChild().Text, ct); + } +} \ No newline at end of file diff --git a/src/TestApp/shared/HotReloadTests_Subject.xaml b/src/TestApp/shared/HotReloadTests_Subject.xaml new file mode 100644 index 0000000..f613814 --- /dev/null +++ b/src/TestApp/shared/HotReloadTests_Subject.xaml @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/TestApp/shared/HotReloadTests_Subject.xaml.cs b/src/TestApp/shared/HotReloadTests_Subject.xaml.cs new file mode 100644 index 0000000..977c900 --- /dev/null +++ b/src/TestApp/shared/HotReloadTests_Subject.xaml.cs @@ -0,0 +1,20 @@ + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI.Xaml.Controls; +#else +using Windows.UI.Xaml.Controls; +#endif + +namespace Uno.UI.RuntimeTests.Engine +{ + /// + /// An empty page that can be used on its own or navigated to within a Frame. + /// + public sealed partial class HotReloadTests_Subject : Page + { + public HotReloadTests_Subject() + { + this.InitializeComponent(); + } + } +} diff --git a/src/TestApp/shared/SecondaryAppTests.cs b/src/TestApp/shared/SecondaryAppTests.cs new file mode 100644 index 0000000..78eb3d9 --- /dev/null +++ b/src/TestApp/shared/SecondaryAppTests.cs @@ -0,0 +1,75 @@ +#pragma warning disable + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Uno.UI.RuntimeTests.Internal.Helpers; + +namespace Uno.UI.RuntimeTests.Engine; + +[TestClass] +public class SecondaryAppSanity +{ + [TestMethod] + public void Is_SecondaryApp_Supported() + { +#if __SKIA__ + Assert.IsTrue(SecondaryApp.IsSupported); +#else + Assert.IsFalse(SecondaryApp.IsSupported); +#endif + } +} + +[TestClass] +[RunsInSecondaryApp(ignoreIfNotSupported: true)] +public class SecondaryAppTests +{ + [TestMethod] + public void Is_From_A_Secondary_App() + { + Assert.IsTrue(Environment.GetEnvironmentVariable("UNO_RUNTIME_TESTS_IS_SECONDARY_APP") is "true"); + } + + [TestMethod] + public async Task Is_DevServer_Connected() + { +#if HAS_UNO_DEVSERVER + Assert.IsNotNull(Uno.UI.RemoteControl.RemoteControlClient.Instance); + + var connected = Uno.UI.RemoteControl.RemoteControlClient.Instance.WaitForConnection(CancellationToken.None); + var timeout = Task.Delay(500); + + Assert.AreEqual(connected, await Task.WhenAny(connected, timeout)); +#else + Assert.Inconclusive("Dev server in not supported on this platform."); +#endif + } + +#if DEBUG + [TestMethod] + public async Task No_Longer_Sane() // expected to fail + { + await Task.Delay(2000); + + throw new Exception("Great works require a touch of insanity."); + } + + [TestMethod, Ignore] + public void Is_An_Illusion() // expected to be ignored + { + } +#endif +} + +[TestClass] +public class NotSecondaryAppTests +{ + [TestMethod] + public void Is_From_A_Secondary_App() + { + Assert.IsFalse(Environment.GetEnvironmentVariable("UNO_RUNTIME_TESTS_IS_SECONDARY_APP") is "true"); + } +} \ No newline at end of file diff --git a/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj b/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj index bab8c8a..28ad0ee 100644 --- a/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj +++ b/src/TestApp/shared/Uno.UI.RuntimeTests.Engine.Shared.shproj @@ -11,6 +11,16 @@ + + + + <_Globbed_Compile Remove="FilterTests.cs" /> + <_Globbed_Compile Remove="HotReloadTests.cs" /> + <_Globbed_Compile Remove="HotReloadTests_Subject.xaml.cs" /> + <_Globbed_Compile Remove="HotReloadTest_SimpleSubject.cs" /> <_Globbed_Compile Remove="MetaAttributes.cs" /> + + <_Globbled_Page Remove="HotReloadTests_Subject.xaml" /> + \ No newline at end of file diff --git a/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems b/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems index 0083c1d..ecf55ea 100644 --- a/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems +++ b/src/TestApp/shared/uno.ui.runtimetests.engine.Shared.projitems @@ -18,12 +18,21 @@ App.xaml + + + + MainPage.xaml + + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj index 06a60fc..b951a15 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj @@ -1,37 +1,36 @@ - + - net6.0-android;net6.0-ios;net6.0-maccatalyst + net7.0-android;net7.0-ios;net7.0-maccatalyst - + true Exe - + - True + True true - 14.2 - 14.0 - 21.0 - 10.14 + 14.2 + 14.0 + 21.0 + 10.14 - iossimulator-x64 - maccatalyst-x64 - osx-x64 + iossimulator-x64 + maccatalyst-x64 + osx-x64 - - - + + + - - + @@ -40,8 +39,8 @@ - - + + $(MtouchExtraArgs) --setenv=MONO_GC_PARAMS=soft-heap-limit=512m,nursery-size=64m,evacuation-threshold=66,major=marksweep,concurrent-sweep $(MtouchExtraArgs) --registrar:static @@ -52,7 +51,7 @@ - + $(MtouchExtraArgs) --setenv=MONO_GC_PARAMS=soft-heap-limit=512m,nursery-size=64m,evacuation-threshold=66,major=marksweep,concurrent-sweep @@ -67,7 +66,7 @@ - + link diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs index 48726c7..95a7f7a 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs @@ -1,6 +1,7 @@ using System; using GLib; using Uno.UI.Runtime.Skia; +using Uno.UI.Runtime.Skia.Gtk; namespace Uno.UI.RuntimeTests.Engine.Skia.Gtk { diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj index 21801e9..b7a6d9d 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj @@ -1,8 +1,10 @@ - + WinExe Exe - net6.0 + net7.0 + $(WarningsNotAsErrors);Uno0001 + $(DefineConstants);__SKIA__ @@ -14,12 +16,11 @@ - - - + + + - diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs index c3b7b2e..8923ca7 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs @@ -1,5 +1,6 @@ using System; using Uno.UI.Runtime.Skia; +using Uno.UI.Runtime.Skia.Linux.FrameBuffer; namespace Uno.UI.RuntimeTests.Engine { diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj index dd53b5f..710b5ae 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj @@ -1,9 +1,10 @@ - + WinExe Exe - net6.0 - $(NoWarn);Uno0001 + net7.0 + $(WarningsNotAsErrors);Uno0001 + $(DefineConstants);__SKIA__ @@ -15,12 +16,11 @@ - - - + + + - diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml index 0ef813e..0606217 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml @@ -1,9 +1,7 @@ - - - + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="clr-namespace:Uno.UI.RuntimeTests.Engine.WPF"> + + diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml.cs b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml.cs index 596521a..cdfad1e 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml.cs +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/App.xaml.cs @@ -1,17 +1,14 @@ using System; -using System.Collections.Generic; -using System.Configuration; -using System.Data; using System.Linq; -using System.Threading.Tasks; using System.Windows; +using Uno.UI.Runtime.Skia.Wpf; -namespace Uno.UI.RuntimeTests.Engine.WPF +namespace Uno.UI.RuntimeTests.Engine.WPF; + +public partial class App : Application { - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application + public App() { + new WpfHost(Dispatcher, () => new Uno.UI.RuntimeTests.Engine.App()).Run(); } -} +} \ No newline at end of file diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/MainWindow.xaml b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/MainWindow.xaml deleted file mode 100644 index 14553fb..0000000 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/MainWindow.xaml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/MainWindow.xaml.cs b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/MainWindow.xaml.cs deleted file mode 100644 index 6a250d1..0000000 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/MainWindow.xaml.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; - -namespace Uno.UI.RuntimeTests.Engine.WPF -{ - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : Window - { - public MainWindow() - { - InitializeComponent(); - - root.Content = new global::Uno.UI.Skia.Platform.WpfHost(Dispatcher, () => new Uno.UI.RuntimeTests.Engine.App()); - } - } -} diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/Uno.UI.RuntimeTests.Engine.Skia.WPF.csproj b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/Uno.UI.RuntimeTests.Engine.Skia.WPF.csproj index 64d7674..7b4baf0 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/Uno.UI.RuntimeTests.Engine.Skia.WPF.csproj +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Skia.WPF/Uno.UI.RuntimeTests.Engine.Skia.WPF.csproj @@ -1,9 +1,11 @@ - + WinExe Exe - net6.0-windows + net7.0-windows true + $(WarningsNotAsErrors);Uno0001 + $(DefineConstants);__SKIA__ @@ -18,12 +20,11 @@ - - - + + + - diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.UWP/Uno.UI.RuntimeTests.Engine.Uwp.csproj b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.UWP/Uno.UI.RuntimeTests.Engine.Uwp.csproj index 7b3c5b0..4548515 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.UWP/Uno.UI.RuntimeTests.Engine.Uwp.csproj +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.UWP/Uno.UI.RuntimeTests.Engine.Uwp.csproj @@ -11,7 +11,6 @@ 6.2.11 - diff --git a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj index c375aeb..13d3d2e 100644 --- a/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj +++ b/src/TestApp/uwp/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj @@ -1,8 +1,8 @@ - + Exe - net6.0 - NU1701;Uno0001 + net7.0 + $(NoWarn);NU1701;Uno0001 true @@ -44,14 +44,13 @@ - - - + + + - diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj index 35b871e..e4f2dcd 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Mobile/Uno.UI.RuntimeTests.Engine.Mobile.csproj @@ -1,33 +1,32 @@ - + - net6.0-android;net6.0-ios;net6.0-maccatalyst;net6.0-macos + net7.0-android;net7.0-ios;net7.0-maccatalyst;net7.0-macos true Exe - iossimulator-x64 - maccatalyst-x64 - osx-x64 + iossimulator-x64 + maccatalyst-x64 + osx-x64 - + - + true - 14.2 - 14.0 - 21.0 - 10.14 + 14.2 + 14.0 + 21.0 + 10.14 - - - + + + - - + @@ -36,8 +35,8 @@ - - + + $(MtouchExtraArgs) --setenv=MONO_GC_PARAMS=soft-heap-limit=512m,nursery-size=64m,evacuation-threshold=66,major=marksweep,concurrent-sweep $(MtouchExtraArgs) --registrar:static @@ -48,7 +47,7 @@ - + $(MtouchExtraArgs) --setenv=MONO_GC_PARAMS=soft-heap-limit=512m,nursery-size=64m,evacuation-threshold=66,major=marksweep,concurrent-sweep @@ -63,7 +62,7 @@ - + diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs index 5cd2ed5..ef5f735 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Program.cs @@ -1,22 +1,21 @@ using GLib; using System; -using Uno.UI.Runtime.Skia; +using Uno.UI.Runtime.Skia.Gtk; -namespace Uno.UI.RuntimeTests.Engine.Skia.Gtk +namespace Uno.UI.RuntimeTests.Engine.Skia.Gtk; + +internal class Program { - internal class Program - { - static void Main(string[] args) - { - ExceptionManager.UnhandledException += delegate (UnhandledExceptionArgs expArgs) - { - Console.WriteLine("GLIB UNHANDLED EXCEPTION" + expArgs.ExceptionObject.ToString()); - expArgs.ExitApplication = true; - }; + static void Main(string[] args) + { + ExceptionManager.UnhandledException += delegate (UnhandledExceptionArgs expArgs) + { + Console.WriteLine("GLIB UNHANDLED EXCEPTION" + expArgs.ExceptionObject.ToString()); + expArgs.ExitApplication = true; + }; - var host = new GtkHost(() => new App()); + var host = new GtkHost(() => new App()); - host.Run(); - } - } -} + host.Run(); + } +} \ No newline at end of file diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj index 69273d1..2cf194e 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/Uno.UI.RuntimeTests.Engine.Skia.Gtk.csproj @@ -1,8 +1,10 @@ - + WinExe Exe - net6.0 + net7.0 + $(WarningsNotAsErrors);Uno0001 + $(DefineConstants);__SKIA__ @@ -14,12 +16,11 @@ - - - + + + - diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/test/PullDevServer.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/test/PullDevServer.csproj new file mode 100644 index 0000000..f7a2947 --- /dev/null +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Gtk/test/PullDevServer.csproj @@ -0,0 +1,9 @@ + + + Exe + net7.0 + + + + + \ No newline at end of file diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs index 63a0f48..b7fd767 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Program.cs @@ -2,6 +2,7 @@ using System; using Uno.UI.Runtime.Skia; using Windows.UI.Core; +using Uno.UI.Runtime.Skia.Linux.FrameBuffer; namespace Uno.UI.RuntimeTests.Engine { diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj index e2a993d..834561a 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer/Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj @@ -1,9 +1,10 @@ - + WinExe Exe - net6.0 - $(NoWarn);Uno0001 + net7.0 + $(WarningsNotAsErrors);Uno0001 + $(DefineConstants);__SKIA__ @@ -15,12 +16,11 @@ - - - + + + - diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml index 6aeb858..f554ee9 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml @@ -1,8 +1,7 @@  + xmlns:local="clr-namespace:Uno.UI.RuntimeTests.Engine.WPF"> diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml.cs b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml.cs index eb1519c..8acc453 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml.cs +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/App.xaml.cs @@ -1,17 +1,19 @@ using System; -using System.Collections.Generic; -using System.Configuration; -using System.Data; using System.Linq; -using System.Threading.Tasks; using System.Windows; +using Uno.UI.RemoteControl; +using Uno.UI.Runtime.Skia.Wpf; -namespace Uno.UI.RuntimeTests.Engine.WPF +namespace Uno.UI.RuntimeTests.Engine.WPF; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : Application { - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application - { - } -} + public App() + { + var host = new WpfHost(Dispatcher, () => new Uno.UI.RuntimeTests.Engine.App()); + host.Run(); + } +} \ No newline at end of file diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/MainWindow.xaml b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/MainWindow.xaml deleted file mode 100644 index ffbd31e..0000000 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/MainWindow.xaml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/MainWindow.xaml.cs b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/MainWindow.xaml.cs deleted file mode 100644 index cd07619..0000000 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/MainWindow.xaml.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; - -namespace Uno.UI.RuntimeTests.Engine.WPF -{ - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : Window - { - public MainWindow() - { - InitializeComponent(); - - root.Content = new global::Uno.UI.Skia.Platform.WpfHost(Dispatcher, () => new Uno.UI.RuntimeTests.Engine.App()); - } - } -} diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/Uno.UI.RuntimeTests.Engine.Skia.Wpf.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/Uno.UI.RuntimeTests.Engine.Skia.Wpf.csproj index 2d3b373..c34066b 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/Uno.UI.RuntimeTests.Engine.Skia.Wpf.csproj +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Skia.Wpf/Uno.UI.RuntimeTests.Engine.Skia.Wpf.csproj @@ -1,9 +1,11 @@ - + WinExe Exe - net6.0-windows + net7.0-windows true + $(WarningsNotAsErrors);Uno0001 + $(DefineConstants);__SKIA__ @@ -18,12 +20,11 @@ - - - + + + - diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj index 2bf367d..3b87420 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Wasm/Uno.UI.RuntimeTests.Engine.Wasm.csproj @@ -1,8 +1,8 @@ - + Exe - net6.0 - NU1701;Uno0001 + net7.0 + $(NoWarn);NU1701;Uno0001 true @@ -44,14 +44,13 @@ - - - + + + - diff --git a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Windows/Uno.UI.RuntimeTests.Engine.Windows.csproj b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Windows/Uno.UI.RuntimeTests.Engine.Windows.csproj index da7eff9..f190fab 100644 --- a/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Windows/Uno.UI.RuntimeTests.Engine.Windows.csproj +++ b/src/TestApp/winui/Uno.UI.RuntimeTests.Engine.Windows/Uno.UI.RuntimeTests.Engine.Windows.csproj @@ -10,8 +10,7 @@ win10-$(Platform).pubxml true true - - WINDOWS_WINUI + $(DefineConstants);WINDOWS_WINUI @@ -28,7 +27,6 @@ - diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestDevServerAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestDevServerAttribute.cs new file mode 100644 index 0000000..b1c5474 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestDevServerAttribute.cs @@ -0,0 +1,30 @@ +#if !UNO_RUNTIMETESTS_DISABLE_EMBEDDEDRUNNER +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Linq; + +namespace Uno.UI.RuntimeTests.Engine; + +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +public sealed class RuntimeTestDevServerAttribute : Attribute +{ + // Note: We prefer to capture it at compilation time instead of using reflection, + // so if dev-server assembly has been overriden with invalid version (e.g. for debug purposes), + // we are still able to get the right one. + + /// + /// The version of the DevServer package used to compile the test engine. + /// + public string Version { get; } + + public RuntimeTestDevServerAttribute(string version) + { + Version = version; + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestEmbeddedRunner.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestEmbeddedRunner.cs new file mode 100644 index 0000000..af91bff --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestEmbeddedRunner.cs @@ -0,0 +1,185 @@ +#if WINDOWS_UWP +#define UNO_RUNTIMETESTS_DISABLE_EMBEDDEDRUNNER +#endif + +#if !UNO_RUNTIMETESTS_DISABLE_EMBEDDEDRUNNER +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Threading; +using Windows.UI.Core; +using Windows.UI.Xaml; +using Microsoft.UI.Xaml; + +namespace Uno.UI.RuntimeTests.Engine; + +/// +/// A runtime-test runner that is embedded in applications that are referencing the runtime-test engine package. +/// +/// +/// This class is intended to be used only by the the test engine itself and should not be used by applications. +/// API contract is not guaranteed and might change in future releases. +/// +internal static partial class RuntimeTestEmbeddedRunner +{ + private enum TestResultKind + { + NUnit = 0, + UnoRuntimeTests, + } + +#pragma warning disable CA2255 // The 'ModuleInitializer' attribute is only intended to be used in application code or advanced source generator scenarios + [ModuleInitializer] +#pragma warning restore CA2255 + public static void AutoStartTests() + { + if (Environment.GetEnvironmentVariable("UNO_RUNTIME_TESTS_RUN_TESTS") is { } testsConfig + && Environment.GetEnvironmentVariable("UNO_RUNTIME_TESTS_OUTPUT_PATH") is { } outputPath) + { + var outputKind = Enum.TryParse(Environment.GetEnvironmentVariable("UNO_RUNTIME_TESTS_OUTPUT_KIND"), ignoreCase: true, out var kind) + ? kind + : TestResultKind.NUnit; + var isSecondaryApp = Environment.GetEnvironmentVariable("UNO_RUNTIME_TESTS_IS_SECONDARY_APP")?.ToLowerInvariant() switch + { + null => false, + "false" => false, + "0" => false, + _ => true + }; + + _ = RunTestsAndExit(testsConfig, outputPath, outputKind, isSecondaryApp); + } + } + + private static async Task RunTestsAndExit(string testsConfigRaw, string outputPath, TestResultKind outputKind, bool isSecondaryApp) + { + var ct = new CancellationTokenSource(); + + try + { + Log("Waiting for app to init before running runtime-tests"); + +#pragma warning disable CA1416 // Validate platform compatibility + Console.CancelKeyPress += (_, _) => ct.Cancel(true); +#pragma warning restore CA1416 // Validate platform compatibility + + // Wait for the app to init it-self + await Task.Delay(500, ct.Token).ConfigureAwait(false); + for (var i = 0; Window.Current is null && i < 100; i++) + { + await Task.Delay(50, ct.Token).ConfigureAwait(false); + } + + var window = Window.Current; + if (window is null or { Dispatcher: null }) + { + throw new InvalidOperationException("Window.Current is null or does not have any valid dispatcher"); + } + + // While the app init, parse the tests config + var config = default(UnitTestEngineConfig?); + if (testsConfigRaw.StartsWith('{')) + { + try + { + config = JsonSerializer.Deserialize(testsConfigRaw); + } + catch { } + } + config ??= new UnitTestEngineConfig { Filter = testsConfigRaw }; + + // Let continue on the dispatcher thread + var tcs = new TaskCompletionSource(); + await window + .Dispatcher + .RunAsync( + CoreDispatcherPriority.Normal, + async () => + { + try + { + await RunTests(window, config, outputPath, outputKind, isSecondaryApp, ct.Token); + tcs.TrySetResult(); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + tcs.TrySetCanceled(); + } + catch (Exception error) + { + tcs.TrySetException(error); + } + }) + .AsTask(ct.Token) + .ConfigureAwait(false); + + await tcs.Task.ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + LogError("Runtime-tests has been cancelled."); + Environment.Exit(-1); + } + catch (Exception error) + { + LogError("Failed to run runtime-test."); + LogError(error.ToString()); + Environment.Exit(1); + } + finally + { + Log("Runtime-test completed, exiting app."); + Environment.Exit(0); + } + } + + private static async Task RunTests(Window window, UnitTestEngineConfig config, string outputPath, TestResultKind outputKind, bool isSecondaryApp, CancellationToken ct) + { + // Wait for the app to init it-self + for (var i = 0; window.Content is null or { ActualSize.X: 0 } or { ActualSize.Y: 0 } && i < 20; i++) + { + await Task.Delay(20, ct); + } + + // Then override the app content by the test control + Log("Initializing runtime-tests engine."); + var engine = new UnitTestsControl { IsSecondaryApp = isSecondaryApp }; + Window.Current.Content = engine; + await UIHelper.WaitForLoaded(engine, ct); + + // Run the test ! + Log($"Running runtime-tests ({config})"); + await engine.RunTests(ct, config).ConfigureAwait(false); + + // Finally save the test results + Log($"Saving runtime-tests results to {outputPath}."); + switch (outputKind) + { + case TestResultKind.UnoRuntimeTests: + await File.WriteAllTextAsync(outputPath, JsonSerializer.Serialize(engine.Results), Encoding.UTF8, ct); + break; + + default: + case TestResultKind.NUnit: + await File.WriteAllTextAsync(outputPath, engine.NUnitTestResultsDocument, Encoding.Unicode, ct); + break; + } + } + + private static void Log(string text) + => Console.WriteLine(text); + + private static void LogError(string text) + => Console.Error.WriteLine(text); +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestsSourceProjectAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestsSourceProjectAttribute.cs new file mode 100644 index 0000000..ba7113e --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestsSourceProjectAttribute.cs @@ -0,0 +1,29 @@ +#if !UNO_RUNTIMETESTS_DISABLE_EMBEDDEDRUNNER +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Uno.UI.RuntimeTests.Engine; + +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +public sealed class RuntimeTestsSourceProjectAttribute : Attribute +{ + // Note: This is somehow a duplicate of the Uno.UI.RemoteControl.ProjectConfigurationAttribute but it allows us to have the attribute + // to be defined on the assembly that is compiling the runtime test engine (instead on the app head only) + // which allows us to use it in HotReloadTestHelper without having any dependency on the app type. + + public string ProjectFullPath { get; } + + public RuntimeTestsSourceProjectAttribute(string projectFullPath) + { + ProjectFullPath = projectFullPath; + } +} + +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/DevServer.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/DevServer.cs new file mode 100644 index 0000000..fed5d2b --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/DevServer.cs @@ -0,0 +1,229 @@ +#if !UNO_RUNTIMETESTS_DISABLE_UI && (__SKIA__ || IS_SECONDARY_APP_SUPPORTED) +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif +#pragma warning disable CA1848 // Log perf + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Uno.Extensions; +using Uno.UI.RuntimeTests.Engine; + +namespace Uno.UI.RuntimeTests.Internal.Helpers; + +/// +/// Helper class to start a dev server instance. +/// +/// +/// This class is intended to be used only by the the test engine itself and should not be used by applications. +/// API contract is not guaranteed and might change in future releases. +/// +internal sealed partial class DevServer : IAsyncDisposable +{ + private static readonly ILogger _log = typeof(DevServer).Log(); + private static int _instance; + private static string? _devServerPath; + + /// + /// Starts a new dev server instance + /// + /// Cancellation token to abort the initialization of the server. + /// The new dev server instance. + public static async Task Start(CancellationToken ct) + { +#if !HAS_UNO_DEVSERVER + throw new NotSupportedException("Dev server is not supported on this platform."); +#else + var path = await GetDevServer(ct); + var port = GetTcpPort(); + + return StartCore(path, port); +#endif + } + + private readonly Process _process; + + private DevServer(Process process, int port) + { + Port = port; + _process = process; + } + + /// + /// The port on which the dev server is listening + /// + public int Port { get; } + + private static async Task GetDevServer(CancellationToken ct) + => _devServerPath ??= await PullDevServer(ct); + + /// + /// Pulls the latest version of dev server from NuGet and returns the path to the executable + /// + private static async Task PullDevServer(CancellationToken ct) + { + var dir = Path.Combine(Path.GetTempPath(), $"DevServer_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + try + { + using (var log = _log.Scope("GET_DOTNET_VERSION")) + { + var rawVersion = await ProcessHelper.ExecuteAsync( + ct, + "dotnet", + new() { "--version" }, + Environment.CurrentDirectory, // Needed to get the version used by the current app (i.e. including global.json) + log); + var dotnetVersion = GetDotnetVersion(rawVersion); + + var csProj = @$" + + Exe + net{dotnetVersion.Major}.{dotnetVersion.Minor} + +"; + await File.WriteAllTextAsync(Path.Combine(dir, "PullDevServer.csproj"), csProj, ct); + } + + using (var log = _log.Scope("PULL_DEV_SERVER")) + { + var args = new List { "add", "package" }; +#if HAS_UNO_WINUI || WINDOWS_WINUI + args.Add("Uno.WinUI.DevServer"); +#else + args.Add("Uno.UI.DevServer"); +#endif + // If the assembly is not a debug version it should have a valid version + // Note: This is the version of the RemoteControl assembly, not the RemoteControl.Host, but they should be in sync (both are part of the DevServer package) + if (Type.GetType("Uno.UI.RemoteControl.RemoteControlClient, Uno.UI.RemoteControl", throwOnError: false) + ?.Assembly + .GetCustomAttribute() + ?.InformationalVersion is { Length: > 0 } runtimeVersion + && Regex.Match(runtimeVersion, @"^(?\d+\.\d+\.\d+(-\w+\.\d+))+") is {Success: true} match) + { + args.Add("--version"); + args.Add(match.Groups["version"].Value); + } + // Otherwise we use the version used to compile the test engine + else if (typeof(DevServer).Assembly.GetCustomAttribute()?.Version is { Length: > 0 } version) + { + args.Add("--version"); + args.Add(version); + } + // As a last chance, we just use the latest version + else + { + args.Add("--prerelease"); // latest version + } + + await ProcessHelper.ExecuteAsync(ct, "dotnet", args, dir, log); + } + + using (var log = _log.Scope("GET_DEV_SERVER_PATH")) + { + var data = await ProcessHelper.ExecuteAsync( + ct, + "dotnet", + new() { "build", "/t:GetRemoteControlHostPath" }, + dir, + log); + + return GetConfigurationValue(data, "RemoteControlHostPath") is { Length: > 0 } path + ? path + : throw new InvalidOperationException("Failed to get remote control host path"); + } + } + finally + { + try + { + Directory.Delete(dir, recursive: true); + } + catch { /* Nothing to do */ } + } + } + + /// + /// Starts the dev server on the given port + /// + private static DevServer StartCore(string hostBinPath, int port) + { + if (!File.Exists(hostBinPath)) + { + _log.LogError($"DevServer {hostBinPath} does not exist"); + throw new InvalidOperationException($"Unable to find {hostBinPath}"); + } + + var arguments = $"\"{hostBinPath}\" --httpPort {port} --ppid {Environment.ProcessId} --metadata-updates true"; + var pi = new ProcessStartInfo("dotnet", arguments) + { + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = Path.GetDirectoryName(hostBinPath), + }; + + var process = new System.Diagnostics.Process { StartInfo = pi }; + + process.StartAndLog(_log.Scope($"DEV_SERVER_{Interlocked.Increment(ref _instance):D2}")); + + return new DevServer(process, port); + } + + #region Misc helpers + private static string? GetConfigurationValue(string msbuildResult, string nodeName) + => Regex.Match(msbuildResult, $"<{nodeName}>(?.*?)") is { Success: true } match + ? match.Groups["value"].Value + : null; + + private static Version GetDotnetVersion(string dotnetRawVersion) + => Version.TryParse(dotnetRawVersion?.Split('-').FirstOrDefault(), out var version) + ? version + : throw new InvalidOperationException("Failed to read dotnet version"); + + private static int GetTcpPort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + #endregion + + /// + public async ValueTask DisposeAsync() + { + if (_process is null or { HasExited: true }) + { + return; + } + + try + { + _process.Kill(true); // Best effort, the app should kill itself anyway + } + catch (Exception e) + { + _log.LogError("Failed to kill dev server", e); + } + + await _process.WaitForExitAsync(CancellationToken.None); + } +} + +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/LogScope.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/LogScope.cs new file mode 100644 index 0000000..54fd811 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/LogScope.cs @@ -0,0 +1,52 @@ +#if !UNO_RUNTIMETESTS_DISABLE_UI +using System; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Uno.UI.RuntimeTests.Internal.Helpers; + +internal static class LoggerExtensions +{ + public static LogScope CreateScopedLog(this Type type, string scopeName) +#if false + => new(type.Log(), type.Log().BeginScope(scopeName)); +#else + => new(Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory.CreateLogger(type.FullName + "#" + scopeName)); +#endif + + public static LogScope Scope(this ILogger log, string scopeName) +#if false + => new(log, log.BeginScope(scopeName)); +#else + => new (Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory.CreateLogger(typeof(T).FullName + "#" + scopeName)); +#endif +} + +internal readonly struct LogScope : IDisposable, ILogger +{ + private readonly ILogger _logger; + private readonly IDisposable? _scope; + + public LogScope(ILogger logger, IDisposable? scope = null) + { + _logger = logger; + _scope = scope; + } + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _logger.Log(logLevel, eventId, state, exception, formatter); + + /// + public bool IsEnabled(LogLevel logLevel) + => _logger.IsEnabled(logLevel); + + /// + public IDisposable BeginScope(TState state) + => _logger.BeginScope(state); + + /// + public void Dispose() + => _scope?.Dispose(); +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/ProcessHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/ProcessHelper.cs new file mode 100644 index 0000000..8bc46d7 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/ProcessHelper.cs @@ -0,0 +1,177 @@ +#if !UNO_RUNTIMETESTS_DISABLE_UI && (__SKIA__ || IS_SECONDARY_APP_SUPPORTED) +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif +#pragma warning disable CA1848 // Log perf + +using System; +using System.Diagnostics; +using System.Text; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Uno.Extensions; + +namespace Uno.UI.RuntimeTests.Internal.Helpers; + +/// +/// An helper class used by test engine tu run tests in a separate process. +/// +/// +/// This class is intended to be used only by the the test engine itself and should not be used by applications. +/// API contract is not guaranteed and might change in future releases. +/// +internal static partial class ProcessHelper +{ + public static async Task ExecuteAsync( + CancellationToken ct, + string executable, + List parameters, + string workingDirectory, + ILogger log, + Dictionary? environmentVariables = null) + { + var process = SetupProcess(executable, parameters, workingDirectory, environmentVariables); + var output = new StringBuilder(); + var error = new StringBuilder(); + + log.LogTrace("Waiting for process exit"); + + // hookup the event handlers to capture the data that is received + process.OutputDataReceived += (sender, args) => output.Append(args.Data); + process.ErrorDataReceived += (sender, args) => error.Append(args.Data); + + if (ct.IsCancellationRequested) + { + return ""; + } + + var pi = process.StartInfo; + log.LogDebug($"Started process (wd:{pi.WorkingDirectory}): {pi.FileName} {string.Join(" ", pi.ArgumentList)})"); + + process.Start(); + + // start our event pumps + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitWithCancellationAsync(ct); + + process.EnsureSuccess(log, error); + + return output.ToString(); + } + + public static async Task ExecuteAndLogAsync( + this Process process, + ILogger log, + CancellationToken ct) + { + process.StartAndLog(log); + + log.LogTrace("Waiting for process exit"); + + await process.WaitForExitWithCancellationAsync(ct); + + process.EnsureSuccess(log); + } + + public static Process StartAndLog( + string executable, + List parameters, + string workingDirectory, + ILogger log, + Dictionary? environmentVariables = null) + => SetupProcess(executable, parameters, workingDirectory, environmentVariables).StartAndLog(log); + + public static Process StartAndLog( + this Process process, + ILogger log) + { + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + + // hookup the event handlers to capture the data that is received + process.OutputDataReceived += (sender, args) => log.LogDebug(args.Data ?? ""); + process.ErrorDataReceived += (sender, args) => log.LogError(args.Data ?? ""); + + var pi = process.StartInfo; + log.LogDebug($"Started process (wd:{pi.WorkingDirectory}): {pi.FileName} {string.Join(" ", pi.ArgumentList)})"); + + process.Start(); + + // start our event pumps + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + return process; + } + + private static Process SetupProcess( + string executable, + List parameters, + string workingDirectory, + Dictionary? environmentVariables = null) + { + var pi = new ProcessStartInfo(executable) + { + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + foreach (var param in parameters) + { + pi.ArgumentList.Add(param); + } + + if (environmentVariables is not null) + { + foreach (var env in environmentVariables) + { + pi.EnvironmentVariables[env.Key] = env.Value; + } + } + + var process = new System.Diagnostics.Process + { + StartInfo = pi + }; + + return process; + } + + public static async Task WaitForExitWithCancellationAsync(this Process process, CancellationToken ct) + { + await using var cancel = ct.Register(process.Close); + await process.WaitForExitAsync(CancellationToken.None); // If the ct has been cancelled, we want to wait for exit! + } + + public static void EnsureSuccess(this Process process, ILogger log, StringBuilder error) + { + if (process.ExitCode != 0) + { + var processError = new InvalidOperationException(error.ToString()); + log.LogError($"Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}", processError); + + throw new InvalidOperationException($"Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}", processError); + } + } + + public static void EnsureSuccess(this Process process, ILogger log) + { + if (process.ExitCode != 0) + { + log.LogError($"Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}"); + + throw new InvalidOperationException($"Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}"); + } + } +} +#endif diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/SecondaryApp.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/SecondaryApp.cs new file mode 100644 index 0000000..a4cd4a5 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/SecondaryApp.cs @@ -0,0 +1,113 @@ +#if !UNO_RUNTIMETESTS_DISABLE_UI +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +#if __SKIA__ +#define IS_SECONDARY_APP_SUPPORTED +#endif + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Uno.Extensions; + +namespace Uno.UI.RuntimeTests.Internal.Helpers; + +/// +/// Helper class to run tests in a secondary app. +/// +/// +/// This class is intended to be used only by the the test engine itself and should not be used by applications. +/// API contract is not guaranteed and might change in future releases. +/// +internal static partial class SecondaryApp +{ + /// + /// Gets a boolean indicating if the current platform supports running tests in a secondary app. + /// + public static bool IsSupported => // Note: not as const to avoid "CS0162 unreachable code" warning +#if IS_SECONDARY_APP_SUPPORTED + true; +#else + false; +#endif + + /// + /// Run the tests defined by the given configuration in another instance of the current application. + /// + /// Test engine configuration. + /// Token to cancel the test run. + /// Indicates if the application should be ran head-less or not. + /// The test results. + internal static async Task RunTest(UnitTestEngineConfig config, CancellationToken ct, bool isAppVisible = false) + { +#if !IS_SECONDARY_APP_SUPPORTED + throw new NotSupportedException("Secondary app is not supported on this platform."); +#else + // First we fetch and start the dev-server (needed to HR tests for instance) + await using var devServer = await DevServer.Start(ct); + + // Second we start the app (requesting it to connect to the test-dev-server and to run the tests) + var resultFile = await RunLocalApp("127.0.0.1", devServer.Port, config, isAppVisible, ct); + + // Finally, read the test results + try + { + var results = await JsonSerializer.DeserializeAsync(File.OpenRead(resultFile), cancellationToken: ct); + + return results ?? Array.Empty(); + } + catch (JsonException error) + { + throw new InvalidOperationException( + $"Failed to deserialize the test results from '{resultFile}', this usually indicates that the secondary app has been closed (or crashed) before the end of the test suit.", + error); + } + } + + private static int _instance; + + private static async Task RunLocalApp(string devServerHost, int devServerPort, UnitTestEngineConfig config, bool isAppVisible, CancellationToken ct) + { + var testOutput = Path.GetTempFileName(); + + var childStartInfo = new ProcessStartInfo( + Environment.ProcessPath ?? throw new InvalidOperationException("Cannot determine the current app executable path"), + string.Join(" ", Environment.GetCommandLineArgs().Select(arg => '"' + arg + '"'))) + { + UseShellExecute = false, + CreateNoWindow = !isAppVisible, + WindowStyle = isAppVisible ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden, + WorkingDirectory = Environment.CurrentDirectory, + }; + + // Configure the runtime to allow hot-reload + childStartInfo.EnvironmentVariables["DOTNET_MODIFIABLE_ASSEMBLIES"] = "debug"; + + // Requests to the uno app to attempt to connect to the given dev-server instance + childStartInfo.EnvironmentVariables.Add("UNO_DEV_SERVER_HOST", devServerHost); + childStartInfo.EnvironmentVariables.Add("UNO_DEV_SERVER_PORT", devServerPort.ToString()); + + // Request to the runtime tests engine to auto-start at startup + childStartInfo.EnvironmentVariables.Add("UNO_RUNTIME_TESTS_RUN_TESTS", JsonSerializer.Serialize(config, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault })); + childStartInfo.EnvironmentVariables.Add("UNO_RUNTIME_TESTS_OUTPUT_PATH", testOutput); + childStartInfo.EnvironmentVariables.Add("UNO_RUNTIME_TESTS_OUTPUT_KIND", "UnoRuntimeTests"); // "NUnit" + childStartInfo.EnvironmentVariables.Add("UNO_RUNTIME_TESTS_IS_SECONDARY_APP", "true"); // "NUnit" + + var childProcess = new Process { StartInfo = childStartInfo }; + + await childProcess.ExecuteAndLogAsync(typeof(SecondaryApp).CreateScopedLog($"CHILD_TEST_APP_{Interlocked.Increment(ref _instance):D2}"), ct); + + return testOutput; +#endif + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/TestCase.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestCase.cs similarity index 100% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/TestCase.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestCase.cs diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/TestCaseResult.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestCaseResult.cs similarity index 52% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/TestCaseResult.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestCaseResult.cs index e0bb2d6..8952cf1 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/TestCaseResult.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestCaseResult.cs @@ -13,6 +13,14 @@ internal record TestCaseResult public string? TestName { get; init; } public TimeSpan Duration { get; init; } public string? Message { get; init; } + public TestCaseError? Error { get; init; } + public string? ConsoleOutput { get; init; } +} + +internal record TestCaseError(string Type, string Message) +{ + public static implicit operator TestCaseError?(Exception? ex) + => ex is null ? default : new TestCaseError(ex.GetType().Name, ex.Message); } diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/TestResult.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestResult.cs similarity index 100% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/TestResult.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/TestResult.cs diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.CustomConsoleOutput.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.CustomConsoleOutput.cs similarity index 100% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.CustomConsoleOutput.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.CustomConsoleOutput.cs diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/TestRun.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.TestRun.cs similarity index 100% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/TestRun.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.TestRun.cs diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.cs similarity index 80% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.cs index 4a8c27b..a34456d 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.cs @@ -1,6 +1,7 @@ #if !UNO_RUNTIMETESTS_DISABLE_UI #nullable enable +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously using System; using System.Collections.Generic; @@ -12,6 +13,7 @@ using System.Reflection; using System.Runtime.InteropServices.WindowsRuntime; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Xml; @@ -19,7 +21,7 @@ using Microsoft.Extensions.Logging; using Windows.ApplicationModel.DataTransfer; using Windows.Storage; -using Newtonsoft.Json; +using Uno.UI.RuntimeTests.Engine; using Uno.UI.RuntimeTests.Internal.Helpers; #if HAS_UNO @@ -65,7 +67,6 @@ public sealed partial class UnitTestsControl : UserControl #endif #pragma warning restore CS0109 - private const StringComparison StrComp = StringComparison.InvariantCultureIgnoreCase; private Task? _runner; private CancellationTokenSource? _cts = new CancellationTokenSource(); #if DEBUG @@ -80,7 +81,7 @@ public sealed partial class UnitTestsControl : UserControl private ApplicationView? _applicationView; #pragma warning restore CS0649 // Unused field - private List _testCases = new List(); + private readonly List _testCases = new(); private TestRun? _currentRun; // On WinUI/UWP dependency properties cannot be accessed outside of @@ -113,6 +114,8 @@ public UnitTestsControl() #endif } + internal IEnumerable Results => _testCases; + private static void OverrideDebugProviderAsserts() { #if NETSTANDARD2_0 || NET5_0_OR_GREATER @@ -139,6 +142,15 @@ public bool IsRunningOnCI public static readonly DependencyProperty IsRunningOnCIProperty = DependencyProperty.Register("IsRunningOnCI", typeof(bool), typeof(UnitTestsControl), new PropertyMetadata(false)); + public bool IsSecondaryApp + { + get { return (bool)GetValue(IsSecondaryAppProperty); } + set { SetValue(IsSecondaryAppProperty, value); } + } + + public static readonly DependencyProperty IsSecondaryAppProperty = + DependencyProperty.Register(nameof(IsSecondaryApp), typeof(bool), typeof(UnitTestsControl), new PropertyMetadata(false)); + /// /// Defines the test group for splitting runtime tests on CI /// @@ -319,17 +331,35 @@ private void ReportTestClass(TypeInfo testClass) } private void ReportTestResult(string testName, TimeSpan duration, TestResult testResult, Exception? error = null, string? message = null, string? console = null) - { - _testCases.Add( + => ReportTestResult( new TestCaseResult { TestName = testName, Duration = duration, TestResult = testResult, - Message = error?.ToString() ?? message + Message = error?.ToString() ?? message, + Error = error, + ConsoleOutput = console }); - void Update() + private void ReportTestResult(params TestCaseResult[] results) + { + _testCases.AddRange(results); + _dispatcher.Invoke(() => + { + foreach (var result in results) + { + UpdateUI(result); + } + }); +#if HAS_UNO + foreach (var result in results) + { + _log?.Info($"Test completed '{result.TestName}'='{result.TestResult}'"); + } +#endif + + void UpdateUI(TestCaseResult result) { if (_currentRun is null) { @@ -354,27 +384,27 @@ void Update() testResultBlock.Inlines.Add(new Run { - Text = GetTestResultIcon(testResult) + ' ' + testName + retriesText, + Text = GetTestResultIcon(result.TestResult) + ' ' + result.TestName + retriesText, FontSize = 13.5d, - Foreground = new SolidColorBrush(UnitTestsControl.GetTestResultColor(testResult)), + Foreground = new SolidColorBrush(UnitTestsControl.GetTestResultColor(result.TestResult)), FontWeight = FontWeights.ExtraBold }); - if (message is { }) + if (result.Message is { }) { - testResultBlock.Inlines.Add(new Run { Text = "\n ..." + message, FontStyle = FontStyle.Italic }); + testResultBlock.Inlines.Add(new Run { Text = "\n ..." + result.Message, FontStyle = FontStyle.Italic }); } - if (error is { }) + if (result.Error is { }) { - var isFailed = testResult == TestResult.Failed || testResult == TestResult.Error; + var isFailed = result.TestResult == TestResult.Failed || result.TestResult == TestResult.Error; var foreground = isFailed ? new SolidColorBrush(Colors.Red) : new SolidColorBrush(Colors.Yellow); - testResultBlock.Inlines.Add(new Run { Text = "\nEXCEPTION>" + error.Message, Foreground = foreground }); + testResultBlock.Inlines.Add(new Run { Text = "\nEXCEPTION>" + result.Error.Message, Foreground = foreground }); if (isFailed) { - failedTestDetails.Text += $"{testResult}: {testName} [{error.GetType()}] \n {error}\n\n"; + failedTestDetails.Text += $"{result.TestResult}: {result.TestName} [{result.Error.Type}] \n {result.Error.Message}\n\n"; if (failedTestDetailsRow.Height.Value == 0) { failedTestDetailsRow.Height = new GridLength(100); @@ -382,9 +412,9 @@ void Update() } } - if (console is { }) + if (result.ConsoleOutput is { }) { - testResultBlock.Inlines.Add(new Run { Text = "\nOUT>" + console, Foreground = new SolidColorBrush(Colors.Gray) }); + testResultBlock.Inlines.Add(new Run { Text = "\nOUT>" + result.ConsoleOutput, Foreground = new SolidColorBrush(Colors.Gray) }); } if (!IsRunningOnCI) @@ -393,13 +423,11 @@ void Update() testResultBlock.StartBringIntoView(); } - if (testResult == TestResult.Error || testResult == TestResult.Failed) + if (result.TestResult == TestResult.Error || result.TestResult == TestResult.Failed) { - failedTests.Text += "§" + testName; + failedTests.Text += "§" + result.TestName; } } - - _dispatcher.Invoke(Update); } private static string GenerateNUnitTestResults(List testCases, TestRun testRun) @@ -492,14 +520,14 @@ private void EnableConfigPersistence() { try { - var config = JsonConvert.DeserializeObject(configStr); + var config = JsonSerializer.Deserialize(configStr); if (config is not null) { consoleOutput.IsChecked = config.IsConsoleOutputEnabled; runIgnored.IsChecked = config.IsRunningIgnored; retry.IsChecked = config.Attempts > 1; - testFilter.Text = string.Join(";", config.Filters ?? Array.Empty()); + testFilter.Text = config.Filter; } } catch (Exception) @@ -525,7 +553,7 @@ private void ListenConfigChanged() void StoreConfig() { var config = BuildConfig(); - ApplicationData.Current.LocalSettings.Values["unitestcontrols_config"] = JsonConvert.SerializeObject(config); + ApplicationData.Current.LocalSettings.Values["unitestcontrols_config"] = JsonSerializer.Serialize(config); } } @@ -542,7 +570,7 @@ private UnitTestEngineConfig BuildConfig() return new UnitTestEngineConfig { - Filters = filter?.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(), + Filter = filter, IsConsoleOutputEnabled = isConsoleOutput, IsRunningIgnored = isRunningIgnored, Attempts = attempts, @@ -675,17 +703,6 @@ await _dispatcher.RunAsync(() => await GenerateTestResults(); } - private static IEnumerable FilterTests(UnitTestClassInfo testClassInfo, string[]? filters) - { - var testClassNameContainsFilters = filters?.Any(f => testClassInfo.Type?.FullName?.Contains(f, StrComp) ?? false) ?? false; - return testClassInfo.Tests?. - Where(t => ((!filters?.Any()) ?? true) - || testClassNameContainsFilters - || (filters?.Any(f => t.DeclaringType?.FullName?.Contains(f, StrComp) ?? false) ?? false) - || (filters?.Any(f => t.Name.Contains(f, StrComp)) ?? false)) - ?? Array.Empty(); - } - private async Task ExecuteTestsForInstance( CancellationToken ct, object instance, @@ -696,7 +713,9 @@ private async Task ExecuteTestsForInstance( ? ConsoleOutputRecorder.Start() : default; - var tests = UnitTestsControl.FilterTests(testClassInfo, config.Filters) + var tests = (config.Filter is null + ? testClassInfo.Tests + : testClassInfo.Tests.Where(test => config.Filter.IsMatch(test))) .Select(method => new UnitTestMethodInfo(instance, method)) .ToArray(); @@ -708,6 +727,52 @@ private async Task ExecuteTestsForInstance( ReportTestClass(testClassInfo.Type.GetTypeInfo()); _ = ReportMessage($"Running {tests.Length} test methods"); + if (testClassInfo.RunsInSecondaryApp is { } secondaryApp && !IsSecondaryApp) + { + try + { + var testCases = tests.SelectMany(t => t.GetCases(ct)).ToList(); + if (!SecondaryApp.IsSupported && secondaryApp.IgnoreIfNotSupported && !config.IsRunningIgnored) + { + foreach (var testCase in testCases) + { + ReportTestResult( + testCase.ToString(), + TimeSpan.Zero, + TestResult.Skipped, + null, + $"Test of class {instance.GetType().Name} are expected to be run in a secondary app, but secondary app is not supported on this platform."); + } + + return; + } + + config = config with { Filter = $"{testClassInfo.Type.FullName} & ({config.Filter})" }; + + var results = await SecondaryApp.RunTest(config, ct, isAppVisible: Debugger.IsAttached); + foreach (var result in results) + { + ReportTestResult(result); + } + + if (results.Length != testCases.Count) + { + ReportTestResult( + instance.GetType().Name, + TimeSpan.Zero, + TestResult.Failed, + new InvalidOperationException($"Unexpected tests results, got {results.Length} test results from the secondary app for class {instance.GetType().Name} while we where expecting {testCases.Count}.\""), + $"Got {results.Length} test results from the secondary app for class {instance.GetType().Name} while we where expecting {testCases.Count}."); + } + } + catch (Exception error) + { + ReportTestResult(instance.GetType().Name, TimeSpan.Zero, TestResult.Failed, error, $"Failed to run tests in secondary app for test class {instance.GetType().Name}"); + } + + return; + } + foreach (var test in tests) { var testName = test.Name; @@ -737,7 +802,7 @@ private async Task ExecuteTestsForInstance( } } - foreach (var testCase in test.GetCases()) + foreach (var testCase in test.GetCases(ct)) { if (ct.IsCancellationRequested) { @@ -766,7 +831,7 @@ async Task InvokeTestMethod(TestCase testCase) await ReportMessage($"Running test {fullTestName}"); ReportTestsResults(); - var cleanupActions = new List>(); + var cleanupActions = new List>(); var sw = new Stopwatch(); var canRetry = true; @@ -778,7 +843,7 @@ async Task InvokeTestMethod(TestCase testCase) { if (test.RequiresFullWindow) { - await _dispatcher.RunAsync(() => + await ExecuteOnDispatcher(() => { #if __ANDROID__ // Hide the systray! @@ -787,10 +852,10 @@ await _dispatcher.RunAsync(() => UnitTestsUIContentHelper.UseActualWindowRoot = true; UnitTestsUIContentHelper.SaveOriginalContent(); - }); + }, ct); cleanupActions.Add(async _ => { - await _dispatcher.RunAsync(() => + await ExecuteOnDispatcher(() => { #if __ANDROID__ // Restore the systray! @@ -798,73 +863,46 @@ await _dispatcher.RunAsync(() => #endif UnitTestsUIContentHelper.RestoreOriginalContent(); UnitTestsUIContentHelper.UseActualWindowRoot = false; - }); + }, CancellationToken.None); }); } - object? returnValue = null; - if (test.RunsOnUIThread) + // Configure pointers injection + await ExecuteOnDispatcher(() => { - await _dispatcher.RunAsync(() => + if (InputInjectorHelper.TryGetCurrent() is not null) { - if (InputInjectorHelper.TryGetCurrent() is not null) - { - InputInjectorHelper.Current.CleanupPointers(); - } - - if (testCase.Pointer is { } pt) - { - var ptSubscription = InputInjectorHelper.Current.SetPointerType(pt); -#pragma warning disable CS1998 - cleanupActions.Add(async _ => ptSubscription.Dispose()); -#pragma warning restore CS1998 - } - - if (instance.GetType().GetProperty("Pointers", BindingFlags.Instance | BindingFlags.Public) is { SetMethod: not null } pointerProp - && pointerProp.PropertyType == typeof(InputInjectorHelper)) - { - pointerProp.SetMethod.Invoke(instance, new[] { InputInjectorHelper.Current }); - } - + InputInjectorHelper.Current.CleanupPointers(); + } - sw.Start(); - testClassInfo.Initialize?.Invoke(instance, Array.Empty()); - returnValue = test.Method.Invoke(instance, testCase.Parameters); - sw.Stop(); - }); - } - else - { if (testCase.Pointer is { } pt) { var ptSubscription = InputInjectorHelper.Current.SetPointerType(pt); -#pragma warning disable CS1998 - cleanupActions.Add(async _ => ptSubscription.Dispose()); -#pragma warning restore CS1998 + cleanupActions.Add(async ct2 => await ExecuteOnDispatcher(ptSubscription.Dispose, ct2)); } - sw.Start(); - testClassInfo.Initialize?.Invoke(instance, Array.Empty()); - returnValue = test.Method.Invoke(instance, testCase.Parameters); - sw.Stop(); - } - - if (test.Method.ReturnType == typeof(Task)) - { - var task = (Task)returnValue!; - var timeoutTask = Task.Delay(DefaultUnitTestTimeout); - - var resultingTask = await Task.WhenAny(task, timeoutTask); - - if (resultingTask == timeoutTask) + if (instance.GetType().GetProperty("Pointers", BindingFlags.Instance | BindingFlags.Public) is { SetMethod: not null } pointerProp + && pointerProp.PropertyType == typeof(InputInjectorHelper)) { - throw new TimeoutException( - $"Test execution timed out after {DefaultUnitTestTimeout}"); + pointerProp.SetMethod.Invoke(instance, new[] { InputInjectorHelper.Current }); } + }, ct); - // Rethrow exception if failed OR task cancelled if task **internally** raised - // a TaskCancelledException (we don't provide any cancellation token). - await resultingTask; + if (test.RunsOnUIThread) + { + await ExecuteOnDispatcher(DoInvoke, ct); + } + else + { + await DoInvoke(); + } + + async ValueTask DoInvoke() + { + sw.Start(); + await WaitResult(testClassInfo.Initialize?.Invoke(instance, Array.Empty()), "initialization"); + await WaitResult(test.Method.Invoke(instance, testCase.Parameters), "execution"); + sw.Stop(); } var console = consoleRecorder?.GetContentAndReset(); @@ -945,13 +983,13 @@ await _dispatcher.RunAsync(() => } } - async Task RunCleanup(object instance, UnitTestClassInfo testClassInfo, string testName, bool runsOnUIThread) + async ValueTask RunCleanup(object instance, UnitTestClassInfo testClassInfo, string testName, bool runsOnUIThread) { - void Run() + async ValueTask DoCleanup() { try { - testClassInfo.Cleanup?.Invoke(instance, Array.Empty()); + await WaitResult(testClassInfo.Cleanup?.Invoke(instance, Array.Empty()), "cleanup"); } catch (Exception e) { @@ -965,13 +1003,60 @@ void Run() if (runsOnUIThread) { - await _dispatcher.RunAsync(Run); + await ExecuteOnDispatcher(DoCleanup, CancellationToken.None); // No CT for cleanup! } else { - Run(); + await DoCleanup(); } } + + async ValueTask WaitResult(object? returnValue, string step) + { + if (returnValue is Task asyncResult) + { + var timeoutTask = Task.Delay(DefaultUnitTestTimeout, ct); + var resultingTask = await Task.WhenAny(asyncResult, timeoutTask); + + if (resultingTask == timeoutTask) + { + throw new TimeoutException($"Test {step} timed out after {DefaultUnitTestTimeout}"); + } + + // Rethrow exception if failed OR task cancelled if task **internally** raised + // a TaskCancelledException (we don't provide any cancellation token). + await resultingTask; + } + } + } + + private async ValueTask ExecuteOnDispatcher(Action asyncAction, CancellationToken ct = default) + => await ExecuteOnDispatcher(async () => asyncAction(), ct); + + private async ValueTask ExecuteOnDispatcher(Func asyncAction, CancellationToken ct = default) + { + var tcs = new TaskCompletionSource(); + await _dispatcher.RunAsync(async () => + { + try + { + if (ct.IsCancellationRequested) + { + tcs.TrySetCanceled(); + } + + using var ctReg = ct.Register(() => tcs.TrySetCanceled()); + await asyncAction(); + + tcs.TrySetResult(default); + } + catch (Exception e) + { + tcs.TrySetException(e); + } + }); + + await tcs.Task; } private IEnumerable InitializeTests() diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.xaml b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.xaml similarity index 100% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsControl.xaml rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsControl.xaml diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsUIContentHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsUIContentHelper.cs similarity index 91% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsUIContentHelper.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsUIContentHelper.cs index e617803..5b5a4d1 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestsUIContentHelper.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/UnitTestsUIContentHelper.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -116,9 +117,12 @@ public static async Task WaitForIdle() /// /// On UWP, may not always wait long enough for the control to be properly measured. /// - /// This method assumes that the control will have a non-zero size once loaded, so it's not appropriate for elements that are + /// WARNING: This method assumes that the control will have a non-zero size once loaded, so it's not appropriate for elements that are /// collapsed, empty, etc. + /// + /// WARNING: This method have a special behavior with ListView for which it will wait for the first item to be materialized. /// + [EditorBrowsable(EditorBrowsableState.Never)] // Prefer to use the UIHelper.WaitForLoaded that relies only on element.IsLoaded and doesn't have any implicit rules. public static async Task WaitForLoaded(FrameworkElement element) { async Task Do() diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestDispatcherCompat.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/_Private/UnitTestDispatcherCompat.cs similarity index 100% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestDispatcherCompat.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UI/_Private/UnitTestDispatcherCompat.cs diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestClassInfo.cs similarity index 63% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestClassInfo.cs index d32c298..388e692 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestClassInfo.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestClassInfo.cs @@ -18,22 +18,29 @@ public UnitTestClassInfo( { Type = type; TestClassName = Type?.Name ?? "(null)"; - Tests = tests; + Tests = tests ?? Array.Empty(); Initialize = initialize; Cleanup = cleanup; + + RunsInSecondaryApp = type?.GetCustomAttribute(); } public string TestClassName { get; } public Type? Type { get; } - public MethodInfo[]? Tests { get; } + public MethodInfo[] Tests { get; } public MethodInfo? Initialize { get; } public MethodInfo? Cleanup { get; } + public RunsInSecondaryAppAttribute? RunsInSecondaryApp { get; } + public override string ToString() => TestClassName; + + private static bool HasCustomAttribute(MemberInfo? testMethod) + => testMethod?.GetCustomAttribute(typeof(T)) != null; } #endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestEngineConfig.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestEngineConfig.cs new file mode 100644 index 0000000..86d2ab0 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestEngineConfig.cs @@ -0,0 +1,26 @@ +#nullable enable + +using System; +using System.Linq; + +namespace Uno.UI.RuntimeTests; + + +#if !UNO_RUNTIMETESTS_DISABLE_UI || !UNO_RUNTIMETESTS_DISABLE_EMBEDDEDRUNNER + +public record UnitTestEngineConfig +{ + public const int DefaultRepeatCount = 3; + + public static UnitTestEngineConfig Default { get; } = new(); + + public UnitTestFilter? Filter { get; init; } + + public int Attempts { get; init; } = DefaultRepeatCount; + + public bool IsConsoleOutputEnabled { get; init; } + + public bool IsRunningIgnored { get; init; } +} + +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestFilter.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestFilter.cs new file mode 100644 index 0000000..2043d31 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestFilter.cs @@ -0,0 +1,128 @@ +#if !UNO_RUNTIMETESTS_DISABLE_UI || !UNO_RUNTIMETESTS_DISABLE_EMBEDDEDRUNNER +using System; +using System.Linq; +using System.Reflection; + +namespace Uno.UI.RuntimeTests; + +public record UnitTestFilter +{ + private readonly IUnitTestEngineFilter _filter; + private static readonly char[] _separators = { '(', ')', '&', '|', ';' }; + + private UnitTestFilter(IUnitTestEngineFilter filter) + { + _filter = filter; + } + + public UnitTestFilter() + { + _filter = new NullFilter(); + } + + public string Value + { + get => ToString(); + init => _filter = Parse(value); + } + + public bool IsMatch(MethodInfo method) => _filter.IsMatch($"{method.DeclaringType?.FullName}.{method.Name}"); + public bool IsMatch(string methodFullname) => _filter.IsMatch(methodFullname); + + public override string ToString() => _filter.ToString() ?? string.Empty; + + public static implicit operator UnitTestFilter(string? syntax) => new(Parse(syntax)); + + public static implicit operator string(UnitTestFilter? filter) => filter?.ToString() ?? string.Empty; + + private static IUnitTestEngineFilter Parse(string? syntax) + { + if (string.IsNullOrWhiteSpace(syntax)) + { + return new NullFilter(); + } + else + { + var index = 0; + return Parse(ref index, syntax.AsSpan()); + } + } + + private static IUnitTestEngineFilter Parse(ref int index, ReadOnlySpan syntax) + { + // Simple syntax parser ... that does not have any operator priority! + + var pending = default(IUnitTestEngineFilter?); + for (; index < syntax.Length; index++) + { + switch (syntax[index]) + { + case '(': + index++; + pending = Parse(ref index, syntax); + break; + + //case ' ': + // break; + + case '&' when pending is not null: + index++; + pending = new AndFilter(pending, Parse(ref index, syntax)); + break; + + case ';' when pending is not null: // Legacy support + case '|' when pending is not null: + index++; + pending = new OrFilter(pending, Parse(ref index, syntax)); + break; + + case ')' when pending is not null: + return pending; + + case ')': + return new NullFilter(); + + default: + { + var j = index; + for (; j < syntax.Length && !_separators.Contains(syntax[j]); j++) { } + pending = new TextFilter(syntax.Slice(index, j - index).ToString().Trim()); + index = j - 1; + break; + } + } + } + + return pending ?? default(NullFilter); + } + + private interface IUnitTestEngineFilter + { + bool IsMatch(string methodFullname); + } + + private readonly record struct AndFilter(IUnitTestEngineFilter Left, IUnitTestEngineFilter Right) : IUnitTestEngineFilter + { + public bool IsMatch(string methodFullname) => Left.IsMatch(methodFullname) && Right.IsMatch(methodFullname); + public override string ToString() => $"({Left} & {Right})"; + } + + private readonly record struct OrFilter(IUnitTestEngineFilter Left, IUnitTestEngineFilter Right) : IUnitTestEngineFilter + { + public bool IsMatch(string methodFullname) => Left.IsMatch(methodFullname) || Right.IsMatch(methodFullname); + public override string ToString() => $"({Left} | {Right})"; + } + + private readonly record struct TextFilter(string Text) : IUnitTestEngineFilter + { + public bool IsMatch(string methodFullname) => methodFullname.Contains(Text, StringComparison.InvariantCultureIgnoreCase); + public override string ToString() => Text; + } + + private readonly struct NullFilter : IUnitTestEngineFilter + { + public bool IsMatch(string methodFullname) => true; + public override string ToString() => string.Empty; + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestMethodInfo.cs similarity index 85% rename from src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestMethodInfo.cs index 1207771..2bf1735 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestMethodInfo.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/UnitTestMethodInfo.cs @@ -6,7 +6,9 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Threading; using Windows.Devices.Input; +using Microsoft.VisualBasic.CompilerServices; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Uno.UI.RuntimeTests; @@ -74,20 +76,24 @@ public bool IsIgnored(out string ignoreMessage) return false; } - public IEnumerable GetCases() + public IEnumerable GetCases(CancellationToken ct) { - List cases = Enumerable.Empty().ToList(); + List cases = new(); if (_casesParameters is { Count: 0 }) { - cases.Add(new TestCase()); + cases.Add(Method.GetParameters().Any(p => p.ParameterType == typeof(CancellationToken)) + ? new TestCase { Parameters = new object[] { ct } } + : new TestCase()); } foreach (var testCaseSource in _casesParameters) { + // Note: CT is not propagated when using a test data source foreach (var caseData in testCaseSource.GetData(Method)) { - var data = testCaseSource.GetData(Method) + var data = testCaseSource + .GetData(Method) .SelectMany(x => x) .ToArray(); diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/AsyncAssert.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/AsyncAssert.cs new file mode 100644 index 0000000..593de94 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/AsyncAssert.cs @@ -0,0 +1,1179 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Uno.UI.RuntimeTests; + +public static partial class AsyncAssert +{ + #region IsTrue + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsTrue( + Func condition, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsTrueCore(condition, $"{conditionExpression} to be true but found false ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsTrue( + Func condition, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsTrueCore(condition, $"{conditionExpression} to be true but found false ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsTrue( + Func condition, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsTrueCore(condition, $"{conditionExpression} to be true but found false ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsTrue(Func condition, string message, CancellationToken ct = default) + => IsTrueCore(condition, message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsTrue(Func condition, string message, TimeSpan timeout, CancellationToken ct = default) + => IsTrueCore(condition, message, timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsTrue(Func condition, string message, int timeoutMs, CancellationToken ct = default) + => IsTrueCore( condition, message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsTrue( + Func> condition, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsTrueCore(condition, $"{conditionExpression} to be true but found false ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsTrue( + Func> condition, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsTrueCore(condition, $"{conditionExpression} to be true but found false ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsTrue( + Func> condition, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsTrueCore(condition, $"{conditionExpression} to be true but found false ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsTrue(Func> condition, string message, CancellationToken ct = default) + => IsTrueCore(condition, message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsTrue(Func> condition, string message, TimeSpan timeout, CancellationToken ct = default) + => IsTrueCore(condition, message, timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is true and throws an exception if the condition is false. + /// + /// The condition the test expects to be true. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsTrue(Func> condition, string message, int timeoutMs, CancellationToken ct = default) + => IsTrueCore(condition, message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + private static async ValueTask IsTrueCore(Func condition, string reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(condition, timeout, ct); + + Assert.IsTrue(condition(), reason); + } + + private static async ValueTask IsTrueCore(Func> condition, string reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(async _ => await condition().ConfigureAwait(false), timeout, ct); + + Assert.IsTrue(await condition().ConfigureAwait(false), reason); + } + #endregion + + #region IsFalse + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsFalse( + Func condition, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsFalseCore(condition, $"{conditionExpression} to be false but found true ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsFalse( + Func condition, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsFalseCore(condition, $"{conditionExpression} to be false but found true ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsFalse( + Func condition, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsFalseCore(condition, $"{conditionExpression} to be false but found true ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsFalse(Func condition, string message, CancellationToken ct = default) + => IsFalseCore(condition, message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsFalse(Func condition, string message, TimeSpan timeout, CancellationToken ct = default) + => IsFalseCore(condition, message, timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsFalse(Func condition, string message, int timeoutMs, CancellationToken ct = default) + => IsFalseCore(condition, message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsFalse( + Func> condition, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsFalseCore(condition, $"{conditionExpression} to be false but found true ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsFalse( + Func> condition, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsFalseCore(condition, $"{conditionExpression} to be false but found true ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsFalse( + Func> condition, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("condition")] string conditionExpression = "") + => IsFalseCore(condition, $"{conditionExpression} to be false but found true ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsFalse(Func> condition, string message, CancellationToken ct = default) + => IsFalseCore(condition, message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsFalse(Func> condition, string message, TimeSpan timeout, CancellationToken ct = default) + => IsFalseCore(condition, message, timeout, ct); + + /// + /// Asynchronously tests whether the specified condition is false and throws an exception if the condition is true. + /// + /// The condition the test expects to be false. + /// The message to include in the exception when condition is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsFalse(Func> condition, string message, int timeoutMs, CancellationToken ct = default) + => IsFalseCore(condition, message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + private static async ValueTask IsFalseCore(Func condition, string reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(() => !condition(), timeout, ct); + + Assert.IsFalse(condition(), reason); + } + + private static async ValueTask IsFalseCore(Func> condition, string reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(async _ => !await condition().ConfigureAwait(false), timeout, ct); + + Assert.IsFalse(await condition().ConfigureAwait(false), reason); + } + #endregion + + #region IsNull + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNull( + Func value, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNull( + Func value, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNull( + Func value, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNull(Func value, string message, CancellationToken ct = default) + => IsNullCore(value, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNull(Func value, string message, TimeSpan timeout, CancellationToken ct = default) + => IsNullCore(value, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNull(Func value, string message, int timeoutMs, CancellationToken ct = default) + => IsNullCore(value, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNull( + Func> value, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNull( + Func> value, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNull( + Func> value, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNull(Func> value, string message, CancellationToken ct = default) + => IsNullCore(value, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNull(Func> value, string message, TimeSpan timeout, CancellationToken ct = default) + => IsNullCore(value, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified object is null and throws an exception if it is not. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNull(Func> value, string message, int timeoutMs, CancellationToken ct = default) + => IsNullCore(value, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + private static async ValueTask IsNullCore(Func value, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(() => object.Equals(null, value()), timeout, ct); + + var a = value(); + Assert.IsNull(a, reason(a)); + } + + private static async ValueTask IsNullCore(Func> value, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(async _ => object.Equals(null, await value().ConfigureAwait(false)), timeout, ct); + + var a = await value().ConfigureAwait(false); + Assert.IsNotNull(a, reason(a)); + } + #endregion + + #region IsNotNull + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNotNull( + Func value, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNotNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNotNull( + Func value, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNotNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNotNull( + Func value, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNotNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNotNull(Func value, string message, CancellationToken ct = default) + => IsNotNullCore(value, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNotNull(Func value, string message, TimeSpan timeout, CancellationToken ct = default) + => IsNotNullCore(value, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNotNull(Func value, string message, int timeoutMs, CancellationToken ct = default) + => IsNotNullCore(value, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNotNull( + Func> value, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNotNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNotNull( + Func> value, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNotNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask IsNotNull( + Func> value, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("value")] string valueExpression = "") + => IsNotNullCore(value, a => $"{valueExpression} to be null but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNotNull(Func> value, string message, CancellationToken ct = default) + => IsNotNullCore(value, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNotNull(Func> value, string message, TimeSpan timeout, CancellationToken ct = default) + => IsNotNullCore(value, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified object is non-null and throws an exception if it is null. + /// + /// The type of values to compare. + /// The object the test expects to be null. + /// The message to include in the exception when value is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask IsNotNull(Func> value, string message, int timeoutMs, CancellationToken ct = default) + => IsNotNullCore(value, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + private static async ValueTask IsNotNullCore(Func value, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(() => !object.Equals(null, value()), timeout, ct); + + var a = value(); + Assert.IsNotNull(a, reason(a)); + } + + private static async ValueTask IsNotNullCore(Func> value, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(async _ => !object.Equals(null, await value().ConfigureAwait(false)), timeout, ct); + + var a = await value().ConfigureAwait(false); + Assert.IsNotNull(a, reason(a)); + } + #endregion + + #region AreEqual + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreEqual( + T expected, + Func actual, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreEqual( + T expected, + Func actual, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreEqual( + T expected, + Func actual, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreEqual(T expected, Func actual, string message, CancellationToken ct = default) + => AreEqualCore(expected, actual, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreEqual(T expected, Func actual, string message, TimeSpan timeout, CancellationToken ct = default) + => AreEqualCore(expected, actual, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreEqual(T expected, Func actual, string message, int timeoutMs, CancellationToken ct = default) + => AreEqualCore(expected, actual, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreEqual( + T expected, + Func> actual, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreEqual( + T expected, + Func> actual, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreEqual( + T expected, + Func> actual, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreEqual(T expected, Func> actual, string message, CancellationToken ct = default) + => AreEqualCore(expected, actual, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreEqual(T expected, Func> actual, string message, TimeSpan timeout, CancellationToken ct = default) + => AreEqualCore(expected, actual, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified values are equal and throws an exception if the two values are not equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreEqual(T expected, Func> actual, string message, int timeoutMs, CancellationToken ct = default) + => AreEqualCore(expected, actual, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + private static async ValueTask AreEqualCore(T expected, Func actual, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(() => object.Equals(expected, actual()), timeout, ct); + + var a = actual(); + Assert.AreEqual(expected, a, reason(a)); + } + + private static async ValueTask AreEqualCore(T expected, Func> actual, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(async _ => object.Equals(expected, await actual().ConfigureAwait(false)), timeout, ct); + + var a = await actual().ConfigureAwait(false); + Assert.AreEqual(expected, a, reason(a)); + } + #endregion + + #region AreNotEqual + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreNotEqual( + T expected, + Func actual, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreNotEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreNotEqual( + T expected, + Func actual, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreNotEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreNotEqual( + T expected, + Func actual, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreNotEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreNotEqual(T expected, Func actual, string message, CancellationToken ct = default) + => AreNotEqualCore(expected, actual, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreNotEqual(T expected, Func actual, string message, TimeSpan timeout, CancellationToken ct = default) + => AreNotEqualCore(expected, actual, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreNotEqual(T expected, Func actual, string message, int timeoutMs, CancellationToken ct = default) + => AreNotEqualCore(expected, actual, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreNotEqual( + T expected, + Func> actual, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreNotEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreNotEqual( + T expected, + Func> actual, + TimeSpan timeout, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreNotEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", timeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + /// For debug purposes. + /// For debug purposes. + /// For debug purposes. + public static ValueTask AreNotEqual( + T expected, + Func> actual, + int timeoutMs, + CancellationToken ct = default, + [CallerLineNumber] int line = -1, + [CallerFilePath] string file = "", + [CallerArgumentExpression("actual")] string actualExpression = "") + => AreNotEqualCore(expected, actual, a => $"{actualExpression} to equals {expected} but found {a} ({Path.GetFileName(file)}@{line})", TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreNotEqual(T expected, Func> actual, string message, CancellationToken ct = default) + => AreNotEqualCore(expected, actual, a => message, TestHelper.DefaultTimeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreNotEqual(T expected, Func> actual, string message, TimeSpan timeout, CancellationToken ct = default) + => AreNotEqualCore(expected, actual, a => message, timeout, ct); + + /// + /// Asynchronously tests whether the specified values are unequal and throws an exception if the two values are equal. Different numeric types are treated as unequal even if the logical values are equal. 42L is not equal to 42. + /// + /// The type of values to compare. + /// The first value to compare. This is the value the tests expects. + /// The second value to compare. This is the value produced by the code under test. + /// The message to include in the exception when actual is not equal to expected. The message is shown in test results. + /// The max duration to wait for in milliseconds. + /// Cancellation token to cancel teh asynchronous operation + public static ValueTask AreNotEqual(T expected, Func> actual, string message, int timeoutMs, CancellationToken ct = default) + => AreNotEqualCore(expected, actual, a => message, TimeSpan.FromMilliseconds(timeoutMs), ct); + + private static async ValueTask AreNotEqualCore(T expected, Func actual, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(() => !object.Equals(expected, actual()), timeout, ct); + + var a = actual(); + Assert.AreNotEqual(expected, a, reason(a)); + } + + private static async ValueTask AreNotEqualCore(T expected, Func> actual, Func reason, TimeSpan timeout, CancellationToken ct) + { + await TestHelper.TryWaitFor(async _ => !object.Equals(expected, await actual().ConfigureAwait(false)), timeout, ct); + + var a = await actual().ConfigureAwait(false); + Assert.AreNotEqual(expected, a, reason(a)); + } + #endregion +} +#endif diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.DevServer.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.DevServer.cs new file mode 100644 index 0000000..6de51d0 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.DevServer.cs @@ -0,0 +1,104 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY && HAS_UNO_DEVSERVER // Set as soon as the DevServer package is referenced, cf. Uno.UI.RuntimeTests.Engine.targets +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif +#pragma warning disable CA1848 // Log perf + +using System; +using System.Collections; +using System.IO; +using System.Linq; +using System.Net.WebSockets; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Uno.UI.RuntimeTests.Internal.Helpers; +using Uno.UI.RemoteControl; // DevServer +using Uno.UI.RemoteControl.HotReload; // DevServer +using Uno.UI.RemoteControl.HotReload.Messages; // DevServer +using Uno.UI.RemoteControl.HotReload.MetadataUpdater; // DevServer + +namespace Uno.UI.RuntimeTests; + +partial class HotReloadHelper +{ + static partial void TryUseDevServerFileUpdater() + => Use(new DevServerUpdater()); + + partial record FileEdit + { + public UpdateFile ToMessage() + => new UpdateFile + { + FilePath = FilePath, + OldText = OldText, + NewText = NewText + }; + } + + private class DevServerUpdater : IFileUpdater + { + /// + public async ValueTask EnsureReady(CancellationToken ct) + { + _log.LogTrace("Getting dev-server ready ..."); + + if (RemoteControlClient.Instance is null) + { + throw new InvalidOperationException("Dev server is not available."); + } + + if (RemoteControlClient.Instance.Processors.OfType().FirstOrDefault() is not { } hotReload) + { + throw new InvalidOperationException("App is not configured to accept hot-reload."); + } + + var timeout = Task.Delay(ConnectionTimeout, ct); + if (await Task.WhenAny(timeout, RemoteControlClient.Instance.WaitForConnection(ct)) == timeout) + { + throw new TimeoutException( + "Timeout while waiting for the app to connect to the dev-server. " + + "This usually indicates that the dev-server is not running on the expected combination of hots and port" + + "(For runtime-tests run in secondary app instance, the server should be listening for connection on " + + $"{Environment.GetEnvironmentVariable("UNO_DEV_SERVER_HOST")}:{Environment.GetEnvironmentVariable("UNO_DEV_SERVER_PORT")})."); + } + + _log.LogTrace("Client connected, waiting for dev-server to load the workspace (i.e. initializing roslyn with the solution) ..."); + + var hotReloadReady = hotReload.WaitForWorkspaceLoaded(ct); + timeout = Task.Delay(WorkspaceTimeout, ct); + if (await Task.WhenAny(timeout, hotReloadReady) == timeout) + { + throw new TimeoutException( + "Timeout while waiting for hot reload workspace to be loaded. " + + "This usually indicates that the dev-server failed to load the solution, you will find more info in dev-server logs " + + "(output in main app logs with [DEV_SERVER] prefix for runtime-tests run in secondary app instance)."); + } + + _log.LogTrace("Workspace is ready on dev-server, sending file update request ..."); + } + + /// + public async ValueTask Apply(FileEdit edition, CancellationToken ct) + { + try + { + await RemoteControlClient.Instance!.SendMessage(edition.ToMessage()); + + return null; + } + catch (WebSocketException) + { + return ct => + { + _log.LogWarning($"WAITING {RemoteControlClient.Instance!.ConnectionRetryInterval:g} to let client reconnect before retry**."); + return Task.Delay(RemoteControlClient.Instance.ConnectionRetryInterval + TimeSpan.FromMilliseconds(100), ct); + }; + } + } + } +} +#endif diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.Local.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.Local.cs new file mode 100644 index 0000000..98a8b09 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.Local.cs @@ -0,0 +1,48 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Uno.UI.RuntimeTests; + +partial class HotReloadHelper +{ + static partial void TryUseLocalFileUpdater() + => Use(new LocalFileUpdater()); + + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static void UseLocalFileUpdater() + => Use(new LocalFileUpdater()); + + private class LocalFileUpdater : IFileUpdater + { + /// + public async ValueTask EnsureReady(CancellationToken ct) { } + + /// + public async ValueTask Apply(FileEdit edition, CancellationToken ct) + { + if (!File.Exists(edition.FilePath)) + { + throw new InvalidOperationException($"Source file {edition.FilePath} does not exist!"); + } + + var originalContent = await File.ReadAllTextAsync(edition.FilePath, ct); + var updatedContent = originalContent.Replace(edition.OldText, edition.NewText); + + await File.WriteAllTextAsync(edition.FilePath, updatedContent, ct); + + return null; + } + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.MetadataUpdateHandler.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.MetadataUpdateHandler.cs new file mode 100644 index 0000000..bc7002e --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.MetadataUpdateHandler.cs @@ -0,0 +1,28 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.Text; +using Uno.UI.RuntimeTests; + +[assembly:System.Reflection.Metadata.MetadataUpdateHandlerAttribute(typeof(HotReloadHelper.MetadataUpdateHandler))] + +namespace Uno.UI.RuntimeTests; + +partial class HotReloadHelper +{ + internal static class MetadataUpdateHandler + { + public static event EventHandler? MetadataUpdated; + + internal static void UpdateApplication(Type[]? types) + { + MetadataUpdated?.Invoke(null, EventArgs.Empty); + } + } +} +#endif diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.NotSupported.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.NotSupported.cs new file mode 100644 index 0000000..4b8ac88 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.NotSupported.cs @@ -0,0 +1,27 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Uno.UI.RuntimeTests; + +public static partial class HotReloadHelper +{ + private class NotSupported : IFileUpdater + { + /// + public ValueTask EnsureReady(CancellationToken ct) + => throw new NotSupportedException("Source code file edition is not supported on this platform."); + + /// + public ValueTask Apply(FileEdit edition, CancellationToken ct) + => throw new NotSupportedException("Source code file edition is not supported on this platform."); + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs new file mode 100644 index 0000000..4c7f99d --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs @@ -0,0 +1,322 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +#pragma warning disable CA1848 // Log perf +#pragma warning disable CA1823 // Field not used + +using System; +using System.Collections; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System.Threading; +using System.IO; +using System.Diagnostics; +using System.Net.WebSockets; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Uno.Extensions; +using Uno.UI.RuntimeTests.Engine; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI.Xaml; +#else +using Windows.UI.Xaml; +#endif + +namespace Uno.UI.RuntimeTests; + +public static partial class HotReloadHelper +{ + public partial record FileEdit(string FilePath, string OldText, string NewText) + { + public FileEdit Revert() => this with { OldText = NewText, NewText = OldText }; + } + + private record RevertFileEdit(FileEdit Edition, bool WaitForMetadataUpdate) : IAsyncDisposable + { + /// + public async ValueTask DisposeAsync() + { + var revert = Edition.Revert(); + + _log.LogInformation($"Reverting changes made on {revert.FilePath} (from: \"{StartEnd(revert.OldText)}\" to \"{StartEnd(revert.NewText)}\")."); + + // Note: We also wait for metadata update here to ensure that the file is reverted before the test continues / we run another test. + if (await SendMessageCore(revert, WaitForMetadataUpdate, CancellationToken.None) is ReConnect reconnect) + { + _log.LogWarning($"Failed to revert file edition, let {_impl} reconnect."); + + await reconnect(CancellationToken.None); + if (await SendMessageCore(revert, WaitForMetadataUpdate, CancellationToken.None) is not null) + { + _log.LogError($"Failed to revert file edition on file {revert.FilePath}."); + } + } + } + } + + public delegate Task ReConnect(CancellationToken ct); + + public interface IFileUpdater + { + ValueTask EnsureReady(CancellationToken ct); + + ValueTask Apply(FileEdit edition, CancellationToken ct); + } + + private static readonly ILogger _log = typeof(HotReloadHelper).Log(); + private static IFileUpdater _impl = new NotSupported(); + + // The delay for the client to connect to the dev-server + private static TimeSpan ConnectionTimeout = TimeSpan.FromSeconds(3); + + // The delay for the server to load the workspace and let the client know it's ready + private static TimeSpan WorkspaceTimeout = TimeSpan.FromSeconds(30); + + // The delay for the server to send metadata update once a file has been modified + private static TimeSpan MetadataUpdateTimeout = TimeSpan.FromSeconds(5); + + static HotReloadHelper() + { + if (!IsSupported) + { + TryUseDevServerFileUpdater(); + } + + //if (!IsSupported) + //{ + // TryUseLocalFileUpdater(); + //} + } + + static partial void TryUseDevServerFileUpdater(); + static partial void TryUseLocalFileUpdater(); + + /// + /// Configures the to use. + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static void Use(IFileUpdater updater) + => _impl = updater; + + /// + /// Gets a boolean which indicates if the HotReloadHelper is supported on the current platform. + /// + /// If this returns false, all methods on this class are expected to throw exceptions. + public static bool IsSupported => _impl is not NotSupported; + + /// + /// Request to replace all occurrences of by the in the given source code file. + /// + /// Path of file in which the replacement should take place, relative to the project which reference the runtime-test engine (usually the application head). + /// The original text to replace. + /// The replacement text. + /// An cancellation token to abort the asynchronous operation. + /// An IAsyncDisposable object that will revert the change when disposed. + public static async ValueTask UpdateSourceFile(string filPathRelativeToProject, string originalText, string replacementText, CancellationToken ct = default) + => await UpdateSourceFile(filPathRelativeToProject, originalText, replacementText, true, ct); + + /// + /// Request to replace all occurrences of by the in the given source code file. + /// + /// Path of file in which the replacement should take place, relative to the project which reference the runtime-test engine (usually the application head). + /// The original text to replace. + /// The replacement text. + /// Request to wait for a metadata update to consider the file update to be applied. + /// An cancellation token to abort the asynchronous operation. + /// An IAsyncDisposable object that will revert the change when disposed. + public static async ValueTask UpdateSourceFile(string filPathRelativeToProject, string originalText, string replacementText, bool waitForMetadataUpdate, CancellationToken ct = default) + { + var projectFile = typeof(HotReloadHelper).Assembly.GetCustomAttribute()?.ProjectFullPath; + if (projectFile is null or { Length: 0 }) + { + throw new InvalidOperationException("The project file path could not be found."); + } + + var projectDir = Path.GetDirectoryName(projectFile) ?? ""; +#if __SKIA__ + if (!File.Exists(projectFile)) // Sanity! + { + throw new InvalidOperationException("Unable to find project file."); + } + + if (!Directory.Exists(projectDir)) + { + throw new InvalidOperationException("Unable to find project directory."); + } +#endif + + return await UpdateSourceFile(new FileEdit(Path.Combine(projectDir, filPathRelativeToProject), originalText, replacementText), waitForMetadataUpdate, ct); + } + + /// + /// Request to replace all occurrences of by the in the source file of the given . + /// + /// Type of the for which the source code should be edited. + /// The original text to replace. + /// The replacement text. + /// An cancellation token to abort the asynchronous operation. + /// An IAsyncDisposable object that will revert the change when disposed. + public static async ValueTask UpdateSourceFile(string originalText, string replacementText, CancellationToken ct = default) + where T : FrameworkElement, new() + { + var edition = new T().CreateFileEdit( + originalText: originalText, + replacementText: replacementText); + + return await UpdateSourceFile(edition, true, ct); + } + + /// + /// Request to apply the given edition on the source code file. + /// + /// The file update request. + /// Request to wait for a metadata update to consider the file update to be applied. + /// An cancellation token to abort the asynchronous operation. + /// An IAsyncDisposable object that will revert the change when disposed. + public static async Task UpdateSourceFile(FileEdit message, bool waitForMetadataUpdate, CancellationToken ct = default) + { + _log.LogTrace($"Waiting for connection in order to update file {message.FilePath} (from: \"{StartEnd(message.OldText)}\" to \"{StartEnd(message.NewText)}\") ..."); + await _impl.EnsureReady(ct); + + var revertMessage = new RevertFileEdit(message, waitForMetadataUpdate); + try + { + await SendMessageCore(message, waitForMetadataUpdate, ct); + } + catch + { + await revertMessage.DisposeAsync(); + + throw; + } + + return revertMessage; + } + + /// + /// Creates a file update request to replace text in the source file of the given . + /// + /// An instance of the for which the source code should be edited. + /// The original text to replace. + /// The replacement text. + /// A file update request that can be sent to the dev-server. + public static FileEdit CreateFileEdit( + this FrameworkElement element, + string originalText, + string replacementText) + => new(element.GetDebugParseContext().FileName, originalText, replacementText); + + private static async Task SendMessageCore(FileEdit message, bool waitForMetadataUpdate, CancellationToken ct) + { + if (waitForMetadataUpdate) + { + var cts = new TaskCompletionSource(); + using var ctReg = ct.Register(() => cts.TrySetCanceled()); + void UpdateReceived(object? sender, object? args) => cts.TrySetResult(default); + + try + { + MetadataUpdateHandler.MetadataUpdated += UpdateReceived; + //MetadataUpdaterHelper.MetadataUpdated += UpdateReceived; + + var reconnect = await _impl.Apply(message, ct); + if (reconnect is not null) + { + _log.LogTrace("Failed to request file edition, ignore metadata update (i.e. the code changes to be applied in the current app) waiting ..."); + + return reconnect; + } + + _log.LogTrace("File edition requested, waiting for metadata update (i.e. the code changes to be applied in the current app) ..."); + + var timeout = Task.Delay(MetadataUpdateTimeout, ct); + if (await Task.WhenAny(timeout, cts.Task) == timeout) + { + throw new TimeoutException( + "Timeout while waiting for metadata update. " + + "This usually indicates that the dev-server failed to process the requested update, you will find more info in dev-server logs " + + "(output in main app logs with [DEV_SERVER] prefix for runtime-tests run in secondary app instance)."); + } + } + finally + { + //MetadataUpdaterHelper.MetadataUpdated -= UpdateReceived; + MetadataUpdateHandler.MetadataUpdated -= UpdateReceived; + } + + await Task.Delay(100, ct); // Let the metadata to be updated by all handlers. + + _log.LogTrace("Received **a** metadata update, continuing test."); + return null; + } + else + { + var result = await _impl.Apply(message, ct); + + _log.LogTrace("File edition requested, continuing test without waiting for metadata update."); + + return result; + } + } + + #region Helpers + private static (string FileName, int FileLine, int LinePosition) GetDebugParseContext(this FrameworkElement element) + { + var dpcProp = typeof(FrameworkElement).GetProperty("DebugParseContext", BindingFlags.Instance | BindingFlags.NonPublic); + if (dpcProp == null) + { + throw new InvalidOperationException("Could not find DebugParseContext property on FrameworkElement. You should consider to define the property '' in your project in order to enable it's generation even in release."); + } + + var dpcForElement = dpcProp.GetValue(element); + + if (dpcForElement is null) + { + return (string.Empty, -1, -1); + } + + var fl = dpcForElement.GetType().GetProperties(); + + if (fl is null) + { + return (string.Empty, -1, -1); + } + + var fileName = fl[0].GetValue(dpcForElement)?.ToString() ?? string.Empty; + + // Don't return details for embedded controls. + if (fileName.StartsWith("ms-appx:///Uno.UI/", StringComparison.InvariantCultureIgnoreCase) + || fileName.EndsWith("mergedstyles.xaml", StringComparison.InvariantCultureIgnoreCase)) + { + return (string.Empty, -1, -1); + } + + _ = int.TryParse(fl[1].GetValue(dpcForElement)?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int line); + _ = int.TryParse(fl[2].GetValue(dpcForElement)?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int pos); + + const string FileTypePrefix = "file:///"; + + // Strip any file protocol prefix as not expected by the server + if (fileName.StartsWith(FileTypePrefix, StringComparison.InvariantCultureIgnoreCase)) + { + fileName = fileName.Substring(FileTypePrefix.Length); + } + + return (fileName, line, pos); + } + + private static string StartEnd(string str, uint chars = 10) + //=> str.Length <= chars * 2 ? str : $"{str[..(int)chars]}...{str[^(int)chars..]}"; // Not supported by WINDOWS_UWP + => str.Length <= chars * 2 ? str : $"{str.Substring(0, (int)chars)}...{str.Substring(str.Length - 1 - (int)chars)}"; + #endregion +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.ExpectedPixels.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.ExpectedPixels.cs new file mode 100644 index 0000000..804c75c --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.ExpectedPixels.cs @@ -0,0 +1,278 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif +#pragma warning disable CA1814 // We use 2D Color[] on purpose + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using Windows.Foundation; +using Windows.UI; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI; +using Microsoft.UI.Xaml.Markup; +#else +using Windows.UI.Xaml.Markup; +#endif + +namespace Uno.UI.RuntimeTests; + +public record struct ExpectedPixels +{ + #region Fluent declaration + public static ExpectedPixels At(string name, float x, float y) + => new() { Name = name, Location = new Point((int)x, (int)y) }; + + public static ExpectedPixels At(float x, float y, [CallerLineNumber] int line = -1) + => new() { Name = $"at line: {line}", Location = new Point((int)x, (int)y) }; + + public static ExpectedPixels At(Point location, [CallerLineNumber] int line = -1) + => new() { Name = $"at line: {line}", Location = location }; + + public static ExpectedPixels At(string name, Point location) + => new() { Name = name, Location = location }; + + public static ExpectedPixels UniformRect( + Rect rect, + string color, + [CallerMemberName] string name = "", + [CallerLineNumber] int line = -1) + { + var c = GetColorFromString(color); + var colors = new Color[(int)rect.Height, (int)rect.Width]; + + for (var py = (int)rect.Height; py < (int)rect.Height; py++) + for (var px = (int)rect.Width; px < (int)rect.Width; px++) + { + colors[py, px] = c; + } + + var location = new Point((int)rect.X, (int)rect.Y); + + return new ExpectedPixels + { + Name = name, + Location = location, + SourceLocation = location, + Values = colors + }; + } + + public ExpectedPixels Named(string name) + => this with { Name = name }; + + public ExpectedPixels Pixel(Color color) + => this with { Values = new[,] { { color } } }; + + public ExpectedPixels Pixel(string color) + => this with { Values = new[,] { { GetColorFromString(color) } } }; + + public ExpectedPixels Pixels(string[,] pixels) + { + var colors = new Color[pixels.GetLength(0), pixels.GetLength(1)]; + for (var py = 0; py < pixels.GetLength(0); py++) + for (var px = 0; px < pixels.GetLength(1); px++) + { + var colorCode = pixels[py, px]; + colors[py, px] = GetColorFromString(colorCode); + } + + return this with { Values = colors }; + } + + public ExpectedPixels Pixels(TestBitmap source, Rect rect) + { + try + { + var colors = new Color[(int)rect.Height, (int)rect.Width]; + for (var py = 0; py < rect.Height; py++) + for (var px = 0; px < rect.Width; px++) + { + colors[py, px] = source.GetPixel((int)rect.X + px, (int)rect.Y + py); + } + + return this with { SourceLocation = new Point(rect.X, rect.Y), Values = colors }; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Unable to create a pixel array of {rect.Width}x{rect.Height} (bitmap is {source.Width}x{source.Height}).", ex); + } + } + + public ExpectedPixels Pixels(TestBitmap source) + { + var colors = new Color[source.Height, source.Width]; + for (var py = 0; py < source.Height; py++) + for (var px = 0; px < source.Width; px++) + { + colors[py, px] = source.GetPixel(px, py); + } + + return this with { SourceLocation = new Point(0, 0), Values = colors }; + } + + public ExpectedPixels WithTolerance(PixelTolerance tolerance) + => this with { Tolerance = tolerance }; + + public ExpectedPixels WithColorTolerance(byte tolerance, ColorToleranceKind kind = default) + => this with { Tolerance = Tolerance.WithColor(tolerance, kind) }; + + public ExpectedPixels WithPixelTolerance(int x = 0, int y = 0, LocationToleranceKind kind = default) + => this with { Tolerance = Tolerance.WithOffset(x, y, kind) }; + + public ExpectedPixels Or(ExpectedPixels alternative) + => this with + { + Alternatives = Alternatives is null + ? alternative.GetAllPossibilities().ToArray() + : Alternatives.Concat(alternative.GetAllPossibilities()).ToArray() + }; + + public ExpectedPixels OrPixel(Color alternativeColor) + => Or(this with { Values = new[,] { { alternativeColor } }, Alternatives = null }); + + public ExpectedPixels OrPixel(string alternativeColor) + => Or(this with { Values = new[,] { { GetColorFromString(alternativeColor) } }, Alternatives = null }); + #endregion + + private ExpectedPixels( + string name, + Point location, + Point? sourceLocation, + Color[,] pixels, + PixelTolerance tolerance, + ExpectedPixels[] alternatives) + { + Name = name; + Location = location; + SourceLocation = sourceLocation; + Values = pixels ?? new Color[0, 0]; + Tolerance = tolerance; + Alternatives = alternatives ?? Array.Empty(); + } + + public string Name { get; init; } + + /// + /// This is the location where the pixels are expected to be in the "actual" coordinate space + /// + public Point Location { get; init; } + + /// + /// This is the location from where the pixels were loaded. + /// This is informational only and is not expected to be used anywhere else than for logging / debugging purposes. + /// + public Point? SourceLocation { get; init; } + + public Color[,] Values { get; init; } = new Color[0, 0]; + + public PixelTolerance Tolerance { get; init; } = PixelTolerance.None; + + public ExpectedPixels[]? Alternatives { get; init; } = Array.Empty(); + + public IEnumerable GetAllPossibilities() + { + yield return this; + if (Alternatives is not null) + { + foreach (var alternative in Alternatives) + { + yield return alternative; + } + } + } + + private static Color GetColorFromString(string colorCode) => + string.IsNullOrWhiteSpace(colorCode) + ? Colors.Transparent + : (Color)XamlBindingHelper.ConvertValue(typeof(Color), colorCode); +} + +public record struct PixelTolerance +{ + #region Fluent declaration + public static PixelTolerance None => default; + + public static PixelTolerance Cummulative(int color) + => new((byte)color, ColorToleranceKind.Cumulative, default, default, default); + public static PixelTolerance Exclusive(byte color) + => new(color, ColorToleranceKind.Exclusive, default, default, default); + + public PixelTolerance WithColor(byte color) + => new(color, ColorKind, Offset, OffsetKind, DiscreteValidation); + public PixelTolerance WithColor(int color, ColorToleranceKind colorKind) + => new((byte)color, colorKind, Offset, OffsetKind, DiscreteValidation); + public PixelTolerance WithKind(ColorToleranceKind colorKind) + => new(Color, colorKind, Offset, OffsetKind, DiscreteValidation); + public PixelTolerance WithOffset(int x = 0, int y = 0) + => new(Color, ColorKind, (x, y), OffsetKind, DiscreteValidation); + public PixelTolerance WithOffset(int x, int y, LocationToleranceKind offsetKind) + => new(Color, ColorKind, (x, y), offsetKind, DiscreteValidation); + public PixelTolerance WithKind(LocationToleranceKind offsetKind) + => new(Color, ColorKind, Offset, offsetKind, DiscreteValidation); + public PixelTolerance Discrete(uint discreteValidation = 20) + => new(Color, ColorKind, Offset, OffsetKind, (discreteValidation, discreteValidation)); + #endregion + + public PixelTolerance(byte color, ColorToleranceKind colorKind, (int x, int y) offset, LocationToleranceKind offsetKind, (uint x, uint y) discreteValidation) + { + Color = color; + ColorKind = colorKind; + Offset = offset; + OffsetKind = offsetKind; + DiscreteValidation = discreteValidation; + } + + public byte Color { get; } + public ColorToleranceKind ColorKind { get; } + + public (int x, int y) Offset { get; } + public LocationToleranceKind OffsetKind { get; } + + /// + /// Configure how many pixels might be ignored for validation. + /// Validation engine will actually test one pixel each . + /// + public (uint x, uint y) DiscreteValidation { get; } + + /// + public override string ToString() => + Color > 0 + ? $"Color {ColorKind} tolerance of {Color} | Location {OffsetKind} tolerance of {Offset.x},{Offset.y} pixels." + : "No color tolerance"; +} + +public enum ColorToleranceKind +{ + /// + /// Each component of the pixel (i.e. a, r, g and b) might differ by the provided color tolerance + /// + Exclusive, + + /// + /// The A, R, G, or B values cannot cumulatively differ by more than the permitted tolerance + /// + Cumulative +} + +public enum LocationToleranceKind +{ + /// + /// The offset applies to all pixel in at once + /// (i.e once the offset has be computed, all pixels must be the same) + /// + PerRange, + + /// + /// Each pixel might be offset by the given offset, independently of other pixels defined in the . + /// (I.e. pixel[0,0] may be offset by x=1, y=5, while pixel[5,5] may have an offset of x=-2, y=0) + /// + PerPixel, +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.Validations.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.Validations.cs new file mode 100644 index 0000000..3e2bfdf --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.Validations.cs @@ -0,0 +1,191 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using static System.Math; +using Windows.UI; +using Windows.Foundation; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI; +#endif + +namespace Uno.UI.RuntimeTests; + +/// +/// Screenshot based assertions, to validate individual colors of an image +/// +partial class ImageAssert +{ + #region Validation core (ExpectedPixels) + private static bool Validate(ExpectedPixels expectation, TestBitmap actualBitmap, double expectedToActualScale, StringBuilder report) + { + foreach (var pixels in expectation.GetAllPossibilities()) + { + report.AppendLine($"{pixels.Name}:"); + + if (pixels.Values is null) + { + report.AppendLine("INVALID EXPECTATION: No pixels defined."); + continue; + } + + bool isSuccess; + switch (pixels.Tolerance.OffsetKind) + { + case LocationToleranceKind.PerRange: + isSuccess = GetLocationOffsets(pixels) + .Any(offset => GetPixelCoordinates(pixels) + .All(pixel => ValidatePixel(actualBitmap, pixels, expectedToActualScale, pixel, offset, report))); + break; + + case LocationToleranceKind.PerPixel: + isSuccess = GetPixelCoordinates(pixels) + .All(pixel => GetLocationOffsets(pixels) + .Any(offset => ValidatePixel(actualBitmap, pixels, expectedToActualScale, pixel, offset, report))); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(pixels.Tolerance.OffsetKind)); + } + + if (isSuccess) // otherwise the report has already been full-filled + { + report?.AppendLine("\tOK"); + return isSuccess; + } + } + + return false; + } + + private static IEnumerable<(int x, int y)> GetLocationOffsets(ExpectedPixels expectation) + { + for (var offsetX = 0; offsetX <= expectation.Tolerance.Offset.x; offsetX++) + for (var offsetY = 0; offsetY <= expectation.Tolerance.Offset.y; offsetY++) + { + yield return (offsetX, offsetY); + if (offsetX > 0) + { + yield return (-offsetX, offsetY); + } + + if (offsetY > 0) + { + yield return (offsetX, -offsetY); + } + + if (offsetX > 0 && offsetY > 0) + { + yield return (-offsetX, -offsetY); + } + } + } + + private static IEnumerable GetPixelCoordinates(ExpectedPixels expectation) + { + var stepX = (int)Math.Max(1, expectation.Tolerance.DiscreteValidation.x); + var stepY = (int)Math.Max(1, expectation.Tolerance.DiscreteValidation.y); + + for (var lin = 0; lin < expectation.Values.GetLength(0); lin += stepY) + for (var col = 0; col < expectation.Values.GetLength(1); col += stepX) + { + yield return new Point(col, lin); + } + } + + private static bool ValidatePixel( + TestBitmap actualBitmap, + ExpectedPixels expectation, + double expectedToActualScale, + Point pixel, + (int x, int y) offset, + StringBuilder report) + { + var expectedColor = expectation.Values[(int)pixel.Y, (int)pixel.X]; + + if (expectedColor == Colors.Transparent) + { + return true; + } + + var actualX = (int)((expectation.Location.X + pixel.X) * expectedToActualScale + offset.x); + var actualY = (int)((expectation.Location.Y + pixel.Y) * expectedToActualScale + offset.y); + if (actualX < 0 || actualY < 0 + || actualX >= actualBitmap.Width || actualY >= actualBitmap.Height) + { + return false; + } + + var actualColor = actualBitmap.GetPixel(actualX, actualY); + if (AreSameColor(expectedColor, actualColor, expectation.Tolerance.Color, out var difference, expectation.Tolerance.ColorKind)) + { + return true; + } + + if (report != null && offset == default) // Generate report only for offset 0,0 + { + // If possible we dump the location in the source coordinates space. + var expectedLocation = expectation.SourceLocation.HasValue + ? $"[{expectation.SourceLocation.Value.X + pixel.X},{expectation.SourceLocation.Value.Y + pixel.Y}] " + : ""; + + report.AppendLine($"{pixel.X},{pixel.Y}: expected: {expectedLocation}{ToArgbCode(expectedColor)} | actual: [{actualX},{actualY}] {ToArgbCode(actualColor)}"); + report.AppendLine($"\ta tolerance of {difference} [{expectation.Tolerance.ColorKind}] would be required for this test to pass."); + report.AppendLine($"\tCurrent: {expectation.Tolerance}"); + + } + + return false; + } + #endregion + + private static Rect Normalize(Rect rect, Size size) + => new( + rect.X < 0 ? size.Width + rect.X : rect.X, + rect.Y < 0 ? size.Height + rect.Y : rect.Y, + Math.Min(rect.Width, size.Width), + Math.Min(rect.Height, size.Height)); + + private static bool AreSameColor(Color a, Color b, byte tolerance, out int currentDifference, ColorToleranceKind kind = ColorToleranceKind.Exclusive) + { + switch (kind) + { + case ColorToleranceKind.Cumulative: + { + currentDifference = + Abs(a.A - b.A) + + Abs(a.R - b.R) + + Abs(a.G - b.G) + + Abs(a.B - b.B); + return currentDifference < tolerance; + } + + case ColorToleranceKind.Exclusive: + { + // comparing ARGB values, because 'named colors' are not considered equal to their unnamed equivalents(!) + var va = Abs(a.A - b.A); + var vr = Abs(a.R - b.R); + var vg = Abs(a.G - b.G); + var vb = Abs(a.B - b.B); + + currentDifference = Max(Max(va, vr), Max(vg, vb)); + + return currentDifference <= tolerance; + } + + default: throw new ArgumentOutOfRangeException(nameof(kind)); + } + } + + private static string ToArgbCode(Color color) + => $"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"; +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.cs new file mode 100644 index 0000000..4159b63 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.cs @@ -0,0 +1,282 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using static System.Math; +using Windows.UI; +using Windows.Foundation; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI.Xaml.Markup; +#else +using Windows.UI.Xaml.Markup; +#endif + +namespace Uno.UI.RuntimeTests; + +/// +/// Screen shot based assertions, to validate individual colors of an image +/// +public static partial class ImageAssert +{ + #region HasColorAt + public static void HasColorAt(TestBitmap screenshot, Windows.Foundation.Point location, string expectedColorCode, byte tolerance = 0, [CallerLineNumber] int line = 0) + => HasColorAtImpl(screenshot, (int)location.X, (int)location.Y, (Color)XamlBindingHelper.ConvertValue(typeof(Color), expectedColorCode), tolerance, line); + + public static void HasColorAt(TestBitmap screenshot, Windows.Foundation.Point location, Color expectedColor, byte tolerance = 0, [CallerLineNumber] int line = 0) + => HasColorAtImpl(screenshot, (int)location.X, (int)location.Y, expectedColor, tolerance, line); + + public static void HasColorAt(TestBitmap screenshot, float x, float y, string expectedColorCode, byte tolerance = 0, [CallerLineNumber] int line = 0) + => HasColorAtImpl(screenshot, (int)x, (int)y, (Color)XamlBindingHelper.ConvertValue(typeof(Color), expectedColorCode), tolerance, line); + + public static void HasColorAt(TestBitmap screenshot, float x, float y, Color expectedColor, byte tolerance = 0, [CallerLineNumber] int line = 0) + => HasColorAtImpl(screenshot, (int)x, (int)y, expectedColor, tolerance, line); + + /// + /// Asserts that a given screenshot has a color anywhere at a given rectangle. + /// + public static void HasColorInRectangle(TestBitmap screenshot, Rect rect, Color expectedColor, byte tolerance = 0, [CallerLineNumber] int line = 0) + { + var bitmap = screenshot; + (int x, int y, int diff, Color color) min = (-1, -1, int.MaxValue, default); + for (var x = rect.Left; x < rect.Right; x++) + { + for (var y = rect.Top; y < rect.Bottom; y++) + { + var pixel = bitmap.GetPixel(x, y); + if (AreSameColor(expectedColor, pixel, tolerance, out var diff)) + { + return; + } + else if (diff < min.diff) + { + min = ((int)x, (int)y, diff, pixel); + } + } + } + + Assert.Fail($"Expected '{ToArgbCode(expectedColor)}' in rectangle '{rect}', but no pixel has this color. The closest pixel found is '{ToArgbCode(min.color)}' at '{min.x},{min.y}' with a (exclusive) difference of {min.diff}."); + } + + /// + /// Asserts that a given screenshot does not have a specific color anywhere within a given rectangle. + /// + public static void DoesNotHaveColorInRectangle(TestBitmap screenshot, Rect rect, Color excludedColor, byte tolerance = 0, [CallerLineNumber] int line = 0) + { + var bitmap = screenshot; + for (var x = rect.Left; x < rect.Right; x++) + { + for (var y = rect.Top; y < rect.Bottom; y++) + { + var pixel = bitmap.GetPixel(x, y); + if (AreSameColor(excludedColor, pixel, tolerance, out var diff)) + { + Assert.Fail($"Color '{ToArgbCode(excludedColor)}' was found at ({x}, {y}) in rectangle '{rect}' (Exclusive difference of {diff})."); + } + } + } + } + + private static void HasColorAtImpl(TestBitmap screenshot, int x, int y, Color expectedColor, byte tolerance, int line) + { + var bitmap = screenshot; + + if (bitmap.Width <= x || bitmap.Height <= y) + { + Assert.Fail(WithContext($"Coordinates ({x}, {y}) falls outside of screenshot dimension {bitmap.Size}")); + } + + var pixel = bitmap.GetPixel(x, y); + + if (!AreSameColor(expectedColor, pixel, tolerance, out var difference)) + { + Assert.Fail(WithContext(builder: builder => builder + .AppendLine($"Color at ({x},{y}) is not expected") + .AppendLine($"expected: {ToArgbCode(expectedColor)} {expectedColor}") + .AppendLine($"actual : {ToArgbCode(pixel)} {pixel}") + .AppendLine($"tolerance: {tolerance}") + .AppendLine($"difference: {difference}") + )); + } + + string WithContext(string? message = null, Action? builder = null) + { + var sb = new StringBuilder() + .AppendLine($"ImageAssert.HasColorAt @ line {line}") + .AppendLine("===================="); + + if (message is not null) + { + sb.AppendLine(message); + } + builder?.Invoke(sb); + + return sb.ToString(); + } + } + #endregion + + #region DoesNotHaveColorAt + public static void DoesNotHaveColorAt(TestBitmap screenshot, float x, float y, string excludedColorCode, byte tolerance = 0, [CallerLineNumber] int line = 0) + => DoesNotHaveColorAtImpl(screenshot, (int)x, (int)y, (Color)XamlBindingHelper.ConvertValue(typeof(Color), excludedColorCode), tolerance, line); + + public static void DoesNotHaveColorAt(TestBitmap screenshot, float x, float y, Color excludedColor, byte tolerance = 0, [CallerLineNumber] int line = 0) + => DoesNotHaveColorAtImpl(screenshot, (int)x, (int)y, excludedColor, tolerance, line); + + private static void DoesNotHaveColorAtImpl(TestBitmap screenshot, int x, int y, Color excludedColor, byte tolerance, int line) + { + var bitmap = screenshot; + if (bitmap.Width <= x || bitmap.Height <= y) + { + Assert.Fail(WithContext($"Coordinates ({x}, {y}) falls outside of screenshot dimension {bitmap.Size}")); + } + + var pixel = bitmap.GetPixel(x, y); + if (AreSameColor(excludedColor, pixel, tolerance, out var difference)) + { + Assert.Fail(WithContext(builder: builder => builder + .AppendLine($"Color at ({x},{y}) is not expected") + .AppendLine($"excluded: {ToArgbCode(excludedColor)}") + .AppendLine($"actual : {ToArgbCode(pixel)} {pixel}") + .AppendLine($"tolerance: {tolerance}") + .AppendLine($"difference: {difference}") + )); + } + + string WithContext(string? message = null, Action? builder = null) + { + var sb = new StringBuilder() + .AppendLine($"ImageAssert.DoesNotHaveColorAt @ line {line}") + .AppendLine("===================="); + + if (message is not null) + { + sb.AppendLine(message); + } + builder?.Invoke(sb); + + return sb.ToString(); + } + } + #endregion + + #region HasPixels + public static void HasPixels(TestBitmap actual, params ExpectedPixels[] expectations) + { + var bitmap = actual; + + foreach (var expectation in expectations) + { + var x = expectation.Location.X; + var y = expectation.Location.Y; + + Assert.IsTrue(bitmap.Width >= x); + Assert.IsTrue(bitmap.Height >= y); + + var result = new StringBuilder(); + result.AppendLine(expectation.Name); + if (!Validate(expectation, bitmap, 1, result)) + { + Assert.Fail(result.ToString()); + } + } + } + #endregion + + internal static Rect GetColorBounds(TestBitmap testBitmap, Color color, byte tolerance = 0) + { + var minX = int.MaxValue; + var minY = int.MaxValue; + var maxX = int.MinValue; + var maxY = int.MinValue; + + for (int x = 0; x < testBitmap.Width; x++) + { + for (int y = 0; y < testBitmap.Height; y++) + { + if (AreSameColor(color, testBitmap.GetPixel(x, y), tolerance, out _)) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + } + } + + return new Rect(new Point(minX, minY), new Size(maxX - minX, maxY - minY)); + } + + public static async Task AreEqual(TestBitmap actual, TestBitmap expected) + { + CollectionAssert.AreEqual(actual.GetRawPixels(), expected.GetRawPixels()); + } + + public static async Task AreNotEqual(TestBitmap actual, TestBitmap expected) + { + CollectionAssert.AreNotEqual(actual.GetRawPixels(), expected.GetRawPixels()); + } + + /// + /// Asserts that two image are similar within the given RMSE + /// The method it based roughly on ImageMagick implementation to ensure consistency. + /// If the error is greater than or equal to 0.022, the differences are visible to human eyes. + /// The image to compare with reference + /// Reference image. + /// It is the threshold beyond which the compared images are not considered equal. Default value is 0.022.> + /// + public static async Task AreSimilarAsync(TestBitmap actual, TestBitmap expected, double imperceptibilityThreshold = 0.022) + { + if (actual.Width != expected.Width || actual.Height != expected.Height) + { + Assert.Fail($"Images have different resolutions. {Environment.NewLine}expected:({expected.Width},{expected.Height}){Environment.NewLine}actual :({actual.Width},{actual.Height})"); + } + + var quantity = actual.Width * actual.Height; + double squaresError = 0; + + const double scale = 1 / 255d; + + for (var x = 0; x < actual.Width; x++) + { + double localError = 0; + + for (var y = 0; y < actual.Height; y++) + { + var expectedAlpha = expected[x, y].A * scale; + var actualAlpha = actual[x, y].A * scale; + + var r = scale * (expectedAlpha * expected[x, y].R - actualAlpha * actual[x, y].R); + var g = scale * (expectedAlpha * expected[x, y].G - actualAlpha * actual[x, y].G); + var b = scale * (expectedAlpha * expected[x, y].B - actualAlpha * actual[x, y].B); + var a = expectedAlpha - actualAlpha; + + var error = r * r + g * g + b * b + a * a; + + localError += error; + } + + squaresError += localError; + } + + var meanSquaresError = squaresError / quantity; + + const int channelCount = 4; + + meanSquaresError = meanSquaresError / channelCount; + var sqrtMeanSquaresError = Sqrt(meanSquaresError); + if (sqrtMeanSquaresError >= imperceptibilityThreshold) + { + Assert.Fail($"the actual image is not the same as the expected one.{Environment.NewLine}actual RSMD: {sqrtMeanSquaresError}{Environment.NewLine}threshold: {imperceptibilityThreshold}"); + } + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelper.MouseHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelper.MouseHelper.cs similarity index 98% rename from src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelper.MouseHelper.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelper.MouseHelper.cs index 28f6eb5..d3f447b 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelper.MouseHelper.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelper.MouseHelper.cs @@ -1,6 +1,10 @@ #if !UNO_RUNTIMETESTS_DISABLE_LIBRARY #nullable enable +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelper.cs similarity index 98% rename from src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelper.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelper.cs index 7382f90..a040c0f 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelper.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelper.cs @@ -1,6 +1,10 @@ #if !UNO_RUNTIMETESTS_DISABLE_LIBRARY #nullable enable +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelperExtensions.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelperExtensions.cs similarity index 98% rename from src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelperExtensions.cs rename to src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelperExtensions.cs index 8723d4d..ceb4ac9 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InputInjectorHelperExtensions.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelperExtensions.cs @@ -1,6 +1,10 @@ #if !UNO_RUNTIMETESTS_DISABLE_LIBRARY #nullable enable +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestBitmap.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestBitmap.cs new file mode 100644 index 0000000..055afea --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestBitmap.cs @@ -0,0 +1,219 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading.Tasks; +using Windows.Foundation; +using Windows.Graphics.Display; +using Windows.Storage.Streams; +using Windows.UI; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Media.Imaging; +#endif + +namespace Uno.UI.RuntimeTests; + +/// +/// Represents a to be tested against. +/// +public partial class TestBitmap +{ + private readonly RenderTargetBitmap _bitmap; + private readonly UIElement _renderedElement; // Allow access through partial implementation + private readonly double _implicitScaling; + + private byte[]? _pixels; + private bool _altered; + + private TestBitmap(RenderTargetBitmap bitmap, UIElement renderedElement, double implicitScaling) + { + _bitmap = bitmap; + _renderedElement = renderedElement; + _implicitScaling = implicitScaling; + } + + /// + /// Prefer using UIHelper.Screenshot() instead. + /// + internal static async Task From(RenderTargetBitmap bitmap, UIElement renderedElement, double? implicitScaling = null) + { + implicitScaling ??= DisplayInformation.GetForCurrentView()?.RawPixelsPerViewPixel ?? 1; + var raw = new TestBitmap(bitmap, renderedElement, implicitScaling.Value); + await raw.Populate(); + + return raw; + } + + /// + /// Enables the method. + /// + /// + private async Task Populate() + { + _pixels ??= (await _bitmap.GetPixelsAsync()).ToArray(); + + // Image is RGBA-premul, we need to un-multiply it to get the actual color in GetPixel(). + ImageHelper.UnMultiplyAlpha(_pixels); + } + + /// + /// The implicit scaling applied on coordinates provided to . + /// + /// + /// When not 1.0, this factor is applied on coordinates requested in indexer and . + /// For example, if scaling is 2.0, then a call to with (10, 10) will return the color of the pixel at (20, 20) in the bitmap. + /// This allows user to work in "logical pixel" while underlying bitmap is at full physical resolution. + /// + public double ImplicitScaling => _implicitScaling; + + /// + /// The **logical** size of the bitmap. + /// + public Size Size => new(Width, Height); + + /// + /// The **logical** width of the bitmap. + /// + public int Width => (int)(_bitmap.PixelWidth / _implicitScaling); + + /// + /// The **logical** height of the bitmap. + /// + public int Height => (int)(_bitmap.PixelHeight / _implicitScaling); + + /// + /// Gets the color of the pixel at the given **logical** coordinates. + /// + /// **Logical** x position + /// **Logical** y position + /// The color of the pixel. + public Color this[int x, int y] => GetPixel(x, y); + + /// + /// Gets the color of the pixel at the given **logical** coordinates. + /// + /// **Logical** x position + /// **Logical** y position + /// The color of the pixel. + public Color GetPixel(int x, int y) + { + if (_pixels is null) + { + throw new InvalidOperationException("Populate must be invoked first"); + } + + x = (int)(x * _implicitScaling); + y = (int)(y * _implicitScaling); + + var offset = (y * _bitmap.PixelWidth + x) * 4; + var a = _pixels[offset + 3]; + var r = _pixels[offset + 2]; + var g = _pixels[offset + 1]; + var b = _pixels[offset + 0]; + + return Color.FromArgb(a, r, g, b); + } + + /// + /// Gets all **physical** pixels of the bitmap. + /// + /// The underlying pixels bitmap. + public byte[] GetRawPixels() + { + if (_pixels is null) + { + throw new InvalidOperationException("Populate must be invoked first"); + } + + return _pixels; + } + + /// + /// Gets the underlying . + /// + /// Indicates that original bitmap should be returned, ignoring any modification made on the current bitmap. + /// + /// + internal async Task GetImageSource(bool preferOriginal = false) + { + if (_pixels is null) + { + throw new InvalidOperationException("Populate must be invoked first"); + } + + if (_altered && !preferOriginal) + { + var output = new WriteableBitmap(_bitmap.PixelWidth, _bitmap.PixelHeight); + await new MemoryStream(_pixels).AsInputStream().ReadAsync(output.PixelBuffer, output.PixelBuffer.Length, InputStreamOptions.None); + return output; + } + else + { + return _bitmap; + } + } + + /// + /// Makes sure that all pixels are opaque. + /// + /// The background color to apply on non-opaque pixels. + public void MakeOpaque(Color? background = null) + { + if (_pixels is null) + { + throw new InvalidOperationException("Populate must be invoked first"); + } + + _altered = ImageHelper.MakeOpaque(_pixels, background); + } + +#if __SKIA__ && DEBUG // DEBUG: Make the build to fail on CI to avoid forgetting to remove the call (would pollute server or other devs disks!). + /// + /// Save the screenshot into the specified path **for debug purposes only**. + /// + /// + /// + /// + /// + internal async Task Save(string path, bool preferOriginal = false) + { + if (_pixels is null) + { + throw new InvalidOperationException("Populate must be invoked first"); + } + + await using var file = File.OpenWrite(path); + + var img = preferOriginal + ? SkiaSharp.SKImage.FromPixelCopy(new SkiaSharp.SKImageInfo(_bitmap.PixelWidth, _bitmap.PixelHeight, SkiaSharp.SKColorType.Bgra8888, SkiaSharp.SKAlphaType.Premul), (await _bitmap.GetPixelsAsync()).ToArray()) + : SkiaSharp.SKImage.FromPixelCopy(new SkiaSharp.SKImageInfo(_bitmap.PixelWidth, _bitmap.PixelHeight, SkiaSharp.SKColorType.Bgra8888, SkiaSharp.SKAlphaType.Unpremul), _pixels); + + img.Encode(SkiaSharp.SKEncodedImageFormat.Png, 100).SaveTo(file); + } +#endif +} + +internal static class TestBitmapExtensions +{ + public static Color GetPixel(this TestBitmap bitmap, double x, double y) + => bitmap.GetPixel((int)x, (int)y); + + public static Color GetPixel(this TestBitmap bitmap, Point point) + => bitmap.GetPixel((int)point.X, (int)point.Y); +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs new file mode 100644 index 0000000..3370bbc --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs @@ -0,0 +1,208 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using System.Threading; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Uno.UI.RuntimeTests; + +public static partial class TestHelper +{ + public static TimeSpan DefaultTimeout => Debugger.IsAttached ? TimeSpan.FromMinutes(60) : TimeSpan.FromSeconds(1); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask WaitFor(Func predicate, CancellationToken ct = default) + => await WaitFor(async _ => predicate(), DefaultTimeout, ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for in milliseconds. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask WaitFor(Func predicate, int timeoutMs, CancellationToken ct = default) + => await WaitFor(async _ => predicate(), TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask WaitFor(Func predicate, TimeSpan timeout, CancellationToken ct = default) + => await WaitFor(async _ => predicate(), timeout, ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Func> predicate, CancellationToken ct = default) + => WaitFor(predicate, DefaultTimeout, ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for in milliseconds. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Func> predicate, int timeoutMs, CancellationToken ct = default) + => WaitFor(predicate, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for. + /// A cancellation token to cancel the wait operation. + public static async ValueTask WaitFor(Func> predicate, TimeSpan timeout, CancellationToken ct = default) + { + if (!await TryWaitFor(predicate, timeout, ct).ConfigureAwait(false)) + { + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); + } + } + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask TryWaitFor(Func predicate, CancellationToken ct = default) + => await TryWaitFor(async _ => predicate(), DefaultTimeout, ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for in milliseconds. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask TryWaitFor(Func predicate, int timeoutMs, CancellationToken ct = default) + => await TryWaitFor(async _ => predicate(), TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask TryWaitFor(Func predicate, TimeSpan timeout, CancellationToken ct = default) + => await TryWaitFor(async _ => predicate(), timeout, ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask TryWaitFor(Func> predicate, CancellationToken ct = default) + => TryWaitFor(predicate, DefaultTimeout, ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for in milliseconds. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask TryWaitFor(Func> predicate, int timeoutMs, CancellationToken ct = default) + => TryWaitFor(predicate, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Wait until a specified is met. + /// + /// Predicate to evaluate repeatedly until it returns true. + /// The max duration to wait for. + /// A cancellation token to cancel the wait operation. + public static async ValueTask TryWaitFor(Func> predicate, TimeSpan timeout, CancellationToken ct = default) + { + using var timeoutSrc = new CancellationTokenSource(timeout); + try + { + ct = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutSrc.Token).Token; + + var interval = Math.Min(1000, (int)(timeout.TotalMilliseconds / 100)); + var steps = timeout.TotalMilliseconds / interval; + + for (var i = 0; i < steps; i++) + { + if (ct.IsCancellationRequested) + { + return false; + } + + if (await predicate(ct).ConfigureAwait(false)) + { + return true; + } + + if (i < steps - 1) + { + await Task.Delay(interval, ct).ConfigureAwait(false); + } + } + + return false; + } + catch (OperationCanceledException) when (timeoutSrc.IsCancellationRequested) + { + return false; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Task task, CancellationToken ct = default) + => WaitFor(task, DefaultTimeout, ct); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Task task, int timeoutMs, CancellationToken ct = default) + => WaitFor(task, TimeSpan.FromMilliseconds(timeoutMs), ct); + + public static async ValueTask WaitFor(Task task, TimeSpan timeout, CancellationToken ct = default) + { + try + { + if (!await TryWaitFor(task, timeout, ct).ConfigureAwait(false)) + { + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested && !task.IsCanceled) + { + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask TryWaitFor(Task task, CancellationToken ct = default) + => TryWaitFor(task, DefaultTimeout, ct); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask TryWaitFor(Task task, int timeoutMs, CancellationToken ct = default) + => TryWaitFor(task, TimeSpan.FromMilliseconds(timeoutMs), ct); + + public static async ValueTask TryWaitFor(Task task, TimeSpan timeout, CancellationToken ct = default) + => await Task.WhenAny(task, Task.Delay(timeout, ct)).ConfigureAwait(false) == task; +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/UIHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/UIHelper.cs new file mode 100644 index 0000000..3605f55 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/UIHelper.cs @@ -0,0 +1,357 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Windows.Foundation; +using Windows.Graphics.Display; +using Windows.UI; +using Windows.System; + +#if HAS_UNO_WINUI || WINDOWS_WINUI +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Media.Imaging; +#endif + +namespace Uno.UI.RuntimeTests; + +/// +/// Set of helpers to interact with the UI during tests. +/// +public static partial class UIHelper +{ + /// + /// Gets or sets the content of the current test area. + /// + public static UIElement? Content + { + get => UnitTestsUIContentHelper.Content; + set => UnitTestsUIContentHelper.Content = value; + } + + /// + /// Set the provided element as and wait for it to be loaded (cf. ). + /// + /// The element to set as content. + /// A cancellation token to cancel the async loading operation. + /// An async operation that will complete when the given element is loaded. + /// As all other method of this class, this assume to be invoked on the UI-thread. + public static async ValueTask Load(FrameworkElement element, CancellationToken ct = default) + { + Content = element; + await WaitForLoaded(element, ct); + } + + /// + /// Waits for the dispatcher to finish processing pending requests + /// + public static async ValueTask WaitForIdle(CancellationToken ct = default) + => await UnitTestsUIContentHelper.WaitForIdle(); + + /// + /// Waits for the given element to be loaded by the visual tree. + /// + /// The element to wait for being loaded. + /// A cancellation token to cancel the async loading operation. + /// + /// + /// As all other method of this class, this assume to be invoked on the UI-thread. + public static async ValueTask WaitForLoaded(FrameworkElement element, CancellationToken ct = default) + { + if (element.IsLoaded) + { + return; + } + + var tcs = new TaskCompletionSource(); + using var _ = ct.CanBeCanceled ? ct.Register(() => tcs.TrySetCanceled()) : default; + try + { + element.Loaded += OnElementLoaded; + + if (!element.IsLoaded) + { + var timeout = Task.Delay(TestHelper.DefaultTimeout, ct); + if (await Task.WhenAny(tcs.Task, timeout) == timeout) + { + throw new TimeoutException($"Failed to load element within {TestHelper.DefaultTimeout}."); + } + } + } + finally + { + element.Loaded -= OnElementLoaded; + } + + void OnElementLoaded(object sender, RoutedEventArgs e) + { + element.Loaded -= OnElementLoaded; + tcs.TrySetResult(default); + } + } + + /// + /// Walks the tree down to find all the children of the given type. + /// + /// Type of the children + /// The root element of the tree to walk, or null to search from . + /// An enumerable sequence of all children of that are of the requested type. + /// If the given is also a , it will be returned in the enumerable sequence. + /// As all other method of this class, this assume to be invoked on the UI-thread. + public static IEnumerable GetChildren(DependencyObject? element = null) + { + element ??= Content; + + if (element is null) + { + yield break; + } + + if (element is T t) + { + yield return t; + } + + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(element); i++) + { + foreach (var child in GetChildren(VisualTreeHelper.GetChild(element, i))) + { + yield return child; + } + } + } + + /// + /// Walks the tree down to find the **single** child of the given type. + /// + /// Type of the child + /// The root element of the tree to walk, or null to search from . + /// The **single** child of that is of the requested type. + /// If the given is also a , it will be returned. + /// As all other method of this class, this assume to be invoked on the UI-thread. + public static T GetChild(DependencyObject? element = null) + => GetChildren(element).Single(); + + /// + /// Walks the tree down to find the **single** child of the given type. + /// + /// Type of the child + /// A predicate to filter the searched element. + /// The root element of the tree to walk, or null to search from . + /// The **single** child of that is of the requested type. + /// If the given is also a , it will be returned. + /// As all other method of this class, this assume to be invoked on the UI-thread. + public static T GetChild(Func predicate, DependencyObject? element = null) + => GetChildren(element).Single(predicate); + + /// + /// Walks the tree down to find the **single** child of the given name. + /// + /// Type of the child + /// The name of searched element. + /// The root element of the tree to walk, or null to search from . + /// The **single** child of that is of the requested type. + /// If the given is also a , it will be returned. + /// As all other method of this class, this assume to be invoked on the UI-thread. + public static T GetChild(string name, DependencyObject? element = null) + where T : FrameworkElement + => GetChildren(element).Single(elt => elt.Name == name); + + /// + /// Takes a screen-shot of the given element. + /// + /// The element to screen-shot. + /// Indicates if the resulting image should be make opaque (i.e. all pixels has an opacity of 0xFF) or not. + /// Indicates the scaling strategy to apply for the image (when screen is not using a 1.0 scale, usually 4K screens). + /// A cancellation token to cancel the async loading operation. + /// + public static async ValueTask ScreenShot(FrameworkElement element, bool opaque = false, ScreenShotScalingMode scaling = ScreenShotScalingMode.UsePhysicalPixelsWithImplicitScaling, CancellationToken ct = default) + { + var renderer = new RenderTargetBitmap(); + element.UpdateLayout(); + await WaitForIdle(ct); + + TestBitmap bitmap; + switch (scaling) + { + case ScreenShotScalingMode.UsePhysicalPixelsWithImplicitScaling: + await renderer.RenderAsync(element); + bitmap = await TestBitmap.From(renderer, element, DisplayInformation.GetForCurrentView()?.RawPixelsPerViewPixel ?? 1); + break; + case ScreenShotScalingMode.UseLogicalPixels: + await renderer.RenderAsync(element, (int)element.RenderSize.Width, (int)element.RenderSize.Height); + bitmap = await TestBitmap.From(renderer, element); + break; + case ScreenShotScalingMode.UsePhysicalPixels: + await renderer.RenderAsync(element); + bitmap = await TestBitmap.From(renderer, element); + break; + default: + throw new NotSupportedException($"Mode {scaling} is not supported."); + } + + if (opaque) + { + bitmap.MakeOpaque(); + } + + return bitmap; + } + + public enum ScreenShotScalingMode + { + /// + /// Screen-shot is made at full resolution, then the returned RawBitmap is configured to implicitly apply screen scaling + /// to requested pixel coordinates in method. + /// + /// This is the best / common option has it avoids artifacts due image scaling while still allowing to use logical pixels. + /// + UsePhysicalPixelsWithImplicitScaling, + + /// + /// Screen-shot is made at full resolution, and access to the returned are assumed to be in physical pixels. + /// + UsePhysicalPixels, + + /// + /// Screen-shot is forcefully scaled down to logical pixels. + /// + UseLogicalPixels + } + + /// + /// Shows the given screenshot on screen for debug purposes + /// + /// The image to show. + /// A cancellation token to cancel the async loading operation. + /// + public static async ValueTask Show(TestBitmap bitmap, CancellationToken ct = default) + { + Image img; + CompositeTransform imgTr; + TextBlock pos; + StackPanel legend; + var popup = new ContentDialog + { + MinWidth = bitmap.Width + 2, + MinHeight = bitmap.Height + 30, + Content = new Grid + { + RowDefinitions = + { + new RowDefinition(), + new RowDefinition { Height = GridLength.Auto } + }, + Children = + { + new Border + { + BorderBrush = new SolidColorBrush(Colors.Black), + BorderThickness = new Thickness(1), + Background = new SolidColorBrush(Colors.Gray), + Width = bitmap.Width * bitmap.ImplicitScaling + 2, + Height = bitmap.Height * bitmap.ImplicitScaling + 2, + Child = img = new Image + { + Width = bitmap.Width * bitmap.ImplicitScaling, + Height = bitmap.Height * bitmap.ImplicitScaling, + Source = await bitmap.GetImageSource(), + Stretch = Stretch.None, + ManipulationMode = ManipulationModes.Scale + | ManipulationModes.ScaleInertia + | ManipulationModes.TranslateX + | ManipulationModes.TranslateY + | ManipulationModes.TranslateInertia, + RenderTransformOrigin = new Point(.5, .5), + RenderTransform = imgTr = new CompositeTransform() + } + }, + (legend = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Children = + { + (pos = new TextBlock + { + Text = $"{bitmap.Width}x{bitmap.Height}", + FontSize = 8 + }) + } + }) + } + }, + PrimaryButtonText = "OK" + }; + Grid.SetRow(legend, 1); + + img.PointerMoved += (snd, e) => DumpState(e.GetCurrentPoint(img).Position); + img.PointerWheelChanged += (snd, e) => + { + if (e.KeyModifiers is VirtualKeyModifiers.Control + && e.GetCurrentPoint(img) is { Properties.IsHorizontalMouseWheel: false } point) + { + var factor = Math.Sign(point.Properties.MouseWheelDelta) is 1 ? 1.2 : 1 / 1.2; + imgTr.ScaleX *= factor; + imgTr.ScaleY *= factor; + + DumpState(point.Position); + } + }; + img.ManipulationDelta += (snd, e) => + { + imgTr.TranslateX += e.Delta.Translation.X; + imgTr.TranslateY += e.Delta.Translation.Y; + imgTr.ScaleX *= e.Delta.Scale; + imgTr.ScaleY *= e.Delta.Scale; + + DumpState(e.Position); + }; + + void DumpState(Point phyLoc) + { + var scaling = bitmap.ImplicitScaling; + var virLoc = new Point(phyLoc.X / scaling, phyLoc.Y / scaling); + var virSize = bitmap.Size; + var phySize = new Size(virSize.Width * scaling, virSize.Height * scaling); + + if (virLoc.X >= 0 && virLoc.X < virSize.Width + && virLoc.Y >= 0 && virLoc.Y < virSize.Height) + { + if (scaling is not 1.0) + { + pos.Text = $"{imgTr.ScaleX:P0} {bitmap.GetPixel((int)virLoc.X, (int)virLoc.Y)} | vir: {virLoc.X:F0},{virLoc.Y:F0} / {virSize.Width}x{virSize.Height} | phy: {phyLoc.X:F0},{phyLoc.Y:F0} / {phySize.Width}x{phySize.Height}"; + } + else + { + pos.Text = $"{imgTr.ScaleX:P0} {bitmap.GetPixel((int)virLoc.X, (int)virLoc.Y)} | {phyLoc.X:F0},{phyLoc.Y:F0} / {virSize.Width}x{virSize.Height}"; + } + } + else + { + pos.Text = $"{imgTr.ScaleX:P0} {bitmap.Width}x{bitmap.Height}"; + } + } + + await popup.ShowAsync(ContentDialogPlacement.Popup).AsTask(ct); + } +} + +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InjectedPointerAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/InjectedPointerAttribute.cs index ea67df5..9bfa77a 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/InjectedPointerAttribute.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/InjectedPointerAttribute.cs @@ -23,11 +23,11 @@ namespace Uno.UI.RuntimeTests; [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public sealed class InjectedPointerAttribute : Attribute { - public PointerDeviceType Type { get; } + public PointerDeviceType Type { get; } - public InjectedPointerAttribute(PointerDeviceType type) - { - Type = type; - } + public InjectedPointerAttribute(PointerDeviceType type) + { + Type = type; + } } #endif diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/RunsInSecondaryAppAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/RunsInSecondaryAppAttribute.cs new file mode 100644 index 0000000..01b96e5 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/RunsInSecondaryAppAttribute.cs @@ -0,0 +1,28 @@ +#nullable enable + +using System; + +namespace Uno.UI.RuntimeTests; + +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY && !UNO_RUNTIMETESTS_DISABLE_RUNSINSECONDARYAPPATTRIBUTE +/// +/// Indicates that the test should be run in a separated app. +/// +/// +/// As starting an external app is a costly operation, this attribute can be set only at the test class level. +/// All tests of the class will be run in the same secondary app instance. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class RunsInSecondaryAppAttribute : Attribute +{ + /// + /// Indicates if the test should be ignored if the platform does not support running tests in a secondary app. + /// + public bool IgnoreIfNotSupported { get; } + + public RunsInSecondaryAppAttribute(bool ignoreIfNotSupported = false) + { + IgnoreIfNotSupported = ignoreIfNotSupported; + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Compat/CallerArgumentExpressionAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Compat/CallerArgumentExpressionAttribute.cs new file mode 100644 index 0000000..6f97d2b --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Compat/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,25 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY && WINDOWS_UWP +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Collections.Generic; +using System.Text; + +namespace System.Runtime.CompilerServices +{ + /// Allows capturing of the expressions passed to a method. + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + public sealed class CallerArgumentExpressionAttribute : Attribute + { + /// Initializes a new instance of the class. + /// The name of the targeted parameter. + public CallerArgumentExpressionAttribute(string parameterName) => this.ParameterName = parameterName; + + /// Gets the target parameter name of the CallerArgumentExpression. + /// The name of the targeted parameter of the CallerArgumentExpression. + public string ParameterName { get; } + } +} +#endif diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Compat/MetadataUpdateHandlerAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Compat/MetadataUpdateHandlerAttribute.cs new file mode 100644 index 0000000..05ac5f6 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Compat/MetadataUpdateHandlerAttribute.cs @@ -0,0 +1,25 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY && WINDOWS_UWP +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + + +using System.Diagnostics.CodeAnalysis; + +#nullable enable +namespace System.Reflection.Metadata +{ + /// Indicates that a type that should receive notifications of metadata updates. + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class MetadataUpdateHandlerAttribute : Attribute + { + /// Initializes the attribute. + /// A type that handles metadata updates and that should be notified when any occur. + public MetadataUpdateHandlerAttribute(/*[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]*/ Type handlerType) => this.HandlerType = handlerType; + + /// Gets the type that handles metadata updates and that should be notified when any occur. + /*[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]*/ + public Type HandlerType { get; } + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Private/ColorExtensions.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Private/ColorExtensions.cs new file mode 100644 index 0000000..8337a48 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Private/ColorExtensions.cs @@ -0,0 +1,40 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + + +using System; +using System.Linq; +using Windows.UI; + +namespace Uno.UI.RuntimeTests; + +/// +/// This class is intended to be used only by the the test engine itself and should not be used by applications. +/// API contract is not guaranteed and might change in future releases. +/// +internal static class ColorExtensions +{ + /// + /// Returns the color that results from blending the color with the given background color. + /// + /// The color to blend. + /// The background color to use. This is assumed to be opaque (not checked for perf reason when used on pixel buffer). + /// The color that results from blending the color with the given background color. + internal static Color ToOpaque(this Color color, Color background) + => Color.FromArgb( + 255, + (byte)(((byte.MaxValue - color.A) * background.R + color.A * color.R) / 255), + (byte)(((byte.MaxValue - color.A) * background.G + color.A * color.G) / 255), + (byte)(((byte.MaxValue - color.A) * background.B + color.A * color.B) / 255) + ); + +#if !HAS_UNO + internal static Color WithOpacity(this Color color, double opacity) + => Color.FromArgb((byte)(color.A * opacity), color.R, color.G, color.B); +#endif +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Private/ImageHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Private/ImageHelper.cs new file mode 100644 index 0000000..418e526 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/_Private/ImageHelper.cs @@ -0,0 +1,84 @@ +#if !UNO_RUNTIMETESTS_DISABLE_LIBRARY +#nullable enable + +#if !IS_UNO_RUNTIMETEST_PROJECT +#pragma warning disable +#endif + +using System; +using System.Linq; +using Windows.UI; + +namespace Uno.UI.RuntimeTests; + +/// +/// This class is intended to be used only by the the test engine itself and should not be used by applications. +/// API contract is not guaranteed and might change in future releases. +/// +internal static partial class ImageHelper +{ + /// + /// Make all pixels of the given buffer opaque. + /// + /// The pixels buffer (must not be pre-multiplied!). + /// The **opaque** background to use. + public static bool MakeOpaque(byte[] rgba8PixelsBuffer, Color? background = null) + { + if (background is { A: not 255 }) + { + throw new ArgumentException("The background color must be opaque.", nameof(background)); + } + + background ??= new Color { A = 255, R = 255, G = 255, B = 255 }; // White + + var modified = false; + for (var i = 0; i < rgba8PixelsBuffer.Length; i += 4) + { + var a = rgba8PixelsBuffer[i + 3]; + if (a == 255) + { + continue; + } + + var r = rgba8PixelsBuffer[i + 2]; + var g = rgba8PixelsBuffer[i + 1]; + var b = rgba8PixelsBuffer[i + 0]; + + var opaque = Color.FromArgb(a, r, g, b).ToOpaque(background.Value); + + rgba8PixelsBuffer[i + 3] = opaque.A; // 255 + rgba8PixelsBuffer[i + 2] = opaque.R; + rgba8PixelsBuffer[i + 1] = opaque.G; + rgba8PixelsBuffer[i + 0] = opaque.B; + + modified = true; + } + + return modified; + } + + /// + /// Un-multiply the alpha channel of each pixel of the given buffer. + /// + /// The pixel buffer. + public static void UnMultiplyAlpha(byte[] rgba8PremulPixelsBuffer) + { + for (var i = 0; i < rgba8PremulPixelsBuffer.Length; i += 4) + { + var a = rgba8PremulPixelsBuffer[i + 3]; + var r = rgba8PremulPixelsBuffer[i + 2]; + var g = rgba8PremulPixelsBuffer[i + 1]; + var b = rgba8PremulPixelsBuffer[i + 0]; + + //a = a; + r = (byte)(255.0 * r / a); + g = (byte)(255.0 * g / a); + b = (byte)(255.0 * b / a); + + rgba8PremulPixelsBuffer[i + 2] = r; + rgba8PremulPixelsBuffer[i + 1] = g; + rgba8PremulPixelsBuffer[i + 0] = b; + } + } +} +#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs b/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs deleted file mode 100644 index c66d02f..0000000 --- a/src/Uno.UI.RuntimeTests.Engine.Library/UI/UnitTestEngineConfig.cs +++ /dev/null @@ -1,22 +0,0 @@ -#nullable enable - -namespace Uno.UI.RuntimeTests; - -#if !UNO_RUNTIMETESTS_DISABLE_UI - -public class UnitTestEngineConfig -{ - public const int DefaultRepeatCount = 3; - - public static UnitTestEngineConfig Default { get; } = new UnitTestEngineConfig(); - - public string[]? Filters { get; set; } - - public int Attempts { get; set; } = DefaultRepeatCount; - - public bool IsConsoleOutputEnabled { get; set; } - - public bool IsRunningIgnored { get; set; } -} - -#endif \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems b/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems index 3c7c839..86cb3a1 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Uno.UI.RuntimeTests.Engine.Library.projitems @@ -9,26 +9,51 @@ Uno.UI.RuntimeTests.Engine.Library + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + - - - - - - - - - - - - - + Designer MSBuild:Compile @@ -41,7 +66,7 @@ - - + + \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Package/Uno.UI.RuntimeTests.Engine.Package.csproj b/src/Uno.UI.RuntimeTests.Engine.Package/Uno.UI.RuntimeTests.Engine.Package.csproj index 6b97d37..bd42141 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Package/Uno.UI.RuntimeTests.Engine.Package.csproj +++ b/src/Uno.UI.RuntimeTests.Engine.Package/Uno.UI.RuntimeTests.Engine.Package.csproj @@ -14,7 +14,6 @@ - diff --git a/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.props b/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.props index d08b7d4..63ffc79 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.props +++ b/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.props @@ -1,19 +1,37 @@  - - + + <_Parameter1>$(MSBuildProjectFullPath) + - - + + <_Parameter1>$([System.IO.Path]::GetFileName(`$(PkgUno_UI_DevServer)`)) + + + <_Parameter1>$([System.IO.Path]::GetFileName(`$(PkgUno_WinUI_DevServer)`)) + + + + + + + + + - + \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.targets b/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.targets index 1655520..ce8dd1c 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.targets +++ b/src/Uno.UI.RuntimeTests.Engine.Package/build/Uno.UI.RuntimeTests.Engine.targets @@ -6,4 +6,10 @@ + + + $(DefineConstants);HAS_UNO_DEVSERVER + true + true + \ No newline at end of file diff --git a/src/Uno.UI.RuntimeTests.Engine.sln b/src/Uno.UI.RuntimeTests.Engine.sln index 9a986aa..08dafd5 100644 --- a/src/Uno.UI.RuntimeTests.Engine.sln +++ b/src/Uno.UI.RuntimeTests.Engine.sln @@ -6,6 +6,8 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{CF39538B-D8EF-4640-BC42-256E7132A064}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets EndProjectSection EndProject Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Uno.UI.RuntimeTests.Engine.Library", "Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.shproj", "{E3F4AF60-8456-4ED6-9992-303CDAD44E0D}" @@ -32,7 +34,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uno.UI.RuntimeTests.Engine. EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UWP", "UWP", "{D31D49DA-BFBD-420C-9F8E-12D77DDF3070}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.UI.RuntimeTests.Engine.Mobile", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.Mobile\Uno.UI.RuntimeTests.Engine.Mobile.csproj", "{C72C5858-9953-4DEB-A551-E25BC5E7AD51}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uno.UI.RuntimeTests.Engine.Mobile", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.Mobile\Uno.UI.RuntimeTests.Engine.Mobile.csproj", "{C72C5858-9953-4DEB-A551-E25BC5E7AD51}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Uno.UI.RuntimeTests.Engine.Uwp", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.UWP\Uno.UI.RuntimeTests.Engine.Uwp.csproj", "{9804C1B9-A958-4A09-B975-AAECF08CFEE1}" EndProject @@ -44,6 +46,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uno.UI.RuntimeTests.Engine. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer", "TestApp\uwp\Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer\Uno.UI.RuntimeTests.Engine.Skia.Linux.FrameBuffer.csproj", "{761D821E-9905-4444-9ADF-76C995FE5427}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestApp", "TestApp", "{1367F3D5-A325-4F50-BD3A-F5628890831F}" + ProjectSection(SolutionItems) = preProject + TestApp\Directory.Build.props = TestApp\Directory.Build.props + TestApp\Directory.Build.targets = TestApp\Directory.Build.targets + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -192,6 +200,22 @@ Global {7BD72040-D89A-4DE8-8713-5A08068ADBCE}.Release|x64.Build.0 = Release|Any CPU {7BD72040-D89A-4DE8-8713-5A08068ADBCE}.Release|x86.ActiveCfg = Release|Any CPU {7BD72040-D89A-4DE8-8713-5A08068ADBCE}.Release|x86.Build.0 = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|arm64.ActiveCfg = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|arm64.Build.0 = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x64.ActiveCfg = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x64.Build.0 = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x86.ActiveCfg = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Debug|x86.Build.0 = Debug|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|Any CPU.Build.0 = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|arm64.ActiveCfg = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|arm64.Build.0 = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x64.ActiveCfg = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x64.Build.0 = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x86.ActiveCfg = Release|Any CPU + {C72C5858-9953-4DEB-A551-E25BC5E7AD51}.Release|x86.Build.0 = Release|Any CPU {9804C1B9-A958-4A09-B975-AAECF08CFEE1}.Debug|Any CPU.ActiveCfg = Debug|x64 {9804C1B9-A958-4A09-B975-AAECF08CFEE1}.Debug|Any CPU.Build.0 = Debug|x64 {9804C1B9-A958-4A09-B975-AAECF08CFEE1}.Debug|Any CPU.Deploy.0 = Debug|x64 @@ -298,6 +322,7 @@ Global {A5B8155A-118F-4794-B551-C6F3CF7E5411} = {D31D49DA-BFBD-420C-9F8E-12D77DDF3070} {4CCFCD14-DB9A-41A8-9F50-EF1956FDD3B5} = {D31D49DA-BFBD-420C-9F8E-12D77DDF3070} {761D821E-9905-4444-9ADF-76C995FE5427} = {D31D49DA-BFBD-420C-9F8E-12D77DDF3070} + {1367F3D5-A325-4F50-BD3A-F5628890831F} = {CF39538B-D8EF-4640-BC42-256E7132A064} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7B2C19BA-4A4D-4602-A2A3-0CBCF1480B0C} @@ -318,6 +343,8 @@ Global Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{9804c1b9-a958-4a09-b975-aaecf08cfee1}*SharedItemsImports = 4 TestApp\shared\Uno.UI.RuntimeTests.Engine.Shared.projitems*{a5b8155a-118f-4794-b551-c6f3cf7e5411}*SharedItemsImports = 5 Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{a5b8155a-118f-4794-b551-c6f3cf7e5411}*SharedItemsImports = 5 + TestApp\shared\Uno.UI.RuntimeTests.Engine.Shared.projitems*{c72c5858-9953-4deb-a551-e25bc5e7ad51}*SharedItemsImports = 5 + Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{c72c5858-9953-4deb-a551-e25bc5e7ad51}*SharedItemsImports = 5 TestApp\shared\Uno.UI.RuntimeTests.Engine.Shared.projitems*{dec0dfbd-2926-492b-be22-2158438556e3}*SharedItemsImports = 5 Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{dec0dfbd-2926-492b-be22-2158438556e3}*SharedItemsImports = 5 Uno.UI.RuntimeTests.Engine.Library\Uno.UI.RuntimeTests.Engine.Library.projitems*{e3f4af60-8456-4ed6-9992-303cdad44e0d}*SharedItemsImports = 13