Implement player PoC (#7)
This commit is contained in:
28
src/Recordingtest.Player/IPlayerHost.cs
Normal file
28
src/Recordingtest.Player/IPlayerHost.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Recordingtest.Player.Model;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
/// <summary>Element bounds in screen pixels.</summary>
|
||||
public readonly record struct ElementBounds(double X, double Y, double Width, double Height);
|
||||
|
||||
/// <summary>Resolved element handle (opaque to the engine).</summary>
|
||||
public readonly record struct ResolvedElement(ElementBounds Bounds, object? Native);
|
||||
|
||||
public readonly record struct ScreenPoint(int X, int Y);
|
||||
|
||||
public interface IPlayerHost
|
||||
{
|
||||
/// <summary>Resolve a UIA path with retry/timeout. Returns null if not found.</summary>
|
||||
ResolvedElement? ResolveElement(string uiaPath, TimeSpan timeout);
|
||||
|
||||
/// <summary>Wait for a wait_for hint to be satisfied. Returns false on timeout.</summary>
|
||||
bool WaitFor(string waitForHint, TimeSpan timeout);
|
||||
|
||||
void Click(ScreenPoint point);
|
||||
void Type(string text);
|
||||
void Drag(ScreenPoint from, ScreenPoint to);
|
||||
void Hotkey(string keys);
|
||||
|
||||
void CaptureCheckpoint(int afterStep, string saveAs);
|
||||
void CaptureFailureArtifacts(int stepIndex, string reason);
|
||||
}
|
||||
28
src/Recordingtest.Player/Model/Scenario.cs
Normal file
28
src/Recordingtest.Player/Model/Scenario.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Recordingtest.Player.Model;
|
||||
|
||||
public sealed class Scenario
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public SutInfo Sut { get; set; } = new();
|
||||
public List<Step> Steps { get; set; } = new();
|
||||
public List<Checkpoint> Checkpoints { get; set; } = new();
|
||||
public List<Baseline> Baselines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SutInfo
|
||||
{
|
||||
public string Exe { get; set; } = string.Empty;
|
||||
public int StartupTimeoutMs { get; set; } = 15000;
|
||||
}
|
||||
|
||||
public sealed class Checkpoint
|
||||
{
|
||||
public int AfterStep { get; set; }
|
||||
public string SaveAs { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class Baseline
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
}
|
||||
28
src/Recordingtest.Player/Model/Step.cs
Normal file
28
src/Recordingtest.Player/Model/Step.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Recordingtest.Player.Model;
|
||||
|
||||
public enum StepKind
|
||||
{
|
||||
Click,
|
||||
Type,
|
||||
Drag,
|
||||
Hotkey,
|
||||
Wait,
|
||||
Checkpoint,
|
||||
Save,
|
||||
}
|
||||
|
||||
public sealed class Step
|
||||
{
|
||||
public StepKind Kind { get; set; }
|
||||
public Target? Target { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public string? WaitFor { get; set; }
|
||||
public int? AfterStep { get; set; }
|
||||
public string? SaveAs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Target
|
||||
{
|
||||
public string UiaPath { get; set; } = string.Empty;
|
||||
public double[] Offset { get; set; } = new double[] { 0.5, 0.5 };
|
||||
}
|
||||
115
src/Recordingtest.Player/PlayerEngine.cs
Normal file
115
src/Recordingtest.Player/PlayerEngine.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Recordingtest.Player.Model;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
public sealed class PlayerEngineOptions
|
||||
{
|
||||
public TimeSpan ResolveTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
public TimeSpan WaitForTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
public sealed class PlayerEngine
|
||||
{
|
||||
private readonly PlayerEngineOptions _options;
|
||||
|
||||
public PlayerEngine(PlayerEngineOptions? options = null)
|
||||
{
|
||||
_options = options ?? new PlayerEngineOptions();
|
||||
}
|
||||
|
||||
public void Run(Scenario scenario, IPlayerHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scenario);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
|
||||
for (int i = 0; i < scenario.Steps.Count; i++)
|
||||
{
|
||||
var step = scenario.Steps[i];
|
||||
try
|
||||
{
|
||||
ExecuteStep(i, step, host);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
host.CaptureFailureArtifacts(i, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteStep(int index, Step step, IPlayerHost host)
|
||||
{
|
||||
if (step.Kind == StepKind.Checkpoint)
|
||||
{
|
||||
var after = step.AfterStep ?? index;
|
||||
var saveAs = step.SaveAs ?? string.Empty;
|
||||
host.CaptureCheckpoint(after, saveAs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(step.WaitFor))
|
||||
{
|
||||
if (!host.WaitFor(step.WaitFor!, _options.WaitForTimeout))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wait_for timeout: '{step.WaitFor}' at step {index}");
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedElement? element = null;
|
||||
ScreenPoint point = default;
|
||||
if (step.Target is not null && !string.IsNullOrEmpty(step.Target.UiaPath))
|
||||
{
|
||||
element = host.ResolveElement(step.Target.UiaPath, _options.ResolveTimeout);
|
||||
if (element is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"failed to resolve uia_path '{step.Target.UiaPath}' at step {index}");
|
||||
}
|
||||
point = ComputeScreenPoint(element.Value.Bounds, step.Target.Offset);
|
||||
}
|
||||
|
||||
switch (step.Kind)
|
||||
{
|
||||
case StepKind.Click:
|
||||
host.Click(point);
|
||||
break;
|
||||
case StepKind.Type:
|
||||
host.Type(step.Value ?? string.Empty);
|
||||
break;
|
||||
case StepKind.Drag:
|
||||
// value format: "dx_norm,dy_norm" relative to bounds
|
||||
var to = point;
|
||||
if (!string.IsNullOrEmpty(step.Value) && element is not null)
|
||||
{
|
||||
var parts = step.Value!.Split(',');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], out var dx) &&
|
||||
double.TryParse(parts[1], out var dy))
|
||||
{
|
||||
to = ComputeScreenPoint(element.Value.Bounds, new[] { dx, dy });
|
||||
}
|
||||
}
|
||||
host.Drag(point, to);
|
||||
break;
|
||||
case StepKind.Hotkey:
|
||||
host.Hotkey(step.Value ?? string.Empty);
|
||||
break;
|
||||
case StepKind.Wait:
|
||||
// wait kind is satisfied by wait_for above; nothing else to do.
|
||||
break;
|
||||
case StepKind.Save:
|
||||
host.Hotkey(step.Value ?? "ctrl+s");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static ScreenPoint ComputeScreenPoint(ElementBounds bounds, double[] offset)
|
||||
{
|
||||
var ox = offset.Length > 0 ? offset[0] : 0.5;
|
||||
var oy = offset.Length > 1 ? offset[1] : 0.5;
|
||||
var x = bounds.X + bounds.Width * ox;
|
||||
var y = bounds.Y + bounds.Height * oy;
|
||||
return new ScreenPoint((int)Math.Round(x), (int)Math.Round(y));
|
||||
}
|
||||
}
|
||||
72
src/Recordingtest.Player/Program.cs
Normal file
72
src/Recordingtest.Player/Program.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Recordingtest.Player;
|
||||
using Recordingtest.Player.Model;
|
||||
|
||||
string? scenarioPath = null;
|
||||
string outputDir = Path.Combine(Directory.GetCurrentDirectory(), "player-output");
|
||||
bool noLaunch = false;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--scenario":
|
||||
scenarioPath = args[++i];
|
||||
break;
|
||||
case "--output-dir":
|
||||
outputDir = args[++i];
|
||||
break;
|
||||
case "--no-launch":
|
||||
noLaunch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (scenarioPath is null)
|
||||
{
|
||||
Console.Error.WriteLine("usage: Recordingtest.Player --scenario <path> [--output-dir <path>] [--no-launch]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Scenario scenario;
|
||||
try
|
||||
{
|
||||
scenario = ScenarioLoader.LoadFromFile(scenarioPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"failed to load scenario: {ex.Message}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var artifactDir = Path.Combine(outputDir, "artifacts", scenario.Name, stamp);
|
||||
Directory.CreateDirectory(artifactDir);
|
||||
|
||||
FlaUI.Core.Application? app = null;
|
||||
if (noLaunch)
|
||||
{
|
||||
app = UiaPlayerHost.AttachByExeName(scenario.Sut.Exe);
|
||||
if (app is null)
|
||||
{
|
||||
Console.Error.WriteLine($"--no-launch: SUT '{scenario.Sut.Exe}' not running. artifact_dir={artifactDir}");
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("launching SUT is disabled in this PoC sandbox; pass --no-launch.");
|
||||
return 5;
|
||||
}
|
||||
|
||||
using var host = new UiaPlayerHost(app, artifactDir);
|
||||
var engine = new PlayerEngine();
|
||||
try
|
||||
{
|
||||
engine.Run(scenario, host);
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"player failed: {ex.Message} artifact_dir={artifactDir}");
|
||||
return 1;
|
||||
}
|
||||
15
src/Recordingtest.Player/Recordingtest.Player.csproj
Normal file
15
src/Recordingtest.Player/Recordingtest.Player.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>false</UseWPF>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<AssemblyName>Recordingtest.Player</AssemblyName>
|
||||
<RootNamespace>Recordingtest.Player</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FlaUI.Core" Version="4.0.0" />
|
||||
<PackageReference Include="FlaUI.UIA3" Version="4.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.1.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
24
src/Recordingtest.Player/ScenarioLoader.cs
Normal file
24
src/Recordingtest.Player/ScenarioLoader.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Recordingtest.Player.Model;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
public static class ScenarioLoader
|
||||
{
|
||||
private static IDeserializer Build() =>
|
||||
new DeserializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public static Scenario LoadFromFile(string path) =>
|
||||
LoadFromString(File.ReadAllText(path));
|
||||
|
||||
public static Scenario LoadFromString(string yaml)
|
||||
{
|
||||
var de = Build();
|
||||
var s = de.Deserialize<Scenario>(yaml);
|
||||
return s ?? new Scenario();
|
||||
}
|
||||
}
|
||||
160
src/Recordingtest.Player/UiaPlayerHost.cs
Normal file
160
src/Recordingtest.Player/UiaPlayerHost.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System.Diagnostics;
|
||||
using FlaUI.Core;
|
||||
using FlaUI.Core.AutomationElements;
|
||||
using FlaUI.Core.Input;
|
||||
using FlaUI.Core.Tools;
|
||||
using FlaUI.Core.WindowsAPI;
|
||||
using FlaUI.UIA3;
|
||||
|
||||
namespace Recordingtest.Player;
|
||||
|
||||
/// <summary>
|
||||
/// Real FlaUI/UIA implementation. Compile-only in PoC; not unit tested
|
||||
/// (real SUT is required). The engine talks to <see cref="IPlayerHost"/>
|
||||
/// so all retry/timing semantics live in PlayerEngine, not here.
|
||||
/// </summary>
|
||||
public sealed class UiaPlayerHost : IPlayerHost, IDisposable
|
||||
{
|
||||
private readonly UIA3Automation _automation;
|
||||
private readonly Application? _app;
|
||||
private readonly string _artifactDir;
|
||||
|
||||
public UiaPlayerHost(Application? app, string artifactDir)
|
||||
{
|
||||
_automation = new UIA3Automation();
|
||||
_app = app;
|
||||
_artifactDir = artifactDir;
|
||||
Directory.CreateDirectory(_artifactDir);
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
},
|
||||
timeout: timeout,
|
||||
ignoreException: true).Result;
|
||||
|
||||
if (element is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var r = element.BoundingRectangle;
|
||||
return new ResolvedElement(
|
||||
new ElementBounds(r.X, r.Y, r.Width, r.Height),
|
||||
element);
|
||||
}
|
||||
|
||||
public bool WaitFor(string waitForHint, TimeSpan timeout)
|
||||
{
|
||||
// PoC: poll the main window's IsEnabled property as a generic readiness signal.
|
||||
var result = Retry.WhileFalse(
|
||||
() =>
|
||||
{
|
||||
var w = _app?.GetMainWindow(_automation, TimeSpan.FromSeconds(1));
|
||||
return w is not null && w.IsEnabled;
|
||||
},
|
||||
timeout: timeout,
|
||||
ignoreException: true);
|
||||
return result.Result;
|
||||
}
|
||||
|
||||
public void Click(ScreenPoint point) =>
|
||||
Mouse.Click(new System.Drawing.Point(point.X, point.Y));
|
||||
|
||||
public void Type(string text) => Keyboard.Type(text);
|
||||
|
||||
public void Drag(ScreenPoint from, ScreenPoint to) =>
|
||||
Mouse.Drag(
|
||||
new System.Drawing.Point(from.X, from.Y),
|
||||
new System.Drawing.Point(to.X, to.Y));
|
||||
|
||||
public void Hotkey(string keys)
|
||||
{
|
||||
// Minimal: support "ctrl+s" style.
|
||||
var parts = keys.Split('+', StringSplitOptions.RemoveEmptyEntries);
|
||||
var modifiers = new List<VirtualKeyShort>();
|
||||
VirtualKeyShort? main = null;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
switch (p.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "ctrl": modifiers.Add(VirtualKeyShort.CONTROL); break;
|
||||
case "shift": modifiers.Add(VirtualKeyShort.SHIFT); break;
|
||||
case "alt": modifiers.Add(VirtualKeyShort.ALT); break;
|
||||
default:
|
||||
if (p.Length == 1)
|
||||
{
|
||||
main = (VirtualKeyShort)char.ToUpperInvariant(p[0]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (var m in modifiers) Keyboard.Press(m);
|
||||
if (main is not null) Keyboard.Type(main.Value);
|
||||
foreach (var m in modifiers) Keyboard.Release(m);
|
||||
}
|
||||
|
||||
public void CaptureCheckpoint(int afterStep, string saveAs)
|
||||
{
|
||||
var dest = Path.Combine(_artifactDir, $"checkpoint-{afterStep}{Path.GetExtension(saveAs)}");
|
||||
if (File.Exists(saveAs))
|
||||
{
|
||||
File.Copy(saveAs, dest, overwrite: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllText(dest + ".missing", $"expected save file not found: {saveAs}");
|
||||
}
|
||||
}
|
||||
|
||||
public void CaptureFailureArtifacts(int stepIndex, string reason)
|
||||
{
|
||||
var log = Path.Combine(_artifactDir, "error.log");
|
||||
File.AppendAllText(log,
|
||||
$"[{DateTime.UtcNow:o}] step={stepIndex} reason={reason}{Environment.NewLine}");
|
||||
}
|
||||
|
||||
private static string ExtractAutomationId(string uiaPath)
|
||||
{
|
||||
// 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 void Dispose()
|
||||
{
|
||||
_automation.Dispose();
|
||||
_app?.Dispose();
|
||||
}
|
||||
|
||||
public static Application? AttachByExeName(string exeName)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(exeName);
|
||||
var procs = Process.GetProcessesByName(name);
|
||||
if (procs.Length == 0) return null;
|
||||
return Application.Attach(procs[0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user