Implement recorder PoC (#6)
This commit is contained in:
296
src/Recordingtest.Recorder/Program.cs
Normal file
296
src/Recordingtest.Recorder/Program.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
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
|
||||
{
|
||||
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;
|
||||
|
||||
try
|
||||
{
|
||||
(app, automation, mainWindow) = TryAttach(args.Attach);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[recorder] attach failed: {ex.Message}");
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
|
||||
int eventCount = 0;
|
||||
int unresolved = 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
ConsumeAsync(channel.Reader, scenario, mainWindow, automation, cts.Token,
|
||||
onEvent: () => eventCount++,
|
||||
onUnresolved: () => unresolved++).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected on Ctrl+C
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
ScenarioWriter.WriteToFile(scenario, args.OutputPath);
|
||||
Console.WriteLine($"[recorder] done. events={eventCount} elapsed={sw.Elapsed} unresolved_paths={unresolved}");
|
||||
|
||||
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,
|
||||
Scenario scenario,
|
||||
AutomationElement? mainWindow,
|
||||
UIA3Automation? automation,
|
||||
CancellationToken ct,
|
||||
Action onEvent,
|
||||
Action onUnresolved)
|
||||
{
|
||||
while (await reader.WaitToReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
while (reader.TryRead(out var ev))
|
||||
{
|
||||
onEvent();
|
||||
if (!IsInterestingForStep(ev.Kind)) continue;
|
||||
|
||||
IElementSnapshot? snap = null;
|
||||
if (automation is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
snap = ResolveAt(automation, ev.X, ev.Y);
|
||||
}
|
||||
catch
|
||||
{
|
||||
snap = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (snap is null)
|
||||
{
|
||||
onUnresolved();
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = ElementPathBuilder.Build(snap);
|
||||
var (dx, dy) = OffsetNormalizer.Normalize(snap.BoundingRectangle, ev.X, ev.Y);
|
||||
var step = new ScenarioStep
|
||||
{
|
||||
Kind = ev.Kind.StartsWith("key", StringComparison.Ordinal) ? "type" : "click",
|
||||
Target = new ScenarioTarget
|
||||
{
|
||||
UiaPath = path,
|
||||
Offset = new[] { dx, dy },
|
||||
},
|
||||
Value = MaskPolicy.IsMasked(snap) ? MaskPolicy.MaskedValue : null,
|
||||
};
|
||||
scenario.Steps.Add(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInterestingForStep(string kind) =>
|
||||
kind == "mouse_down_l" || kind == "mouse_down_r" || kind == "key_down";
|
||||
|
||||
private static IElementSnapshot? ResolveAt(UIA3Automation automation, int x, int y)
|
||||
{
|
||||
var raw = automation.FromPoint(new System.Drawing.Point(x, y));
|
||||
if (raw is null) return null;
|
||||
return new FlaUiSnapshot(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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!; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user