Fix smoke 1차 follow-up gaps: player resolver, type target, filter, utf8 (#12)
This commit is contained in:
131
src/Recordingtest.Player/IUiaPathResolver.cs
Normal file
131
src/Recordingtest.Player/IUiaPathResolver.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
/// <summary>
|
||||
/// Pure adapter over an UIA element tree. Lets <see cref="UiaPathResolver"/>
|
||||
/// be unit tested with a fake in-memory tree.
|
||||
/// </summary>
|
||||
public interface IUiaTreeNode
|
||||
{
|
||||
string ClassName { get; }
|
||||
string? AutomationId { get; }
|
||||
string? Name { get; }
|
||||
ElementBounds Bounds { get; }
|
||||
IEnumerable<IUiaTreeNode> Children { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a UIA path string against a tree rooted at a window node.
|
||||
/// Pure logic, fully unit testable.
|
||||
/// </summary>
|
||||
public static class UiaPathResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Descend a tree following the segments of <paramref name="uiaPath"/>.
|
||||
/// The first segment matches the root (or its first descendant whose
|
||||
/// ClassName matches). Subsequent segments search direct children of
|
||||
/// the previously matched node.
|
||||
/// </summary>
|
||||
/// <returns>The resolved node or null when any segment fails to match.</returns>
|
||||
public static IUiaTreeNode? Resolve(IUiaTreeNode root, string uiaPath)
|
||||
{
|
||||
var segments = UiaPathParser.Parse(uiaPath);
|
||||
if (segments.Count == 0) return null;
|
||||
|
||||
IUiaTreeNode? current = MatchRoot(root, segments[0]);
|
||||
if (current is null) return null;
|
||||
|
||||
for (int i = 1; i < segments.Count; i++)
|
||||
{
|
||||
current = FindChild(current, segments[i]);
|
||||
if (current is null) return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static IUiaTreeNode? MatchRoot(IUiaTreeNode root, UiaPathParser.Segment seg)
|
||||
{
|
||||
if (Matches(root, seg)) return root;
|
||||
// Allow matching a descendant in case the caller passed an absolute
|
||||
// path that starts at a parent we don't see (e.g. desktop chrome).
|
||||
foreach (var d in Descendants(root))
|
||||
{
|
||||
if (Matches(d, seg)) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IUiaTreeNode? FindChild(IUiaTreeNode parent, UiaPathParser.Segment seg)
|
||||
{
|
||||
foreach (var c in parent.Children)
|
||||
{
|
||||
if (Matches(c, seg)) return c;
|
||||
}
|
||||
// Fallback: search descendants up to a small depth so element-tree
|
||||
// wrappers (e.g. ContentPresenter chains) don't break the resolve.
|
||||
foreach (var d in DescendantsBounded(parent, maxDepth: 4))
|
||||
{
|
||||
if (Matches(d, seg)) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Matches(IUiaTreeNode node, UiaPathParser.Segment seg)
|
||||
{
|
||||
// Priority: AutomationId > Name > ClassName
|
||||
if (!string.IsNullOrEmpty(seg.AutomationId))
|
||||
{
|
||||
if (!string.Equals(node.AutomationId, seg.AutomationId, System.StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// ClassName, when both sides have one, must also agree (loose).
|
||||
if (!string.IsNullOrEmpty(seg.ClassName) &&
|
||||
!string.IsNullOrEmpty(node.ClassName) &&
|
||||
!string.Equals(node.ClassName, seg.ClassName, System.StringComparison.Ordinal))
|
||||
{
|
||||
// Allow class mismatch — AutomationId is the strong key.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(seg.Name))
|
||||
{
|
||||
if (!string.Equals(node.Name, seg.Name, System.StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Only ClassName.
|
||||
if (string.IsNullOrEmpty(seg.ClassName)) return false;
|
||||
return string.Equals(node.ClassName, seg.ClassName, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IEnumerable<IUiaTreeNode> Descendants(IUiaTreeNode root)
|
||||
{
|
||||
var stack = new Stack<IUiaTreeNode>();
|
||||
foreach (var c in root.Children) stack.Push(c);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var n = stack.Pop();
|
||||
yield return n;
|
||||
foreach (var c in n.Children) stack.Push(c);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IUiaTreeNode> DescendantsBounded(IUiaTreeNode root, int maxDepth)
|
||||
{
|
||||
var stack = new Stack<(IUiaTreeNode Node, int Depth)>();
|
||||
foreach (var c in root.Children) stack.Push((c, 1));
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var (n, d) = stack.Pop();
|
||||
yield return n;
|
||||
if (d < maxDepth)
|
||||
{
|
||||
foreach (var c in n.Children) stack.Push((c, d + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/Recordingtest.Player/UiaPathParser.cs
Normal file
115
src/Recordingtest.Player/UiaPathParser.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
/// <summary>
|
||||
/// Pure parser for UIA path strings of the form
|
||||
/// <c>ClassName[@AutomationId='...'][@Name='...']/ClassName[@AutomationId='...']</c>.
|
||||
///
|
||||
/// Segments are separated by <c>/</c> at depth zero (slashes inside <c>'...'</c>
|
||||
/// quoted attribute values are preserved). Attributes supported: AutomationId, Name.
|
||||
/// Both attributes are optional; ClassName must always be present.
|
||||
/// </summary>
|
||||
public static class UiaPathParser
|
||||
{
|
||||
public sealed record Segment(string ClassName, string? AutomationId, string? Name);
|
||||
|
||||
public static IReadOnlyList<Segment> Parse(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return Array.Empty<Segment>();
|
||||
}
|
||||
|
||||
var raw = SplitTopLevel(path);
|
||||
var result = new List<Segment>(raw.Count);
|
||||
foreach (var s in raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) continue;
|
||||
result.Add(ParseSegment(s));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Segment ParseSegment(string segment)
|
||||
{
|
||||
// ClassName[@AutomationId='...'][@Name='...']
|
||||
// ClassName may contain '#' (e.g. "#32769") and dots.
|
||||
int bracket = segment.IndexOf('[');
|
||||
string className = bracket < 0 ? segment : segment.Substring(0, bracket);
|
||||
className = className.Trim();
|
||||
|
||||
string? automationId = null;
|
||||
string? name = null;
|
||||
|
||||
int i = bracket;
|
||||
while (i >= 0 && i < segment.Length && segment[i] == '[')
|
||||
{
|
||||
int end = segment.IndexOf(']', i + 1);
|
||||
if (end < 0) break;
|
||||
// Skip ] inside quoted strings
|
||||
int qStart = segment.IndexOf('\'', i + 1);
|
||||
if (qStart >= 0 && qStart < end)
|
||||
{
|
||||
int qEnd = segment.IndexOf('\'', qStart + 1);
|
||||
if (qEnd > end)
|
||||
{
|
||||
end = segment.IndexOf(']', qEnd + 1);
|
||||
if (end < 0) break;
|
||||
}
|
||||
}
|
||||
var inside = segment.Substring(i + 1, end - i - 1);
|
||||
ParseAttribute(inside, ref automationId, ref name);
|
||||
i = end + 1;
|
||||
if (i >= segment.Length) break;
|
||||
}
|
||||
|
||||
return new Segment(className, automationId, name);
|
||||
}
|
||||
|
||||
private static void ParseAttribute(string inside, ref string? automationId, ref string? name)
|
||||
{
|
||||
// form: @AutomationId='value' or @Name='value'
|
||||
inside = inside.Trim();
|
||||
if (!inside.StartsWith("@", StringComparison.Ordinal)) return;
|
||||
int eq = inside.IndexOf('=');
|
||||
if (eq < 0) return;
|
||||
var key = inside.Substring(1, eq - 1).Trim();
|
||||
var rawVal = inside.Substring(eq + 1).Trim();
|
||||
if (rawVal.Length >= 2 && rawVal[0] == '\'' && rawVal[^1] == '\'')
|
||||
{
|
||||
rawVal = rawVal.Substring(1, rawVal.Length - 2);
|
||||
}
|
||||
if (string.Equals(key, "AutomationId", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
automationId = rawVal;
|
||||
}
|
||||
else if (string.Equals(key, "Name", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = rawVal;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> SplitTopLevel(string path)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var sb = new System.Text.StringBuilder();
|
||||
bool inQuote = false;
|
||||
foreach (var c in path)
|
||||
{
|
||||
if (c == '\'') inQuote = !inQuote;
|
||||
if (c == '/' && !inQuote)
|
||||
{
|
||||
parts.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
if (sb.Length > 0) parts.Add(sb.ToString());
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -29,37 +29,28 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
||||
|
||||
public ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout)
|
||||
{
|
||||
// Best-effort: search by AutomationId fragment in the last segment.
|
||||
// A full UIA-path resolver is out of PoC scope; recorder produces
|
||||
// simple AutomationId-based paths in the bootstrap scenarios.
|
||||
var automationId = ExtractAutomationId(uiaPath);
|
||||
// Issue #12 — full UIA path resolver. Walk the segment chain via the
|
||||
// pure UiaPathResolver against a FlaUI-backed tree adapter so the
|
||||
// logic is unit testable independent of the live SUT.
|
||||
var window = _app?.GetMainWindow(_automation, timeout);
|
||||
if (window is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var element = Retry.WhileNull(
|
||||
() =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(automationId))
|
||||
{
|
||||
return window.FindFirstDescendant(cf => cf.ByAutomationId(automationId));
|
||||
}
|
||||
return window.FindFirstDescendant();
|
||||
},
|
||||
var resolved = Retry.WhileNull<IUiaTreeNode?>(
|
||||
() => UiaPathResolver.Resolve(new FlaUiTreeNode(window), uiaPath),
|
||||
timeout: timeout,
|
||||
ignoreException: true).Result;
|
||||
|
||||
if (element is null)
|
||||
if (resolved is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var r = element.BoundingRectangle;
|
||||
return new ResolvedElement(
|
||||
new ElementBounds(r.X, r.Y, r.Width, r.Height),
|
||||
element);
|
||||
var b = resolved.Bounds;
|
||||
var native = resolved is FlaUiTreeNode fn ? (object?)fn.Element : null;
|
||||
return new ResolvedElement(b, native);
|
||||
}
|
||||
|
||||
public bool WaitFor(string waitForHint, TimeSpan timeout)
|
||||
@@ -132,16 +123,58 @@ public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
||||
$"[{DateTime.UtcNow:o}] step={stepIndex} reason={reason}{Environment.NewLine}");
|
||||
}
|
||||
|
||||
private static string ExtractAutomationId(string uiaPath)
|
||||
/// <summary>
|
||||
/// FlaUI-backed adapter for the pure <see cref="IUiaTreeNode"/> interface.
|
||||
/// </summary>
|
||||
private sealed class FlaUiTreeNode : IUiaTreeNode
|
||||
{
|
||||
// Look for [@AutomationId='...'] in the last segment.
|
||||
var marker = "@AutomationId='";
|
||||
var idx = uiaPath.LastIndexOf(marker, StringComparison.Ordinal);
|
||||
if (idx < 0) return string.Empty;
|
||||
var start = idx + marker.Length;
|
||||
var end = uiaPath.IndexOf('\'', start);
|
||||
if (end < 0) return string.Empty;
|
||||
return uiaPath.Substring(start, end - start);
|
||||
public AutomationElement Element { get; }
|
||||
|
||||
public FlaUiTreeNode(AutomationElement el)
|
||||
{
|
||||
Element = el;
|
||||
}
|
||||
|
||||
public string ClassName
|
||||
{
|
||||
get { try { return Element.ClassName ?? string.Empty; } catch { return string.Empty; } }
|
||||
}
|
||||
public string? AutomationId
|
||||
{
|
||||
get { try { return Element.AutomationId; } catch { return null; } }
|
||||
}
|
||||
public string? Name
|
||||
{
|
||||
get { try { return Element.Name; } catch { return null; } }
|
||||
}
|
||||
public ElementBounds Bounds
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = Element.BoundingRectangle;
|
||||
return new ElementBounds(r.X, r.Y, r.Width, r.Height);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ElementBounds(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
public IEnumerable<IUiaTreeNode> Children
|
||||
{
|
||||
get
|
||||
{
|
||||
AutomationElement[] arr;
|
||||
try { arr = Element.FindAllChildren(); }
|
||||
catch { yield break; }
|
||||
foreach (var c in arr)
|
||||
{
|
||||
yield return new FlaUiTreeNode(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
Reference in New Issue
Block a user