Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ScanOptions.NativeWindowHandle to scope Scans to rooted UIA sub-tree #1022

Merged
merged 23 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6093d8f
Allow callers to pass ScanOptions specifying the Native Window Handle…
blackcatsonly May 1, 2024
5e1bdda
Add Integration Test for Scan scoped via HWND of a sub-tree root elem…
blackcatsonly May 1, 2024
e9b8554
Rename ScanOptions.WindowHandle -> ScanRootWindowHandle
blackcatsonly May 2, 2024
bfc0365
Pass ScanOptions.ScanRootWindowHandle -> ScanTools.ScanRootWindowHand…
blackcatsonly May 2, 2024
c00d978
Move A11yAutomation test-specific implementations to an A11yAutomatio…
blackcatsonly May 2, 2024
70a8b7e
Fix A11yAutomation interals visibility in signed environments.
blackcatsonly May 2, 2024
15d8ce3
Fallback to full process scan when scoped window handle does not find…
blackcatsonly May 2, 2024
98772bd
Add CLI support for ScanRootWindowHandle
blackcatsonly May 2, 2024
9263224
Add CLI Options test for ScanRootWindowHandle.
blackcatsonly May 3, 2024
09563ec
Fallback to pid search from root element when ElementFromHandle fails.
blackcatsonly May 3, 2024
430ac50
Implement GetDepthFirstLastLeafHWNDElement to ensure we find a contro…
blackcatsonly May 3, 2024
4f524db
Print the WindowScanOutput.Errors to help with debugging ValidateOutput
blackcatsonly May 3, 2024
4b13a97
Rename Scan_Integration_WildlifeManager_Scoped -> Scan_Integration_Wi…
blackcatsonly May 3, 2024
4ae37bc
Setup ScanTools.ScanRootWindowHandle for SnapshotCommandTests.
blackcatsonly May 3, 2024
1ddf12c
Update WebViewSampleKnownErrorCount to match actual number reported b…
blackcatsonly May 3, 2024
3e5f8eb
Update CLI docs to describe ScanRootWindowHandle parameter.
blackcatsonly May 3, 2024
028fa58
Update docs for ScanOptions' constructor.
blackcatsonly May 10, 2024
66373f3
Move using
codeofdusk May 12, 2024
a5c9242
Remove nullable operator from IntPtr docs.
blackcatsonly May 13, 2024
52b76d5
Prepend HWND CLI banner output with space.
blackcatsonly May 13, 2024
b0662ae
Add test to cover root window handle banner output.
blackcatsonly May 13, 2024
0a39f01
Make TextWriterVerifier.VerifyAll write the expected format with the …
blackcatsonly May 13, 2024
37b41b3
Add Integration test to ensure invalid process ID throws expected Exc…
blackcatsonly May 13, 2024
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
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