diff --git a/.github/workflows/devflow-integration.yml b/.github/workflows/devflow-integration.yml index ae82cce83..d271281c1 100644 --- a/.github/workflows/devflow-integration.yml +++ b/.github/workflows/devflow-integration.yml @@ -536,7 +536,7 @@ jobs: xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" dotnet test $TEST_PROJECT -c Debug - --filter "Category!=Device&Category!=Sensors&Category!=SecureStorage" + --filter "Category!=Device&Category!=Sensors&Category!=SecureStorage&fixture!=GenericWebView" --logger "trx;LogFileName=devflow-integration-gtk-maui.trx" --logger "console;verbosity=normal" --results-directory ./artifacts/TestResults/devflow-integration/gtk diff --git a/Directory.Packages.props b/Directory.Packages.props index bed39f6ef..b56682bf1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,6 +50,7 @@ + diff --git a/samples/DevFlow.Sample/AppShell.xaml.cs b/samples/DevFlow.Sample/AppShell.xaml.cs index 1f7a635e7..7b3d7f9f6 100644 --- a/samples/DevFlow.Sample/AppShell.xaml.cs +++ b/samples/DevFlow.Sample/AppShell.xaml.cs @@ -9,9 +9,12 @@ public partial class AppShell : Shell public AppShell() { InitializeComponent(); + AddWebViewTestPages(); #if MACOS FlyoutBehavior = FlyoutBehavior.Locked; MacOSShell.SetUseNativeSidebar(this, true); #endif } + + partial void AddWebViewTestPages(); } diff --git a/samples/DevFlow.Sample/DevFlow.Sample.csproj b/samples/DevFlow.Sample/DevFlow.Sample.csproj index 4119cfcc8..d56ba34c0 100644 --- a/samples/DevFlow.Sample/DevFlow.Sample.csproj +++ b/samples/DevFlow.Sample/DevFlow.Sample.csproj @@ -38,6 +38,7 @@ + diff --git a/samples/DevFlow.Sample/MauiProgram.cs b/samples/DevFlow.Sample/MauiProgram.cs index 0f048b541..c8801a46d 100644 --- a/samples/DevFlow.Sample/MauiProgram.cs +++ b/samples/DevFlow.Sample/MauiProgram.cs @@ -3,6 +3,7 @@ using Microsoft.Maui.DevFlow.Agent; using Microsoft.Maui.DevFlow.Agent.Core; using Microsoft.Maui.DevFlow.Blazor; +using Microsoft.Maui.DevFlow.WebView; namespace DevFlow.Sample; @@ -118,6 +119,7 @@ public static MauiApp CreateMauiApp() PreferenceDiagnostics.Register(diagnostics); }); builder.AddMauiBlazorDevFlowTools(); + builder.AddMauiWebViewDevFlowTools(); #endif return builder.Build(); diff --git a/samples/DevFlow.Sample/Platforms/Android/AndroidManifest.xml b/samples/DevFlow.Sample/Platforms/Android/AndroidManifest.xml index 56226b353..80119ce21 100644 --- a/samples/DevFlow.Sample/Platforms/Android/AndroidManifest.xml +++ b/samples/DevFlow.Sample/Platforms/Android/AndroidManifest.xml @@ -1,6 +1,6 @@  - + diff --git a/samples/DevFlow.Sample/Platforms/Android/Resources/xml/network_security_config.xml b/samples/DevFlow.Sample/Platforms/Android/Resources/xml/network_security_config.xml new file mode 100644 index 000000000..093b4c828 --- /dev/null +++ b/samples/DevFlow.Sample/Platforms/Android/Resources/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + 10.0.2.2 + 127.0.0.1 + + diff --git a/samples/DevFlow.Sample/Platforms/Windows/App.xaml.cs b/samples/DevFlow.Sample/Platforms/Windows/App.xaml.cs index 41768b7d6..f2cb0c3c1 100644 --- a/samples/DevFlow.Sample/Platforms/Windows/App.xaml.cs +++ b/samples/DevFlow.Sample/Platforms/Windows/App.xaml.cs @@ -17,6 +17,11 @@ public partial class App : MauiWinUIApplication /// public App() { +#if DEBUG + UnhandledException += (_, args) => WriteUnhandledDiagnostic(args.Exception); + AppDomain.CurrentDomain.UnhandledException += (_, args) => + WriteUnhandledDiagnostic(args.ExceptionObject as Exception); +#endif this.InitializeComponent(); #if DEBUG @@ -25,6 +30,34 @@ public App() } #if DEBUG + static void WriteUnhandledDiagnostic(Exception? exception) + { + var message = $"{DateTimeOffset.UtcNow:O} pid={Environment.ProcessId} unhandled: {exception}"; + if (exception is not null) + { + foreach (var frame in new System.Diagnostics.StackTrace(exception, false).GetFrames()) + { + var method = frame.GetMethod(); + message += $"{Environment.NewLine}frame: {method?.DeclaringType?.FullName}.{method?.Name}; " + + $"assembly={method?.Module.Assembly.FullName}; mvid={method?.Module.ModuleVersionId}; " + + $"ilOffset={frame.GetILOffset()}; nativeOffset={frame.GetNativeOffset()}"; + } + } + WriteRuntimeDiagnostic(message); + } + + internal static void WriteRuntimeDiagnostic(string message) + { + Console.Error.WriteLine(message); + var path = Environment.GetEnvironmentVariable("DEVFLOW_TEST_DIAGNOSTICS_PATH"); + if (!string.IsNullOrEmpty(path)) + { + try { File.AppendAllText(path, message + Environment.NewLine); } + catch (IOException ex) { Console.Error.WriteLine($"Unable to write sample diagnostics: {ex}"); } + catch (UnauthorizedAccessException ex) { Console.Error.WriteLine($"Unable to write sample diagnostics: {ex}"); } + } + } + static bool IsEnabled(string? value) => value is not null && (value.Equals("1", StringComparison.OrdinalIgnoreCase) || diff --git a/samples/DevFlow.Sample/README.md b/samples/DevFlow.Sample/README.md new file mode 100644 index 000000000..875795609 --- /dev/null +++ b/samples/DevFlow.Sample/README.md @@ -0,0 +1,92 @@ +# DevFlow maintained sample + +This experimental .NET 10 MAUI app exercises native automation and the shared +DevFlow WebView bridge. Build it with the matching Agent, WebView, Blazor, Client +and CLI versions from this repository. Debug builds register the agent and both +WebView adapters; Blazor registration already includes the generic adapter, so +the explicit generic registration also exercises idempotency. +The optional Shell partial hook adds these pages only where their source is +compiled. Backend samples that link the shared Shell keep their existing routes; +this change does not add backend handlers or claim backend runtime coverage. + +| Shell route | Maintained fixture | +|---|---| +| `//native` | Native todo controls and screenshots | +| `//blazor` | Real Blazor todo events and `/counter` routing | +| `//webview` | Standard WebView loading packaged HTML, input events, one-click count, console and controlled DOM defects | +| `//hybrid` | Actual `MauiAsset` resources, original Hybrid raw JSON messages in both directions, relative navigation and reload | +| `//mixedwebviews` | All three hosts together: unnamed standard host and intentionally duplicate Hybrid/Blazor AutomationIds | + +Use the native restore/recreate buttons to reset fixtures. The standard fixture +has a pink background; Hybrid has a green background. Both include a 60px +ellipsis and an 80x80 child clipped by a 40x40 parent (75% area loss). The mixed +page also has a native 80x80 label clipped by a 40x40 grid, so disabling DOM layout +inspection can be checked without disabling native diagnostics. + +Standard inline HTML is self-contained. Its relative link is exercised only +after the integration test navigates that host to its owned loopback HTTP server: +navigation from `HtmlWebViewSource` to cache files is platform-restricted. +Hybrid relative links use the real packaged resources and MAUI resource/message +infrastructure. Tests restore standard HTML **before** disposing their HTTP +fixture; the sample never needs a test server after a test ends. + +## Run and inspect + +```bash +dotnet build samples/DevFlow.Sample/DevFlow.Sample.csproj -f net10.0-maccatalyst -c Debug +# Launch the resulting MauiTodo.app, then select its actual agent port: +maui devflow --agent-port agent status +maui devflow --agent-port webview webviews +maui devflow --agent-port webview --context-id webview- source +``` + +Context IDs come from the live contexts response; do not hard-code indexes or +use AutomationIds as browser selectors. Hidden Shell pages may retain registered +hosts, but only the active page participates in layout diagnostics. Recreation +removes old registrations, including when a new native host reuses the same +AutomationId. A native element ID can remain stable while its browser context ID +changes. + +## Cross-host integration matrix + +```bash +# Reuse a freshly built/running app, or omit DEVFLOW_TEST_PORT to let the fixture build/launch it. +DEVFLOW_TEST_PLATFORM=maccatalyst DEVFLOW_TEST_PORT= \ + dotnet test src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests \ + --filter 'FullyQualifiedName~GenericWebViewTests|FullyQualifiedName~WebViewTests' +``` + +`GenericWebViewTests` verifies canonical ready-only contexts, actual owner +correlation, input events/fill/insertText, exactly one click, original Hybrid +ready/ack messages, real Blazor counter events/routing, decoded native-resolution +host-sized PNGs with host-specific colors, HTTP and packaged relative navigation, +reload/recreation, console delivery, measured layout defects and native-only +scope/revision stability. It returns to the native page after each case. + +All 24 generic fixture cases run on the Android, iOS, Mac Catalyst and Windows +MAUI sample heads. They carry `fixture=GenericWebView` separately from the +`Category=WebView` trait. GTK excludes only that fixture because its linked Shell +does not compile these pages; its existing Blazor WebView and native integration +cases remain selected. AppKit/WPF retain their existing WebView exclusions, and +plain .NET samples retain the `framework!=maui` filter. + +For the class-order screenshot reproduction in [#407](https://github.com/dotnet/maui-labs/issues/407), +run the **complete existing class**, repeatedly on the same app: + +```bash +for run in 1 2 3 4 5; do + sleep 11 # Let the previous process's default 10-second mutation lease expire. + DEVFLOW_TEST_PLATFORM=maccatalyst DEVFLOW_TEST_PORT= \ + dotnet test src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests --no-build \ + --filter 'FullyQualifiedName~Microsoft.Maui.DevFlow.Agent.IntegrationTests.WebViewTests' \ + --logger "trx;LogFileName=webview-$run.trx" +done +``` + +Use a longer wait if the app configures a longer lease timeout. Separate test +runner processes must not compete for the mutation lease; a lease rejection +does not reproduce the in-suite screenshot failure. + +The app targets Android, iOS, Mac Catalyst and Windows. A run on one host is not +runtime evidence for the others; repeated local passes alone do not establish +that an intermittent native WebKit crash or #407 is resolved. diff --git a/samples/DevFlow.Sample/Resources/Raw/webview-fixture/index.html b/samples/DevFlow.Sample/Resources/Raw/webview-fixture/index.html new file mode 100644 index 000000000..5f1d806fd --- /dev/null +++ b/samples/DevFlow.Sample/Resources/Raw/webview-fixture/index.html @@ -0,0 +1,56 @@ + + + + + + Hybrid WebView fixture + + + + +

Generic WebView fixture

+

Click count: 0

+ +

Input event value:

+ + +

Message from .NET: none

+

Deliberately overflowing fixture text

+
Clipped
+ Navigate to second document (packaged Hybrid or HTTP) + + + diff --git a/samples/DevFlow.Sample/Resources/Raw/webview-fixture/second.html b/samples/DevFlow.Sample/Resources/Raw/webview-fixture/second.html new file mode 100644 index 000000000..1d9da083d --- /dev/null +++ b/samples/DevFlow.Sample/Resources/Raw/webview-fixture/second.html @@ -0,0 +1,22 @@ + + + + + + Second WebView document + + + +

Second document loaded

+

Message from .NET: none

+ Return to fixture + + + diff --git a/samples/DevFlow.Sample/WebViewTestPages.cs b/samples/DevFlow.Sample/WebViewTestPages.cs new file mode 100644 index 000000000..2a4b39e5f --- /dev/null +++ b/samples/DevFlow.Sample/WebViewTestPages.cs @@ -0,0 +1,377 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components.WebView.Maui; +using Microsoft.Maui.Dispatching; + +namespace DevFlow.Sample; + +public partial class AppShell +{ + // The backend samples link AppShell, but do not compile these standard-MAUI fixtures. + partial void AddWebViewTestPages() + { + Items.Add(new ShellContent + { + Title = "WebView", Route = "webview", ContentTemplate = new DataTemplate(typeof(WebViewTestPage)) + }); + Items.Add(new ShellContent + { + Title = "HybridWebView", Route = "hybrid", ContentTemplate = new DataTemplate(typeof(HybridWebViewTestPage)) + }); + Items.Add(new ShellContent + { + Title = "Mixed WebViews", Route = "mixedwebviews", ContentTemplate = new DataTemplate(typeof(MixedWebViewTestPage)) + }); + } +} + +public sealed class WebViewTestPage : ContentPage +{ + readonly Grid _grid = WebViewTestContent.CreateRows(3); + readonly Label _status = new() { AutomationId = "StandardNavigationStatus" }; + readonly Label _documentStatus = new() { AutomationId = "StandardDocumentStatus", IsVisible = false }; + WebView? _view; + + public WebViewTestPage() + { + Title = "Standard WebView"; + var buttons = new HorizontalStackLayout(); + var reset = new Button { AutomationId = "ResetStandardWebViewButton", Text = "Restore HTML fixture" }; + reset.Clicked += async (_, _) => await RestoreAsync(recreate: false); + var recreate = new Button { AutomationId = "RecreateStandardWebViewButton", Text = "Recreate host" }; + recreate.Clicked += async (_, _) => await RestoreAsync(recreate: true); + buttons.Add(reset); + buttons.Add(recreate); + _grid.Add(buttons, 0, 0); + _grid.Add(_status, 0, 1); + _grid.Add(_documentStatus, 0, 1); + Content = _grid; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + if (_view is null) + await RestoreAsync(recreate: true); + } + + async Task RestoreAsync(bool recreate) + { + try + { + var source = await WebViewTestContent.LoadStandardSourceAsync("Standard WebView fixture"); + if (recreate || _view is null) + { + if (_view is not null) + { + _grid.Remove(_view); + _view.Handler?.DisconnectHandler(); + } + _view = new WebView { AutomationId = "StandardWebView" }; + _view.Navigated += async (sender, args) => + { + _status.Text = $"{args.Result}: {args.Url}"; + if (sender is WebView view && ReferenceEquals(view, _view)) + { + _documentStatus.Text = $"Inspecting native document after {args.Result}: {args.Url}"; + await ReadDocumentStatusAsync(view); + } + }; + _grid.Add(_view, 0, 2); + } + _status.Text = "Loading packaged HTML"; + _view.Source = source; + } + catch (Exception ex) + { + Console.WriteLine(ex); + _status.Text = $"Unable to load WebView fixture: {ex.Message}"; + } + } + + async Task ReadDocumentStatusAsync(WebView view) + { + try + { + await view.Dispatcher.DispatchAsync(async () => + { + if (!ReferenceEquals(view, _view) || view.Handler is null) + return; + _documentStatus.Text = await view.EvaluateJavaScriptAsync( + """ + JSON.stringify({ + location: location.href, + title: document.title, + readyState: document.readyState, + body: document.body?.innerText.slice(0, 2048) + }) + """); + Console.WriteLine($"Standard WebView document: {_documentStatus.Text}"); + }); + } + catch (Exception ex) + { + Console.WriteLine($"Unable to inspect the standard WebView document: {ex}"); + await _documentStatus.Dispatcher.DispatchAsync(() => + _documentStatus.Text = $"Native document inspection failed: {ex.Message}"); + } + } +} + +public sealed class HybridWebViewTestPage : ContentPage +{ + readonly Grid _grid = WebViewTestContent.CreateRows(3); + readonly Label _status = new() { AutomationId = "HybridMessageStatus", Margin = 8 }; + HybridWebView? _view; + + public HybridWebViewTestPage() + { + Title = "HybridWebView"; + var buttons = new HorizontalStackLayout(); + var reset = new Button { AutomationId = "ResetHybridWebViewButton", Text = "Recreate packaged host" }; + reset.Clicked += (_, _) => Restore(); + var send = new Button { AutomationId = "HybridSendMessageButton", Text = "Send JSON to JavaScript" }; + send.Clicked += (_, _) => WebViewTestContent.SendMessage(_view!, _status); + buttons.Add(reset); + buttons.Add(send); + _grid.Add(buttons, 0, 0); + _grid.Add(_status, 0, 1); + Content = _grid; + Restore(); + } + + void Restore() + { + if (_view is not null) + { + _grid.Remove(_view); + _view.Handler?.DisconnectHandler(); + } + _status.Text = "Waiting for original Hybrid ready message"; + _view = WebViewTestContent.CreateHybrid("HybridWebView", _status); + _grid.Add(_view, 0, 2); + } +} + +public sealed class MixedWebViewTestPage : ContentPage +{ + readonly Grid _grid = WebViewTestContent.CreateRows(3); + readonly Grid _hosts = new() + { + ColumnDefinitions = { new(GridLength.Star), new(GridLength.Star), new(GridLength.Star) } + }; + readonly Label _status = new() { AutomationId = "MixedHybridMessageStatus" }; + HybridWebView? _hybrid; + bool _loaded; + + public MixedWebViewTestPage() + { + Title = "Mixed WebView Hosts"; + var buttons = new HorizontalStackLayout(); + var reset = new Button { AutomationId = "ResetMixedWebViewsButton", Text = "Recreate all three hosts" }; + reset.Clicked += async (_, _) => await RestoreAsync(); + var send = new Button { AutomationId = "MixedHybridSendMessageButton", Text = "Send to Hybrid" }; + send.Clicked += (_, _) => WebViewTestContent.SendMessage(_hybrid!, _status); + buttons.Add(reset); + buttons.Add(send); + var nativeClip = new AbsoluteLayout + { + AutomationId = "WebViewNativeClipHost", + WidthRequest = 40, + HeightRequest = 40, + MinimumWidthRequest = 40, + MinimumHeightRequest = 40, + MaximumWidthRequest = 40, + MaximumHeightRequest = 40, + HorizontalOptions = LayoutOptions.Start, + VerticalOptions = LayoutOptions.Start, + IsClippedToBounds = true + }; + var clippedLabel = new Label + { + AutomationId = "WebViewNativeClipped", + Text = "Clipped native fixture", + LineBreakMode = LineBreakMode.NoWrap, + MinimumWidthRequest = 80, + MinimumHeightRequest = 80, + MaximumWidthRequest = 80, + MaximumHeightRequest = 80, + // Fill the explicit AbsoluteLayout allocation instead of using native text's desired size. + HorizontalOptions = LayoutOptions.Fill, + VerticalOptions = LayoutOptions.Fill, + BackgroundColor = Color.FromArgb("#345678") + }; + AbsoluteLayout.SetLayoutBounds(clippedLabel, new Rect(0, 0, 80, 80)); + nativeClip.Add(clippedLabel); + var toolbar = new Grid + { + ColumnDefinitions = { new(GridLength.Star), new(GridLength.Auto) } + }; + toolbar.Add(new ScrollView { Orientation = ScrollOrientation.Horizontal, Content = buttons }, 0, 0); + toolbar.Add(nativeClip, 1, 0); + _grid.Add(toolbar, 0, 0); + _grid.Add(_status, 0, 1); + _grid.Add(_hosts, 0, 2); + Content = _grid; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + if (!_loaded) + await RestoreAsync(); + } + + async Task RestoreAsync() + { + try + { + var source = await WebViewTestContent.LoadStandardSourceAsync("Mixed standard WebView fixture"); + foreach (var child in _hosts.Children.OfType().ToArray()) + { + _hosts.Remove(child); + child.Handler?.DisconnectHandler(); + } + // An unnamed host and duplicate ids intentionally require actual owner correlation. + _hosts.Add(new WebView { Source = source }, 0, 0); + _status.Text = "Waiting for original Hybrid ready message"; + _hybrid = WebViewTestContent.CreateHybrid("MixedDuplicateWebView", _status); + _hosts.Add(_hybrid, 1, 0); + var blazor = new BlazorWebView + { + HostPage = "wwwroot/index.html", + AutomationId = "MixedDuplicateWebView" + }; + blazor.RootComponents.Add(new RootComponent + { + Selector = "#app", + ComponentType = typeof(Components.Routes) + }); + _hosts.Add(blazor, 2, 0); + _loaded = true; + } + catch (Exception ex) + { + _loaded = false; + Console.WriteLine(ex); + _status.Text = $"Unable to load mixed fixture: {ex.Message}"; + } + } +} + +static class WebViewTestContent +{ + public static Grid CreateRows(int count) + { + var grid = new Grid(); + for (var row = 0; row < count; row++) + grid.RowDefinitions.Add(new RowDefinition(row == count - 1 ? GridLength.Star : GridLength.Auto)); + return grid; + } + + public static HybridWebView CreateHybrid(string automationId, Label status) + { + void UpdateStatus(string text) + { + if (!status.Dispatcher.IsDispatchRequired) + status.Text = text; + else if (!status.Dispatcher.Dispatch(() => status.Text = text)) + throw new InvalidOperationException("Unable to dispatch the Hybrid message to its UI."); + } + + var view = new HybridWebView + { + AutomationId = automationId, + HybridRoot = "webview-fixture", + DefaultFile = "index.html" + }; +#if WINDOWS + view.WebViewInitializing += (_, args) => + { + var languages = global::Windows.Globalization.ApplicationLanguages.Languages; + if (languages.Count > 0) + { + // Match WinUI's default environment shared by the standard and Blazor WebViews. + var platformArgs = args.PlatformArgs + ?? throw new InvalidOperationException("Windows HybridWebView initialization requires platform arguments."); + var options = platformArgs.EnvironmentOptions + ??= new Microsoft.Web.WebView2.Core.CoreWebView2EnvironmentOptions(); + options.Language = languages[0]; + } +#if DEBUG + var language = languages.Count > 0 ? languages[0] : ""; + WinUI.App.WriteRuntimeDiagnostic( + $"{DateTimeOffset.UtcNow:O} pid={Environment.ProcessId} Hybrid '{automationId}' environment language={language}"); +#endif + }; +#endif +#if WINDOWS && DEBUG + Microsoft.UI.Xaml.Controls.WebView2? observed = null; + global::Windows.Foundation.TypedEventHandler initialized = (native, args) => + WinUI.App.WriteRuntimeDiagnostic( + $"{DateTimeOffset.UtcNow:O} pid={Environment.ProcessId} Hybrid '{automationId}' CoreWebView2Initialized: " + + $"corePresent={native.CoreWebView2 is not null}, loaded={native.IsLoaded}, exception={args.Exception}"); + view.HandlerChanging += (_, _) => + { + if (observed is not null) + observed.CoreWebView2Initialized -= initialized; + observed = null; + }; + view.HandlerChanged += (_, _) => + { + if (view.Handler?.PlatformView is not Microsoft.UI.Xaml.Controls.WebView2 native) + return; + observed = native; + native.CoreWebView2Initialized += initialized; + WinUI.App.WriteRuntimeDiagnostic( + $"{DateTimeOffset.UtcNow:O} pid={Environment.ProcessId} Hybrid '{automationId}' handler attached: " + + $"corePresent={native.CoreWebView2 is not null}, loaded={native.IsLoaded}"); + }; +#endif + view.RawMessageReceived += (_, args) => + { + try + { + var message = JsonSerializer.Deserialize( + args.Message ?? throw new JsonException("Missing Hybrid message."), + WebViewTestJsonContext.Default.WebViewTestMessage) + ?? throw new JsonException("Missing Hybrid message value."); + UpdateStatus($"{message.Action}: {message.Value}"); + } + catch (JsonException ex) + { + Console.WriteLine(ex); + UpdateStatus($"Invalid Hybrid message: {ex.Message}"); + } + }; + return view; + } + + public static void SendMessage(HybridWebView view, Label status) + { + status.Text = "Sending native message"; + view.SendRawMessage(JsonSerializer.Serialize( + new WebViewTestMessage("native-message", "native-to-js-ok"), + WebViewTestJsonContext.Default.WebViewTestMessage)); + } + + public static async Task LoadStandardSourceAsync(string title) + { + using var stream = await FileSystem.OpenAppPackageFileAsync("webview-fixture/index.html"); + using var reader = new StreamReader(stream); + return new HtmlWebViewSource + { + Html = (await reader.ReadToEndAsync()) + .Replace("""""", "") + .Replace("Hybrid WebView fixture", $"{title}") + .Replace("data-host=\"hybrid\"", "data-host=\"standard\"") + }; + } +} + +public sealed record WebViewTestMessage(string Action, string? Value); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(WebViewTestMessage))] +internal partial class WebViewTestJsonContext : JsonSerializerContext; diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WebViewHttpFixture.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WebViewHttpFixture.cs new file mode 100644 index 000000000..58c29035c --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WebViewHttpFixture.cs @@ -0,0 +1,129 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.Maui.DevFlow.Agent.IntegrationTests.Fixtures; + +internal sealed class WebViewHttpFixture : IAsyncDisposable +{ + readonly TcpListener _listener = new(IPAddress.Loopback, 0); + readonly CancellationTokenSource _shutdown = new(); + readonly Task _requests; + readonly string _resourceDirectory; + int _acceptedConnections; + int _startedRequests; + + public int Port { get; } + public int AcceptedConnectionCount => Volatile.Read(ref _acceptedConnections); + public int StartedRequestCount => Volatile.Read(ref _startedRequests); + public ConcurrentQueue RequestPaths { get; } = new(); + public ConcurrentQueue ServedResponses { get; } = new(); + public ConcurrentQueue ClientDisconnects { get; } = new(); + + public WebViewHttpFixture() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "MauiLabs.slnx"))) + root = root.Parent; + _resourceDirectory = Path.Combine( + root?.FullName ?? throw new DirectoryNotFoundException("Cannot find the sample resources."), + "samples", "DevFlow.Sample", "Resources", "Raw", "webview-fixture"); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _requests = ServeAsync(_shutdown.Token); + } + + public string GetUrl(string platform) + => $"http://{(platform == "android" ? "10.0.2.2" : "127.0.0.1")}:{Port}/index.html"; + + async Task ServeAsync(CancellationToken cancellationToken) + { + var connections = new List(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + var connection = await _listener.AcceptTcpClientAsync(cancellationToken); + Interlocked.Increment(ref _acceptedConnections); + connections.Add(RespondAsync(connection, cancellationToken)); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + finally + { + await Task.WhenAll(connections); + } + } + + async Task RespondAsync(TcpClient connection, CancellationToken cancellationToken) + { + using (connection) + { + try + { + using var stream = connection.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + var request = await reader.ReadLineAsync(cancellationToken); + if (request is null) + return; + Interlocked.Increment(ref _startedRequests); + var path = request.Split(' ').ElementAtOrDefault(1)?.Split('?')[0] ?? ""; + for (var count = 0; ; count++) + { + var header = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrEmpty(header)) + break; + if (count >= 64) + throw new InvalidDataException("Too many HTTP fixture request headers."); + } + RequestPaths.Enqueue(path); + var found = path is "/index.html" or "/second.html"; + var html = found + ? await File.ReadAllTextAsync(Path.Combine(_resourceDirectory, path[1..]), cancellationToken) + : "Not found"; + html = html.Replace("""""", "") + .Replace("Hybrid WebView fixture", "HTTP WebView fixture") + .Replace("data-host=\"hybrid\"", "data-host=\"standard\"") + .Replace("window.HybridWebView.SendRawMessage", "window.HybridWebView?.SendRawMessage"); + var bytes = Encoding.UTF8.GetBytes(html); + var headers = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {(found ? "200 OK" : "404 Not Found")}\r\nContent-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {bytes.Length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(headers, cancellationToken); + await stream.WriteAsync(bytes, cancellationToken); + ServedResponses.Enqueue(new WebViewHttpResponse( + path, found ? 200 : 404, bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)), html)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException ex) when (ex.InnerException is SocketException + { SocketErrorCode: SocketError.ConnectionReset or SocketError.ConnectionAborted }) + { + var socketError = ((SocketException)ex.InnerException).SocketErrorCode; + ClientDisconnects.Enqueue(socketError); + Console.WriteLine($"WebView HTTP fixture client disconnected: {socketError}"); + } + } + } + + public async ValueTask DisposeAsync() + { + await _shutdown.CancelAsync(); + try + { + await _requests; + } + finally + { + _listener.Stop(); + _shutdown.Dispose(); + } + } + + internal sealed record WebViewHttpResponse(string Path, int StatusCode, int BodyLength, string BodySha256, string Body); +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WindowsFixture.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WindowsFixture.cs index 086e0cbe2..cea688191 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WindowsFixture.cs +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Fixtures/WindowsFixture.cs @@ -8,6 +8,12 @@ namespace Microsoft.Maui.DevFlow.Agent.IntegrationTests.Fixtures; public sealed class WindowsFixture : AppFixtureBase { Process? _appProcess; + Process? _launcherProcess; + readonly List _outputPumps = []; + readonly List _exitObservations = []; + readonly object _diagnosticsGate = new(); + string _diagnosticsPath = ""; + volatile bool _stopping; public override string Platform => "windows"; @@ -31,28 +37,91 @@ await BuildSampleAsync( $"-p:ApplicationId={applicationId}"); var exePath = FindExecutable(); + var diagnosticsDirectory = Path.Combine( + FindRepoRoot(), "artifacts", "TestResults", "devflow-integration", "windows", "diagnostics"); + Directory.CreateDirectory(diagnosticsDirectory); + _diagnosticsPath = Path.Combine(diagnosticsDirectory, $"sample-{AgentPort}.log"); var psi = new ProcessStartInfo(exePath) { UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, }; psi.Environment["DEVFLOW_TEST_PORT"] = AgentPort.ToString(); + psi.Environment["DEVFLOW_TEST_DIAGNOSTICS_PATH"] = + Path.Combine(diagnosticsDirectory, $"sample-{AgentPort}-unhandled.log"); - _appProcess = Process.Start(psi) + _launcherProcess = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to launch {exePath}"); + _appProcess = _launcherProcess; + _exitObservations.Add(ObserveExitAsync(_launcherProcess, "launcher")); + _outputPumps.Add(DrainAsync(_launcherProcess.StandardOutput, "stdout")); + _outputPumps.Add(DrainAsync(_launcherProcess.StandardError, "stderr")); + WriteDiagnostic($"Launched pid={_launcherProcess.Id}, port={AgentPort}, executable={exePath}."); + + await WaitForAgentAsync(timeoutSeconds: 120); + var status = await Client.GetStatusAsync(); + var agentPid = status?.App?.ProcessId + ?? throw new InvalidOperationException("The Windows agent did not report its process ID."); + if (agentPid != _launcherProcess.Id) + { + var actualProcess = Process.GetProcessById(agentPid); + if (!string.Equals(actualProcess.MainModule?.FileName, exePath, StringComparison.OrdinalIgnoreCase) + || actualProcess.StartTime.ToUniversalTime() < _launcherProcess.StartTime.ToUniversalTime()) + { + actualProcess.Dispose(); + throw new InvalidOperationException($"Agent PID {agentPid} is not the owned sample process."); + } + _appProcess = actualProcess; + _exitObservations.Add(ObserveExitAsync(actualProcess, "agent")); + } + WriteDiagnostic($"Connected to actual agent pid={agentPid}, launcher pid={_launcherProcess.Id}, port={AgentPort}."); + Console.WriteLine($"[DevFlow Windows sample] Agent pid={agentPid}; diagnostics: {_diagnosticsPath}"); }); } + async Task ObserveExitAsync(Process process, string role) + { + await process.WaitForExitAsync(); + WriteDiagnostic($"{role} pid={process.Id} exited: code={process.ExitCode} " + + $"(0x{process.ExitCode:X8}), fixtureStopping={_stopping}."); + } + + async Task DrainAsync(StreamReader reader, string source) + { + while (await reader.ReadLineAsync() is { } line) + WriteDiagnostic($"{source}: {line}"); + } + + void WriteDiagnostic(string message) + { + lock (_diagnosticsGate) + File.AppendAllText(_diagnosticsPath, $"{DateTimeOffset.UtcNow:O} {message}{Environment.NewLine}"); + } + protected override async Task DisposePlatformAsync() { - if (_appProcess is { HasExited: false }) + _stopping = true; + try { - _appProcess.Kill(entireProcessTree: true); - try { await _appProcess.WaitForExitAsync(new CancellationTokenSource(5000).Token); } catch { } + foreach (var process in new[] { _appProcess, _launcherProcess }.OfType().DistinctBy(p => p.Id)) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + } + await Task.WhenAll(_outputPumps.Concat(_exitObservations)).WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + if (!ReferenceEquals(_appProcess, _launcherProcess)) + _launcherProcess?.Dispose(); + _appProcess?.Dispose(); } - - _appProcess?.Dispose(); } static string FindExecutable() diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/GenericWebViewTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/GenericWebViewTests.cs new file mode 100644 index 000000000..fb60bb4dc --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/GenericWebViewTests.cs @@ -0,0 +1,674 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Maui.DevFlow.Agent.IntegrationTests.Fixtures; +using Microsoft.Maui.DevFlow.Driver; +using SkiaSharp; +using Xunit.Abstractions; + +namespace Microsoft.Maui.DevFlow.Agent.IntegrationTests; + +[Collection("AgentIntegration")] +[Trait("Category", "WebView")] +[Trait("fixture", "GenericWebView")] +[Trait(TestFramework.Trait, TestFramework.Maui)] +public class GenericWebViewTests(AppFixture app, ITestOutputHelper output) + : IntegrationTestBase(app, output), IAsyncLifetime +{ + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + await NavigateAsync("//native"); + App.InvalidateBlazorReady(); + } + + [Theory] + [InlineData("webview", "StandardWebView", "Standard WebView fixture")] + [InlineData("hybrid", "HybridWebView", "Hybrid WebView fixture")] + public async Task Context_GenericHost_IsCanonicalReadyAndCorrelated(string kind, string id, string title) + { + var context = await EnsureHostAsync(kind, title); + Assert.Equal(id, context.GetProperty("automationId").GetString()); + AssertContext(context); + var element = await FindElementAsync(id); + Assert.Equal(element.Id, context.GetProperty("elementId").GetString()); + Assert.Equal(title, (await EvaluateAsync("document.title", ContextId(context))).GetString()); + Assert.Contains(title, await Client.GetCdpSourceAsync(ContextId(context))); + var status = await Client.GetStatusAsync(); + Assert.NotNull(status); + Assert.NotNull(status.Capabilities); + Assert.True(status.Capabilities.WebView); + } + + [Theory] + [InlineData("webview", "Standard WebView fixture")] + [InlineData("hybrid", "Hybrid WebView fixture")] + public async Task Input_GenericHost_DeliversEventsAndClicksExactlyOnce(string kind, string title) + { + var id = ContextId(await EnsureHostAsync(kind, title)); + await AssertInputAndClickAsync(id); + } + + [Theory] + [InlineData("webview", "Standard WebView fixture")] + [InlineData("hybrid", "Hybrid WebView fixture")] + [InlineData("blazor", "SampleMauiApp")] + public async Task Failure_SelectedHost_PreservesJavaScriptDetailsAndNeverReplays(string kind, string title) + { + JsonElement context; + if (kind == "blazor") + { + await App.EnsureBlazorReadyAsync(); + context = Assert.Single(Contexts(await Client.GetCdpWebViewsAsync()), c => c.GetProperty("active").GetBoolean()); + } + else + { + context = await EnsureHostAsync(kind, title); + } + var id = ContextId(context); + await EvaluateAsync("window.unit5Attempts = 0", id); + var error = await Assert.ThrowsAsync(() => Client.SendCdpCommandAsync( + "Runtime.evaluate", + new JsonObject { ["expression"] = "window.unit5Attempts++; throw new Error('unit5-exact-js-failure')" }, id)); + Assert.Contains("unit5-exact-js-failure", error.Message); + Assert.Equal(1, (await EvaluateAsync("window.unit5Attempts", id)).GetInt32()); + var missing = await Assert.ThrowsAsync( + () => Client.ClickWebViewAsync("#unit5-missing-element", id)); + Assert.Contains("No element matches selector", missing.Message); + Assert.Contains("#unit5-missing-element", missing.Message); + } + + [Theory] + [InlineData("webview", "Standard WebView fixture", 251, 228, 228)] + [InlineData("hybrid", "Hybrid WebView fixture", 226, 244, 231)] + public async Task Screenshot_GenericHost_IsDecodedHostSizedAndHostColored( + string kind, string title, byte red, byte green, byte blue) + { + var context = await EnsureHostAsync(kind, title); + await AssertScreenshotAsync(context, new SKColor(red, green, blue)); + } + + [Theory] + [InlineData("webview", "Standard WebView fixture")] + [InlineData("hybrid", "Hybrid WebView fixture")] + public async Task Reload_GenericHost_ReinjectsAndPreservesNativeInfrastructure(string kind, string title) + { + var context = await EnsureHostAsync(kind, title); + var id = ContextId(context); + await using var fixture = kind == "webview" ? new WebViewHttpFixture() : null; + var reloadTitle = fixture is null ? title : "HTTP WebView fixture"; + try + { + if (fixture is not null) + { + Assert.True(await Client.NavigateWebViewAsync(fixture.GetUrl(Platform), id)); + await WaitForDocumentAsync(kind, reloadTitle); + } + Assert.True(await Client.ClickWebViewAsync("#fixture-button", id)); + Assert.Equal(1, (await EvaluateAsync("count", id)).GetInt32()); + await Client.SendCdpCommandAsync("Page.reload", contextId: id); + context = await WaitForDocumentAsync(kind, reloadTitle, expectedCount: 0); + Assert.Equal(id, ContextId(context)); + await AssertInputAndClickAsync(id); + if (kind == "hybrid") + await AssertHybridMessagingAsync(id, "HybridMessageStatus", "HybridSendMessageButton"); + else + { + Assert.StartsWith("Success:", (await FindElementAsync("StandardNavigationStatus")).Text); + Assert.True(fixture!.RequestPaths.Count(p => p == "/index.html") >= 2); + } + } + finally + { + if (fixture is not null) + { + WriteHttpEvidence(fixture); + Assert.True(await Client.TapAsync((await FindElementAsync("ResetStandardWebViewButton")).Id)); + await WaitForDocumentAsync(kind, title); + } + } + } + + [Theory] + [InlineData("webview", "Standard WebView fixture", "RecreateStandardWebViewButton")] + [InlineData("hybrid", "Hybrid WebView fixture", "ResetHybridWebViewButton")] + public async Task Recreate_GenericHost_RemovesOldRegistrationAndState(string kind, string title, string buttonId) + { + var previous = await EnsureHostAsync(kind, title); + Assert.True(await Client.ClickWebViewAsync("#fixture-button", ContextId(previous))); + Assert.True(await Client.TapAsync((await FindElementAsync(buttonId)).Id)); + var current = await WaitForDocumentAsync(kind, title, ContextId(previous)); + Assert.NotEqual(ContextId(previous), ContextId(current)); + Assert.Equal((await FindElementAsync(current.GetProperty("automationId").GetString()!)).Id, + current.GetProperty("elementId").GetString()); + Assert.Equal(0, (await EvaluateAsync("count", ContextId(current))).GetInt32()); + var contexts = await Client.GetCdpWebViewsAsync(); + Assert.DoesNotContain(Contexts(contexts), c => ContextId(c) == ContextId(previous)); + using var stale = await GetRawAsync($"/api/v1/webview/source?contextId={ContextId(previous)}"); + Assert.Equal(HttpStatusCode.BadRequest, stale.StatusCode); + await AssertInputAndClickAsync(ContextId(current)); + } + + [Theory] + [InlineData("webview", "Standard WebView fixture")] + [InlineData("hybrid", "Hybrid WebView fixture")] + public async Task Layout_GenericHost_ReportsMeasuredDomDefectsAndHonestCapabilities(string kind, string title) + { + var context = await EnsureHostAsync(kind, title); + await AssertDomClipGeometryAsync(ContextId(context)); + var capabilities = (await Client.GetCapabilitiesAsync()).GetProperty("capabilities"); + var layout = capabilities.GetProperty("ui.layoutDiagnostics"); + Assert.True(layout.GetProperty("webview").GetProperty("supported").GetBoolean()); + Assert.False(layout.TryGetProperty("blazor", out _)); + Assert.Contains(layout.GetProperty("features").EnumerateArray(), f => f.GetString() == "webview-dom"); + var result = await AnalyzeAsync(includeDom: true); + await WriteLayoutEvidenceAsync(result, [context]); + var text = Assert.Single(result.Findings, f => f.Element.AutomationId == "fixture-overflow" + && f.RuleId == LayoutDiagnosticRules.TextNotFullyRendered); + Assert.StartsWith($"web-{context.GetProperty("index").GetInt32()}-", text.Element.Id); + Assert.Equal(context.GetProperty("elementId").GetString(), text.Element.ParentId); + var clip = Assert.Single(result.Findings, f => f.Element.AutomationId == "fixture-clipped" + && f.RuleId == LayoutDiagnosticRules.ElementClipped); + Assert.NotNull(clip.Evidence?.LostAreaRatio); + Assert.InRange(clip.Evidence!.LostAreaRatio!.Value, 0.74, 0.76); + Assert.Contains(clip.Evidence.ClipChain, c => c.Kind == "ancestor-layout-clip"); + var nativeOnly = await AnalyzeAsync(includeDom: false); + Assert.DoesNotContain(nativeOnly.Findings, f => f.Element.Id.StartsWith("web-", StringComparison.Ordinal)); + Assert.Equal(result.Snapshot.TreeRevision, nativeOnly.Snapshot.TreeRevision); + Assert.True(nativeOnly.Snapshot.NodeCount < result.Snapshot.NodeCount); + } + + [Fact] + public async Task HybridMessaging_OriginalReadyAndRoundTripSurviveNavigationReloadAndRecreation() + { + var context = await EnsureHostAsync("hybrid", "Hybrid WebView fixture"); + await WaitForAsync(async () => (await FindElementAsync("HybridMessageStatus")).Text == "ready: Hybrid WebView fixture"); + await AssertHybridMessagingAsync(ContextId(context), "HybridMessageStatus", "HybridSendMessageButton"); + Assert.True(await Client.ClickWebViewAsync("#fixture-link", ContextId(context))); + context = await WaitForDocumentAsync("hybrid", "Second WebView document"); + await WaitForAsync(async () => (await FindElementAsync("HybridMessageStatus")).Text == "ready: Second WebView document"); + Assert.EndsWith("/second.html", (await EvaluateAsync("location.pathname", ContextId(context))).GetString()); + Assert.Equal("Second document loaded", + (await EvaluateAsync("document.querySelector('#second-document').textContent", ContextId(context))).GetString()); + await AssertHybridMessagingAsync(ContextId(context), "HybridMessageStatus", "HybridSendMessageButton"); + await Client.SendCdpCommandAsync("Page.reload", contextId: ContextId(context)); + context = await WaitForDocumentAsync("hybrid", "Second WebView document", expectedNativeMessage: "none"); + await AssertHybridMessagingAsync(ContextId(context), "HybridMessageStatus", "HybridSendMessageButton"); + Assert.True(await Client.NavigateWebViewAsync("index.html", ContextId(context))); + context = await WaitForDocumentAsync("hybrid", "Hybrid WebView fixture"); + await AssertInputAndClickAsync(ContextId(context)); + var previousId = ContextId(context); + Assert.True(await Client.TapAsync((await FindElementAsync("ResetHybridWebViewButton")).Id)); + context = await WaitForDocumentAsync("hybrid", "Hybrid WebView fixture", previousId); + Assert.NotEqual(previousId, ContextId(context)); + await AssertHybridMessagingAsync(ContextId(context), "HybridMessageStatus", "HybridSendMessageButton"); + } + + [Fact] + public async Task Navigate_StandardHttp_RootRelativeDocumentReloadAndNativeRestoration() + { + var context = await EnsureHostAsync("webview", "Standard WebView fixture"); + await using var fixture = new WebViewHttpFixture(); + using var http = new HttpClient(); + using var readiness = await http.GetAsync(fixture.GetUrl("maccatalyst")); + readiness.EnsureSuccessStatusCode(); + try + { + Assert.True(await Client.NavigateWebViewAsync(fixture.GetUrl(Platform), ContextId(context))); + context = await WaitForDocumentAsync("webview", "HTTP WebView fixture"); + await AssertInputAndClickAsync(ContextId(context)); + Assert.True(await Client.NavigateWebViewAsync("/second.html", ContextId(context))); + context = await WaitForDocumentAsync("webview", "Second WebView document"); + Assert.Equal("/second.html", (await EvaluateAsync("location.pathname", ContextId(context))).GetString()); + Assert.Equal("Second document loaded", + (await EvaluateAsync("document.querySelector('#second-document').textContent", ContextId(context))).GetString()); + await WaitForAsync(async () => (await FindElementAsync("StandardNavigationStatus")).Text == + $"Success: {fixture.GetUrl(Platform).Replace("/index.html", "/second.html")}"); + await Client.SendCdpCommandAsync("Page.reload", contextId: ContextId(context)); + context = await WaitForDocumentAsync("webview", "Second WebView document"); + Assert.True(await Client.ClickWebViewAsync("#return-link", ContextId(context))); + context = await WaitForDocumentAsync("webview", "HTTP WebView fixture"); + Assert.Equal(0, (await EvaluateAsync("count", ContextId(context))).GetInt32()); + Assert.Contains("/index.html", fixture.RequestPaths); + Assert.True(fixture.RequestPaths.Count(p => p == "/second.html") >= 2); + } + finally + { + WriteHttpEvidence(fixture); + Assert.True(await Client.TapAsync((await FindElementAsync("ResetStandardWebViewButton")).Id)); + await WaitForDocumentAsync("webview", "Standard WebView fixture"); + } + } + + [Theory] + [InlineData("webview", "Standard WebView fixture")] + [InlineData("hybrid", "Hybrid WebView fixture")] + public async Task Console_GenericHost_DeliversOriginalBrowserEvent(string kind, string title) + { + var context = await EnsureHostAsync(kind, title); + var path = $"/api/v1/webview/console?contextId={ContextId(context)}"; + var before = await GetJsonAsync(path); + Assert.True(await Client.ClickWebViewAsync("#fixture-log", ContextId(context))); + var marker = (await EvaluateAsync("document.querySelector('#fixture-log').dataset.lastMarker", ContextId(context))).GetString(); + Assert.NotNull(marker); + Assert.StartsWith("devflow-generic-webview-console-marker-", marker); + Assert.DoesNotContain(before.EnumerateArray(), log => log.GetProperty("m").GetString() == marker); + await WaitForAsync(async () => + { + var console = await GetJsonAsync(path); + return console.EnumerateArray().Any(log => log.GetProperty("m").GetString() == marker); + }, timeoutMs: 10000); + } + + [Fact] + public async Task HttpFixture_ServesRealResourcesRejectsUnknownPathsAndStopsOwnedListener() + { + var fixture = new WebViewHttpFixture(); + var port = fixture.Port; + try + { + using var idleConnection = new System.Net.Sockets.TcpClient(); + await idleConnection.ConnectAsync("127.0.0.1", port); + using (var abandoned = new System.Net.Sockets.TcpClient()) + { + await abandoned.ConnectAsync("127.0.0.1", port); + await WaitForAsync(() => Task.FromResult(fixture.AcceptedConnectionCount >= 2)); + await abandoned.GetStream().WriteAsync( + System.Text.Encoding.ASCII.GetBytes("GET /index.html HTTP/1.1\r\nX-Abandoned: ")); + await WaitForAsync(() => Task.FromResult(fixture.StartedRequestCount == 1)); + abandoned.Client.LingerState = new System.Net.Sockets.LingerOption(true, 0); + abandoned.Client.Close(); + } + await WaitForAsync(() => Task.FromResult(!fixture.ClientDisconnects.IsEmpty)); + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var html = await http.GetStringAsync(fixture.GetUrl("maccatalyst")); + Assert.Contains("HTTP WebView fixture", html); + Assert.Contains("fixture-button", html); + using var missing = await http.GetAsync($"http://127.0.0.1:{port}/missing.html"); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + } + finally + { + await fixture.DisposeAsync(); + } + using var socket = new System.Net.Sockets.TcpClient(); + await Assert.ThrowsAsync(() => socket.ConnectAsync("127.0.0.1", port)); + } + + [Fact] + public async Task MixedHosts_UnnamedAndDuplicateIds_RouteStateSourceAndNativePngIndependently() + { + var active = await EnsureMixedAsync(); + Assert.Equal(3, active.Select(ContextId).Distinct().Count()); + Assert.Equal(3, active.Select(c => c.GetProperty("elementId").GetString()).Distinct().Count()); + foreach (var context in active) + AssertContext(context); + var standard = Assert.Single(active, c => HostKind(c) == "webview"); + var hybrid = Assert.Single(active, c => HostKind(c) == "hybrid"); + var blazor = Assert.Single(active, c => HostKind(c) == "blazor"); + Assert.Equal(JsonValueKind.Null, standard.GetProperty("automationId").ValueKind); + Assert.Equal(hybrid.GetProperty("automationId").GetString(), blazor.GetProperty("automationId").GetString()); + await AssertInputAndClickAsync(ContextId(standard)); + Assert.Equal(0, (await EvaluateAsync("count", ContextId(hybrid))).GetInt32()); + await AssertInputAndClickAsync(ContextId(hybrid)); + Assert.Contains("Mixed standard WebView fixture", await Client.GetCdpSourceAsync(ContextId(standard))); + Assert.Contains("Hybrid WebView fixture", await Client.GetCdpSourceAsync(ContextId(hybrid))); + Assert.Contains("todo-container", await Client.GetCdpSourceAsync(ContextId(blazor))); + await AssertHybridMessagingAsync(ContextId(hybrid), "MixedHybridMessageStatus", "MixedHybridSendMessageButton"); + await AssertBlazorCounterAsync(ContextId(blazor)); + Assert.Equal(1, (await EvaluateAsync("count", ContextId(standard))).GetInt32()); + Assert.Equal(1, (await EvaluateAsync("count", ContextId(hybrid))).GetInt32()); + await AssertScreenshotAsync(standard, new SKColor(251, 228, 228)); + await AssertScreenshotAsync(hybrid, new SKColor(226, 244, 231)); + using var colors = JsonDocument.Parse((await EvaluateAsync( + """JSON.stringify(getComputedStyle(document.querySelector('.todo-container')).backgroundColor.match(/\d+/g).slice(0, 3).map(Number))""", + ContextId(blazor))).GetString()!); + var rgb = colors.RootElement; + await AssertScreenshotAsync(blazor, new SKColor(rgb[0].GetByte(), rgb[1].GetByte(), rgb[2].GetByte())); + } + + [Fact] + public async Task MixedHosts_LayoutScopesKeepNativeDefectsAndRevisionWithoutHiddenDom() + { + var active = await EnsureMixedAsync(); + foreach (var context in active.Where(c => HostKind(c) != "blazor")) + await AssertDomClipGeometryAsync(ContextId(context)); + var nativeHost = await FindElementAsync("WebViewNativeClipHost"); + var nativeChild = await FindElementAsync("WebViewNativeClipped"); + var result = await AnalyzeAsync(includeDom: true); + await WriteLayoutEvidenceAsync(result, active); + Assert.NotNull(nativeHost.Bounds); + Assert.NotNull(nativeChild.Bounds); + Assert.Equal(40, nativeHost.Bounds.Width, precision: 1); + Assert.Equal(40, nativeHost.Bounds.Height, precision: 1); + Assert.Equal(80, nativeChild.Bounds.Width, precision: 1); + Assert.Equal(80, nativeChild.Bounds.Height, precision: 1); + foreach (var context in active.Where(c => HostKind(c) != "blazor")) + { + var prefix = $"web-{context.GetProperty("index").GetInt32()}-"; + var finding = Assert.Single(result.Findings, f => f.Element.Id.StartsWith(prefix, StringComparison.Ordinal) + && f.Element.AutomationId == "fixture-overflow" && f.RuleId == LayoutDiagnosticRules.TextNotFullyRendered); + Assert.Equal(context.GetProperty("elementId").GetString(), finding.Element.ParentId); + var host = Assert.Single(Flatten(await Client.GetTreeAsync()), e => e.Id == finding.Element.ParentId); + Assert.NotNull(host.WindowBounds); + Assert.NotNull(finding.Evidence?.FullRegion); + Assert.True(finding.Evidence!.FullRegion!.Bounds.X >= host.WindowBounds!.X); + Assert.True(finding.Evidence.FullRegion.Bounds.Y >= host.WindowBounds.Y); + } + var native = Assert.Single(result.Findings, f => f.Element.AutomationId == "WebViewNativeClipped" + && f.RuleId == LayoutDiagnosticRules.ElementClipped); + Assert.InRange(Assert.IsType(native.Evidence?.LostAreaRatio), 0.74, 0.76); + var nativeOnly = await AnalyzeAsync(includeDom: false); + await WriteLayoutEvidenceAsync(nativeOnly, active); + Assert.Equal(result.Snapshot.TreeRevision, nativeOnly.Snapshot.TreeRevision); + Assert.DoesNotContain(nativeOnly.Findings, f => f.Element.Id.StartsWith("web-", StringComparison.Ordinal)); + var preserved = Assert.Single(nativeOnly.Findings, f => f.Element.AutomationId == "WebViewNativeClipped" + && f.RuleId == LayoutDiagnosticRules.ElementClipped); + Assert.Equal(native.Evidence!.LostAreaRatio, preserved.Evidence!.LostAreaRatio); + Assert.True(await Client.NavigateAsync("//native")); + var hidden = await AnalyzeAsync(includeDom: true); + Assert.DoesNotContain(hidden.Findings, f => f.Element.Id.StartsWith("web-", StringComparison.Ordinal)); + Assert.DoesNotContain(Contexts(await Client.GetCdpWebViewsAsync()), c => c.GetProperty("active").GetBoolean()); + } + + [Fact] + public async Task MixedHosts_RecreateAndRetainedPagesNeverCrossMapOldContexts() + { + var previous = await EnsureMixedAsync(); + var previousIds = previous.Select(ContextId).ToArray(); + Assert.True(await Client.TapAsync((await FindElementAsync("ResetMixedWebViewsButton")).Id)); + await WaitForAsync(async () => !Contexts(await Client.GetCdpWebViewsAsync()) + .Any(c => previousIds.Contains(ContextId(c))), timeoutMs: 10000); + var current = await WaitForMixedAsync(); + Assert.DoesNotContain(current, c => previousIds.Contains(ContextId(c))); + Assert.DoesNotContain(Contexts(await Client.GetCdpWebViewsAsync()), c => previousIds.Contains(ContextId(c))); + foreach (var id in previousIds) + { + using var stale = await GetRawAsync($"/api/v1/webview/screenshot?contextId={id}"); + Assert.Equal(HttpStatusCode.BadRequest, stale.StatusCode); + } + Assert.True(await Client.NavigateAsync("//native")); + Assert.DoesNotContain(Contexts(await Client.GetCdpWebViewsAsync()), c => c.GetProperty("active").GetBoolean()); + Assert.True(await Client.NavigateAsync("//mixedwebviews")); + var retained = await WaitForMixedAsync(); + Assert.Equal(current.Select(ContextId).Order(), retained.Select(ContextId).Order()); + Assert.Equal("Mixed standard WebView fixture", + (await EvaluateAsync("document.title", ContextId(Assert.Single(retained, c => HostKind(c) == "webview")))).GetString()); + } + + [Fact] + public async Task Blazor_RealComponentEventAndRoutingWorkAlongsideNativeAutomation() + { + await App.EnsureBlazorReadyAsync(); + var context = Assert.Single(Contexts(await Client.GetCdpWebViewsAsync()), c => c.GetProperty("active").GetBoolean()); + await AssertBlazorCounterAsync(ContextId(context)); + Assert.True(await Client.NavigateAsync("//native")); + var button = await FindElementAsync("AddButton"); + Assert.True(button.IsVisible); + Assert.NotNull(await Client.ScreenshotAsync(elementId: button.Id)); + App.InvalidateBlazorReady(); + } + + async Task AssertBlazorCounterAsync(string id) + { + Assert.True(await Client.NavigateWebViewAsync("/counter", id)); + await WaitForAsync(async () => + (await EvaluateAsync("document.querySelector('[role=status]')?.textContent", id)).GetString() == "Current count: 0", + timeoutMs: 30000); + Assert.EndsWith("/counter", (await EvaluateAsync("location.pathname", id)).GetString()); + Assert.True(await Client.ClickWebViewAsync("button.btn-primary", id)); + await WaitForAsync(async () => + (await EvaluateAsync("document.querySelector('[role=status]').textContent", id)).GetString() == "Current count: 1"); + Assert.True(await Client.NavigateWebViewAsync("/", id)); + await WaitForAsync(async () => (await EvaluateAsync("!!document.querySelector('.todo-container')", id)).GetBoolean()); + Assert.DoesNotContain("Current count:", await Client.GetCdpSourceAsync(id)); + } + + async Task AssertDomClipGeometryAsync(string id) + { + var value = await EvaluateAsync( + """ + JSON.stringify((() => { + const clip = document.querySelector('#fixture-clip').getBoundingClientRect(); + const child = document.querySelector('#fixture-clipped').getBoundingClientRect(); + return { clip: clip.toJSON(), child: child.toJSON(), width: innerWidth, height: innerHeight }; + })()) + """, id); + using var geometry = JsonDocument.Parse(value.GetString()!); + var root = geometry.RootElement; + var clip = root.GetProperty("clip"); + var child = root.GetProperty("child"); + Output.WriteLine($"Controlled clipping geometry for {id}: {root}"); + Assert.Equal(40, clip.GetProperty("width").GetDouble(), precision: 1); + Assert.Equal(40, clip.GetProperty("height").GetDouble(), precision: 1); + Assert.Equal(80, child.GetProperty("width").GetDouble(), precision: 1); + Assert.Equal(80, child.GetProperty("height").GetDouble(), precision: 1); + Assert.True(clip.GetProperty("left").GetDouble() >= 0); + Assert.True(clip.GetProperty("top").GetDouble() >= 0); + Assert.True(clip.GetProperty("right").GetDouble() <= root.GetProperty("width").GetDouble()); + Assert.True(clip.GetProperty("bottom").GetDouble() <= root.GetProperty("height").GetDouble()); + } + + void WriteHttpEvidence(WebViewHttpFixture fixture) + => Output.WriteLine(JsonSerializer.Serialize(new + { + httpFixturePort = fixture.Port, + accepted = fixture.AcceptedConnectionCount, + started = fixture.StartedRequestCount, + paths = fixture.RequestPaths.ToArray(), + served = fixture.ServedResponses.ToArray(), + disconnects = fixture.ClientDisconnects.ToArray() + })); + + async Task WriteLayoutEvidenceAsync(LayoutInspectionResult result, JsonElement[] contexts) + { + var tree = Flatten(await Client.GetTreeAsync()).ToArray(); + var hosts = contexts.Select(context => + { + var elementId = context.GetProperty("elementId").GetString(); + var element = tree.SingleOrDefault(node => node.Id == elementId); + return new { context, elementId, bounds = element?.Bounds, windowBounds = element?.WindowBounds }; + }).ToArray(); + Output.WriteLine($"Layout hosts: {JsonSerializer.Serialize(hosts)}"); + Output.WriteLine($"Native clip fixture: {JsonSerializer.Serialize(tree + .Where(node => node.AutomationId is "WebViewNativeClipHost" or "WebViewNativeClipped") + .Select(node => new { node.Id, node.AutomationId, node.Bounds, node.WindowBounds }))}"); + Output.WriteLine($"Layout inspection: {JsonSerializer.Serialize(result)}"); + } + + async Task AssertInputAndClickAsync(string id) + { + Assert.Equal(0, (await EvaluateAsync("count", id)).GetInt32()); + Assert.True(await Client.FillWebViewAsync("#fixture-input", "generic input", id)); + Assert.Equal("generic input", (await EvaluateAsync("document.querySelector('#fixture-input').value", id)).GetString()); + Assert.Equal("generic input", (await EvaluateAsync("document.querySelector('#fixture-input-value').textContent", id)).GetString()); + Assert.True(await Client.InsertWebViewTextAsync(" appended", id)); + Assert.Equal("generic input appended", (await EvaluateAsync("document.querySelector('#fixture-input').value", id)).GetString()); + Assert.Equal("generic input appended", (await EvaluateAsync("document.querySelector('#fixture-input-value').textContent", id)).GetString()); + Assert.True(await Client.ClickWebViewAsync("#fixture-button", id)); + Assert.Equal(1, (await EvaluateAsync("count", id)).GetInt32()); + Assert.Equal("1", (await EvaluateAsync("document.querySelector('#fixture-count').textContent", id)).GetString()); + } + + async Task AssertHybridMessagingAsync(string id, string statusId, string buttonId) + { + Assert.True(await Client.TapAsync((await FindElementAsync(buttonId)).Id)); + await WaitForAsync(async () => (await FindElementAsync(statusId)).Text == "js-ack: native-to-js-ok"); + Assert.Equal("native-to-js-ok", + (await EvaluateAsync("document.querySelector('#native-message').textContent", id)).GetString()); + } + + async Task AssertScreenshotAsync(JsonElement context, SKColor? expectedColor = null) + { + var bytes = await Client.GetWebViewScreenshotAsync(ContextId(context)); + Assert.Equal(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }, bytes.Take(8)); + using var image = SKBitmap.Decode(bytes); + Assert.NotNull(image); + var element = Assert.Single(Flatten(await Client.GetTreeAsync()), e => e.Id == context.GetProperty("elementId").GetString()); + Assert.NotNull(element.Bounds); + var density = (await GetJsonAsync("/api/v1/agent/status")).GetProperty("device").GetProperty("displayDensity").GetDouble(); + Assert.InRange(image.Width, (int)Math.Floor(element.Bounds!.Width * density) - 2, + (int)Math.Ceiling(element.Bounds.Width * density) + 2); + Assert.InRange(image.Height, (int)Math.Floor(element.Bounds.Height * density) - 2, + (int)Math.Ceiling(element.Bounds.Height * density) + 2); + Assert.True(image.Width > 50 && image.Height > 50); + var samples = new List(); + for (var y = 0; y < image.Height; y += 8) + for (var x = 0; x < image.Width; x += 8) + samples.Add(image.GetPixel(x, y)); + Assert.True(samples.Distinct().Count() > 10, "Expected actual rendered content, not a blank PNG."); + if (expectedColor is { } color) + { + var ratio = (double)samples.Count(p => Math.Abs(p.Red - color.Red) < 5 + && Math.Abs(p.Green - color.Green) < 5 && Math.Abs(p.Blue - color.Blue) < 5) / samples.Count; + Assert.True(ratio > 0.35, $"Expected host-specific background coverage >35%, got {ratio:P1}."); + } + Output.WriteLine($"{HostKind(context)} {ContextId(context)} PNG {image.Width}x{image.Height}, {bytes.Length} bytes."); + } + + async Task EnsureHostAsync(string kind, string title) + { + var route = kind == "webview" ? "//webview" : "//hybrid"; + var reset = kind == "webview" ? "RecreateStandardWebViewButton" : "ResetHybridWebViewButton"; + await NavigateAsync(route); + var previous = await WaitForDocumentAsync(kind, title); + Assert.True(await Client.TapAsync((await FindElementAsync(reset)).Id)); + return await WaitForDocumentAsync(kind, title, ContextId(previous)); + } + + async Task EnsureMixedAsync() + { + await NavigateAsync("//mixedwebviews"); + var previous = await WaitForMixedAsync(); + Assert.True(await Client.TapAsync((await FindElementAsync("ResetMixedWebViewsButton")).Id)); + await WaitForAsync(async () => !Contexts(await Client.GetCdpWebViewsAsync()) + .Any(c => previous.Any(p => ContextId(p) == ContextId(c))), timeoutMs: 10000); + return await WaitForMixedAsync(); + } + + async Task WaitForMixedAsync() + { + foreach (var (kind, title) in new[] { ("webview", "Mixed standard WebView fixture"), ("hybrid", "Hybrid WebView fixture") }) + await WaitForDocumentAsync(kind, title); + JsonElement[] active = []; + await WaitForAsync(async () => + { + active = Contexts(await Client.GetCdpWebViewsAsync()).Where(c => c.GetProperty("active").GetBoolean()).ToArray(); + if (active.Length != 3 || active.Any(c => !c.GetProperty("ready").GetBoolean())) + return false; + var blazor = Assert.Single(active, c => HostKind(c) == "blazor"); + return (await EvaluateAsync("!!document.querySelector('.todo-container')", ContextId(blazor))).GetBoolean(); + }, timeoutMs: 60000); + return active; + } + + async Task WaitForDocumentAsync( + string kind, string title, string? previousId = null, int? expectedCount = null, string? expectedNativeMessage = null) + { + JsonElement selected = default; + JsonElement lastContexts = default; + string? lastTitle = null; + string? lastError = null; + try + { + await WaitForAsync(async () => + { + lastContexts = await Client.GetCdpWebViewsAsync(); + var active = Contexts(lastContexts).Where(c => + c.GetProperty("active").GetBoolean() && c.GetProperty("ready").GetBoolean() && HostKind(c) == kind + && ContextId(c) != previousId).ToArray(); + if (active.Length != 1) + return false; + selected = active[0]; + try + { + lastTitle = (await EvaluateAsync("document.title", ContextId(selected))).GetString(); + return lastTitle == title + && (expectedCount is null || (await EvaluateAsync("count", ContextId(selected))).GetInt32() == expectedCount) + && (expectedNativeMessage is null || (await EvaluateAsync( + "document.querySelector('#native-message').textContent", ContextId(selected))).GetString() == expectedNativeMessage); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("not ready", StringComparison.OrdinalIgnoreCase)) + { + lastError = ex.Message; + return false; + } + }, timeoutMs: 60000); + } + catch (TimeoutException) + { + Output.WriteLine($"Document readiness failed: kind={kind}, expectedTitle={title}, previousId={previousId}, " + + $"lastTitle={lastTitle}, lastError={lastError}, contexts={lastContexts}"); + try + { + var statusIds = kind == "webview" + ? new[] { "StandardNavigationStatus", "StandardDocumentStatus" } + : new[] { "HybridMessageStatus" }; + foreach (var statusId in statusIds) + foreach (var status in await Client.QueryAsync(automationId: statusId)) + Output.WriteLine($"Native {statusId}: text={status.Text}, visible={status.IsVisible}."); + } + catch (HttpRequestException ex) + { + Output.WriteLine($"Native readiness diagnostic could not reach the agent: {ex.Message}"); + } + catch (InvalidOperationException ex) + { + Output.WriteLine($"Native readiness diagnostic failed: {ex.Message}"); + } + throw; + } + return selected; + } + + async Task NavigateAsync(string route) + { + using var response = await PostRawAsync("/api/v1/ui/actions/navigate", new { route }); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, + $"Navigation to '{route}' failed: HTTP {(int)response.StatusCode}: {body}"); + using var result = JsonDocument.Parse(body); + Assert.True(result.RootElement.GetProperty("success").GetBoolean(), + $"Navigation to '{route}' did not succeed: {body}"); + } + + async Task EvaluateAsync(string expression, string id) + { + var response = await Client.SendCdpCommandAsync("Runtime.evaluate", + new JsonObject { ["expression"] = expression, ["returnByValue"] = true }, id); + return response.GetProperty("result").GetProperty("result").GetProperty("value").Clone(); + } + + async Task AnalyzeAsync(bool includeDom) + => Assert.IsType(await Client.AnalyzeLayoutAsync(new LayoutInspectionRequest + { + Profile = "strict", + MinimumSeverity = "info", + Scope = new LayoutInspectionScope { IncludeWebViewElements = includeDom, IncludeNativeElements = true }, + Stability = new LayoutStabilityOptions { Mode = "immediate" } + })); + + static IEnumerable Contexts(JsonElement json) => json.GetProperty("webviews").EnumerateArray(); + static string ContextId(JsonElement context) => context.GetProperty("id").GetString()!; + static string HostKind(JsonElement context) => context.GetProperty("hostKind").GetString()!; + + static void AssertContext(JsonElement context) + { + Assert.True(context.GetProperty("active").GetBoolean()); + Assert.True(context.GetProperty("ready").GetBoolean()); + Assert.False(context.TryGetProperty("isReady", out _)); + Assert.Equal($"webview-{context.GetProperty("index").GetInt32()}", ContextId(context)); + Assert.False(string.IsNullOrEmpty(context.GetProperty("elementId").GetString())); + } + + static IEnumerable Flatten(IEnumerable elements) + { + foreach (var element in elements) + { + yield return element; + foreach (var child in Flatten(element.Children ?? [])) + yield return child; + } + } +} diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Microsoft.Maui.DevFlow.Agent.IntegrationTests.csproj b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Microsoft.Maui.DevFlow.Agent.IntegrationTests.csproj index 70c7b4d5c..fe44b9f86 100644 --- a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Microsoft.Maui.DevFlow.Agent.IntegrationTests.csproj +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/Microsoft.Maui.DevFlow.Agent.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/WebViewHttpFixtureTests.cs b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/WebViewHttpFixtureTests.cs new file mode 100644 index 000000000..2f03af600 --- /dev/null +++ b/src/DevFlow/Microsoft.Maui.DevFlow.Agent.IntegrationTests/WebViewHttpFixtureTests.cs @@ -0,0 +1,99 @@ +using System.Net.Sockets; +using System.Xml.Linq; +using Microsoft.Maui.DevFlow.Agent.IntegrationTests.Fixtures; +using SkiaSharp; + +namespace Microsoft.Maui.DevFlow.Agent.IntegrationTests; + +public class WebViewHttpFixtureTests +{ + [Fact] + public void AndroidHttpPolicy_AllowsOnlyTheOwnedFixtureLoopbackHosts() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "MauiLabs.slnx"))) + root = root.Parent; + Assert.NotNull(root); + var android = Path.Combine(root.FullName, "samples", "DevFlow.Sample", "Platforms", "Android"); + var manifest = XDocument.Load(Path.Combine(android, "AndroidManifest.xml")); + XNamespace ns = "http://schemas.android.com/apk/res/android"; + var application = Assert.Single(manifest.Root!.Elements("application")); + Assert.Null(application.Attribute(ns + "usesCleartextTraffic")); + Assert.Equal("@xml/network_security_config", application.Attribute(ns + "networkSecurityConfig")?.Value); + var policy = XDocument.Load(Path.Combine(android, "Resources", "xml", "network_security_config.xml")); + Assert.Empty(policy.Root!.Elements("base-config")); + var domainConfig = Assert.Single(policy.Root.Elements("domain-config")); + Assert.Equal("true", domainConfig.Attribute("cleartextTrafficPermitted")?.Value); + Assert.Equal(new[] { "10.0.2.2", "127.0.0.1" }, + domainConfig.Elements("domain").Select(domain => domain.Value).Order()); + Assert.All(domainConfig.Elements("domain"), + domain => Assert.Equal("false", domain.Attribute("includeSubdomains")?.Value)); + } + + [Fact] + public void PngDecoder_ReadsEncodedPixelsInTheTestHost() + { + using var source = new SKBitmap(80, 80); + source.Erase(new SKColor(226, 244, 231)); + source.SetPixel(20, 20, SKColors.Red); + using var image = SKImage.FromBitmap(source); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + using var decoded = SKBitmap.Decode(data.ToArray()); + + Assert.NotNull(decoded); + Assert.Equal(80, decoded.Width); + Assert.Equal(80, decoded.Height); + Assert.Equal(SKColors.Red, decoded.GetPixel(20, 20)); + Assert.Equal(new SKColor(226, 244, 231), decoded.GetPixel(60, 60)); + } + + [Fact] + public async Task ClientReset_IncompleteRequest_DoesNotStopOtherConnectionsOrDisposal() + { + var fixture = new WebViewHttpFixture(); + var port = fixture.Port; + try + { + using var idle = new TcpClient(); + await idle.ConnectAsync("127.0.0.1", port); + for (var attempt = 1; attempt <= 20; attempt++) + { + using var abandoned = new TcpClient(); + await abandoned.ConnectAsync("127.0.0.1", port); + await WaitForAsync(() => fixture.AcceptedConnectionCount == attempt + 1); + await abandoned.GetStream().WriteAsync( + System.Text.Encoding.ASCII.GetBytes("GET /index.html HTTP/1.1\r\nX-Abandoned: ")); + await WaitForAsync(() => fixture.StartedRequestCount == attempt); + abandoned.Client.LingerState = new LingerOption(true, 0); + abandoned.Client.Close(); + await WaitForAsync(() => fixture.ClientDisconnects.Count == attempt); + } + Assert.All(fixture.ClientDisconnects, error => + Assert.True(error is SocketError.ConnectionReset or SocketError.ConnectionAborted, error.ToString())); + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var body = await http.GetStringAsync(fixture.GetUrl("maccatalyst")); + Assert.Contains("HTTP WebView fixture", body); + await WaitForAsync(() => !fixture.ServedResponses.IsEmpty); + var served = Assert.Single(fixture.ServedResponses); + Assert.Equal("/index.html", served.Path); + Assert.Equal(200, served.StatusCode); + Assert.Equal(body, served.Body); + Assert.Equal(System.Text.Encoding.UTF8.GetByteCount(body), served.BodyLength); + Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(body))), served.BodySha256); + } + finally + { + await fixture.DisposeAsync(); + } + using var socket = new TcpClient(); + await Assert.ThrowsAsync(() => socket.ConnectAsync("127.0.0.1", port)); + } + + static async Task WaitForAsync(Func condition) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!condition()) + await Task.Delay(25, timeout.Token); + } +}