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

orion-ecs / keen-eye / 20771366732

07 Jan 2026 05:12AM UTC coverage: 87.406% (-0.02%) from 87.421%
20771366732

push

github

tyevco
feat: Add process controller to TestBridge (Phase 4)

Add IProcessController for managing child processes during testing,
enabling external game process spawning, stdin/stdout communication,
and signal handling for integration and end-to-end testing.

New abstractions:
- IProcessController interface with process management methods
- ProcessStartOptions for configurable process spawning
- ProcessInfo for process state snapshots
- ProcessExitResult for exit information with captured output

Implementation features:
- Start processes with environment variables and working directory
- Capture stdout/stderr with configurable buffer limits (FIFO trimming)
- Write to stdin for interactive processes
- Wait for specific output patterns or process exit
- Graceful termination (SIGTERM/Ctrl+C) and forced kill (SIGKILL)
- Manage multiple processes with cleanup on disposal

Tests: 27 new tests (26 pass, 1 skipped Windows-only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

9138 of 12206 branches covered (74.86%)

Branch coverage included in aggregate %.

350 of 432 new or added lines in 8 files covered. (81.02%)

4 existing lines in 3 files now uncovered.

158311 of 179370 relevant lines covered (88.26%)

1.01 hits per line

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

84.82
/src/KeenEyes.TestBridge/Process/ProcessControllerImpl.cs
1
using System.Collections.Concurrent;
2
using System.Diagnostics;
3
using KeenEyes.TestBridge.Process;
4
using KeenEyes.TestBridge.ProcessManagement;
5

6
namespace KeenEyes.TestBridge.ProcessImpl;
7

8
/// <summary>
9
/// Implementation of <see cref="IProcessController"/> for managing child processes.
10
/// </summary>
11
internal sealed class ProcessControllerImpl : IProcessController, IDisposable
12
{
13
    private readonly ConcurrentDictionary<int, ManagedProcess> processes = new();
1✔
14
    private bool disposed;
15

16
    public IReadOnlyList<ProcessInfo> RunningProcesses
17
    {
18
        get
19
        {
20
            var running = new List<ProcessInfo>();
1✔
21

22
            foreach (var kvp in processes)
1✔
23
            {
24
                var info = kvp.Value.GetInfo();
1✔
25
                if (!info.HasExited)
1✔
26
                {
27
                    running.Add(info);
1✔
28
                }
29
            }
30

31
            return running;
1✔
32
        }
33
    }
34

35
    public Task<ProcessInfo> StartAsync(string executable, string? arguments = null, CancellationToken cancellationToken = default)
36
    {
37
        return StartAsync(new ProcessStartOptions
1✔
38
        {
1✔
39
            Executable = executable,
1✔
40
            Arguments = arguments
1✔
41
        }, cancellationToken);
1✔
42
    }
43

44
    public Task<ProcessInfo> StartAsync(ProcessStartOptions options, CancellationToken cancellationToken = default)
45
    {
46
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
47

48
        cancellationToken.ThrowIfCancellationRequested();
1✔
49

50
        var startInfo = new ProcessStartInfo
1✔
51
        {
1✔
52
            FileName = options.Executable,
1✔
53
            Arguments = options.Arguments ?? string.Empty,
1✔
54
            WorkingDirectory = options.WorkingDirectory ?? Environment.CurrentDirectory,
1✔
55
            RedirectStandardInput = options.RedirectStdin,
1✔
56
            RedirectStandardOutput = options.RedirectStdout,
1✔
57
            RedirectStandardError = options.RedirectStderr,
1✔
58
            UseShellExecute = options.UseShellExecute,
1✔
59
            CreateNoWindow = options.CreateNoWindow
1✔
60
        };
1✔
61

62
        // Add environment variables
63
        if (options.EnvironmentVariables != null)
1✔
64
        {
NEW
65
            foreach (var kvp in options.EnvironmentVariables)
×
66
            {
NEW
67
                startInfo.Environment[kvp.Key] = kvp.Value;
×
68
            }
69
        }
70

71
        System.Diagnostics.Process process;
72

73
        try
74
        {
75
            process = System.Diagnostics.Process.Start(startInfo)
1✔
76
                ?? throw new InvalidOperationException($"Failed to start process: {options.Executable}");
1✔
77
        }
1✔
78
        catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 2) // ERROR_FILE_NOT_FOUND
1✔
79
        {
80
            throw new FileNotFoundException($"Executable not found: {options.Executable}", options.Executable, ex);
1✔
81
        }
82

83
        var managed = new ManagedProcess(process, options);
1✔
84
        processes[process.Id] = managed;
1✔
85

86
        return Task.FromResult(managed.GetInfo());
1✔
87
    }
88

89
    public ProcessInfo? GetProcess(int processId)
90
    {
91
        if (processes.TryGetValue(processId, out var managed))
1✔
92
        {
93
            return managed.GetInfo();
1✔
94
        }
95

96
        return null;
1✔
97
    }
98

99
    public async Task WriteLineAsync(int processId, string line, CancellationToken cancellationToken = default)
100
    {
101
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
102

103
        if (!processes.TryGetValue(processId, out var managed))
1✔
104
        {
105
            throw new InvalidOperationException($"Process {processId} is not managed by this controller.");
1✔
106
        }
107

NEW
108
        await managed.WriteLineAsync(line, cancellationToken);
×
NEW
109
    }
×
110

111
    public string ReadStdout(int processId)
112
    {
113
        if (!processes.TryGetValue(processId, out var managed))
1✔
114
        {
115
            return string.Empty;
1✔
116
        }
117

118
        return managed.ReadStdout();
1✔
119
    }
120

121
    public string ReadStderr(int processId)
122
    {
NEW
123
        if (!processes.TryGetValue(processId, out var managed))
×
124
        {
NEW
125
            return string.Empty;
×
126
        }
127

NEW
128
        return managed.ReadStderr();
×
129
    }
130

131
    public string PeekStdout(int processId)
132
    {
133
        if (!processes.TryGetValue(processId, out var managed))
1✔
134
        {
NEW
135
            return string.Empty;
×
136
        }
137

138
        return managed.PeekStdout();
1✔
139
    }
140

141
    public string PeekStderr(int processId)
142
    {
NEW
143
        if (!processes.TryGetValue(processId, out var managed))
×
144
        {
NEW
145
            return string.Empty;
×
146
        }
147

NEW
148
        return managed.PeekStderr();
×
149
    }
150

151
    public async Task<ProcessExitResult> WaitForExitAsync(int processId, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
152
    {
153
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
154

155
        if (!processes.TryGetValue(processId, out var managed))
1✔
156
        {
157
            throw new InvalidOperationException($"Process {processId} is not managed by this controller.");
1✔
158
        }
159

160
        return await managed.WaitForExitAsync(timeout, cancellationToken);
1✔
161
    }
1✔
162

163
    public async Task<bool> WaitForOutputAsync(int processId, string text, TimeSpan timeout, CancellationToken cancellationToken = default)
164
    {
165
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
166

167
        if (!processes.TryGetValue(processId, out var managed))
1✔
168
        {
NEW
169
            return false;
×
170
        }
171

172
        return await managed.WaitForOutputAsync(text, timeout, cancellationToken);
1✔
173
    }
1✔
174

175
    public async Task TerminateAsync(int processId, CancellationToken cancellationToken = default)
176
    {
177
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
178
        cancellationToken.ThrowIfCancellationRequested();
1✔
179

180
        if (!processes.TryGetValue(processId, out var managed))
1✔
181
        {
182
            return;
1✔
183
        }
184

185
        await managed.TerminateAsync();
1✔
186
    }
1✔
187

188
    public async Task KillAsync(int processId)
189
    {
190
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
191

192
        if (!processes.TryGetValue(processId, out var managed))
1✔
193
        {
194
            return;
1✔
195
        }
196

197
        await managed.KillAsync();
1✔
198
    }
1✔
199

200
    public async Task KillAllAsync()
201
    {
202
        ObjectDisposedException.ThrowIf(disposed, this);
1✔
203

204
        var tasks = new List<Task>();
1✔
205

206
        foreach (var kvp in processes)
1✔
207
        {
208
            tasks.Add(kvp.Value.KillAsync());
1✔
209
        }
210

211
        await Task.WhenAll(tasks);
1✔
212
    }
1✔
213

214
    public void Dispose()
215
    {
216
        if (disposed)
1✔
217
        {
218
            return;
1✔
219
        }
220

221
        disposed = true;
1✔
222

223
        foreach (var kvp in processes)
1✔
224
        {
225
            try
226
            {
227
                kvp.Value.Dispose();
1✔
228
            }
1✔
NEW
229
            catch
×
230
            {
231
                // Ignore errors during cleanup
NEW
232
            }
×
233
        }
234

235
        processes.Clear();
1✔
236
    }
1✔
237
}
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