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/.yaml --attach "); Console.Error.WriteLine(" --attach is REQUIRED. The recorder never launches the SUT itself."); } private static int Run(CliArgs args) { var channel = Channel.CreateUnbounded(); 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(); 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 ?? ""}"); 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 reader, System.Collections.Generic.List buffer, CancellationToken ct, Action onEvent) { while (await reader.WaitToReadAsync(ct).ConfigureAwait(false)) { while (reader.TryRead(out var ev)) { onEvent(); buffer.Add(ev); } } } /// /// FlaUI/Win32-backed . 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 /// rule which falls back to the primary /// result when the fallback returns null. /// 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; } } } /// /// Adapter wrapping a FlaUI AutomationElement as IElementSnapshot. /// Resolved on demand from the main loop (never from the hook thread). /// 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(Func f) { try { return f(); } catch { return default!; } } }