Implement recorder PoC (#6)
This commit is contained in:
40
docs/history/2026-04-07_이슈6-recorder-generator.md
Normal file
40
docs/history/2026-04-07_이슈6-recorder-generator.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 2026-04-07 이슈 #6 — Recorder Generator
|
||||||
|
|
||||||
|
- **이슈**: #6
|
||||||
|
- **소요 시간**: 약 25분
|
||||||
|
- **Context 사용량**: 약 45k 토큰
|
||||||
|
|
||||||
|
## 작업 요약
|
||||||
|
|
||||||
|
`docs/contracts/recorder.md` 계약을 바탕으로 `Recordingtest.Recorder` 콘솔 PoC와
|
||||||
|
xUnit 테스트 프로젝트를 신규 작성했다.
|
||||||
|
|
||||||
|
### 산출물
|
||||||
|
|
||||||
|
- `src/Recordingtest.Recorder/`
|
||||||
|
- `Recordingtest.Recorder.csproj` (`net8.0-windows`, FlaUI.Core/UIA3 4.0.0, YamlDotNet 16.1.3)
|
||||||
|
- `NativeMethods.cs` — Win32 P/Invoke (WH_KEYBOARD_LL, WH_MOUSE_LL, GetMessage 등)
|
||||||
|
- `LowLevelHook.cs` — 전용 STA 스레드 + message loop, `Channel<RawEvent>` 푸시
|
||||||
|
- `ElementPathBuilder.cs` — `IElementSnapshot` 기반 순수 로직
|
||||||
|
- `OffsetNormalizer.cs` — 정규화 [0..1] 클램프 포함
|
||||||
|
- `MaskPolicy.cs` — `PasswordBox` / `IsPassword` → `<MASKED>`
|
||||||
|
- `Scenario.cs`, `ScenarioWriter.cs` — YAML 직렬화 (UnderscoredNamingConvention)
|
||||||
|
- `Program.cs` — CLI 파싱, attach 로직(절대 Launch 없음), Ctrl+C 플러시
|
||||||
|
- `tests/Recordingtest.Recorder.Tests/`
|
||||||
|
- 5 단위 테스트 (path builder / normalizer / mask / yaml roundtrip / CLI exit 2)
|
||||||
|
|
||||||
|
### 결과
|
||||||
|
|
||||||
|
- `dotnet build recordingtest.sln` — 경고 0, 오류 0
|
||||||
|
- `dotnet test tests/Recordingtest.Recorder.Tests` — 5/5 통과
|
||||||
|
|
||||||
|
### 주의
|
||||||
|
|
||||||
|
- SUT(EG-BIM Modeler)는 코드/스크립트 어디에서도 launch하지 않는다. attach만 지원.
|
||||||
|
- `--attach` 누락 시 usage 출력 후 exit 2.
|
||||||
|
- 실 hook/UIA는 단위 테스트에서 다루지 않으며, 순수 로직 4종 + CLI 1종만 검증.
|
||||||
|
|
||||||
|
### 미해결 / 후속
|
||||||
|
|
||||||
|
- 실제 SUT 대상 통합 테스트는 Evaluator 단계에서 별도 진행.
|
||||||
|
- IME 조합 키, 드래그 합성, 휠 등 고차 이벤트 합성은 후속 스프린트.
|
||||||
@@ -15,6 +15,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.Tests", "tests\Recordingtest.DiffReporter.Tests\Recordingtest.DiffReporter.Tests.csproj", "{65290E3F-D498-452B-9A76-FBC460E53A9F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.DiffReporter.Tests", "tests\Recordingtest.DiffReporter.Tests\Recordingtest.DiffReporter.Tests.csproj", "{65290E3F-D498-452B-9A76-FBC460E53A9F}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Recorder", "src\Recordingtest.Recorder\Recordingtest.Recorder.csproj", "{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Recorder.Tests", "tests\Recordingtest.Recorder.Tests\Recordingtest.Recorder.Tests.csproj", "{74D292F5-8004-4946-8CC3-808AFD9C52C1}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Player", "src\Recordingtest.Player\Recordingtest.Player.csproj", "{D8962656-55EC-4595-8F19-8FBBF9256A04}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.Player.Tests", "tests\Recordingtest.Player.Tests\Recordingtest.Player.Tests.csproj", "{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -97,6 +105,54 @@ Global
|
|||||||
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x64.Build.0 = Release|Any CPU
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.ActiveCfg = Release|Any CPU
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.Build.0 = Release|Any CPU
|
{65290E3F-D498-452B-9A76-FBC460E53A9F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -107,5 +163,9 @@ Global
|
|||||||
{21A2E01D-FFC3-446D-B56E-775FF7E14C76} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{21A2E01D-FFC3-446D-B56E-775FF7E14C76} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{234EAA83-19DE-45A6-B9B2-2C0E85A17E4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{65290E3F-D498-452B-9A76-FBC460E53A9F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{65290E3F-D498-452B-9A76-FBC460E53A9F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{8C34DAA9-DB54-433B-86C1-E559EE36B5EE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{74D292F5-8004-4946-8CC3-808AFD9C52C1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{D8962656-55EC-4595-8F19-8FBBF9256A04} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{7A5C0D53-BDFC-4AF6-8F4D-49E7EB8245F5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
61
src/Recordingtest.Recorder/ElementPathBuilder.cs
Normal file
61
src/Recordingtest.Recorder/ElementPathBuilder.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-logic snapshot of a UIA element. We avoid depending on FlaUI types here
|
||||||
|
/// so the path builder is fully unit-testable.
|
||||||
|
/// </summary>
|
||||||
|
public interface IElementSnapshot
|
||||||
|
{
|
||||||
|
string ClassName { get; }
|
||||||
|
string? AutomationId { get; }
|
||||||
|
string? Name { get; }
|
||||||
|
bool IsPassword { get; }
|
||||||
|
/// <summary>Screen-space rectangle: left, top, width, height.</summary>
|
||||||
|
(double Left, double Top, double Width, double Height) BoundingRectangle { get; }
|
||||||
|
IElementSnapshot? Parent { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ElementPathBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build "ClassName[@AutomationId='...']/ClassName[@Name='...']/..." walking
|
||||||
|
/// from the topmost ancestor down to the given element.
|
||||||
|
/// </summary>
|
||||||
|
public static string Build(IElementSnapshot element)
|
||||||
|
{
|
||||||
|
var chain = new List<IElementSnapshot>();
|
||||||
|
IElementSnapshot? cur = element;
|
||||||
|
while (cur is not null)
|
||||||
|
{
|
||||||
|
chain.Add(cur);
|
||||||
|
cur = cur.Parent;
|
||||||
|
}
|
||||||
|
chain.Reverse();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < chain.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append('/');
|
||||||
|
sb.Append(FormatSegment(chain[i]));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSegment(IElementSnapshot e)
|
||||||
|
{
|
||||||
|
var cls = string.IsNullOrEmpty(e.ClassName) ? "Element" : e.ClassName;
|
||||||
|
if (!string.IsNullOrEmpty(e.AutomationId))
|
||||||
|
{
|
||||||
|
return $"{cls}[@AutomationId='{Escape(e.AutomationId!)}']";
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(e.Name))
|
||||||
|
{
|
||||||
|
return $"{cls}[@Name='{Escape(e.Name!)}']";
|
||||||
|
}
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string s) => s.Replace("'", "'");
|
||||||
|
}
|
||||||
133
src/Recordingtest.Recorder/LowLevelHook.cs
Normal file
133
src/Recordingtest.Recorder/LowLevelHook.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
public sealed record RawEvent(long TimestampMs, string Kind, int X, int Y, uint Code, int WheelDelta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs WH_KEYBOARD_LL and WH_MOUSE_LL hooks on a dedicated thread with its own message loop.
|
||||||
|
/// Pushes RawEvent into a Channel for the main loop to consume.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LowLevelHook : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Channel<RawEvent> _channel;
|
||||||
|
private Thread? _thread;
|
||||||
|
private IntPtr _kbHook = IntPtr.Zero;
|
||||||
|
private IntPtr _mouseHook = IntPtr.Zero;
|
||||||
|
private NativeMethods.HookProc? _kbProc;
|
||||||
|
private NativeMethods.HookProc? _mouseProc;
|
||||||
|
private uint _threadId;
|
||||||
|
private volatile bool _running;
|
||||||
|
|
||||||
|
public LowLevelHook(Channel<RawEvent> channel)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChannelReader<RawEvent> Reader => _channel.Reader;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_running) return;
|
||||||
|
_running = true;
|
||||||
|
var ready = new ManualResetEventSlim(false);
|
||||||
|
_thread = new Thread(() => HookThreadMain(ready))
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "LowLevelHookThread",
|
||||||
|
};
|
||||||
|
_thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
_thread.Start();
|
||||||
|
ready.Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HookThreadMain(ManualResetEventSlim ready)
|
||||||
|
{
|
||||||
|
_threadId = GetCurrentThreadId();
|
||||||
|
_kbProc = KeyboardProc;
|
||||||
|
_mouseProc = MouseProc;
|
||||||
|
var hMod = NativeMethods.GetModuleHandle(null);
|
||||||
|
_kbHook = NativeMethods.SetWindowsHookEx(NativeMethods.WH_KEYBOARD_LL, _kbProc, hMod, 0);
|
||||||
|
_mouseHook = NativeMethods.SetWindowsHookEx(NativeMethods.WH_MOUSE_LL, _mouseProc, hMod, 0);
|
||||||
|
ready.Set();
|
||||||
|
|
||||||
|
while (_running && NativeMethods.GetMessage(out var msg, IntPtr.Zero, 0, 0) > 0)
|
||||||
|
{
|
||||||
|
NativeMethods.TranslateMessage(ref msg);
|
||||||
|
NativeMethods.DispatchMessage(ref msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_kbHook != IntPtr.Zero) NativeMethods.UnhookWindowsHookEx(_kbHook);
|
||||||
|
if (_mouseHook != IntPtr.Zero) NativeMethods.UnhookWindowsHookEx(_mouseHook);
|
||||||
|
_kbHook = IntPtr.Zero;
|
||||||
|
_mouseHook = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntPtr KeyboardProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||||
|
{
|
||||||
|
if (nCode >= 0)
|
||||||
|
{
|
||||||
|
var data = System.Runtime.InteropServices.Marshal.PtrToStructure<NativeMethods.KBDLLHOOKSTRUCT>(lParam);
|
||||||
|
var msg = wParam.ToInt32();
|
||||||
|
var kind = msg switch
|
||||||
|
{
|
||||||
|
NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN => "key_down",
|
||||||
|
NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP => "key_up",
|
||||||
|
_ => "key",
|
||||||
|
};
|
||||||
|
_channel.Writer.TryWrite(new RawEvent(NowMs(), kind, 0, 0, data.vkCode, 0));
|
||||||
|
}
|
||||||
|
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntPtr MouseProc(int nCode, IntPtr wParam, IntPtr lParam)
|
||||||
|
{
|
||||||
|
if (nCode >= 0)
|
||||||
|
{
|
||||||
|
var data = System.Runtime.InteropServices.Marshal.PtrToStructure<NativeMethods.MSLLHOOKSTRUCT>(lParam);
|
||||||
|
var msg = wParam.ToInt32();
|
||||||
|
var kind = msg switch
|
||||||
|
{
|
||||||
|
NativeMethods.WM_LBUTTONDOWN => "mouse_down_l",
|
||||||
|
NativeMethods.WM_LBUTTONUP => "mouse_up_l",
|
||||||
|
NativeMethods.WM_RBUTTONDOWN => "mouse_down_r",
|
||||||
|
NativeMethods.WM_RBUTTONUP => "mouse_up_r",
|
||||||
|
NativeMethods.WM_MBUTTONDOWN => "mouse_down_m",
|
||||||
|
NativeMethods.WM_MBUTTONUP => "mouse_up_m",
|
||||||
|
NativeMethods.WM_MOUSEWHEEL => "wheel",
|
||||||
|
NativeMethods.WM_MOUSEMOVE => "move",
|
||||||
|
_ => "mouse",
|
||||||
|
};
|
||||||
|
int wheel = 0;
|
||||||
|
if (msg == NativeMethods.WM_MOUSEWHEEL)
|
||||||
|
{
|
||||||
|
wheel = (short)((data.mouseData >> 16) & 0xFFFF);
|
||||||
|
}
|
||||||
|
_channel.Writer.TryWrite(new RawEvent(NowMs(), kind, data.pt.x, data.pt.y, 0, wheel));
|
||||||
|
}
|
||||||
|
return NativeMethods.CallNextHookEx(IntPtr.Zero, nCode, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("kernel32.dll")]
|
||||||
|
private static extern uint GetCurrentThreadId();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
_running = false;
|
||||||
|
if (_threadId != 0)
|
||||||
|
{
|
||||||
|
// Post a WM_QUIT to break GetMessage loop
|
||||||
|
PostThreadMessage(_threadId, 0x0012 /* WM_QUIT */, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
_thread?.Join(1000);
|
||||||
|
_channel.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||||
|
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
}
|
||||||
28
src/Recordingtest.Recorder/MaskPolicy.cs
Normal file
28
src/Recordingtest.Recorder/MaskPolicy.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
public static class MaskPolicy
|
||||||
|
{
|
||||||
|
public const string MaskedValue = "<MASKED>";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the masked value if the focused element is a password input.
|
||||||
|
/// Detection: IsPassword flag, or ClassName equals "PasswordBox".
|
||||||
|
/// </summary>
|
||||||
|
public static string Apply(IElementSnapshot? focused, string? rawValue)
|
||||||
|
{
|
||||||
|
if (focused is null) return rawValue ?? string.Empty;
|
||||||
|
if (focused.IsPassword) return MaskedValue;
|
||||||
|
if (string.Equals(focused.ClassName, "PasswordBox", System.StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return MaskedValue;
|
||||||
|
}
|
||||||
|
return rawValue ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsMasked(IElementSnapshot? focused)
|
||||||
|
{
|
||||||
|
if (focused is null) return false;
|
||||||
|
return focused.IsPassword
|
||||||
|
|| string.Equals(focused.ClassName, "PasswordBox", System.StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Recordingtest.Recorder/NativeMethods.cs
Normal file
89
src/Recordingtest.Recorder/NativeMethods.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
internal static class NativeMethods
|
||||||
|
{
|
||||||
|
public const int WH_KEYBOARD_LL = 13;
|
||||||
|
public const int WH_MOUSE_LL = 14;
|
||||||
|
|
||||||
|
public const int WM_KEYDOWN = 0x0100;
|
||||||
|
public const int WM_KEYUP = 0x0101;
|
||||||
|
public const int WM_SYSKEYDOWN = 0x0104;
|
||||||
|
public const int WM_SYSKEYUP = 0x0105;
|
||||||
|
|
||||||
|
public const int WM_LBUTTONDOWN = 0x0201;
|
||||||
|
public const int WM_LBUTTONUP = 0x0202;
|
||||||
|
public const int WM_RBUTTONDOWN = 0x0204;
|
||||||
|
public const int WM_RBUTTONUP = 0x0205;
|
||||||
|
public const int WM_MBUTTONDOWN = 0x0207;
|
||||||
|
public const int WM_MBUTTONUP = 0x0208;
|
||||||
|
public const int WM_MOUSEWHEEL = 0x020A;
|
||||||
|
public const int WM_MOUSEMOVE = 0x0200;
|
||||||
|
|
||||||
|
public delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct POINT
|
||||||
|
{
|
||||||
|
public int x;
|
||||||
|
public int y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct MSLLHOOKSTRUCT
|
||||||
|
{
|
||||||
|
public POINT pt;
|
||||||
|
public uint mouseData;
|
||||||
|
public uint flags;
|
||||||
|
public uint time;
|
||||||
|
public IntPtr dwExtraInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct KBDLLHOOKSTRUCT
|
||||||
|
{
|
||||||
|
public uint vkCode;
|
||||||
|
public uint scanCode;
|
||||||
|
public uint flags;
|
||||||
|
public uint time;
|
||||||
|
public IntPtr dwExtraInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||||
|
public static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
public static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||||
|
public static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||||
|
public static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool TranslateMessage(ref MSG lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern void PostQuitMessage(int nExitCode);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct MSG
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public IntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public POINT pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Recordingtest.Recorder/OffsetNormalizer.cs
Normal file
30
src/Recordingtest.Recorder/OffsetNormalizer.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
public static class OffsetNormalizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a screen point into normalized [0..1] offsets relative to the
|
||||||
|
/// element bounding rectangle. Out-of-bounds points are clamped.
|
||||||
|
/// Returns (0,0) for zero-sized rectangles.
|
||||||
|
/// </summary>
|
||||||
|
public static (double DxNorm, double DyNorm) Normalize(
|
||||||
|
(double Left, double Top, double Width, double Height) bounds,
|
||||||
|
double screenX,
|
||||||
|
double screenY)
|
||||||
|
{
|
||||||
|
if (bounds.Width <= 0 || bounds.Height <= 0)
|
||||||
|
{
|
||||||
|
return (0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double dx = (screenX - bounds.Left) / bounds.Width;
|
||||||
|
double dy = (screenY - bounds.Top) / bounds.Height;
|
||||||
|
|
||||||
|
if (dx < 0) dx = 0;
|
||||||
|
if (dx > 1) dx = 1;
|
||||||
|
if (dy < 0) dy = 0;
|
||||||
|
if (dy > 1) dy = 1;
|
||||||
|
|
||||||
|
return (dx, dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
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!; }
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Recordingtest.Recorder/Recordingtest.Recorder.csproj
Normal file
16
src/Recordingtest.Recorder/Recordingtest.Recorder.csproj
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>false</UseWPF>
|
||||||
|
<UseWindowsForms>false</UseWindowsForms>
|
||||||
|
<AssemblyName>Recordingtest.Recorder</AssemblyName>
|
||||||
|
<RootNamespace>Recordingtest.Recorder</RootNamespace>
|
||||||
|
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||||
|
</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>
|
||||||
32
src/Recordingtest.Recorder/Scenario.cs
Normal file
32
src/Recordingtest.Recorder/Scenario.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
public sealed class Scenario
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public ScenarioSut Sut { get; set; } = new();
|
||||||
|
public List<ScenarioStep> Steps { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScenarioSut
|
||||||
|
{
|
||||||
|
public string Exe { get; set; } = "EG-BIM Modeler/EG-BIM Modeler.exe";
|
||||||
|
public int StartupTimeoutMs { get; set; } = 15000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScenarioStep
|
||||||
|
{
|
||||||
|
/// <summary>click | type | drag | hotkey | wait</summary>
|
||||||
|
public string Kind { get; set; } = "click";
|
||||||
|
public ScenarioTarget? Target { get; set; }
|
||||||
|
public string? Value { get; set; }
|
||||||
|
public string? WaitFor { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScenarioTarget
|
||||||
|
{
|
||||||
|
public string UiaPath { get; set; } = string.Empty;
|
||||||
|
public double[] Offset { get; set; } = new double[] { 0.5, 0.5 };
|
||||||
|
}
|
||||||
40
src/Recordingtest.Recorder/ScenarioWriter.cs
Normal file
40
src/Recordingtest.Recorder/ScenarioWriter.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.IO;
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
|
namespace Recordingtest.Recorder;
|
||||||
|
|
||||||
|
public static class ScenarioWriter
|
||||||
|
{
|
||||||
|
private static ISerializer BuildSerializer() =>
|
||||||
|
new SerializerBuilder()
|
||||||
|
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||||
|
.ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static IDeserializer BuildDeserializer() =>
|
||||||
|
new DeserializerBuilder()
|
||||||
|
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||||
|
.IgnoreUnmatchedProperties()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
public static string Serialize(Scenario scenario)
|
||||||
|
{
|
||||||
|
return BuildSerializer().Serialize(scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Scenario Deserialize(string yaml)
|
||||||
|
{
|
||||||
|
return BuildDeserializer().Deserialize<Scenario>(yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteToFile(Scenario scenario, string path)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
File.WriteAllText(path, Serialize(scenario));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
tests/Recordingtest.Recorder.Tests/FakeElement.cs
Normal file
12
tests/Recordingtest.Recorder.Tests/FakeElement.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Recordingtest.Recorder.Tests;
|
||||||
|
|
||||||
|
internal sealed class FakeElement : IElementSnapshot
|
||||||
|
{
|
||||||
|
public string ClassName { get; set; } = "Element";
|
||||||
|
public string? AutomationId { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public bool IsPassword { get; set; }
|
||||||
|
public (double Left, double Top, double Width, double Height) BoundingRectangle { get; set; }
|
||||||
|
= (0, 0, 0, 0);
|
||||||
|
public IElementSnapshot? Parent { get; set; }
|
||||||
|
}
|
||||||
141
tests/Recordingtest.Recorder.Tests/RecorderTests.cs
Normal file
141
tests/Recordingtest.Recorder.Tests/RecorderTests.cs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Recordingtest.Recorder.Tests;
|
||||||
|
|
||||||
|
public class RecorderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ElementPathBuilder_WithNestedElements_ReturnsFullPath()
|
||||||
|
{
|
||||||
|
var window = new FakeElement
|
||||||
|
{
|
||||||
|
ClassName = "Window",
|
||||||
|
Name = "Main",
|
||||||
|
};
|
||||||
|
var panel = new FakeElement
|
||||||
|
{
|
||||||
|
ClassName = "StackPanel",
|
||||||
|
AutomationId = "ToolStrip",
|
||||||
|
Parent = window,
|
||||||
|
};
|
||||||
|
var button = new FakeElement
|
||||||
|
{
|
||||||
|
ClassName = "Button",
|
||||||
|
AutomationId = "BoxCommand",
|
||||||
|
Parent = panel,
|
||||||
|
};
|
||||||
|
|
||||||
|
var path = ElementPathBuilder.Build(button);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"Window[@Name='Main']/StackPanel[@AutomationId='ToolStrip']/Button[@AutomationId='BoxCommand']",
|
||||||
|
path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OffsetNormalizer_ClicksInsideElement_ReturnsZeroToOne()
|
||||||
|
{
|
||||||
|
var bounds = (Left: 100.0, Top: 200.0, Width: 400.0, Height: 200.0);
|
||||||
|
|
||||||
|
var (dx, dy) = OffsetNormalizer.Normalize(bounds, 300, 300);
|
||||||
|
|
||||||
|
Assert.Equal(0.5, dx, 6);
|
||||||
|
Assert.Equal(0.5, dy, 6);
|
||||||
|
|
||||||
|
var (dx2, dy2) = OffsetNormalizer.Normalize(bounds, 100, 200);
|
||||||
|
Assert.Equal(0.0, dx2, 6);
|
||||||
|
Assert.Equal(0.0, dy2, 6);
|
||||||
|
|
||||||
|
// Out-of-bounds clamps into [0,1]
|
||||||
|
var (dx3, dy3) = OffsetNormalizer.Normalize(bounds, 9999, -9999);
|
||||||
|
Assert.InRange(dx3, 0.0, 1.0);
|
||||||
|
Assert.InRange(dy3, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocusedElementIsPassword_ReturnsMasked()
|
||||||
|
{
|
||||||
|
var pwd = new FakeElement
|
||||||
|
{
|
||||||
|
ClassName = "PasswordBox",
|
||||||
|
IsPassword = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var value = MaskPolicy.Apply(pwd, "supersecret");
|
||||||
|
|
||||||
|
Assert.Equal("<MASKED>", value);
|
||||||
|
Assert.True(MaskPolicy.IsMasked(pwd));
|
||||||
|
|
||||||
|
var plain = new FakeElement { ClassName = "TextBox" };
|
||||||
|
Assert.Equal("hello", MaskPolicy.Apply(plain, "hello"));
|
||||||
|
Assert.False(MaskPolicy.IsMasked(plain));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void YamlSerializer_RoundtripsScenario()
|
||||||
|
{
|
||||||
|
var s = new Scenario
|
||||||
|
{
|
||||||
|
Name = "smoke",
|
||||||
|
Description = "round trip",
|
||||||
|
Sut = new ScenarioSut
|
||||||
|
{
|
||||||
|
Exe = "EG-BIM Modeler/EG-BIM Modeler.exe",
|
||||||
|
StartupTimeoutMs = 15000,
|
||||||
|
},
|
||||||
|
Steps =
|
||||||
|
{
|
||||||
|
new ScenarioStep
|
||||||
|
{
|
||||||
|
Kind = "click",
|
||||||
|
Target = new ScenarioTarget
|
||||||
|
{
|
||||||
|
UiaPath = "Window[@Name='Main']/Button[@AutomationId='BoxCommand']",
|
||||||
|
Offset = new[] { 0.5, 0.5 },
|
||||||
|
},
|
||||||
|
Value = null,
|
||||||
|
WaitFor = null,
|
||||||
|
},
|
||||||
|
new ScenarioStep
|
||||||
|
{
|
||||||
|
Kind = "type",
|
||||||
|
Target = new ScenarioTarget
|
||||||
|
{
|
||||||
|
UiaPath = "Window[@Name='Main']/Edit[@AutomationId='Pwd']",
|
||||||
|
Offset = new[] { 0.5, 0.5 },
|
||||||
|
},
|
||||||
|
Value = "<MASKED>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var yaml = ScenarioWriter.Serialize(s);
|
||||||
|
var parsed = ScenarioWriter.Deserialize(yaml);
|
||||||
|
|
||||||
|
Assert.Equal(s.Name, parsed.Name);
|
||||||
|
Assert.Equal(s.Description, parsed.Description);
|
||||||
|
Assert.Equal(s.Sut.Exe, parsed.Sut.Exe);
|
||||||
|
Assert.Equal(s.Sut.StartupTimeoutMs, parsed.Sut.StartupTimeoutMs);
|
||||||
|
Assert.Equal(s.Steps.Count, parsed.Steps.Count);
|
||||||
|
Assert.Equal(s.Steps[0].Target!.UiaPath, parsed.Steps[0].Target!.UiaPath);
|
||||||
|
Assert.Equal(s.Steps[1].Value, parsed.Steps[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cli_MissingAttach_ExitTwo()
|
||||||
|
{
|
||||||
|
var stderr = Console.Error;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.SetError(new StringWriter());
|
||||||
|
var rc = Program.Main(new[] { "--output", "scenarios/x.yaml" });
|
||||||
|
Assert.Equal(2, rc);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetError(stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>false</UseWPF>
|
||||||
|
<UseWindowsForms>false</UseWindowsForms>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<RootNamespace>Recordingtest.Recorder.Tests</RootNamespace>
|
||||||
|
<AssemblyName>Recordingtest.Recorder.Tests</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Recordingtest.Recorder\Recordingtest.Recorder.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user