Fix smoke 1차 follow-up gaps: player resolver, type target, filter, utf8 (#12)

This commit is contained in:
minsung
2026-04-07 20:30:59 +09:00
parent 3738a0df5c
commit 8784fec923
13 changed files with 802 additions and 30 deletions

View 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));
}
}
}
}

View 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;
}
}

View File

@@ -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()

View File

@@ -38,6 +38,14 @@ public sealed class DragCollapser
// Active modifiers (ctrl/shift/alt/win) held down.
var modsDown = new HashSet<string>(StringComparer.Ordinal);
// Issue #12 — fallback target for type steps when no resolver result
// is available for the key event itself. Priority order:
// 1. typeRes (resolver result captured at first key_down)
// 2. _lastFocusPath (most recent focus_change)
// 3. _lastMousePath (most recent successful mouse resolution)
string? lastFocusPath = null;
string? lastMousePath = null;
void FlushType()
{
if (typeBuf.Length == 0 || typeFirst is null) return;
@@ -59,6 +67,18 @@ public sealed class DragCollapser
step.Value = MaskPolicy.MaskedValue;
}
}
else
{
var fallback = lastFocusPath ?? lastMousePath;
if (!string.IsNullOrEmpty(fallback))
{
step.Target = new ScenarioTarget
{
UiaPath = fallback!,
Offset = new[] { 0.5, 0.5 },
};
}
}
steps.Add(step);
typeBuf.Clear();
typeFirst = null;
@@ -103,6 +123,10 @@ public sealed class DragCollapser
var threshSq = DragThresholdPx * DragThresholdPx;
var downRes = resolver(down);
if (downRes is not null)
{
lastMousePath = downRes.UiaPath;
}
if (useSq >= threshSq)
{
// drag step
@@ -161,6 +185,7 @@ public sealed class DragCollapser
case "mouse_down_r":
{
var res = resolver(ev);
if (res is not null) lastMousePath = res.UiaPath;
var step = new ScenarioStep
{
Kind = "click",
@@ -304,6 +329,7 @@ public sealed class DragCollapser
UiaPath = ev.FocusedElementPath!,
Offset = new[] { 0.5, 0.5 },
};
lastFocusPath = ev.FocusedElementPath;
}
steps.Add(step);
break;

View File

@@ -21,6 +21,12 @@ public sealed class LowLevelHook : IDisposable
private uint _threadId;
private volatile bool _running;
/// <summary>
/// Mutable filter — set after attach so the recorder can drop events
/// from non-SUT windows (issue #12 Gap C). Default keeps everything.
/// </summary>
public IWindowFilter Filter { get; set; } = new PassThroughWindowFilter();
public LowLevelHook(Channel<RawEvent> channel)
{
_channel = channel;
@@ -77,7 +83,11 @@ public sealed class LowLevelHook : IDisposable
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
_ => "key",
};
_channel.Writer.TryWrite(new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0));
var ev = new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0);
if (Filter.ShouldKeep(ev))
{
_channel.Writer.TryWrite(ev);
}
}
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
}
@@ -105,7 +115,11 @@ public sealed class LowLevelHook : IDisposable
{
wheel = (short)((data.mouseData >> 16) & 0xFFFF);
}
_channel.Writer.TryWrite(new RawEvent(NowMs(), kind, data.pt.x, data.pt.y, 0, wheel));
var ev = new RawEvent(NowMs(), kind, data.pt.x, data.pt.y, 0, wheel);
if (Filter.ShouldKeep(ev))
{
_channel.Writer.TryWrite(ev);
}
}
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
}

View File

@@ -76,6 +76,15 @@ internal static class NativeMethods
[DllImport("user32.dll")]
public static extern void PostQuitMessage(int nExitCode);
[DllImport("user32.dll")]
public static extern IntPtr WindowFromPoint(POINT pt);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[StructLayout(LayoutKind.Sequential)]
public struct MSG
{

View File

@@ -82,6 +82,30 @@ public static class Program
Console.Error.WriteLine($"[recorder] attach failed: {ex.Message}");
}
// Issue #12 Gap C — once attached, drop events that originate from
// any window not owned by the SUT process.
if (app is not null)
{
int sutPid = app.ProcessId;
hook.Filter = new SutProcessWindowFilter(
sutPid,
processFromPoint: (x, y) =>
{
var hwnd = NativeMethods.WindowFromPoint(new NativeMethods.POINT { x = x, y = y });
if (hwnd == IntPtr.Zero) return 0;
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
return (int)pid;
},
processFromForeground: () =>
{
var hwnd = NativeMethods.GetForegroundWindow();
if (hwnd == IntPtr.Zero) return 0;
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
return (int)pid;
});
Console.WriteLine($"[recorder] window filter active for pid={sutPid}");
}
var scenario = new Scenario
{
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),

View File

@@ -1,4 +1,5 @@
using System.IO;
using System.Text;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -35,6 +36,11 @@ public static class ScenarioWriter
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(path, Serialize(scenario));
// Issue #12 — write UTF-8 without BOM so the file round-trips through
// PowerShell / cross-tool readers without garbling Korean text. The
// default `File.WriteAllText` overload also produces UTF-8 without BOM
// on .NET Core+, but we make it explicit to lock the contract.
var enc = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
File.WriteAllText(path, Serialize(scenario), enc);
}
}

View File

@@ -0,0 +1,69 @@
using System;
namespace Recordingtest.Recorder;
/// <summary>
/// Decides whether a raw event belongs to the attached SUT process.
/// Pure interface so the rule is unit testable without P/Invoke.
/// </summary>
public interface IWindowFilter
{
/// <summary>Return true to keep the event, false to drop it.</summary>
bool ShouldKeep(RawEvent ev);
}
/// <summary>Always keeps every event. Used when no SUT is attached yet.</summary>
public sealed class PassThroughWindowFilter : IWindowFilter
{
public bool ShouldKeep(RawEvent ev) => true;
}
/// <summary>
/// Pure SUT-process filter. Uses two pluggable lookups so the rule can be
/// exercised in unit tests with fake delegates instead of real Win32.
///
/// <c>processFromPoint</c> : (x,y) → owning process id, or 0 if unknown
/// <c>processFromForeground</c>: () → foreground process id (used for keys)
/// </summary>
public sealed class SutProcessWindowFilter : IWindowFilter
{
private readonly int _sutPid;
private readonly Func<int, int, int> _processFromPoint;
private readonly Func<int> _processFromForeground;
public SutProcessWindowFilter(
int sutPid,
Func<int, int, int> processFromPoint,
Func<int> processFromForeground)
{
_sutPid = sutPid;
_processFromPoint = processFromPoint;
_processFromForeground = processFromForeground;
}
public bool ShouldKeep(RawEvent ev)
{
if (_sutPid <= 0) return true;
switch (ev.Kind)
{
case "key_down":
case "key_up":
{
var pid = _processFromForeground();
return pid == 0 || pid == _sutPid;
}
case "focus_change":
// focus_change comes from the UIA callback already scoped to
// the SUT process, keep unconditionally.
return true;
default:
{
var pid = _processFromPoint(ev.X, ev.Y);
// pid==0 means lookup failed; be permissive (don't drop legit
// events when WindowFromPoint races with the cursor).
return pid == 0 || pid == _sutPid;
}
}
}
}