Adds a background focus poller that periodically calls
Automation.FocusedElement() and stamps the path onto key_down RawEvents,
so DragCollapser can fill type-step targets without relying on the stale
post-hoc Resolve() pass. Plumbing:
Program.cs — focus poller Task + diagnostic counters
LowLevelHook — volatile CurrentFocusedPath, stamped on key_down
RawEvent — FocusedElementPath already existed (focus_change)
DragCollapser— typeFocusPath captured at first printable key_down,
takes precedence over lastFocusPath/lastMousePath
Result on box-v7.yaml live recording: null_target_steps unchanged (13).
Root cause: EG-BIM Modeler's CommandBox and similar input controls lack
AutomationPeer, so UIA-based focus tracking — from any external process —
cannot see them. The WPF-internal Keyboard.FocusedElement is in-process
only and unreachable from the recorder.
Deferred. The plumbing stays in place because the same stamping path can
be reused by a future generic WPF DLL-injection probe. Player's existing
null-target fallback (Type→OS focus, Click→raw_coord) remains the official
strategy and successfully replays box-v7 end-to-end.
See docs/history/2026-04-08_gap-i1-deferred.md for analysis and future
options (generic WPF injection / AutomationPeer AI attachment).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
488 lines
16 KiB
C#
488 lines
16 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.Threading;
|
|
using System.Threading.Channels;
|
|
using System.Threading.Tasks;
|
|
using FlaUI.Core;
|
|
using FlaUI.Core.AutomationElements;
|
|
using FlaUI.UIA3;
|
|
|
|
namespace Recordingtest.Recorder;
|
|
|
|
public static class Program
|
|
{
|
|
[STAThread]
|
|
public static int Main(string[] args)
|
|
{
|
|
var parsed = ParseArgs(args);
|
|
if (parsed is null)
|
|
{
|
|
PrintUsage();
|
|
return 2;
|
|
}
|
|
|
|
Console.WriteLine($"[recorder] output={parsed.OutputPath} attach={parsed.Attach}");
|
|
|
|
try
|
|
{
|
|
return Run(parsed);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"[recorder] error: {ex.Message}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
internal sealed record CliArgs(string OutputPath, string Attach);
|
|
|
|
internal static CliArgs? ParseArgs(string[] args)
|
|
{
|
|
string? output = null;
|
|
string? attach = null;
|
|
for (int i = 0; i < args.Length; i++)
|
|
{
|
|
switch (args[i])
|
|
{
|
|
case "--output" when i + 1 < args.Length:
|
|
output = args[++i];
|
|
break;
|
|
case "--attach" when i + 1 < args.Length:
|
|
attach = args[++i];
|
|
break;
|
|
}
|
|
}
|
|
if (string.IsNullOrEmpty(attach)) return null;
|
|
if (string.IsNullOrEmpty(output)) output = "scenarios/recorded.yaml";
|
|
return new CliArgs(output!, attach!);
|
|
}
|
|
|
|
internal static void PrintUsage()
|
|
{
|
|
Console.Error.WriteLine("Usage: Recordingtest.Recorder --output scenarios/<name>.yaml --attach <pid|title>");
|
|
Console.Error.WriteLine(" --attach is REQUIRED. The recorder never launches the SUT itself.");
|
|
}
|
|
|
|
private static int Run(CliArgs args)
|
|
{
|
|
var channel = Channel.CreateUnbounded<RawEvent>();
|
|
using var hook = new LowLevelHook(channel);
|
|
hook.Start();
|
|
|
|
Application? app = null;
|
|
UIA3Automation? automation = null;
|
|
AutomationElement? mainWindow = null;
|
|
int sutPid = 0;
|
|
|
|
try
|
|
{
|
|
(app, automation, mainWindow) = TryAttach(args.Attach);
|
|
if (app is not null) sutPid = app.ProcessId;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
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)
|
|
{
|
|
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),
|
|
Description = "Recorded session",
|
|
};
|
|
|
|
var cts = new CancellationTokenSource();
|
|
Console.CancelKeyPress += (_, e) =>
|
|
{
|
|
e.Cancel = true;
|
|
cts.Cancel();
|
|
};
|
|
|
|
// Register UIA focus changed event. The callback only captures the
|
|
// element path and pushes a synthetic RawEvent into the same queue;
|
|
// it does NOT compute anything else inside the UIA callback.
|
|
try
|
|
{
|
|
if (automation is not null)
|
|
{
|
|
automation.RegisterFocusChangedEvent(el =>
|
|
{
|
|
try
|
|
{
|
|
if (el is null) return;
|
|
// Issue #13 Gap F — drop focus events from non-SUT processes.
|
|
int elPid = 0;
|
|
try { elPid = el.Properties.ProcessId.ValueOrDefault; }
|
|
catch { elPid = 0; }
|
|
if (!FocusEventFilter.ShouldAccept(elPid, sutPid)) return;
|
|
var snap = new FlaUiSnapshot(el);
|
|
var path = ElementPathBuilder.Build(snap);
|
|
channel.Writer.TryWrite(new RawEvent(
|
|
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
"focus_change", 0, 0, 0, 0, path));
|
|
}
|
|
catch
|
|
{
|
|
// never throw from UIA callback
|
|
}
|
|
});
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"[recorder] focus subscribe failed: {ex.Message}");
|
|
}
|
|
|
|
// Issue #14 Gap I-1 — background focus poller. Periodically queries
|
|
// Automation.FocusedElement() and publishes the element path into
|
|
// LowLevelHook.CurrentFocusedPath, so key_down events captured on the
|
|
// hook thread can be stamped with the live focused-element path at
|
|
// the exact instant the user started typing. This catches custom WPF
|
|
// controls (e.g. CommandBox) that do NOT raise UIA focus_changed
|
|
// events reliably.
|
|
var pollerCts = new CancellationTokenSource();
|
|
Task? pollerTask = null;
|
|
int pollerSuccess = 0;
|
|
int pollerNullFocus = 0;
|
|
int pollerWrongPid = 0;
|
|
int pollerErrors = 0;
|
|
string? pollerLastError = null;
|
|
if (automation is not null)
|
|
{
|
|
var auto = automation;
|
|
var pid = sutPid;
|
|
pollerTask = Task.Run(async () =>
|
|
{
|
|
while (!pollerCts.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var focused = auto.FocusedElement();
|
|
if (focused is null)
|
|
{
|
|
System.Threading.Interlocked.Increment(ref pollerNullFocus);
|
|
}
|
|
else
|
|
{
|
|
int elPid = 0;
|
|
try { elPid = focused.Properties.ProcessId.ValueOrDefault; }
|
|
catch { elPid = 0; }
|
|
if (pid == 0 || elPid == pid)
|
|
{
|
|
var snap = new FlaUiSnapshot(focused);
|
|
hook.CurrentFocusedPath = ElementPathBuilder.Build(snap);
|
|
System.Threading.Interlocked.Increment(ref pollerSuccess);
|
|
}
|
|
else
|
|
{
|
|
System.Threading.Interlocked.Increment(ref pollerWrongPid);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Threading.Interlocked.Increment(ref pollerErrors);
|
|
pollerLastError = ex.GetType().Name + ": " + ex.Message;
|
|
}
|
|
try { await Task.Delay(100, pollerCts.Token); }
|
|
catch (OperationCanceledException) { break; }
|
|
}
|
|
}, pollerCts.Token);
|
|
}
|
|
|
|
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
|
|
int eventCount = 0;
|
|
int unresolvedPaths = 0; // resolver ran but returned null
|
|
int noResolverAttempt = 0; // resolver skipped entirely (e.g. automation null, key event)
|
|
var sw = Stopwatch.StartNew();
|
|
var rawBuffer = new System.Collections.Generic.List<RawEvent>();
|
|
|
|
try
|
|
{
|
|
ConsumeAsync(channel.Reader, rawBuffer, cts.Token,
|
|
onEvent: () => eventCount++).GetAwaiter().GetResult();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// expected on Ctrl+C
|
|
}
|
|
|
|
sw.Stop();
|
|
|
|
// Stop the focus poller before UIA teardown.
|
|
pollerCts.Cancel();
|
|
try { pollerTask?.Wait(500); } catch { /* ignore */ }
|
|
|
|
// Collapse buffered raw events into scenario steps via DragCollapser.
|
|
var collapser = new DragCollapser();
|
|
UiaResolution? Resolve(RawEvent ev)
|
|
{
|
|
// Issue #14 Gap I-1 — key events: Resolve() runs at collapse time
|
|
// (after the recording ended), so querying FocusedElement() HERE
|
|
// would be stale (focus has already left the SUT). The focused
|
|
// element path is captured at key_down time by the FocusPoller
|
|
// and baked into RawEvent.FocusedElementPath. DragCollapser reads
|
|
// that directly; Resolve() simply returns null for key events.
|
|
if (ev.Kind == "key_down" || ev.Kind == "key_up")
|
|
{
|
|
noResolverAttempt++;
|
|
return null;
|
|
}
|
|
if (automation is null)
|
|
{
|
|
noResolverAttempt++;
|
|
return null;
|
|
}
|
|
try
|
|
{
|
|
var source = new FlaUiPointSource(automation, mainWindow);
|
|
var snap = WindowPointResolver.Resolve(source, ev.X, ev.Y, sutPid);
|
|
if (snap is null)
|
|
{
|
|
unresolvedPaths++;
|
|
return null;
|
|
}
|
|
var path = ElementPathBuilder.Build(snap);
|
|
return new UiaResolution(snap, path);
|
|
}
|
|
catch
|
|
{
|
|
unresolvedPaths++;
|
|
return null;
|
|
}
|
|
}
|
|
foreach (var step in collapser.Collapse(rawBuffer, Resolve))
|
|
{
|
|
scenario.Steps.Add(step);
|
|
}
|
|
int nullTargetSteps = 0;
|
|
foreach (var s in scenario.Steps)
|
|
{
|
|
if (s.Target is null && s.Kind != "wait" && s.Kind != "checkpoint")
|
|
{
|
|
nullTargetSteps++;
|
|
}
|
|
}
|
|
ScenarioWriter.WriteToFile(scenario, args.OutputPath);
|
|
Console.WriteLine(
|
|
$"[recorder] done. events={eventCount} elapsed={sw.Elapsed} " +
|
|
$"unresolved_paths={unresolvedPaths} no_resolver_attempt={noResolverAttempt} " +
|
|
$"null_target_steps={nullTargetSteps}");
|
|
Console.WriteLine(
|
|
$"[recorder] focus_poller success={pollerSuccess} null_focus={pollerNullFocus} " +
|
|
$"wrong_pid={pollerWrongPid} errors={pollerErrors} last_path={hook.CurrentFocusedPath ?? "<null>"}");
|
|
if (pollerLastError is not null)
|
|
Console.WriteLine($"[recorder] focus_poller last_error: {pollerLastError}");
|
|
|
|
automation?.Dispose();
|
|
return 0;
|
|
}
|
|
|
|
private static (Application?, UIA3Automation?, AutomationElement?) TryAttach(string attach)
|
|
{
|
|
// NOTE: We never Launch() the SUT here. Only attach by pid or window title.
|
|
Application? app = null;
|
|
if (int.TryParse(attach, out var pid))
|
|
{
|
|
app = Application.Attach(pid);
|
|
}
|
|
else
|
|
{
|
|
var procs = Process.GetProcesses();
|
|
foreach (var p in procs)
|
|
{
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(p.MainWindowTitle) &&
|
|
p.MainWindowTitle.Contains(attach, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
app = Application.Attach(p.Id);
|
|
break;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore inaccessible processes
|
|
}
|
|
}
|
|
}
|
|
if (app is null) return (null, null, null);
|
|
|
|
var automation = new UIA3Automation();
|
|
var main = app.GetMainWindow(automation, TimeSpan.FromSeconds(5));
|
|
return (app, automation, main);
|
|
}
|
|
|
|
private static async Task ConsumeAsync(
|
|
ChannelReader<RawEvent> reader,
|
|
System.Collections.Generic.List<RawEvent> buffer,
|
|
CancellationToken ct,
|
|
Action onEvent)
|
|
{
|
|
while (await reader.WaitToReadAsync(ct).ConfigureAwait(false))
|
|
{
|
|
while (reader.TryRead(out var ev))
|
|
{
|
|
onEvent();
|
|
buffer.Add(ev);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// FlaUI/Win32-backed <see cref="IWindowPointSource"/>. The SUT-scope
|
|
/// fallback is a best-effort stub (returns null) pending live verification
|
|
/// in smoke 3 — the load-bearing piece of Gap G is the pure
|
|
/// <see cref="WindowPointResolver"/> rule which falls back to the primary
|
|
/// result when the fallback returns null.
|
|
/// </summary>
|
|
private sealed class FlaUiPointSource : IWindowPointSource
|
|
{
|
|
private readonly UIA3Automation _automation;
|
|
private readonly AutomationElement? _mainWindow;
|
|
|
|
public FlaUiPointSource(UIA3Automation automation, AutomationElement? mainWindow)
|
|
{
|
|
_automation = automation;
|
|
_mainWindow = mainWindow;
|
|
}
|
|
|
|
public int? GetProcessIdAt(int x, int y)
|
|
{
|
|
try
|
|
{
|
|
var hwnd = NativeMethods.WindowFromPoint(new NativeMethods.POINT { x = x, y = y });
|
|
if (hwnd == IntPtr.Zero) return null;
|
|
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
|
|
return (int)pid;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public IElementSnapshot? GetElementAt(int x, int y)
|
|
{
|
|
try
|
|
{
|
|
var raw = _automation.FromPoint(new System.Drawing.Point(x, y));
|
|
return raw is null ? null : new FlaUiSnapshot(raw);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public IElementSnapshot? GetElementFromSutScope(int x, int y)
|
|
{
|
|
// Partial Gap G: honest stub. Returning null lets WindowPointResolver
|
|
// fall back to the primary element as a last resort. Full hit-test
|
|
// walker to be implemented once smoke 3 validates the surface.
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adapter wrapping a FlaUI AutomationElement as IElementSnapshot.
|
|
/// Resolved on demand from the main loop (never from the hook thread).
|
|
/// </summary>
|
|
internal sealed class FlaUiSnapshot : IElementSnapshot
|
|
{
|
|
private readonly AutomationElement _el;
|
|
private readonly FlaUiSnapshot? _parentSnap;
|
|
|
|
public FlaUiSnapshot(AutomationElement el, FlaUiSnapshot? parentSnap = null)
|
|
{
|
|
_el = el;
|
|
_parentSnap = parentSnap;
|
|
}
|
|
|
|
public string ClassName => SafeGet(() => _el.ClassName ?? string.Empty);
|
|
public string? AutomationId => SafeGet(() => _el.AutomationId);
|
|
public string? Name => SafeGet(() => _el.Name);
|
|
|
|
public bool IsPassword
|
|
{
|
|
get
|
|
{
|
|
try
|
|
{
|
|
var ct = _el.ControlType;
|
|
if (ct == FlaUI.Core.Definitions.ControlType.Edit &&
|
|
string.Equals(_el.ClassName, "PasswordBox", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public (double Left, double Top, double Width, double Height) BoundingRectangle
|
|
{
|
|
get
|
|
{
|
|
try
|
|
{
|
|
var r = _el.BoundingRectangle;
|
|
return (r.Left, r.Top, r.Width, r.Height);
|
|
}
|
|
catch
|
|
{
|
|
return (0, 0, 0, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
public IElementSnapshot? Parent
|
|
{
|
|
get
|
|
{
|
|
if (_parentSnap is not null) return _parentSnap;
|
|
try
|
|
{
|
|
var p = _el.Parent;
|
|
return p is null ? null : new FlaUiSnapshot(p);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static T SafeGet<T>(Func<T> f)
|
|
{
|
|
try { return f(); } catch { return default!; }
|
|
}
|
|
}
|