\
+ 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:
+ Increment once
+ Write console marker
+ Message from .NET: none
+ Deliberately overflowing fixture text
+
+ 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);
+ }
+}