Skip to content

Commit b9b040e

Browse files
committed
test(executor): add integration tests for runOpencode with mock process
- Add dependency injection for opencode command via initWithCmd() - Create mock opencode scripts for testing different scenarios - Add tests for successful execution, failure handling, and session continuity - Fix double-free bug in runOpencode error path - Add test helper functions for logger creation/cleanup Tests cover: - Successful command execution and stdout capture - Non-zero exit code handling - Session ID passing with --session flag Signed-off-by: leocavalcante <[email protected]>
1 parent 7f493f0 commit b9b040e

File tree

4 files changed

+226
-6
lines changed

4 files changed

+226
-6
lines changed

src/executor.zig

Lines changed: 185 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub const Executor = struct {
2222
log: *Logger,
2323
allocator: Allocator,
2424
session_id: ?[]const u8,
25+
opencode_cmd: []const u8,
2526

2627
/// Initialize executor
2728
pub fn init(cfg: *const config.Config, log: *Logger, allocator: Allocator) Executor {
@@ -30,6 +31,18 @@ pub const Executor = struct {
3031
.log = log,
3132
.allocator = allocator,
3233
.session_id = null,
34+
.opencode_cmd = "opencode",
35+
};
36+
}
37+
38+
/// Initialize executor with custom opencode command (for testing)
39+
pub fn initWithCmd(cfg: *const config.Config, log: *Logger, allocator: Allocator, opencode_cmd: []const u8) Executor {
40+
return Executor{
41+
.cfg = cfg,
42+
.log = log,
43+
.allocator = allocator,
44+
.session_id = null,
45+
.opencode_cmd = opencode_cmd,
3346
};
3447
}
3548

@@ -166,7 +179,7 @@ pub const Executor = struct {
166179
var args = std.ArrayListUnmanaged([]const u8){};
167180
defer args.deinit(self.allocator);
168181

169-
try args.append(self.allocator, "opencode");
182+
try args.append(self.allocator, self.opencode_cmd);
170183
try args.append(self.allocator, "run");
171184
try args.append(self.allocator, "--model");
172185
try args.append(self.allocator, model);
@@ -195,14 +208,16 @@ pub const Executor = struct {
195208

196209
// Read stdout
197210
var stdout_list = std.ArrayListUnmanaged(u8){};
198-
errdefer stdout_list.deinit(self.allocator);
199211

200212
if (child.stdout) |stdout| {
201213
var buf: [4096]u8 = undefined;
202214
while (true) {
203215
const n = stdout.read(&buf) catch break;
204216
if (n == 0) break;
205-
try stdout_list.appendSlice(self.allocator, buf[0..n]);
217+
stdout_list.appendSlice(self.allocator, buf[0..n]) catch |err| {
218+
stdout_list.deinit(self.allocator);
219+
return err;
220+
};
206221
}
207222
}
208223

@@ -235,14 +250,178 @@ pub const Executor = struct {
235250
// Tests
236251
// ============================================================================
237252

253+
// Test helper: Create a minimal mock logger for testing
254+
fn createTestLogger(allocator: Allocator) !*Logger {
255+
// Create a temp directory for logging
256+
const temp_dir = try std.fmt.allocPrint(allocator, "/tmp/opencoder_test_{d}", .{std.time.milliTimestamp()});
257+
defer allocator.free(temp_dir);
258+
259+
try std.fs.cwd().makePath(temp_dir);
260+
261+
const cycle_log_dir = try std.fs.path.join(allocator, &.{ temp_dir, "cycles" });
262+
const alerts_file = try std.fs.path.join(allocator, &.{ temp_dir, "alerts.log" });
263+
264+
try std.fs.cwd().makePath(cycle_log_dir);
265+
266+
const logger_ptr = try allocator.create(Logger);
267+
logger_ptr.* = Logger{
268+
.main_log = null,
269+
.cycle_log_dir = cycle_log_dir,
270+
.alerts_file = alerts_file,
271+
.cycle = 0,
272+
.verbose = false,
273+
.allocator = allocator,
274+
};
275+
return logger_ptr;
276+
}
277+
278+
fn destroyTestLogger(logger_ptr: *Logger, allocator: Allocator) void {
279+
// Clean up temp directory
280+
const temp_base = std.fs.path.dirname(logger_ptr.cycle_log_dir) orelse "/tmp";
281+
std.fs.cwd().deleteTree(temp_base) catch {};
282+
283+
allocator.free(logger_ptr.cycle_log_dir);
284+
allocator.free(logger_ptr.alerts_file);
285+
allocator.destroy(logger_ptr);
286+
}
287+
238288
test "Executor.init creates executor" {
239-
// We can't fully test without a Logger, but we can test initialization structure
240-
// This is more of a compile-time check
241289
const allocator = std.testing.allocator;
242-
_ = allocator;
290+
const test_logger = try createTestLogger(allocator);
291+
defer destroyTestLogger(test_logger, allocator);
292+
293+
const test_cfg = config.Config{
294+
.planning_model = "test/model",
295+
.execution_model = "test/model",
296+
.project_dir = "/tmp",
297+
.verbose = false,
298+
.user_hint = null,
299+
.max_retries = 3,
300+
.backoff_base = 1,
301+
.log_retention = 30,
302+
};
303+
304+
const executor = Executor.init(&test_cfg, test_logger, allocator);
305+
try std.testing.expectEqualStrings("opencode", executor.opencode_cmd);
306+
try std.testing.expect(executor.session_id == null);
307+
}
308+
309+
test "Executor.initWithCmd creates executor with custom command" {
310+
const allocator = std.testing.allocator;
311+
const test_logger = try createTestLogger(allocator);
312+
defer destroyTestLogger(test_logger, allocator);
313+
314+
const test_cfg = config.Config{
315+
.planning_model = "test/model",
316+
.execution_model = "test/model",
317+
.project_dir = "/tmp",
318+
.verbose = false,
319+
.user_hint = null,
320+
.max_retries = 3,
321+
.backoff_base = 1,
322+
.log_retention = 30,
323+
};
324+
325+
const executor = Executor.initWithCmd(&test_cfg, test_logger, allocator, "./test_helpers/mock_opencode.sh");
326+
try std.testing.expectEqualStrings("./test_helpers/mock_opencode.sh", executor.opencode_cmd);
243327
}
244328

245329
test "ExecutionResult enum values" {
246330
try std.testing.expectEqual(ExecutionResult.success, ExecutionResult.success);
247331
try std.testing.expectEqual(ExecutionResult.failure, ExecutionResult.failure);
248332
}
333+
334+
test "runOpencode handles successful execution" {
335+
const allocator = std.testing.allocator;
336+
const test_logger = try createTestLogger(allocator);
337+
defer destroyTestLogger(test_logger, allocator);
338+
339+
const test_cfg = config.Config{
340+
.planning_model = "test/model",
341+
.execution_model = "test/model",
342+
.project_dir = "/tmp",
343+
.verbose = false,
344+
.user_hint = null,
345+
.max_retries = 3,
346+
.backoff_base = 1,
347+
.log_retention = 30,
348+
};
349+
350+
// Get absolute path to mock script
351+
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
352+
const cwd = try std.fs.cwd().realpath(".", &cwd_buf);
353+
const mock_path = try std.fs.path.join(allocator, &.{ cwd, "test_helpers/mock_opencode_success.sh" });
354+
defer allocator.free(mock_path);
355+
356+
var executor = Executor.initWithCmd(&test_cfg, test_logger, allocator, mock_path);
357+
defer executor.deinit();
358+
359+
const result = try executor.runOpencode("test/model", "Test", "test prompt", false);
360+
defer allocator.free(result);
361+
362+
try std.testing.expect(result.len > 0);
363+
try std.testing.expect(std.mem.indexOf(u8, result, "Mock opencode output") != null);
364+
}
365+
366+
test "runOpencode handles process failure" {
367+
const allocator = std.testing.allocator;
368+
const test_logger = try createTestLogger(allocator);
369+
defer destroyTestLogger(test_logger, allocator);
370+
371+
const test_cfg = config.Config{
372+
.planning_model = "test/model",
373+
.execution_model = "test/model",
374+
.project_dir = "/tmp",
375+
.verbose = false,
376+
.user_hint = null,
377+
.max_retries = 3,
378+
.backoff_base = 1,
379+
.log_retention = 30,
380+
};
381+
382+
// Get absolute path to mock script
383+
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
384+
const cwd = try std.fs.cwd().realpath(".", &cwd_buf);
385+
const mock_path = try std.fs.path.join(allocator, &.{ cwd, "test_helpers/mock_opencode_failure.sh" });
386+
defer allocator.free(mock_path);
387+
388+
var executor = Executor.initWithCmd(&test_cfg, test_logger, allocator, mock_path);
389+
defer executor.deinit();
390+
391+
const result = executor.runOpencode("test/model", "Test", "test prompt", false);
392+
try std.testing.expectError(error.OpencodeFailed, result);
393+
}
394+
395+
test "runOpencode passes session ID when continuing" {
396+
const allocator = std.testing.allocator;
397+
const test_logger = try createTestLogger(allocator);
398+
defer destroyTestLogger(test_logger, allocator);
399+
400+
const test_cfg = config.Config{
401+
.planning_model = "test/model",
402+
.execution_model = "test/model",
403+
.project_dir = "/tmp",
404+
.verbose = false,
405+
.user_hint = null,
406+
.max_retries = 3,
407+
.backoff_base = 1,
408+
.log_retention = 30,
409+
};
410+
411+
// Get absolute path to mock script
412+
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
413+
const cwd = try std.fs.cwd().realpath(".", &cwd_buf);
414+
const mock_path = try std.fs.path.join(allocator, &.{ cwd, "test_helpers/mock_opencode_success.sh" });
415+
defer allocator.free(mock_path);
416+
417+
var executor = Executor.initWithCmd(&test_cfg, test_logger, allocator, mock_path);
418+
defer executor.deinit();
419+
420+
// Set a session ID
421+
executor.session_id = try std.fmt.allocPrint(allocator, "test_session_123", .{});
422+
423+
const result = try executor.runOpencode("test/model", "Test", "test prompt", true);
424+
defer allocator.free(result);
425+
426+
try std.testing.expect(result.len > 0);
427+
}

test_helpers/mock_opencode.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
# Mock opencode script for testing
3+
4+
# Check for mode file first (for parallel test safety)
5+
MODE_FILE="/tmp/mock_opencode_mode_$$"
6+
if [ -f "$MODE_FILE" ]; then
7+
MODE=$(cat "$MODE_FILE")
8+
rm -f "$MODE_FILE"
9+
else
10+
MODE="${MOCK_OPENCODE_MODE:-success}"
11+
fi
12+
13+
case "$MODE" in
14+
success)
15+
echo "Mock opencode output"
16+
exit 0
17+
;;
18+
failure)
19+
echo "Mock opencode error" >&2
20+
exit 1
21+
;;
22+
signal)
23+
# Simulate being killed by signal
24+
kill -TERM $$
25+
;;
26+
slow)
27+
sleep 2
28+
echo "Mock opencode output"
29+
exit 0
30+
;;
31+
*)
32+
echo "Unknown mode: $MODE" >&2
33+
exit 1
34+
;;
35+
esac
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env bash
2+
echo "Mock opencode error" >&2
3+
exit 1
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env bash
2+
echo "Mock opencode output"
3+
exit 0

0 commit comments

Comments
 (0)