Skip to content

Commit 52361a2

Browse files
authored
Merge pull request #5 from g4-api/development
Development
2 parents 5c46ec0 + ba8e073 commit 52361a2

10 files changed

Lines changed: 219 additions & 11 deletions

File tree

src/Common.Domain/Models/RecorderNodeModel.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Text.Json.Serialization;
34

45
using Common.Domain.Extensions;
@@ -10,6 +11,7 @@ namespace Common.Domain.Models
1011
/// </summary>
1112
public class RecorderNodeModel<T>
1213
{
14+
#region *** Properties ***
1315
/// <summary>
1416
/// The automation-specific identifier assigned to the element (AutomationId).
1517
/// </summary>
@@ -76,11 +78,18 @@ public class RecorderNodeModel<T>
7678
/// </summary>
7779
public int ProcessId { get; set; }
7880

81+
/// <summary>
82+
/// Gets or sets a collection of key-value pairs representing additional properties.
83+
/// </summary>
84+
public Dictionary<string, object> Properties { get; set; }
85+
7986
/// <summary>
8087
/// The runtime identifier assigned by UIA to uniquely identify the element.
8188
/// </summary>
8289
public int[] RuntimeId { get; set; }
90+
#endregion
8391

92+
#region *** Nested Types ***
8493
/// <summary>
8594
/// Represents the bounding rectangle of a UI element in screen coordinates.
8695
/// </summary>
@@ -140,5 +149,6 @@ public class PatternDataModel
140149
/// </summary>
141150
public string Name { get; set; }
142151
}
152+
#endregion
143153
}
144154
}

src/UiaPeek.Domain/Extensions/LocalExtensions.cs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Linq;
66
using System.Runtime.InteropServices;
77
using System.Text;
8+
using System.Text.RegularExpressions;
89

910
using UiaPeek.Domain.Models;
1011

@@ -135,9 +136,7 @@ public static UiaChainModel NewAncestorChain(this CUIAutomation8 automation, IUI
135136
/// <returns>A deterministic locator string beginning with <c>/Desktop</c>.</returns>
136137
public static string ResolveLocator(this UiaChainModel chain)
137138
{
138-
// Identifiers containing quotes cannot be safely embedded in XPath attribute predicates.
139-
static bool IsBroken(string input) => input.Contains('\'') || input.Contains('"');
140-
139+
// Extract the ancestor nodes from the chain, defaulting to an empty list if the chain is null.
141140
var nodes = chain?.Path ?? [];
142141
var builder = new StringBuilder("/Desktop");
143142

@@ -163,9 +162,12 @@ public static string ResolveLocator(this UiaChainModel chain)
163162
var separator = isGap ? "//" : "/";
164163
isGap = false;
165164

165+
// Attempt to use AutomationId as the strongest available identifier, falling back to Name when safe,
166+
// otherwise using a positional index among siblings of the same ControlType.
166167
var automationId = node.AutomationId;
167168
var name = node.Name;
168169

170+
// Identifiers that contain quotes cannot be used in XPath predicates and are considered broken.
169171
var hasAutomationId = !string.IsNullOrEmpty(automationId) && !IsBroken(automationId);
170172
var hasName = !string.IsNullOrEmpty(name) && !IsBroken(name);
171173

@@ -186,9 +188,98 @@ public static string ResolveLocator(this UiaChainModel chain)
186188
}
187189
}
188190

191+
// Return the constructed locator string.
189192
return builder.ToString();
193+
194+
// Identifiers containing quotes cannot be safely embedded in XPath attribute predicates.
195+
static bool IsBroken(string input) => input.Contains('\'') || input.Contains('"');
196+
}
197+
198+
// TODO: Add support for other stable attributes such as ClassName when they can be safely used in XPath predicates.
199+
/// <summary>
200+
/// Builds a compact semantic XPath from the fallback UIA XPath.
201+
/// </summary>
202+
/// <param name="chain">The UIA chain model that contains the fallback locator.</param>
203+
/// <returns>
204+
/// A normalized XPath that starts from <c>/Desktop</c> and targets the final
205+
/// element by a stable identity, or <c>null</c> when a safe normalized locator
206+
/// cannot be generated.
207+
/// </returns>
208+
public static string FormatXpath(this UiaChainModel chain)
209+
{
210+
// Get the original full fallback XPath from the UIA chain.
211+
var xpath = chain?.FallbackLocator;
212+
213+
// A valid fallback XPath must be present and must start from the root.
214+
if (string.IsNullOrWhiteSpace(xpath) || !xpath.StartsWith('/'))
215+
{
216+
return null;
217+
}
218+
219+
// Split the XPath into segments.
220+
// The regex captures each XPath separator and the segment that follows it.
221+
var matches = Regex.Matches(xpath, @"(/+)([^/]+)");
222+
223+
// A normalized locator needs at least a root segment and a target segment.
224+
if (matches.Count < 2)
225+
{
226+
return null;
227+
}
228+
229+
// Keep only the segment text.
230+
// Example: "/Desktop/Window[1]/Button[@Name='OK']"
231+
// becomes: "Desktop", "Window[1]", "Button[@Name='OK']".
232+
var segments = matches
233+
.Select(m => m.Groups[2].Value)
234+
.ToArray();
235+
236+
// The final segment is the selected UIA element.
237+
var finalSegment = segments[^1];
238+
239+
// The final element must have a stable identity.
240+
// Index-only locators are intentionally rejected for normalized output.
241+
if (!finalSegment.Contains("[@AutomationId=") && !finalSegment.Contains("[@Name="))
242+
{
243+
return null;
244+
}
245+
246+
// Do not generate a normalized element locator when the selected target
247+
// is itself a Window.
248+
if (ControlType(finalSegment).Equals("Window", StringComparison.OrdinalIgnoreCase))
249+
{
250+
return null;
251+
}
252+
253+
// Find the first Window ancestor before the final target segment.
254+
// The target itself is excluded from this search.
255+
var windowSegment = segments[..^1]
256+
.FirstOrDefault(s => ControlType(s).Equals("Window", StringComparison.OrdinalIgnoreCase));
257+
258+
// If no Window ancestor exists, search from Desktop directly to the target.
259+
if (windowSegment == null)
260+
{
261+
return $"/Desktop//{finalSegment}";
262+
}
263+
264+
// Anchor the locator through the Window ancestor and then search below it
265+
// for the final target element.
266+
var result = $"/Desktop//{windowSegment}//{finalSegment}";
267+
268+
// Return null when normalization produced the same value as the fallback.
269+
return result == xpath ? null : result;
270+
271+
// Gets the control type name from an XPath segment.
272+
static string ControlType(string segment)
273+
{
274+
// The control type is stored before the first '[' character.
275+
var idx = segment.IndexOf('[');
276+
277+
// If the segment has no predicate, the whole segment is the control type.
278+
return idx < 0 ? segment : segment[..idx];
279+
}
190280
}
191281

282+
// TODO: Export all properties that can be safely retrieved from the element, such as IsContentElement, IsControlElement, IsEnabled, etc.
192283
// Converts an IUIAutomationElement into a UiaNodeModel representation.
193284
private static UiaNodeModel Convert(IUIAutomationElement element, bool metadata)
194285
{
@@ -341,32 +432,44 @@ private static (int All, int SameControlType) GetSiblingIndexes(
341432

342433
try
343434
{
435+
// Start with the first child of the parent element.
436+
// If the parent has no children or retrieval fails, this will be null and the loop will be skipped.
344437
var child = Safe(() => walker.GetFirstChildElement(parent), fallback: null);
345438

439+
// Walk through siblings until the target element is found, counting positions.
346440
while (child != null)
347441
{
348442
// Check whether this sibling is the target element.
349443
var isTarget = false;
350444

445+
// CompareElements can throw if either element is stale or the provider
446+
// is buggy, so we catch exceptions and treat them as non-matches.
351447
try
352448
{
353449
isTarget = automation.CompareElements(child, target) == 1;
354450
}
355451
catch (COMException) { }
356452
catch (InvalidComObjectException) { }
357453

454+
// If this sibling is the target, stop counting; otherwise,
455+
// increment counts and move to the next sibling.
358456
if (isTarget)
359457
{
360458
break;
361459
}
362460

461+
// Increment the count of all siblings encountered so far.
462+
// This count is used to determine the target's position among all siblings.
363463
allCount++;
364464

465+
// If this sibling shares the same ControlTypeId as the target, increment the same-type count.
365466
if (Safe(() => child.CurrentControlType) == targetControlTypeId)
366467
{
367468
sameTypeCount++;
368469
}
369470

471+
// Move to the next sibling element, handling potential COM exceptions safely.
472+
// If retrieval fails, child will be set to null and the loop will exit.
370473
child = Safe(() => walker.GetNextSiblingElement(child), fallback: null);
371474
}
372475
}

src/UiaPeek.Domain/Middlewares/UiaEventCaptureService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ private void ResolveMouseEvent(EventRecord eventRecord)
455455
var clickMessage = new UiaEventModel
456456
{
457457
Chain = _repository.Peek(x: mouse.pt.X, y: mouse.pt.Y), // UI element at cursor position.
458-
Event = GetMouseEventName(eventRecord.WParam), // Resolve readable event name.
458+
Event = GetMouseEventName(eventRecord.WParam), // Resolve readable event name.
459459
Timestamp = eventRecord.Timestamp,
460460
Type = "Mouse",
461461
Value = new

src/UiaPeek.Domain/Models/UiaChainModel.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ namespace UiaPeek.Domain.Models
1212
/// </summary>
1313
public class UiaChainModel : ChainModel<UiaNodeModel>
1414
{
15-
// Intentionally empty — provides a domain-specific type for UIA chains.
16-
// Add members here if UIA chains require additional metadata or logic.
15+
/// <summary>
16+
/// Gets or sets a fallback locator string for the trigger element.
17+
/// </summary>
18+
public string FallbackLocator { get; set; } = string.Empty;
1719
}
1820
}

src/UiaPeek.Domain/UiaPeekRepository.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ public UiaChainModel Peek(int x, int y)
2626
chain.Point = new RecorderPointModel { XPos = x, YPos = y };
2727

2828
// Build the absolute XPath locator for the ancestor chain.
29-
chain.Locator = chain.ResolveLocator();
29+
chain.FallbackLocator = chain.ResolveLocator();
30+
chain.Locator = chain.FormatXpath();
31+
32+
// If the formatted XPath locator is empty, fall back to the absolute locator.
33+
chain.Locator = string.IsNullOrEmpty(chain.Locator)
34+
? chain.FallbackLocator
35+
: chain.Locator;
3036

3137
// Indicate that this chain was triggered by a hover action.
3238
chain.Trigger = "Hover";
@@ -49,11 +55,17 @@ public UiaChainModel Peek()
4955
var chain = automation.NewAncestorChain(element) ?? new UiaChainModel();
5056

5157
// Generate the absolute XPath locator for the ancestor chain.
52-
chain.Locator = chain.ResolveLocator();
58+
chain.FallbackLocator = chain.ResolveLocator();
59+
chain.Locator = chain.FormatXpath();
5360

5461
// Indicate that this chain was triggered by a focus action.
5562
chain.Trigger = "Focus";
5663

64+
// If the formatted XPath locator is empty, fall back to the absolute locator.
65+
chain.Locator = string.IsNullOrEmpty(chain.Locator)
66+
? chain.FallbackLocator
67+
: chain.Locator;
68+
5769
// Return the ancestor chain model.
5870
return chain;
5971
}

src/UiaPeek.PathFinder/MainWindow.xaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@
5353
AccessKeyManager.AccessKeyPressed="BtnTestPath_AccessKeyPressed"
5454
Cursor="Hand"/>
5555

56+
<Button Content="_Toggle Path"
57+
HorizontalAlignment="Left"
58+
Margin="235,162,0,0"
59+
VerticalAlignment="Top"
60+
RenderTransformOrigin="-0.57,1.481"
61+
Height="30"
62+
Width="70"
63+
Name="BtnTogglePath"
64+
Click="BtnTogglePath_Click"
65+
AccessKeyManager.AccessKeyPressed="BtnTogglePath_AccessKeyPressed"
66+
Cursor="Hand"/>
67+
5668
<Label Content="Idle"
5769
HorizontalAlignment="Left"
5870
Margin="11,133,0,0"
@@ -129,5 +141,12 @@
129141
Click="BtnSetPosition_Click"
130142
AccessKeyManager.AccessKeyPressed="BtnSetPosition_AccessKeyPressed"
131143
Cursor="Hand"/>
144+
145+
<Label Content=""
146+
HorizontalAlignment="Left"
147+
Margin="350,238,0,0"
148+
Name="LblHidden"
149+
VerticalAlignment="Top"
150+
Visibility="Hidden"/>
132151
</Grid>
133152
</Window>

src/UiaPeek.PathFinder/MainWindow.xaml.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,52 @@ public MainWindow()
5050
InitializeComponent();
5151
}
5252

53+
#region *** Toggle Path ***
54+
/// <summary>
55+
/// Handles the path toggle button click and swaps the visible path value
56+
/// with the hidden path value.
57+
/// </summary>
58+
/// <param name="sender">The button that raised the click event.</param>
59+
/// <param name="e">The routed event arguments for the click event.</param>
60+
private void BtnTogglePath_Click(object sender, RoutedEventArgs e)
61+
{
62+
TogglePath();
63+
}
64+
65+
/// <summary>
66+
/// Handles the access key press for the path toggle button and swaps the
67+
/// visible path value with the hidden path value.
68+
/// </summary>
69+
/// <param name="sender">The control that raised the access key event.</param>
70+
/// <param name="e">The access key event arguments.</param>
71+
private void BtnTogglePath_AccessKeyPressed(object sender, AccessKeyPressedEventArgs e)
72+
{
73+
TogglePath();
74+
}
75+
76+
// Swaps the value displayed in the path text box with the value stored in
77+
// the hidden label.
78+
private void TogglePath()
79+
{
80+
Task.Run(() =>
81+
{
82+
// Marshal the UI update back to the WPF dispatcher thread because
83+
// TxbPath and LblHidden are UI elements and must be accessed from it.
84+
Dispatcher.BeginInvoke(() =>
85+
{
86+
// Capture the current visible path before replacing it.
87+
var path = TxbPath.Text.Trim();
88+
89+
// Move the hidden path value into the visible text box.
90+
TxbPath.Text = LblHidden?.Content?.ToString();
91+
92+
// Store the previous visible path as the new hidden path value.
93+
LblHidden.Content = path;
94+
});
95+
});
96+
}
97+
#endregion
98+
5399
#region *** Start/Stop ***
54100
/// <summary>
55101
/// Handles the Click event for the Start/Stop button.
@@ -101,6 +147,7 @@ private void StartStop(Button startStopButton)
101147
TxbPath.Text = xpath;
102148
TxbAxisX.Text = point.X.ToString();
103149
TxbAxisY.Text = point.Y.ToString();
150+
LblHidden.Content = chain.FallbackLocator;
104151
});
105152

106153
// Delay the loop iteration to avoid excessive updates
@@ -168,6 +215,7 @@ private void SetPosition()
168215
}
169216
#endregion
170217

218+
// TODO: Bug, xpath does not resolve as it supposed to while it resolves just fine in the driver using the same parser.
171219
#region *** Test Path ***
172220
// Handles the Click event for the Test Path button.
173221
private void BtnTestPath_Click(object sender, RoutedEventArgs e)

src/UiaPeek.PathFinder/XpathParser.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ private static List<string> FormatXpath(string xpath)
5757
var matches = Regex.Matches(xpath, pattern);
5858

5959
// Convert the matches to a list of strings, trim whitespace, and filter out empty tokens
60-
return [.. matches.Cast<Match>()
60+
return matches.Cast<Match>()
6161
.Select(match => match.Value.Trim(' ', '/'))
62-
.Where(token => !string.IsNullOrEmpty(token))];
62+
.Where(token => !string.IsNullOrEmpty(token))
63+
.ToList();
6364
}
6465

6566
// Creates a new UI Automation condition tree based on the specified segments.

src/UiaPeek/AppSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace UiaPeek
2+
{
3+
public static class AppSettings
4+
{
5+
}
6+
}

0 commit comments

Comments
 (0)