Skip to content

Commit

Permalink
Add ScanOptions.NativeWindowHandle to scope Scans to rooted UIA sub-t…
Browse files Browse the repository at this point in the history
…ree (#1022)

Adds:
- ScanOptions property settable to IntPtr for HWND of root of subtree to be scanned
- Teach PID descendants search to operate on HWND's subtree instead of GetRootElement
- Add Integration test for test app leaf node scan

Proposal to address issue #1021
  • Loading branch information
blackcatsonly authored May 13, 2024
1 parent fb236ad commit 6d72b85
Show file tree
Hide file tree
Showing 29 changed files with 365 additions and 35 deletions.
1 change: 1 addition & 0 deletions docs/AutomationReference.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ The `ScanOptions` constructor accepts the following arguments:
**Name** | **Type** | **Description** | **Default value**
---|---|---|---
scanId | `string` | A string identifier for the scan. If the scan produces output files based on the `Config` object used to create the scanner, the output files will be given the name of the scan id (e.g., MyScanId.a11ytest). | `null`
scanRootWindowHandle | `IntPtr` | The native window handle (HWND) of the UIA element whose sub-tree should be scanned. If not specified or no element matches the HWND, the process's full UIA tree will be scanned. | `null`

#### ScanOutput
Methods of `IScanner` return a `ScanOutput` object with the following properties:
Expand Down
9 changes: 8 additions & 1 deletion src/Automation/Data/ScanOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@ public class ScanOptions
/// </summary>
public string ScanId { get; }

/// <summary>
/// The window handle for the root of the UIA subtree to scan.
/// </summary>
public System.IntPtr ScanRootWindowHandle { get; }

/// <summary>
/// Constructor
/// </summary>
/// <param name="scanId">The ID of this scan. Must be null or meet the requirements for a file name.</param>
public ScanOptions(string scanId = null)
/// <param name="scanRootWindowHandle">The window handle for the root of the UIA subtree to scan.</param>
public ScanOptions(string scanId = null, System.IntPtr? scanRootWindowHandle = null)
{
ScanId = scanId;
ScanRootWindowHandle = scanRootWindowHandle.GetValueOrDefault(System.IntPtr.Zero);
}
}
}
3 changes: 3 additions & 0 deletions src/Automation/Interfaces/IScanTools.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;

namespace Axe.Windows.Automation
{
/// <summary>
Expand All @@ -13,5 +15,6 @@ internal interface IScanTools
ITargetElementLocator TargetElementLocator { get; }
IAxeWindowsActions Actions { get; }
IDPIAwareness DpiAwareness { get; }
IntPtr ScanRootWindowHandle { get; set; }
} // interface
} // namespace
2 changes: 1 addition & 1 deletion src/Automation/Interfaces/ITargetElementLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ namespace Axe.Windows.Automation
{
internal interface ITargetElementLocator
{
IEnumerable<A11yElement> LocateRootElements(int processId, IActionContext actionContext);
IEnumerable<A11yElement> LocateRootElements(int processId, IActionContext actionContext, System.IntPtr rootWindowHandle);
} // interface
} // namespace
1 change: 1 addition & 0 deletions src/Automation/ScanTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ScanTools : IScanTools
public ITargetElementLocator TargetElementLocator { get; }
public IAxeWindowsActions Actions { get; }
public IDPIAwareness DpiAwareness { get; }
public IntPtr ScanRootWindowHandle { get; set; } = IntPtr.Zero;

public ScanTools(IOutputFileHelper outputFileHelper, IScanResultsAssembler resultsAssembler, ITargetElementLocator targetElementLocator, IAxeWindowsActions actions, IDPIAwareness dpiAwareness)
{
Expand Down
1 change: 1 addition & 0 deletions src/Automation/Scanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private void HandleScanOptions(ScanOptions scanOptions)
{
scanOptions = scanOptions ?? DefaultScanOptions;
_scanTools.OutputFileHelper.SetScanId(scanOptions.ScanId);
_scanTools.ScanRootWindowHandle = scanOptions.ScanRootWindowHandle;
}
} // class
} // namespace
2 changes: 1 addition & 1 deletion src/Automation/SnapshotCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ private static ScanOutput GetScanOutput(Config config, IScanTools scanTools, Can

using (var actionContext = ScopedActionContext.CreateInstance(cancellationToken))
{
var rootElements = scanTools.TargetElementLocator.LocateRootElements(config.ProcessId, actionContext);
var rootElements = scanTools.TargetElementLocator.LocateRootElements(config.ProcessId, actionContext, scanTools.ScanRootWindowHandle);

if (rootElements is null || !rootElements.Any())
{
Expand Down
4 changes: 2 additions & 2 deletions src/Automation/TargetElementLocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ namespace Axe.Windows.Automation
{
class TargetElementLocator : ITargetElementLocator
{
public IEnumerable<A11yElement> LocateRootElements(int processId, IActionContext actionContext)
public IEnumerable<A11yElement> LocateRootElements(int processId, IActionContext actionContext, IntPtr rootWindowHandle)
{
try
{
var desktopElements = A11yAutomation.ElementsFromProcessId(processId, actionContext.DesktopDataContext);
var desktopElements = A11yAutomation.ElementsFromProcessId(processId, rootWindowHandle, actionContext.DesktopDataContext);
return GetA11yElementsFromDesktopElements(desktopElements);
}
catch (Exception ex)
Expand Down
98 changes: 98 additions & 0 deletions src/AutomationTests/A11yAutomationUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Axe.Windows.Core.Enums;
using Axe.Windows.Desktop.UIAutomation;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using UIAutomationClient;

namespace Axe.Windows.AutomationTests
{
/// <summary>
/// Wrapper for CUIAutomation COM object
/// </summary>
public class A11yAutomationUtilities
{
internal static DesktopElement GetRootElement()
{
IUIAutomation uiAutomation = A11yAutomation.GetDefaultInstance().UIAutomation;
IUIAutomationElement focusedElement = uiAutomation.GetRootElement();
return new DesktopElement(focusedElement, keepElement: true, setMembers: true);
}

internal static DesktopElement GetFocusedElement()
{
IUIAutomation uiAutomation = A11yAutomation.GetDefaultInstance().UIAutomation;
IUIAutomationElement focusedElement = uiAutomation.GetFocusedElement();
return new DesktopElement(focusedElement, keepElement: true, setMembers: true);
}

internal static DesktopElement GetDepthFirstLastLeafHWNDElement(DesktopElement rootElement)
{
var walker = A11yAutomation.GetDefaultInstance().GetTreeWalker(TreeViewMode.Control);
try
{
IUIAutomationElement rootAutomationElement = rootElement.PlatformObject;
IUIAutomationElement leafElement = FindLastWindowHandleElement(rootAutomationElement);

return leafElement is null
? rootElement
: new DesktopElement(leafElement, keepElement: true, setMembers: true);
}
finally
{
Marshal.ReleaseComObject(walker);
}

IUIAutomationElement FindLastWindowHandleElement(IUIAutomationElement parent)
{
List<IUIAutomationElement> matchingElements = new();
for (var child = walker.GetFirstChildElement(parent); child != null; child = MatchAndMoveNext(child))
{
}

foreach (var element in matchingElements)
{
FindLastWindowHandleElement(element);
}

foreach (var element in matchingElements.Take(matchingElements.Count - 1))
{
Marshal.ReleaseComObject(element);
}

return matchingElements.LastOrDefault();

IUIAutomationElement MatchAndMoveNext(IUIAutomationElement element)
{
IUIAutomationElement next;
try
{
next = walker.GetNextSiblingElement(element);
}
catch(COMException e)
{
Debug.WriteLine(message: $"Error getting sibling of {element.CurrentName}: {e}");
next = null;
}

if (element.CurrentNativeWindowHandle != IntPtr.Zero)
{
Debug.WriteLine(message: $"HWND={element.CurrentNativeWindowHandle}; Name='{element.CurrentName}'; ClassName='{element.CurrentClassName}'");
matchingElements.Add(element);
}
else
{
Marshal.ReleaseComObject(element);
}

return next;
}
}
}
}
}
72 changes: 61 additions & 11 deletions src/AutomationTests/AutomationIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Axe.Windows.Automation;
using Axe.Windows.Automation.Data;
using Axe.Windows.Desktop.UIAutomation;
using Axe.Windows.UnitTestSharedLibrary;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
Expand All @@ -27,7 +28,7 @@ public class AutomationIntegrationTests
const int WpfControlSamplerKnownErrorCount = 7;
const int WindowsFormsMultiWindowSamplerAppAllErrorCount = 12;
// Note: This should change to 159 after https://github.com/MicrosoftEdge/WebView2Feedback/issues/3530 is fixed and integrated
const int WebViewSampleKnownErrorCount = 6;
const int WebViewSampleKnownErrorCount = 7;

readonly string _wildlifeManagerAppPath = Path.GetFullPath("../../../../../tools/WildlifeManager/WildlifeManager.exe");
readonly string _win32ControlSamplerAppPath = Path.GetFullPath("../../../../../tools/Win32ControlSampler/Win32ControlSampler.exe");
Expand Down Expand Up @@ -79,6 +80,14 @@ public void Cleanup()
CleanupTestOutput();
}

[TestMethod]
[ExpectedException(typeof(AxeWindowsAutomationException))]
public void Scan_Integration_InvalidProcessId()
{
const int BogusProcessId = 47;
ScanIntegrationCore(sync: true, testAppPath: null, expectedErrorCount: 0, processId: BogusProcessId);
}

[DataTestMethod]
[DataRow(true)]
[DataRow(false)]
Expand All @@ -92,6 +101,37 @@ public void Scan_Integration_WildlifeManager(bool sync)
});
}

[DataTestMethod]
[DataRow(true)]
[DataRow(false)]
public void Scan_Integration_WildlifeManager_ValidRoot(bool sync)
{
RunWithTimedExecutionWrapper(TimeSpan.FromSeconds(30), () =>
{
static ScanOptions makeScopedScanOptions(int _)
{
using (DesktopElement focusedElement = A11yAutomationUtilities.GetFocusedElement())
{
var leafElement = A11yAutomationUtilities.GetDepthFirstLastLeafHWNDElement(focusedElement);
return new ScanOptions(scanRootWindowHandle: leafElement.NativeWindowHandle);
}
}
ScanIntegrationCore(sync, _wildlifeManagerAppPath, 2 * WildlifeManagerKnownErrorCount, expectedWindowCount: WildlifeManagerKnownErrorCount, processId: null, makeScanOptions: makeScopedScanOptions);
});
}

[DataTestMethod]
[DataRow(true)]
[DataRow(false)]
public void Scan_Integration_WildlifeManager_InvalidRoot(bool sync)
{
RunWithTimedExecutionWrapper(TimeSpan.FromSeconds(30), () =>
{
static ScanOptions makeScanOptionsWithInvalidRoot(int _) => new(scanRootWindowHandle: new IntPtr(42));
ScanIntegrationCore(sync, _wildlifeManagerAppPath, WildlifeManagerKnownErrorCount, processId: null, makeScanOptions: makeScanOptionsWithInvalidRoot);
});
}

// [DataTestMethod]
// [DataRow(true)]
// [DataRow(false)]
Expand Down Expand Up @@ -121,7 +161,7 @@ public void Scan_Integration_WindowsFormsMultiWindowSample(bool sync)
{
RunWithTimedExecutionWrapper(TimeSpan.FromSeconds(30), () =>
{
ScanIntegrationCore(sync, _windowsFormsMultiWindowSamplerAppPath, WindowsFormsMultiWindowSamplerAppAllErrorCount, 2);
ScanIntegrationCore(sync, _windowsFormsMultiWindowSamplerAppPath, WindowsFormsMultiWindowSamplerAppAllErrorCount, expectedWindowCount: 2);
});
}

Expand Down Expand Up @@ -265,7 +305,7 @@ private void RunWithTimedExecutionWrapper(TimeSpan allowedTime, Action testActio
}
}

private WindowScanOutput ScanIntegrationCore(bool sync, string testAppPath, int expectedErrorCount, int expectedWindowCount = 1, int? processId = null)
private WindowScanOutput ScanIntegrationCore(bool sync, string testAppPath, int expectedErrorCount, int expectedWindowCount = 1, int? processId = null, Func<int,ScanOptions> makeScanOptions = null)
{
if (processId == null)
{
Expand All @@ -279,14 +319,15 @@ private WindowScanOutput ScanIntegrationCore(bool sync, string testAppPath, int

var scanner = ScannerFactory.CreateScanner(config);

ScanOptions scanOptions = makeScanOptions is null ? null : makeScanOptions(processId.Value);
IReadOnlyCollection<WindowScanOutput> output;
if (sync)
{
output = ScanSyncWithProvisionForBuildAgents(scanner);
output = ScanSyncWithProvisionForBuildAgents(scanner, scanOptions);
}
else
{
output = ScanAsyncWithProvisionForBuildAgents(scanner);
output = ScanAsyncWithProvisionForBuildAgents(scanner, scanOptions);
}

return ValidateOutput(output, expectedErrorCount, expectedWindowCount);
Expand Down Expand Up @@ -324,8 +365,11 @@ private IEnumerable<Task<ScanOutput>> GetAsyncScanTasks(string testAppPath, IEnu
private WindowScanOutput ValidateOutput(IReadOnlyCollection<WindowScanOutput> output, int expectedErrorCount, int expectedWindowCount = 1)
{
Assert.AreEqual(expectedWindowCount, output.Count);
Assert.AreEqual(expectedErrorCount, output.Sum(x => x.ErrorCount));
Assert.AreEqual(expectedErrorCount, output.Sum(x => x.Errors.Count()));

int aggregateErrorCount = output.Sum(x => x.ErrorCount);
int totalErrors = output.Sum(x => x.Errors.Count());
Assert.AreEqual(expectedErrorCount, aggregateErrorCount, message: IsTestRunningInPipeline() ? string.Empty : PrintAll(output));
Assert.AreEqual(expectedErrorCount, totalErrors);

if (expectedErrorCount > 0)
{
Expand Down Expand Up @@ -354,11 +398,11 @@ private static void ValidateTaskCancelled(Task<ScanOutput> task)
Assert.IsTrue(task.IsCanceled);
}

private IReadOnlyCollection<WindowScanOutput> ScanSyncWithProvisionForBuildAgents(IScanner scanner)
private IReadOnlyCollection<WindowScanOutput> ScanSyncWithProvisionForBuildAgents(IScanner scanner, ScanOptions scanOptions = null)
{
try
{
return scanner.Scan(null).WindowScanOutputs;
return scanner.Scan(scanOptions).WindowScanOutputs;
}
catch (Exception)
{
Expand All @@ -370,11 +414,11 @@ private IReadOnlyCollection<WindowScanOutput> ScanSyncWithProvisionForBuildAgent
}
}

private IReadOnlyCollection<WindowScanOutput> ScanAsyncWithProvisionForBuildAgents(IScanner scanner)
private IReadOnlyCollection<WindowScanOutput> ScanAsyncWithProvisionForBuildAgents(IScanner scanner, ScanOptions scanOptions = null)
{
try
{
return scanner.ScanAsync(null, CancellationToken.None).Result.WindowScanOutputs;
return scanner.ScanAsync(scanOptions, CancellationToken.None).Result.WindowScanOutputs;
}
catch (Exception)
{
Expand Down Expand Up @@ -441,5 +485,11 @@ private static bool IsTestRunningInPipeline()
// The BUILD_BUILDID environment variable is only set on build agents
return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BUILD_BUILDID"));
}

static string PrintAll(IReadOnlyCollection<WindowScanOutput> output) => StringJoin(output.Select(PrintErrors), Environment.NewLine);
static string PrintErrors(WindowScanOutput output, int index) => $"Output #{index}:\r\n\t{StringJoin(output.Errors.Select(PrintError), "\r\n\t")}";
static string PrintError(ScanResult error, int index) => $"Error #{index}: {error.Rule}\r\n\t\t{PrintElementProperties(error.Element)}";
static string PrintElementProperties(ElementInfo e) => StringJoin(e.Properties.Select(p => $"{p.Key}='{p.Value}'"), "\r\n\t\t");
static string StringJoin(IEnumerable<string> lines, string separator) => string.Join(separator, lines);
}
}
14 changes: 14 additions & 0 deletions src/AutomationTests/AutomationTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@
<ProjectReference Include="..\UnitTestSharedLibrary\UnitTestSharedLibrary.csproj" />
</ItemGroup>

<ItemGroup>
<Reference Include="Interop.UIAutomationClient">
<HintPath>..\UIAAssemblies\Win10.17713\Interop.UIAutomationClient.dll</HintPath>
<EmbedInteropTypes>true</EmbedInteropTypes>
</Reference>
</ItemGroup>

<ItemGroup>
<Reference Include="Interop.UIAutomationCore">
<HintPath>..\InteropDummy\bin\$(Configuration)\net6.0\Interop.UIAutomationCore.dll</HintPath>
<EmbedInteropTypes>true</EmbedInteropTypes>
</Reference>
</ItemGroup>

<Import Project="..\..\build\NetStandardTest.targets" />

</Project>
Loading

0 comments on commit 6d72b85

Please sign in to comment.