From c1026fc57d857f770af7ece29949c710715678ad Mon Sep 17 00:00:00 2001 From: David Date: Mon, 16 Oct 2023 18:45:48 -0400 Subject: [PATCH] feat: Improve reliability of hot reload test engine --- src/TestApp/Directory.Build.props | 12 ++ src/TestApp/shared/App.xaml.cs | 4 + .../RuntimeTestDevServerAttribute.cs | 30 +++ .../RuntimeTestsSourceProjectAttribute.cs | 4 + .../ExternalRunner/_Private/DevServer.cs | 101 ++++++--- .../ExternalRunner/_Private/LogScope.cs | 45 ++++ .../ExternalRunner/_Private/ProcessHelper.cs | 35 ++-- .../ExternalRunner/_Private/SecondaryApp.cs | 3 +- .../Library/Helpers/HotReloadHelper.cs | 63 ++++-- .../Helpers/ImageAssert.ExpectedPixels.cs | 2 +- .../Library/Helpers/TestHelper.cs | 198 +++++++++++++++++- ...o.UI.RuntimeTests.Engine.Library.projitems | 3 +- .../build/Uno.UI.RuntimeTests.Engine.props | 7 + src/global.json | 10 + 14 files changed, 439 insertions(+), 78 deletions(-) create mode 100644 src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestDevServerAttribute.cs create mode 100644 src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/LogScope.cs create mode 100644 src/global.json diff --git a/src/TestApp/Directory.Build.props b/src/TestApp/Directory.Build.props index d6d3a01..54367bb 100644 --- a/src/TestApp/Directory.Build.props +++ b/src/TestApp/Directory.Build.props @@ -5,4 +5,16 @@ $(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/shared/App.xaml.cs b/src/TestApp/shared/App.xaml.cs index db58f40..173b73a 100644 --- a/src/TestApp/shared/App.xaml.cs +++ b/src/TestApp/shared/App.xaml.cs @@ -166,7 +166,11 @@ private static void InitializeLogging() 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) // Generic Xaml events // builder.AddFilter("Windows.UI.Xaml", LogLevel.Debug ); 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/RuntimeTestsSourceProjectAttribute.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestsSourceProjectAttribute.cs index 487d587..e8bea1e 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestsSourceProjectAttribute.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/RuntimeTestsSourceProjectAttribute.cs @@ -14,6 +14,9 @@ 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 even in release ==>> TODO: which is needed anyway ??? + // and also 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) @@ -21,4 +24,5 @@ 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 index d1d1828..704cdc5 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/DevServer.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/DevServer.cs @@ -10,14 +10,19 @@ #endif 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 HarfBuzzSharp; +using Microsoft.Extensions.Logging; using Uno.Extensions; using Uno.Logging; using Uno.UI.RuntimeTests.Engine; @@ -33,6 +38,8 @@ namespace Uno.UI.RuntimeTests.Internal.Helpers; /// internal sealed partial class DevServer : IAsyncDisposable { + private static readonly ILogger _log = typeof(DevServer).Log(); + private static int _instance; private static string? _devServerPath; /// @@ -78,45 +85,72 @@ private static async Task PullDevServer(CancellationToken ct) try { - var rawVersion = await ProcessHelper.ExecuteAsync( - ct, - "dotnet", - new() { "--version" }, - dir, - "[GET_DOTNET_VERSION]"); - var dotnetVersion = GetDotnetVersion(rawVersion); - - var csProj = @$" + 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); - - await ProcessHelper.ExecuteAsync( - ct, - "dotnet", - new() { "add", "package", "Uno.WinUI.DevServer", "--prerelease" }, - dir, - "[PULL_DEV_SERVER]"); - - var data = await ProcessHelper.ExecuteAsync( - ct, - "dotnet", - new() { "build", "/t:GetRemoteControlHostPath" }, - dir, - "[GET_DEV_SERVER_PATH]"); - - if (GetConfigurationValue(data, "RemoteControlHostPath") is { Length: > 0 } path) - { - return path; +"; + await File.WriteAllTextAsync(Path.Combine(dir, "PullDevServer.csproj"), csProj, ct); } - else + + using (var log = _log.Scope("PULL_DEV_SERVER")) { - throw new InvalidOperationException("Failed to get remote control host path"); + 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 { @@ -135,7 +169,7 @@ private static DevServer StartCore(string hostBinPath, int port) { if (!File.Exists(hostBinPath)) { - typeof(DevServer).Log().Error($"DevServer {hostBinPath} does not exist"); + _log.Error($"DevServer {hostBinPath} does not exist"); throw new InvalidOperationException($"Unable to find {hostBinPath}"); } @@ -150,7 +184,7 @@ private static DevServer StartCore(string hostBinPath, int port) var process = new System.Diagnostics.Process { StartInfo = pi }; - process.StartAndLog("DEV_SERVER"); + process.StartAndLog(_log.Scope($"DEV_SERVER_{Interlocked.Increment(ref _instance):D2}")); return new DevServer(process, port); } @@ -196,4 +230,5 @@ public async ValueTask DisposeAsync() 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..87afe53 --- /dev/null +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/LogScope.cs @@ -0,0 +1,45 @@ +#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 Scope(this ILogger log, string scopeName) +#if false + => new(log, log.BeginScope(scopeName)); +#else + => new (Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory.CreateLogger(typeof(DevServer).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 index 3cdbefa..90995c7 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/ProcessHelper.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/ProcessHelper.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Uno.Extensions; using Uno.Logging; @@ -26,14 +27,14 @@ public static async Task ExecuteAsync( string executable, List parameters, string workingDirectory, - string logPrefix, + ILogger log, Dictionary? environmentVariables = null) { var process = SetupProcess(executable, parameters, workingDirectory, environmentVariables); var output = new StringBuilder(); var error = new StringBuilder(); - typeof(ProcessHelper).Log().Debug(logPrefix + " waiting for process exit"); + log.Debug("Waiting for process exit"); // hookup the event handlers to capture the data that is received process.OutputDataReceived += (sender, args) => output.Append(args.Data); @@ -55,46 +56,46 @@ public static async Task ExecuteAsync( await process.WaitForExitWithCancellationAsync(ct); - process.EnsureSuccess(logPrefix, error); + process.EnsureSuccess(log, error); return output.ToString(); } public static async Task ExecuteAndLogAsync( this Process process, - string logPrefix, + ILogger log, CancellationToken ct) { - process.StartAndLog(logPrefix); + process.StartAndLog(log); - typeof(ProcessHelper).Log().Debug(logPrefix + " waiting for process exit"); + typeof(ProcessHelper).Log().Debug(log + " waiting for process exit"); await process.WaitForExitWithCancellationAsync(ct); - process.EnsureSuccess(logPrefix); + process.EnsureSuccess(log); } public static Process StartAndLog( string executable, List parameters, string workingDirectory, - string logPrefix, + ILogger log, Dictionary? environmentVariables = null) - => SetupProcess(executable, parameters, workingDirectory, environmentVariables).StartAndLog(logPrefix); + => SetupProcess(executable, parameters, workingDirectory, environmentVariables).StartAndLog(log); public static Process StartAndLog( this Process process, - string logPrefix) + 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) => typeof(ProcessHelper).Log().Debug(logPrefix + ": " + args.Data ?? ""); - process.ErrorDataReceived += (sender, args) => typeof(ProcessHelper).Log().Error(logPrefix + ": " + args.Data ?? ""); + process.OutputDataReceived += (sender, args) => log.Debug(args.Data ?? ""); + process.ErrorDataReceived += (sender, args) => log.Error(args.Data ?? ""); var pi = process.StartInfo; - typeof(ProcessHelper).Log().Debug($"Started process (wd:{pi.WorkingDirectory}): {pi.FileName} {string.Join(" ", pi.ArgumentList)})"); + log.Debug($"Started process (wd:{pi.WorkingDirectory}): {pi.FileName} {string.Join(" ", pi.ArgumentList)})"); process.Start(); @@ -148,22 +149,22 @@ public static async Task WaitForExitWithCancellationAsync(this Process process, await process.WaitForExitAsync(ct); } - public static void EnsureSuccess(this Process process, string logPrefix, StringBuilder error) + public static void EnsureSuccess(this Process process, ILogger log, StringBuilder error) { if (process.ExitCode != 0) { var processError = new InvalidOperationException(error.ToString()); - typeof(ProcessHelper).Log().Error(logPrefix + $" Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}", processError); + log.Error($"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, string logPrefix) + public static void EnsureSuccess(this Process process, ILogger log) { if (process.ExitCode != 0) { - typeof(ProcessHelper).Log().Error(logPrefix + $" Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}"); + log.Error($"Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}"); throw new InvalidOperationException($"Process '{process.StartInfo.FileName}' failed with code {process.ExitCode}"); } 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 index 27880d9..d0d2bb3 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/SecondaryApp.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Engine/ExternalRunner/_Private/SecondaryApp.cs @@ -17,6 +17,7 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; +using Uno.Extensions; namespace Uno.UI.RuntimeTests.Internal.Helpers; @@ -85,7 +86,7 @@ private static async Task RunLocalApp(string devServerHost, int devServe var childProcess = new Process { StartInfo = childStartInfo }; - await childProcess.ExecuteAndLogAsync($"CHILD_TEST_APP_{Interlocked.Increment(ref _instance):D2}", ct); + await childProcess.ExecuteAndLogAsync(typeof(SecondaryApp).Log().Scope($"CHILD_TEST_APP_{Interlocked.Increment(ref _instance):D2}"), ct); return testOutput; } diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs index 72860da..9ff36b7 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/HotReloadHelper.cs @@ -12,17 +12,23 @@ #endif using System; +using System.Collections; using System.Globalization; using System.Linq; using System.Reflection; using System.Threading.Tasks; using System.Threading; 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 using System.IO; using System.Diagnostics; using System.Net.WebSockets; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using Uno.Extensions; +using Uno.Logging; using Uno.UI.RuntimeTests.Engine; @@ -36,8 +42,17 @@ namespace Uno.UI.RuntimeTests; public static partial class HotReloadHelper { - public static async ValueTask UpdateServerFile(string filPathRelativeToProject, string originalText, string replacementText, CancellationToken ct) - => await UpdateServerFile(filPathRelativeToProject, originalText, replacementText, true, ct); + // 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); + + private static readonly ILogger _log = typeof(HotReloadHelper).Log(); + + public static async ValueTask UpdateServerFile(string filPathRelativeToProject, string originalText, string replacementText, CancellationToken ct) + => await UpdateServerFile(filPathRelativeToProject, originalText, replacementText, true, ct); /// /// Update the @@ -50,7 +65,7 @@ public static partial class HotReloadHelper /// /// /// - public static async ValueTask UpdateServerFile(string filPathRelativeToProject, string originalText, string replacementText, bool waitForMetadataUpdate, CancellationToken ct) + public static async ValueTask UpdateServerFile(string filPathRelativeToProject, string originalText, string replacementText, bool waitForMetadataUpdate, CancellationToken ct) { var projectFile = typeof(HotReloadHelper).Assembly.GetCustomAttribute()?.ProjectFullPath; if (projectFile is null or { Length: 0 }) @@ -100,17 +115,37 @@ public static partial class HotReloadHelper { return default; } - Console.WriteLine("========== Waiting for connection"); - await RemoteControlClient.Instance.WaitForConnection(ct); + _log.Trace($"Request to update file {message.FilePath}, waiting for connection ..."); - Console.WriteLine("========== Connected"); + 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")})."); + } - - var revertMessage = new RevertFileUpdate(RemoteControlClient.Instance, message); + _log.Trace("Client connected, waiting for dev-server to load the workspace (i.e. initializing roslyn with the solution) ..."); - Console.WriteLine("========== Sending update message for file: " + message.FilePath); + var processors = typeof(RemoteControlClient).GetField("_processors", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(RemoteControlClient.Instance) as IDictionary ?? throw new InvalidOperationException("Processors is null"); + var processor = processors["hotreload"] as ClientHotReloadProcessor ?? throw new InvalidOperationException("HotReloadProcessor is null"); + var hotReloadReady = typeof(ClientHotReloadProcessor).GetProperty("HotReloadWorkspaceLoaded", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(processor) as Task ?? throw new IOException("HotReloadWorkspaceLoaded is null"); + 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.Trace("Workspace is ready on dev-server, sending file update request ..."); + + var revertMessage = new RevertFileUpdate(RemoteControlClient.Instance, message); if (waitForMetadataUpdate) { var cts = new TaskCompletionSource(); @@ -119,13 +154,13 @@ public static partial class HotReloadHelper await RemoteControlClient.Instance.SendMessage(message); - Console.WriteLine("========== Sent message, waiting for update"); + _log.Trace("File update message sent to the dev-server, waiting for metadata update (i.e. the code changes to be applied in the current app) ..."); try { MetadataUpdaterHelper.MetadataUpdated += UpdateReceived; - var timeout = Task.Delay(TestHelper.DefaultTimeout, ct); + timeout = Task.Delay(MetadataUpdateTimeout, ct); //TestHelper.DefaultTimeout * 45, ct); if (await Task.WhenAny(timeout, cts.Task) == timeout) { throw new TimeoutException( @@ -145,7 +180,7 @@ public static partial class HotReloadHelper MetadataUpdaterHelper.MetadataUpdated -= UpdateReceived; } - Console.WriteLine("========== Got updates"); + _log.Trace("Received **a** metadata update, continuing test."); } else { @@ -173,7 +208,7 @@ public async ValueTask DisposeAsync() } catch (WebSocketException) { - Console.WriteLine("Failed to revert file update, connection to the dev-server might have been closed *** WAITING 5 sec to let client reconnect before retry**."); + _log.Warn($"Failed to revert file update, connection to the dev-server might have been closed *** WAITING {DevServer.ConnectionRetryInterval:g} to let client reconnect before retry**."); // Wait for the client to attempt re-connection await Task.Delay(DevServer.ConnectionRetryInterval + TimeSpan.FromMilliseconds(100)); @@ -184,7 +219,7 @@ public async ValueTask DisposeAsync() } catch (WebSocketException) { - Console.WriteLine($"Failed to revert file update, connection to the dev-server might have been closed. Cannot revert changes made on file {Message.FilePath}."); + _log.Error($"Failed to revert file update, connection to the dev-server might have been closed. Cannot revert changes made on file {Message.FilePath}."); } } } 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 index 2162095..804c75c 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.ExpectedPixels.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/ImageAssert.ExpectedPixels.cs @@ -194,7 +194,7 @@ private static Color GetColorFromString(string colorCode) => : (Color)XamlBindingHelper.ConvertValue(typeof(Color), colorCode); } -public struct PixelTolerance +public record struct PixelTolerance { #region Fluent declaration public static PixelTolerance None => default; diff --git a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs index 001b61a..b4cfcc7 100644 --- a/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs +++ b/src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/TestHelper.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Threading; using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Uno.UI.RuntimeTests; @@ -23,23 +24,64 @@ public static partial class TestHelper /// /// Predicate to evaluate repeatedly until it returns true. /// A cancellation token to cancel the wait operation. - public static async ValueTask WaitFor(Func predicate, CancellationToken ct) - => await WaitFor(async _ => predicate(), ct); + [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. - public static async ValueTask WaitFor(Func> predicate, CancellationToken ct) + [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) { - using var timeout = new CancellationTokenSource(DefaultTimeout); + using var timeoutSrc = new CancellationTokenSource(timeout); try { - ct = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token).Token; + ct = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutSrc.Token).Token; - var interval = Math.Min(1000, (int)(DefaultTimeout.TotalMilliseconds / 100)); - var steps = DefaultTimeout.TotalMilliseconds / interval; + var interval = Math.Min(1000, (int)(timeout.TotalMilliseconds / 100)); + var steps = timeout.TotalMilliseconds / interval; for (var i = 0; i < steps; i++) { @@ -50,14 +92,148 @@ public static async ValueTask WaitFor(Func> p return; } - await Task.Delay(interval, ct); + if (i < steps - 1) + { + await Task.Delay(interval, ct); + } + } + + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); + } + catch (OperationCanceledException) when (timeoutSrc.IsCancellationRequested) + { + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); + } + } + + /// + /// Wait until a specified value provider equals the value. + /// + /// The type of the value to validate. + /// Actual provider to evaluate repeatedly until it returns the value. + /// The expected value to wait for. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask WaitFor(Func actual, T expected, CancellationToken ct = default) + where T : IEquatable + => await WaitFor(async _ => actual(), expected, DefaultTimeout, ct); + + /// + /// Wait until a specified value provider equals the value. + /// + /// The type of the value to validate. + /// Actual provider to evaluate repeatedly until it returns the value. + /// The expected value to wait for. + /// 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 actual, T expected, int timeoutMs, CancellationToken ct = default) + where T : IEquatable + => await WaitFor(async _ => actual(), expected, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Wait until a specified value provider equals the value. + /// + /// The type of the value to validate. + /// Actual provider to evaluate repeatedly until it returns the value. + /// The expected value to wait for. + /// The max duration to wait for. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask WaitFor(Func actual, T expected, TimeSpan timeout, CancellationToken ct = default) + where T : IEquatable + => await WaitFor(async _ => actual(), expected, timeout, ct); + + /// + /// Wait until a specified value provider equals the value. + /// + /// The type of the value to validate. + /// Actual provider to evaluate repeatedly until it returns the value. + /// The expected value to wait for. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Func> actual, T expected, CancellationToken ct = default) + where T : IEquatable + => WaitFor(actual, expected, DefaultTimeout, ct); + + /// + /// Wait until a specified value provider equals the value. + /// + /// The type of the value to validate. + /// Actual provider to evaluate repeatedly until it returns the value. + /// The expected value to wait for. + /// The max duration to wait for in milliseconds. + /// A cancellation token to cancel the wait operation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Func> actual, T expected, int timeoutMs, CancellationToken ct = default) + where T : IEquatable + => WaitFor(actual, expected, TimeSpan.FromMilliseconds(timeoutMs), ct); + + /// + /// Wait until a specified value provider equals the value. + /// + /// The type of the value to validate. + /// Actual provider to evaluate repeatedly until it returns the value. + /// The expected value to wait for. + /// The max duration to wait for. + /// A cancellation token to cancel the wait operation. + public static async ValueTask WaitFor(Func> actual, T expected, TimeSpan timeout, CancellationToken ct = default) + where T : IEquatable + { + using var timeoutSrc = new CancellationTokenSource(timeout); + + var actualValue = default(T); + 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++) + { + ct.ThrowIfCancellationRequested(); + + if (expected.Equals(actualValue = await actual(ct))) + { + return; + } + + if (i < steps - 1) + { + await Task.Delay(interval, ct); + } } - throw new TimeoutException(); + throw new TimeoutException($"Actual value is '{actualValue}' instead of the expected '{expected}' after {timeout:g}."); + } + catch (OperationCanceledException) when (timeoutSrc.IsCancellationRequested) + { + throw new TimeoutException($"Actual value is '{actualValue}' instead of the expected '{expected}' after {timeout:g}."); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask WaitFor(Task task, CancellationToken ct = default) + => WaitFor(task, 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) + { + var timeoutTask = Task.Delay(timeout, ct); + try + { + if (await Task.WhenAny(task, timeoutTask) == timeoutTask) + { + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); + } } - catch (OperationCanceledException) when (timeout.IsCancellationRequested) + catch (OperationCanceledException) when (ct.IsCancellationRequested && !task.IsCanceled) { - throw new TimeoutException(); + throw new TimeoutException($"Operation has been cancelled after {timeout:g}."); } } } 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 6f33f66..05a9f4a 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,6 +9,8 @@ Uno.UI.RuntimeTests.Engine.Library + + @@ -57,7 +59,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 38ac0fb..32fe142 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 @@ -3,6 +3,13 @@ <_Parameter1>$(MSBuildProjectFullPath) + + + <_Parameter1>$([System.IO.Path]::GetFileName(`$(PkgUno_UI_DevServer)`)) + + + <_Parameter1>$([System.IO.Path]::GetFileName(`$(PkgUno_WinUI_DevServer)`)) + diff --git a/src/global.json b/src/global.json new file mode 100644 index 0000000..a6e67ee --- /dev/null +++ b/src/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "version": "7.0.401", + "allowPrerelease": false, + "rollForward": "latestFeature" + }, + "tools": { + "dotnet": "7.0.401" + } +}