• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20898707016

11 Jan 2026 05:00PM UTC coverage: 85.421%. First build
20898707016

Pull #926

github

web-flow
Merge 40002321b into e92f9bff8
Pull Request #926: feat(testbridge): Add IWindowController for window state queries

9596 of 13578 branches covered (70.67%)

Branch coverage included in aggregate %.

232 of 262 new or added lines in 10 files covered. (88.55%)

166204 of 192225 relevant lines covered (86.46%)

0.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.48
/src/KeenEyes.TestBridge.Client/TestBridgeClient.cs
1
using System.Collections.Concurrent;
2
using System.Text.Json;
3
using KeenEyes.Input.Abstractions;
4
using KeenEyes.TestBridge.Capture;
5
using KeenEyes.TestBridge.Commands;
6
using KeenEyes.TestBridge.Input;
7
using KeenEyes.TestBridge.Ipc;
8
using KeenEyes.TestBridge.Ipc.Protocol;
9
using KeenEyes.TestBridge.Ipc.Transport;
10
using KeenEyes.TestBridge.Logging;
11
using KeenEyes.TestBridge.Process;
12
using KeenEyes.TestBridge.State;
13
using KeenEyes.TestBridge.Window;
14

15
namespace KeenEyes.TestBridge.Client;
16

17
/// <summary>
18
/// Client for connecting to a running KeenEyes application via IPC.
19
/// </summary>
20
/// <remarks>
21
/// <para>
22
/// This client implements <see cref="ITestBridge"/> allowing the same test code
23
/// to work in-process or out-of-process.
24
/// </para>
25
/// <para>
26
/// All operations are asynchronous and communicate over the configured transport
27
/// (named pipes or TCP).
28
/// </para>
29
/// </remarks>
30
/// <example>
31
/// <code>
32
/// // Connect to running application
33
/// await using var client = new TestBridgeClient(new IpcOptions { PipeName = "MyGame.TestBridge" });
34
/// await client.ConnectAsync();
35
///
36
/// // Use same API as in-process
37
/// await client.Input.KeyPressAsync(Key.Space);
38
/// var entity = await client.State.GetEntityByNameAsync("Player");
39
/// </code>
40
/// </example>
41
public sealed class TestBridgeClient : ITestBridge, IAsyncDisposable
42
{
43
    private readonly IIpcTransport transport;
44
    private readonly ConcurrentDictionary<int, TaskCompletionSource<IpcResponse>> pendingRequests;
45
    private readonly TimeSpan requestTimeout;
46
    private int nextRequestId;
47
    private bool disposed;
48

49
    /// <summary>
50
    /// Creates a new test bridge client.
51
    /// </summary>
52
    /// <param name="options">IPC configuration options.</param>
53
    public TestBridgeClient(KeenEyes.TestBridge.IpcOptions? options = null)
1✔
54
    {
55
        options ??= new KeenEyes.TestBridge.IpcOptions();
1✔
56

57
        transport = options.TransportMode switch
1✔
58
        {
1✔
59
            KeenEyes.TestBridge.IpcTransportMode.NamedPipe => new NamedPipeTransport(options.PipeName, isServer: false),
1✔
60
            KeenEyes.TestBridge.IpcTransportMode.Tcp => new TcpIpcTransport(options.TcpBindAddress, options.TcpPort, isServer: false),
×
61
            _ => throw new ArgumentException($"Unknown transport mode: {options.TransportMode}", nameof(options))
×
62
        };
1✔
63

64
        pendingRequests = new ConcurrentDictionary<int, TaskCompletionSource<IpcResponse>>();
1✔
65
        requestTimeout = options.ConnectionTimeout;
1✔
66

67
        Input = new RemoteInputController(this);
1✔
68
        State = new RemoteStateController(this);
1✔
69
        Capture = new RemoteCaptureController(this);
1✔
70
        Logs = new RemoteLogController(this);
1✔
71
        Window = new RemoteWindowController(this);
1✔
72

73
        transport.MessageReceived += OnMessageReceived;
1✔
74
        transport.ConnectionChanged += OnConnectionChanged;
1✔
75
    }
1✔
76

77
    /// <inheritdoc />
78
    public bool IsConnected => transport.IsConnected;
1✔
79

80
    /// <inheritdoc />
81
    public IInputController Input { get; }
1✔
82

83
    /// <inheritdoc />
84
    public IStateController State { get; }
1✔
85

86
    /// <inheritdoc />
87
    public ICaptureController Capture { get; }
1✔
88

89
    /// <inheritdoc />
90
    /// <remarks>
91
    /// Process management is not supported over IPC. Use in-process testing
92
    /// with <see cref="InProcessBridge"/> for process management capabilities.
93
    /// </remarks>
94
    public IProcessController Process => throw new NotSupportedException(
×
95
        "Process management is not supported over IPC. Use in-process testing for process management.");
×
96

97
    /// <inheritdoc />
98
    public ILogController Logs { get; }
×
99

100
    /// <inheritdoc />
NEW
101
    public IWindowController Window { get; }
×
102

103
    /// <inheritdoc />
104
    /// <remarks>
105
    /// Direct input context access is not supported over IPC. Use the <see cref="Input"/>
106
    /// controller to inject input events, which will be forwarded to the server.
107
    /// </remarks>
108
    public IInputContext InputContext => throw new NotSupportedException(
×
109
        "Direct input context access is not supported over IPC. Use the Input controller to inject input events.");
×
110

111
    /// <summary>
112
    /// Raised when the connection state changes.
113
    /// </summary>
114
    public event Action<bool>? ConnectionChanged;
115

116
    /// <summary>
117
    /// Connects to the IPC server.
118
    /// </summary>
119
    /// <param name="cancellationToken">Cancellation token.</param>
120
    /// <returns>A task that completes when connected.</returns>
121
    public async Task ConnectAsync(CancellationToken cancellationToken = default)
122
    {
123
        ThrowIfDisposed();
1✔
124
        await transport.ConnectAsync(cancellationToken);
1✔
125
    }
1✔
126

127
    /// <summary>
128
    /// Disconnects from the IPC server.
129
    /// </summary>
130
    /// <returns>A task that completes when disconnected.</returns>
131
    public async Task DisconnectAsync()
132
    {
133
        await transport.DisconnectAsync();
1✔
134
    }
1✔
135

136
    /// <inheritdoc />
137
    public async Task<CommandResult> ExecuteAsync(ITestCommand command, CancellationToken cancellationToken = default)
138
    {
139
        ThrowIfDisposed();
×
140

141
        try
142
        {
143
            var response = await SendRequestAsync(command.CommandType, null, cancellationToken);
×
144
            return new CommandResult
×
145
            {
×
146
                Success = response.Success,
×
147
                Error = response.Error,
×
148
                // Return the raw JsonElement clone for maximum flexibility
×
149
                Data = response.Data.HasValue ? response.Data.Value.Clone() : null
×
150
            };
×
151
        }
152
        catch (Exception ex)
×
153
        {
154
            return CommandResult.Fail(ex.Message);
×
155
        }
156
    }
×
157

158
    /// <inheritdoc />
159
    public async Task<bool> WaitForAsync(
160
        Func<IStateController, Task<bool>> condition,
161
        TimeSpan timeout,
162
        TimeSpan? pollInterval = null,
163
        CancellationToken cancellationToken = default)
164
    {
165
        ThrowIfDisposed();
1✔
166

167
        if (!IsConnected)
1✔
168
        {
169
            return false;
×
170
        }
171

172
        var interval = pollInterval ?? TimeSpan.FromMilliseconds(16); // ~60fps
1✔
173
        var deadline = DateTime.UtcNow + timeout;
1✔
174

175
        while (DateTime.UtcNow < deadline && IsConnected)
1✔
176
        {
177
            cancellationToken.ThrowIfCancellationRequested();
1✔
178

179
            if (await condition(State))
1✔
180
            {
181
                return true;
1✔
182
            }
183

184
            await Task.Delay(interval, cancellationToken);
1✔
185
        }
186

187
        return false;
1✔
188
    }
1✔
189

190
    /// <inheritdoc />
191
    public async Task<bool> WaitForAsync(
192
        Func<IStateController, bool> condition,
193
        TimeSpan timeout,
194
        TimeSpan? pollInterval = null,
195
        CancellationToken cancellationToken = default)
196
    {
197
        return await WaitForAsync(
1✔
198
            state => Task.FromResult(condition(state)),
1✔
199
            timeout,
1✔
200
            pollInterval,
1✔
201
            cancellationToken);
1✔
202
    }
1✔
203

204
    /// <summary>
205
    /// Sends a request to the server and waits for a response.
206
    /// </summary>
207
    internal async Task<IpcResponse> SendRequestAsync(
208
        string command,
209
        object? args,
210
        CancellationToken cancellationToken)
211
    {
212
        ThrowIfDisposed();
1✔
213
        ThrowIfNotConnected();
1✔
214

215
        var requestId = Interlocked.Increment(ref nextRequestId);
1✔
216
        var tcs = new TaskCompletionSource<IpcResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
1✔
217

218
        pendingRequests[requestId] = tcs;
1✔
219

220
        try
221
        {
222
            var request = new IpcRequest
1✔
223
            {
1✔
224
                Id = requestId,
1✔
225
                Command = command,
1✔
226
                Args = args != null
1✔
227
                    ? JsonSerializer.SerializeToElement(args, args.GetType(), IpcJsonContext.Default)
1✔
228
                    : null
1✔
229
            };
1✔
230

231
            var json = JsonSerializer.SerializeToUtf8Bytes(request, IpcJsonContext.Default.IpcRequest);
1✔
232

233
            // Transport handles framing, just send the raw JSON
234
            await transport.SendAsync(json, cancellationToken);
1✔
235

236
            // Wait for response with timeout
237
            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
1✔
238
            timeoutCts.CancelAfter(requestTimeout);
1✔
239

240
            await using (timeoutCts.Token.Register(() => tcs.TrySetCanceled(timeoutCts.Token)))
1✔
241
            {
242
                return await tcs.Task;
1✔
243
            }
244
        }
×
245
        finally
246
        {
247
            pendingRequests.TryRemove(requestId, out _);
1✔
248
        }
249
    }
1✔
250

251
    /// <summary>
252
    /// Sends a request and deserializes the response data.
253
    /// </summary>
254
    internal async Task<T?> SendRequestAsync<T>(
255
        string command,
256
        object? args,
257
        CancellationToken cancellationToken)
258
    {
259
        var response = await SendRequestAsync(command, args, cancellationToken);
1✔
260

261
        if (!response.Success)
1✔
262
        {
263
            throw new InvalidOperationException(response.Error ?? "Command failed");
×
264
        }
265

266
        if (!response.Data.HasValue)
1✔
267
        {
268
            return default;
1✔
269
        }
270

271
        return DeserializeResponse<T>(response.Data.Value);
1✔
272
    }
1✔
273

274
    /// <summary>
275
    /// AOT-compatible deserialization helper using type dispatch.
276
    /// </summary>
277
    private static T? DeserializeResponse<T>(JsonElement element)
278
    {
279
        var type = typeof(T);
1✔
280

281
        // Primitives - use direct JsonElement methods
282
        if (type == typeof(int))
1✔
283
        {
284
            return (T)(object)element.GetInt32();
1✔
285
        }
286

287
        if (type == typeof(bool))
1✔
288
        {
289
            return (T)(object)element.GetBoolean();
1✔
290
        }
291

292
        if (type == typeof(string))
1✔
293
        {
294
            return (T?)(object?)element.GetString();
×
295
        }
296

297
        if (type == typeof(float))
1✔
298
        {
299
            return (T)(object)element.GetSingle();
×
300
        }
301

302
        if (type == typeof(double))
1✔
303
        {
304
            return (T)(object)element.GetDouble();
×
305
        }
306

307
        if (type == typeof(long))
1✔
308
        {
309
            return (T)(object)element.GetInt64();
×
310
        }
311

312
        // Nullable primitives
313
        if (type == typeof(int?))
1✔
314
        {
315
            return element.ValueKind == JsonValueKind.Null ? default : (T)(object)element.GetInt32();
×
316
        }
317

318
        if (type == typeof(bool?))
1✔
319
        {
320
            return element.ValueKind == JsonValueKind.Null ? default : (T)(object)element.GetBoolean();
×
321
        }
322

323
        // Complex types - use IpcJsonContext TypeInfo
324
        // Note: For reference types, T and T? are the same at runtime
325
        if (type == typeof(EntitySnapshot))
1✔
326
        {
327
            return element.ValueKind == JsonValueKind.Null ? default : (T?)(object?)element.Deserialize(IpcJsonContext.Default.EntitySnapshot);
1✔
328
        }
329

330
        if (type == typeof(EntitySnapshot[]))
1✔
331
        {
332
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.EntitySnapshotArray);
1✔
333
        }
334

335
        if (type == typeof(WorldStats))
1✔
336
        {
337
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.WorldStats);
1✔
338
        }
339

340
        if (type == typeof(SystemInfo[]))
1✔
341
        {
342
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.SystemInfoArray);
×
343
        }
344

345
        if (type == typeof(PerformanceMetrics))
1✔
346
        {
347
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.PerformanceMetrics);
×
348
        }
349

350
        if (type == typeof(FrameCapture))
1✔
351
        {
352
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.FrameCapture);
×
353
        }
354

355
        if (type == typeof(FrameCapture[]))
1✔
356
        {
357
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.FrameCaptureArray);
×
358
        }
359

360
        if (type == typeof(FrameSizeResult))
1✔
361
        {
362
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.FrameSizeResult);
×
363
        }
364

365
        if (type == typeof(MousePositionResult))
1✔
366
        {
367
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.MousePositionResult);
1✔
368
        }
369

370
        if (type == typeof(int[]))
1✔
371
        {
372
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.Int32Array);
1✔
373
        }
374

375
        if (type == typeof(Dictionary<string, object?>))
×
376
        {
377
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.DictionaryStringObject);
×
378
        }
379

380
        // Logging types
381
        if (type == typeof(LogEntrySnapshot[]))
×
382
        {
383
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.LogEntrySnapshotArray);
×
384
        }
385

386
        if (type == typeof(LogStatsSnapshot))
×
387
        {
388
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.LogStatsSnapshot);
×
389
        }
390

391
        // Window types
NEW
392
        if (type == typeof(WindowStateSnapshot))
×
393
        {
NEW
394
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.WindowStateSnapshot);
×
395
        }
396

NEW
397
        if (type == typeof(WindowSizeResult))
×
398
        {
NEW
399
            return (T?)(object?)element.Deserialize(IpcJsonContext.Default.WindowSizeResult);
×
400
        }
401

402
        // Fallback for unknown types - let the exception surface during development
403
        throw new NotSupportedException($"Type {type.Name} is not registered in IpcJsonContext for AOT deserialization. Add it to DeserializeResponse<T>().");
×
404
    }
405

406
    private void OnConnectionChanged(bool isConnected)
407
    {
408
        if (!isConnected)
1✔
409
        {
410
            // Cancel all pending requests on disconnect
411
            foreach (var kvp in pendingRequests)
1✔
412
            {
413
                kvp.Value.TrySetException(new InvalidOperationException("Connection lost"));
×
414
            }
415
            pendingRequests.Clear();
1✔
416
        }
417

418
        ConnectionChanged?.Invoke(isConnected);
1✔
419
    }
1✔
420

421
    private void OnMessageReceived(ReadOnlyMemory<byte> data)
422
    {
423
        try
424
        {
425
            var response = JsonSerializer.Deserialize(data.Span, IpcJsonContext.Default.IpcResponse);
1✔
426
            if (response == null)
1✔
427
            {
428
                return;
×
429
            }
430

431
            if (pendingRequests.TryGetValue(response.Id, out var tcs))
1✔
432
            {
433
                tcs.TrySetResult(response);
1✔
434
            }
435
        }
1✔
436
        catch
×
437
        {
438
            // Ignore malformed messages
439
        }
×
440
    }
1✔
441

442
    /// <inheritdoc />
443
    public void Dispose()
444
    {
445
        if (disposed)
1✔
446
        {
447
            return;
×
448
        }
449

450
        disposed = true;
1✔
451
        transport.Dispose();
1✔
452

453
        // Cancel all pending requests
454
        foreach (var kvp in pendingRequests)
1✔
455
        {
456
            kvp.Value.TrySetCanceled();
×
457
        }
458
        pendingRequests.Clear();
1✔
459
    }
1✔
460

461
    /// <inheritdoc />
462
    public async ValueTask DisposeAsync()
463
    {
464
        if (disposed)
1✔
465
        {
466
            return;
1✔
467
        }
468

469
        await DisconnectAsync();
1✔
470
        Dispose();
1✔
471
    }
1✔
472

473
    private void ThrowIfDisposed()
474
    {
475
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
476
    }
1✔
477

478
    private void ThrowIfNotConnected()
479
    {
480
        if (!IsConnected)
1✔
481
        {
482
            throw new InvalidOperationException("Not connected to server.");
1✔
483
        }
484
    }
1✔
485
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc