Implement player PoC (#7)

This commit is contained in:
minsung
2026-04-07 14:28:11 +09:00
parent d486cbb4d9
commit f17e764678
12 changed files with 727 additions and 0 deletions

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

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

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

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

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

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

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

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