using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Automation.Provider;
using Microsoft.UI.Xaml.Media;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Controls;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Infrastructure.Storage;
using XFEWorkflow.ViewModels;
using XFEWorkflow.Views;
namespace XFEWorkflow.Services;
internal static partial class UiSmokeTest
{
internal static IEnumerable<T> Descendants<T>(DependencyObject root) where T : DependencyObject
{
if (root is T target) yield return target;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) foreach (var item in Descendants<T>(VisualTreeHelper.GetChild(root, i))) yield return item;
}
internal static async Task InvokeButtonAsync(Button button)
{
if (!button.IsEnabled) throw new InvalidOperationException("按钮不可用:" + button.Content);
var peer = FrameworkElementAutomationPeer.FromElement(button) ?? new ButtonAutomationPeer(button);
((IInvokeProvider)peer.GetPattern(PatternInterface.Invoke)).Invoke(); await Task.Delay(180);
}
private static async Task WaitManagementAsync(Func<bool> ready)
{ for (var i = 0; i < 80; i++) { if (ready()) return; await Task.Delay(50); } throw new InvalidOperationException("页面没有加载期望的数据"); }
private static async Task SmokeManagementPagesAsync(string output)
{
var services = AppServices.Current; var shell = AppShellPage.Current!; var navigation = shell.ViewModel.NavigationViewService;
var frame = (Frame)shell.FindName("navigationFrame"); var root = (FrameworkElement)App.MainWindow.Content; var theme = root.RequestedTheme; var size = App.MainWindow.AppWindow.Size;
var scale = root.XamlRoot.RasterizationScale;
void Resize(int width, int height) => App.MainWindow.AppWindow.Resize(new((int)(width * scale), (int)(height * scale)));
var workflow = WorkflowTemplates.CreateGraph(); workflow.Name = "页面联动验收";
const string logMessage = "工作流运行日志验收\n第二行:输入与输出完整保留";
var logNode = new NodeDefinition { CapabilityId = "core.log", Name = "界面日志", Parameters = new() { ["message"] = logMessage } };
workflow.Graph.Nodes.Insert(1, logNode); workflow.Graph.Edges.Clear();
for (var i = 0; i < workflow.Graph.Nodes.Count - 1; i++) workflow.Graph.Edges.Add(new() { FromNode = workflow.Graph.Nodes[i].Id, ToNode = workflow.Graph.Nodes[i + 1].Id });
var runInputs = new System.Text.Json.Nodes.JsonObject { ["message"] = "第一行\n第二行", ["options"] = new System.Text.Json.Nodes.JsonObject { ["count"] = 2 } };
await services.Workflows.SaveAsync(workflow); var run = await services.Runs.RunAsync(workflow, inputs: runInputs);
if (run.Status != RunStatus.Succeeded) throw new InvalidOperationException(run.Error);
try
{
shell.ShowBrowser(false); shell.SetNavigationOpenForSmoke(false); Resize(1440, 960); root.RequestedTheme = ElementTheme.Dark;
navigation.NavigateTo<PluginsPage>(); await WaitManagementAsync(() => frame.Content is PluginsPage && Descendants<GridView>(frame).Any(x => x.Items.Count > 0));
var plugins = (PluginsPage)frame.Content;
if (Descendants<Pivot>(plugins).Any() || !Descendants<Button>(plugins).Any(x => AutomationProperties.GetAutomationId(x) == "OpenSpace")) throw new InvalidOperationException("插件中心没有将 Space 独立为页面");
var catalog = Descendants<GridView>(plugins).Single(); if (catalog.Items.Any(x => x is not PluginCardModel)) throw new InvalidOperationException("插件卡片没有真实清单数据");
var pluginPanels = Descendants<DetailsWorkspace>(plugins).Single(); await Task.Delay(300); var previousWidth = pluginPanels.Details.ActualWidth;
await CaptureWindowAsync(Path.Combine(output, "plugins-before-resize.png"));
App.MainWindow.Activate(); SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow)); await Task.Delay(150);
await DragElementAsync(pluginPanels.Splitter, -30, 0);
await AtomicJson.WriteAsync(Path.Combine(output, "plugins-splitter.json"), new { before = previousWidth, after = pluginPanels.Details.ActualWidth, width = pluginPanels.ActualWidth, height = pluginPanels.ActualHeight, orientation = pluginPanels.Splitter.Orientation.ToString(), preferred = pluginPanels.Splitter.PreferredSize });
if (Math.Abs(pluginPanels.Details.ActualWidth - previousWidth - 30) > 4) throw new InvalidOperationException($"插件详情面板无法拖动调整:{previousWidth} → {pluginPanels.Details.ActualWidth}, orientation={pluginPanels.Splitter.Orientation}");
await CaptureWindowAsync(Path.Combine(output, "plugins-center-dark.png"));
root.RequestedTheme = ElementTheme.Light; await Task.Delay(100); await CaptureWindowAsync(Path.Combine(output, "plugins-center-light.png"));
root.RequestedTheme = ElementTheme.Dark;
navigation.NavigateTo<RunsPage>(run.Id); await WaitManagementAsync(() => frame.Content is RunsPage && Descendants<ListView>(frame).Any(x => x.SelectedItem is RunRowModel row && row.Record.Id == run.Id));
var runInputBox = (TextBox)Descendants<Expander>(frame).Single(x => x.Header as string == "输入参数").Content;
if (!System.Text.Json.Nodes.JsonNode.DeepEquals(System.Text.Json.Nodes.JsonNode.Parse(runInputBox.Text), runInputs)) throw new InvalidOperationException("运行记录的多行 JSON 输入显示不完整");
await CaptureWindowAsync(Path.Combine(output, "runs-master-details.png"));
await InvokeButtonAsync(Descendants<Button>(frame).Single(x => AutomationProperties.GetAutomationId(x) == "RunFullLog"));
await WaitManagementAsync(() => frame.Content is LogViewPage && Descendants<ListView>(frame).Any(x => x.Items.OfType<WorkflowLogEntry>().Any(e => e.RunId == run.Id && e.LogText.Contains("工作流运行日志验收"))));
var logs = Descendants<ListView>(frame).First(x => x.Items.OfType<WorkflowLogEntry>().Any());
if (logs.Items.OfType<WorkflowLogEntry>().Any(x => x.RunId != run.Id)) throw new InvalidOperationException("运行记录到日志的筛选未生效");
logs.SelectedItem = logs.Items.OfType<WorkflowLogEntry>().First(x => x.NodeId == logNode.Id && x.LogText.Contains(logMessage)); await Task.Delay(150);
var logDetailBoxes = Descendants<TextBox>(frame).Where(x => x.IsReadOnly).ToArray();
await AtomicJson.WriteAsync(Path.Combine(output, "logs-multiline-details.json"), new { expected = logMessage, actual = logDetailBoxes.Select(x => x.Text) });
if (!logDetailBoxes.Any(x => x.Text.ReplaceLineEndings("\n").Contains(logMessage))) throw new InvalidOperationException("日志详情的多行消息被截断");
await CaptureWindowAsync(Path.Combine(output, "logs-workflow-run-filter.png"));
await InvokeButtonAsync(Descendants<Button>(frame).Single(x => AutomationProperties.GetAutomationId(x) == "LocateLogWorkflow"));
await WaitManagementAsync(() => frame.Content is WorkflowEditorPage && WorkflowEditorPage.Current?.ActiveEditor?.ViewModel.SelectedWorkflow?.Id == workflow.Id);
navigation.NavigateTo<SettingPage>("credentials"); await WaitManagementAsync(() => frame.Content is SettingPage && Descendants<Pivot>(frame).FirstOrDefault()?.SelectedIndex == 1);
if (!Descendants<CredentialSettings>(frame).Any()) throw new InvalidOperationException("凭据没有移入设置页面");
await CaptureWindowAsync(Path.Combine(output, "settings-credentials.png"));
var settings = (SettingPage)frame.Content; var settingsTabs = Descendants<Pivot>(settings).First(); settingsTabs.SelectedIndex = 2; await Task.Delay(500);
if (!Descendants<DesktopInputSettings>(settings).Any()) throw new InvalidOperationException("桌面输入没有移入设置页面");
var driverStatus = Descendants<TextBlock>(settings).Single(x => AutomationProperties.GetAutomationId(x) == "DriverStatus").Text;
if (string.IsNullOrWhiteSpace(driverStatus) || driverStatus.Contains(nameof(XFEExtension.NetCore.InputSimulator.DriverCheckResult))) throw new InvalidOperationException("HID 状态显示了类型名而非检测说明");
await CaptureWindowAsync(Path.Combine(output, "settings-desktop-input.png"));
settingsTabs.SelectedIndex = 0; await Task.Delay(500);
if (!Descendants<TextBlock>(settings).Any(x => x.Text == ClientState.LocalServerAddress) || Descendants<TextBox>(settings).Any(x => x.Text == ClientState.LocalServerAddress)) throw new InvalidOperationException("服务器地址未固定或仍可修改");
foreach (var page in new[] { typeof(PluginsPage), typeof(SpacePage), typeof(RunsPage), typeof(LogViewPage), typeof(SettingPage) })
{
Resize(760, 940); navigation.NavigateTo(page.FullName!); await Task.Delay(280);
var panel = Descendants<DetailsWorkspace>(frame).FirstOrDefault();
if (panel is not null && (panel.Splitter.Orientation != Orientation.Horizontal || panel.Master.ActualHeight < 100 || panel.Details.ActualHeight < 100)) throw new InvalidOperationException("窄窗口详情布局不可用:" + page.Name);
await CaptureWindowAsync(Path.Combine(output, page.Name + "-narrow.png"));
}
}
finally { root.RequestedTheme = theme; App.MainWindow.AppWindow.Resize(size); navigation.NavigateTo<MainPage>(); }
}
}
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Automation.Provider;
using Microsoft.UI.Xaml.Media;
using XFEWorkflow.Abstractions;
using XFEWorkflow.Controls;
using XFEWorkflow.Core.Runtime;
using XFEWorkflow.Infrastructure.Storage;
using XFEWorkflow.ViewModels;
using XFEWorkflow.Views;
namespace XFEWorkflow.Services;
internal static partial class UiSmokeTest
{
internal static IEnumerable<T> Descendants<T>(DependencyObject root) where T : DependencyObject
{
if (root is T target) yield return target;
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) foreach (var item in Descendants<T>(VisualTreeHelper.GetChild(root, i))) yield return item;
}
internal static async Task InvokeButtonAsync(Button button)
{
if (!button.IsEnabled) throw new InvalidOperationException("按钮不可用:" + button.Content);
var peer = FrameworkElementAutomationPeer.FromElement(button) ?? new ButtonAutomationPeer(button);
((IInvokeProvider)peer.GetPattern(PatternInterface.Invoke)).Invoke(); await Task.Delay(180);
}
private static async Task WaitManagementAsync(Func<bool> ready)
{ for (var i = 0; i < 80; i++) { if (ready()) return; await Task.Delay(50); } throw new InvalidOperationException("页面没有加载期望的数据"); }
private static async Task SmokeManagementPagesAsync(string output)
{
var services = AppServices.Current; var shell = AppShellPage.Current!; var navigation = shell.ViewModel.NavigationViewService;
var frame = (Frame)shell.FindName("navigationFrame"); var root = (FrameworkElement)App.MainWindow.Content; var theme = root.RequestedTheme; var size = App.MainWindow.AppWindow.Size;
var scale = root.XamlRoot.RasterizationScale;
void Resize(int width, int height) => App.MainWindow.AppWindow.Resize(new((int)(width * scale), (int)(height * scale)));
var workflow = WorkflowTemplates.CreateGraph(); workflow.Name = "页面联动验收";
const string logMessage = "工作流运行日志验收\n第二行:输入与输出完整保留";
var logNode = new NodeDefinition { CapabilityId = "core.log", Name = "界面日志", Parameters = new() { ["message"] = logMessage } };
workflow.Graph.Nodes.Insert(1, logNode); workflow.Graph.Edges.Clear();
for (var i = 0; i < workflow.Graph.Nodes.Count - 1; i++) workflow.Graph.Edges.Add(new() { FromNode = workflow.Graph.Nodes[i].Id, ToNode = workflow.Graph.Nodes[i + 1].Id });
var runInputs = new System.Text.Json.Nodes.JsonObject { ["message"] = "第一行\n第二行", ["options"] = new System.Text.Json.Nodes.JsonObject { ["count"] = 2 } };
await services.Workflows.SaveAsync(workflow); var run = await services.Runs.RunAsync(workflow, inputs: runInputs);
if (run.Status != RunStatus.Succeeded) throw new InvalidOperationException(run.Error);
try
{
shell.ShowBrowser(false); shell.SetNavigationOpenForSmoke(false); Resize(1440, 960); root.RequestedTheme = ElementTheme.Dark;
navigation.NavigateTo<PluginsPage>(); await WaitManagementAsync(() => frame.Content is PluginsPage && Descendants<GridView>(frame).Any(x => x.Items.Count > 0));
var plugins = (PluginsPage)frame.Content;
if (Descendants<Pivot>(plugins).Any() || !Descendants<Button>(plugins).Any(x => AutomationProperties.GetAutomationId(x) == "OpenSpace")) throw new InvalidOperationException("插件中心没有将 Space 独立为页面");
var catalog = Descendants<GridView>(plugins).Single(); if (catalog.Items.Any(x => x is not PluginCardModel)) throw new InvalidOperationException("插件卡片没有真实清单数据");
var pluginPanels = Descendants<DetailsWorkspace>(plugins).Single(); await Task.Delay(300); var previousWidth = pluginPanels.Details.ActualWidth;
await CaptureWindowAsync(Path.Combine(output, "plugins-before-resize.png"));
App.MainWindow.Activate(); SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow)); await Task.Delay(150);
await DragElementAsync(pluginPanels.Splitter, -30, 0);
await AtomicJson.WriteAsync(Path.Combine(output, "plugins-splitter.json"), new { before = previousWidth, after = pluginPanels.Details.ActualWidth, width = pluginPanels.ActualWidth, height = pluginPanels.ActualHeight, orientation = pluginPanels.Splitter.Orientation.ToString(), preferred = pluginPanels.Splitter.PreferredSize });
if (Math.Abs(pluginPanels.Details.ActualWidth - previousWidth - 30) > 4) throw new InvalidOperationException($"插件详情面板无法拖动调整:{previousWidth} → {pluginPanels.Details.ActualWidth}, orientation={pluginPanels.Splitter.Orientation}");
await CaptureWindowAsync(Path.Combine(output, "plugins-center-dark.png"));
root.RequestedTheme = ElementTheme.Light; await Task.Delay(100); await CaptureWindowAsync(Path.Combine(output, "plugins-center-light.png"));
root.RequestedTheme = ElementTheme.Dark;
navigation.NavigateTo<RunsPage>(run.Id); await WaitManagementAsync(() => frame.Content is RunsPage && Descendants<ListView>(frame).Any(x => x.SelectedItem is RunRowModel row && row.Record.Id == run.Id));
var runInputBox = (TextBox)Descendants<Expander>(frame).Single(x => x.Header as string == "输入参数").Content;
if (!System.Text.Json.Nodes.JsonNode.DeepEquals(System.Text.Json.Nodes.JsonNode.Parse(runInputBox.Text), runInputs)) throw new InvalidOperationException("运行记录的多行 JSON 输入显示不完整");
await CaptureWindowAsync(Path.Combine(output, "runs-master-details.png"));
await InvokeButtonAsync(Descendants<Button>(frame).Single(x => AutomationProperties.GetAutomationId(x) == "RunFullLog"));
await WaitManagementAsync(() => frame.Content is LogViewPage && Descendants<ListView>(frame).Any(x => x.Items.OfType<WorkflowLogEntry>().Any(e => e.RunId == run.Id && e.LogText.Contains("工作流运行日志验收"))));
var logs = Descendants<ListView>(frame).First(x => x.Items.OfType<WorkflowLogEntry>().Any());
if (logs.Items.OfType<WorkflowLogEntry>().Any(x => x.RunId != run.Id)) throw new InvalidOperationException("运行记录到日志的筛选未生效");
logs.SelectedItem = logs.Items.OfType<WorkflowLogEntry>().First(x => x.NodeId == logNode.Id && x.LogText.Contains(logMessage)); await Task.Delay(150);
var logDetailBoxes = Descendants<TextBox>(frame).Where(x => x.IsReadOnly).ToArray();
await AtomicJson.WriteAsync(Path.Combine(output, "logs-multiline-details.json"), new { expected = logMessage, actual = logDetailBoxes.Select(x => x.Text) });
if (!logDetailBoxes.Any(x => x.Text.ReplaceLineEndings("\n").Contains(logMessage))) throw new InvalidOperationException("日志详情的多行消息被截断");
await CaptureWindowAsync(Path.Combine(output, "logs-workflow-run-filter.png"));
await InvokeButtonAsync(Descendants<Button>(frame).Single(x => AutomationProperties.GetAutomationId(x) == "LocateLogWorkflow"));
await WaitManagementAsync(() => frame.Content is WorkflowEditorPage && WorkflowEditorPage.Current?.ActiveEditor?.ViewModel.SelectedWorkflow?.Id == workflow.Id);
navigation.NavigateTo<SettingPage>("credentials"); await WaitManagementAsync(() => frame.Content is SettingPage && Descendants<Pivot>(frame).FirstOrDefault()?.SelectedIndex == 1);
if (!Descendants<CredentialSettings>(frame).Any()) throw new InvalidOperationException("凭据没有移入设置页面");
await CaptureWindowAsync(Path.Combine(output, "settings-credentials.png"));
var settings = (SettingPage)frame.Content; var settingsTabs = Descendants<Pivot>(settings).First(); settingsTabs.SelectedIndex = 2; await Task.Delay(500);
if (!Descendants<DesktopInputSettings>(settings).Any()) throw new InvalidOperationException("桌面输入没有移入设置页面");
var driverStatus = Descendants<TextBlock>(settings).Single(x => AutomationProperties.GetAutomationId(x) == "DriverStatus").Text;
if (string.IsNullOrWhiteSpace(driverStatus) || driverStatus.Contains(nameof(XFEExtension.NetCore.InputSimulator.DriverCheckResult))) throw new InvalidOperationException("HID 状态显示了类型名而非检测说明");
await CaptureWindowAsync(Path.Combine(output, "settings-desktop-input.png"));
settingsTabs.SelectedIndex = 0; await Task.Delay(500);
if (!Descendants<TextBlock>(settings).Any(x => x.Text == ClientState.LocalServerAddress) || Descendants<TextBox>(settings).Any(x => x.Text == ClientState.LocalServerAddress)) throw new InvalidOperationException("服务器地址未固定或仍可修改");
foreach (var page in new[] { typeof(PluginsPage), typeof(SpacePage), typeof(RunsPage), typeof(LogViewPage), typeof(SettingPage) })
{
Resize(760, 940); navigation.NavigateTo(page.FullName!); await Task.Delay(280);
var panel = Descendants<DetailsWorkspace>(frame).FirstOrDefault();
if (panel is not null && (panel.Splitter.Orientation != Orientation.Horizontal || panel.Master.ActualHeight < 100 || panel.Details.ActualHeight < 100)) throw new InvalidOperationException("窄窗口详情布局不可用:" + page.Name);
await CaptureWindowAsync(Path.Combine(output, page.Name + "-narrow.png"));
}
}
finally { root.RequestedTheme = theme; App.MainWindow.AppWindow.Resize(size); navigation.NavigateTo<MainPage>(); }
}
}