Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/ONBOARDING_WIZARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,9 @@ The browser-only profile route is not implemented through native credentials.

The connection editor shows the Gateway-stage progress indicator above a
separate row of wrapping Back, Cancel, Check and Next actions.
Connection results stay outside the scrollable form, above the navigation
actions, so a failed check and its retry guidance remain visible without
scrolling on shorter windows.
Both the Welcome existing-Gateway choice and Advanced existing/remote routes
use this editor. Its labels, accessible names and status messages use
`Onboarding_NativeConnection_*` resources in every supported locale.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Style="{StaticResource BodyTextBlockStyle}" />
</DataTemplate>
</Page.Resources>
<Grid Padding="40,4,40,24" RowDefinitions="Auto,*,Auto" RowSpacing="24">
<Grid Padding="40,4,40,24" RowDefinitions="Auto,*,Auto,Auto" RowSpacing="24">
<StackPanel Spacing="4" MaxWidth="600" HorizontalAlignment="Stretch">
<controls:OnboardingMascot x:Name="MascotHero" Mood="Curious" HorizontalAlignment="Center" />
<TextBlock x:Name="TitleText" Style="{StaticResource TitleTextBlockStyle}" TextWrapping="Wrap" TextAlignment="Center" />
Expand Down Expand Up @@ -60,11 +60,11 @@
</toolkit:SettingsExpander>
</StackPanel>
</ContentControl>
<InfoBar x:Name="ResultBar" IsOpen="False" IsClosable="False"
AutomationProperties.LiveSetting="Polite" />
</StackPanel>
</ScrollViewer>
<Grid x:Name="NavigationFooter" Grid.Row="2" RowDefinitions="Auto,Auto"
<InfoBar x:Name="ResultBar" Grid.Row="2" MaxWidth="560" HorizontalAlignment="Stretch"
IsOpen="False" IsClosable="False" AutomationProperties.LiveSetting="Polite" />
<Grid x:Name="NavigationFooter" Grid.Row="3" RowDefinitions="Auto,Auto"
ColumnDefinitions="*,*,*,*" ColumnSpacing="8" RowSpacing="12">
<controls:SetupProgressIndicator x:Name="FlowProgress" Grid.ColumnSpan="4" HorizontalAlignment="Center" />
<Button x:Name="BackButton" Grid.Row="1" Click="Back_Click" MinWidth="0" HorizontalAlignment="Stretch"
Expand Down
110 changes: 110 additions & 0 deletions tests/OpenClaw.Tray.UITests/MountedControlCapture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.UI.Xaml;

namespace OpenClaw.Tray.UITests;

internal static class MountedControlCapture
{
internal static async Task OptionalAsync(Window window, string state)
{
var directory = Environment.GetEnvironmentVariable("OPENCLAW_MOUNTED_CONTROL_CAPTURE_DIR");
if (string.IsNullOrWhiteSpace(directory))
return;

OnboardingNativeProof.AssertIsolatedRoots();
var desktop = new StringBuilder(256);
Assert.True(GetUserObjectInformationW(GetThreadDesktop(GetCurrentThreadId()), 2,
desktop, 512, out _));
Assert.NotEqual("Default", desktop.ToString());
var handle = WinRT.Interop.WindowNative.GetWindowHandle(window);
GetWindowThreadProcessId(handle, out var owner);
Assert.Equal((uint)Environment.ProcessId, owner);
var work = Microsoft.UI.Windowing.DisplayArea.GetFromWindowId(window.AppWindow.Id,
Microsoft.UI.Windowing.DisplayAreaFallback.Nearest).WorkArea;
// Mounted fixtures normally live offscreen. Requested capture stays on the private desktop.
window.AppWindow.Move(new Windows.Graphics.PointInt32(work.X, work.Y));
await OnboardingNativeProof.NextCompositionAsync();
Assert.Equal(0, DwmFlush());
Assert.True(IsWindowVisible(handle));
Assert.False(IsIconic(handle));
Assert.True(GetWindowRect(handle, out var bounds));
Assert.True(bounds.Right > bounds.Left && bounds.Bottom > bounds.Top);
using var bitmap = new Bitmap(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top);
using (var graphics = Graphics.FromImage(bitmap))
{
var dc = graphics.GetHdc();
try
{
Assert.True(PrintWindow(handle, dc, 2), "Owned-window capture failed. No fallback is permitted.");
}
finally
{
graphics.ReleaseHdc(dc);
}
}
var colors = new HashSet<int>();
for (var y = 0; y < bitmap.Height; y += Math.Max(1, bitmap.Height / 40))
for (var x = 0; x < bitmap.Width; x += Math.Max(1, bitmap.Width / 40))
colors.Add(bitmap.GetPixel(x, y).ToArgb());
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, state + ".attempt.json"), JsonSerializer.Serialize(new
{
state,
qualification = "Capture attempt only. Not evidence of rendered content.",
method = "PrintWindow(PW_RENDERFULLCONTENT); no fallback",
head = Environment.GetEnvironmentVariable("OPENCLAW_MOUNTED_CONTROL_CAPTURE_HEAD"),
assemblySha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(typeof(MountedControlCapture).Assembly.Location))),
desktop = desktop.ToString(),
ownerProcessId = owner,
left = bounds.Left,
top = bounds.Top,
right = bounds.Right,
bottom = bounds.Bottom,
workLeft = work.X,
workTop = work.Y,
workWidth = work.Width,
workHeight = work.Height,
sampledColors = colors.Count,
attemptedUtc = DateTimeOffset.UtcNow,
}, new JsonSerializerOptions { WriteIndented = true }));
Assert.True(bounds.Left >= work.X && bounds.Top >= work.Y &&
bounds.Right <= work.X + work.Width && bounds.Bottom <= work.Y + work.Height,
"Owned capture window must fit within its display work area.");
Assert.True(colors.Count >= 8, "Mounted production-control frame is blank or near-uniform.");
var image = Path.Combine(directory, state + ".png");
bitmap.Save(image, ImageFormat.Png);
File.WriteAllText(Path.Combine(directory, state + ".json"), JsonSerializer.Serialize(new
{
state,
qualification = "Synthetic dependencies; private owned production window. Not native compositor, keyboard, installed-app, or live Gateway proof.",
method = "PrintWindow(PW_RENDERFULLCONTENT); no fallback",
head = Environment.GetEnvironmentVariable("OPENCLAW_MOUNTED_CONTROL_CAPTURE_HEAD"),
assemblySha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(typeof(MountedControlCapture).Assembly.Location))),
imageSha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(image))),
width = bitmap.Width,
height = bitmap.Height,
sampledColors = colors.Count,
capturedUtc = DateTimeOffset.UtcNow,
}, new JsonSerializerOptions { WriteIndented = true }));
}

[StructLayout(LayoutKind.Sequential)]
private struct Bounds { public int Left, Top, Right, Bottom; }

[DllImport("user32.dll")] private static extern bool PrintWindow(IntPtr window, IntPtr dc, uint flags);
[DllImport("dwmapi.dll")] private static extern int DwmFlush();
[DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr window, out Bounds bounds);
[DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] private static extern bool IsIconic(IntPtr window);
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint process);
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] private static extern IntPtr GetThreadDesktop(uint thread);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetUserObjectInformationW(IntPtr handle, int index, StringBuilder value,
uint length, out uint needed);
}
45 changes: 45 additions & 0 deletions tests/OpenClaw.Tray.UITests/OnboardingWindowsFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,51 @@ await InvokeCardAsync(window, choices,
Assert.All(host.Checks.Concat(host.Connects), request => Assert.Equal("wss://mounted-flow.invalid", request.GatewayUrl));
}

[Theory]
[InlineData(SetupGatewayRoute.Existing, 0)]
[InlineData(SetupGatewayRoute.Remote, 1)]
public async Task NativeEditor_FailureRemainsVisibleWithoutScrolling(SetupGatewayRoute route, int choiceIndex)
{
var host = new FailingNativeHost();
await WithWindowAsync(async (window, frame, data) =>
{
window.NavigateToAdvancedSetup();
var choices = await MountedPageAsync<AdvancedSetupPage>(window, frame);
await InvokeCardAsync(window, choices,
TestSupport.FindDescendants<SettingsCard>(choices).ElementAt(choiceIndex), route + "_Click");
var editor = await MountedPageAsync<SetupNativeConnectionPage>(window, frame);
var scale = editor.XamlRoot.RasterizationScale;
window.AppWindow.Resize(new((int)(480 * scale), (int)(640 * scale)));
await ui.YieldToRenderAsync();
Find<TextBox>(editor, "AddressInput").Text = "wss://mounted-flow.invalid";
await ui.YieldToRenderAsync();
Invoke(Find<Button>(editor, "CheckButton"));
await WaitAsync(() => host.Checks.Count == 1 && Find<Button>(editor, "CheckButton").IsEnabled, "failed Check");
var failure = Find<InfoBar>(editor, "ResultBar");
failure.ApplyTemplate();
editor.InvalidateMeasure();
editor.UpdateLayout();
await ui.YieldToRenderAsync();
editor.UpdateLayout();
Assert.True(failure.IsOpen);
Assert.Equal("Synthetic check failure. No Gateway was contacted.", failure.Message);
var bounds = Bounds(failure, editor);
var footer = Bounds(Find<Grid>(editor, "NavigationFooter"), editor);
Assert.True(bounds.Height > 0 && bounds.Top >= 0 && bounds.Bottom <= footer.Top + 0.1,
$"Failure must be visible above the actions without scrolling. Failure: {bounds}; footer: {footer}.");
await MountedControlCapture.OptionalAsync(window, $"native-editor-failure-{route}");
for (var ancestor = VisualTreeHelper.GetParent(failure); ancestor is not null;
ancestor = VisualTreeHelper.GetParent(ancestor))
{
if (ancestor is ScrollViewer viewport)
AssertFullyVisible(failure, viewport);
}
Assert.Same(editor, frame.Content);
Assert.False(window.AccessDraft.GatewayAvailable);
Assert.False(File.Exists(Path.Combine(data, "gateways.json")));
}, host);
}

private static void AssertNativeConnectionText(string suffix, object actual)
{
// Resolve expected copy independently, using the same default language context as the page.
Expand Down
Loading