Skip to content

Commit e3c67be

Browse files
committed
feat: add support for interactive mode
1 parent fee9b01 commit e3c67be

File tree

3 files changed

+138
-5
lines changed

3 files changed

+138
-5
lines changed

src/root.zig

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
const std = @import("std");
22
const testing = std.testing;
33
const Child = std.process.Child;
4+
const Writer = std.Io.Writer;
5+
const Reader = std.Io.Reader;
6+
const ArrayList = std.ArrayList;
47

58
/// Options for `run` controlling I/O, allocator, and output limits.
69
pub const RunOptions = struct {
@@ -151,3 +154,85 @@ pub fn run(options: RunOptions) !RunResult {
151154
.allocator = options.allocator,
152155
};
153156
}
157+
158+
/// Options for `spawn` controlling the child process environment.
159+
pub const SpawnOptions = struct {
160+
argv: []const []const u8,
161+
allocator: std.mem.Allocator = testing.allocator,
162+
cwd: ?[]const u8 = null,
163+
env_map: ?*const std.process.EnvMap = null,
164+
};
165+
166+
/// Manages a long-running, interactive child process for testing.
167+
pub const InteractiveProcess = struct {
168+
child: Child,
169+
stdout_buffer: [1024]u8 = undefined,
170+
stdin_buffer: [1024]u8 = undefined,
171+
stderr_buffer: [1024]u8 = undefined,
172+
173+
/// Cleans up resources and ensures the child process is terminated.
174+
/// This should always be called, typically with `defer`.
175+
pub fn deinit(self: *InteractiveProcess) void {
176+
if (self.child.stdin) |stdin_file| {
177+
stdin_file.close();
178+
self.child.stdin = null;
179+
}
180+
_ = self.child.wait() catch {};
181+
}
182+
183+
/// Writes bytes to the child process's stdin.
184+
pub fn writeToStdin(self: *InteractiveProcess, bytes: []const u8) !void {
185+
const stdin_file = self.child.stdin orelse return error.MissingStdin;
186+
var stdin_writer = stdin_file.writer(&self.stdin_buffer);
187+
var stdin = &stdin_writer.interface;
188+
try stdin.writeAll(bytes);
189+
try stdin.flush();
190+
}
191+
192+
/// Reads from the child's stdout until a newline is found or the buffer is full.
193+
/// The returned slice does not include the newline character.
194+
pub fn readLineFromStdout(self: *InteractiveProcess) ![]const u8 {
195+
const stdout_file = self.child.stdout orelse return error.MissingStdout;
196+
var stdout_reader = stdout_file.reader(&self.stdout_buffer);
197+
var stdout = &stdout_reader.interface;
198+
const line = try stdout.takeDelimiter('\n') orelse return error.EmptyLine;
199+
// Handle potential CR on Windows if the child outputs CRLF
200+
const trimmed = std.mem.trimEnd(u8, line, "\r");
201+
return trimmed;
202+
}
203+
204+
/// Reads from the child's stderr until a newline is found.
205+
pub fn readLineFromStderr(self: *InteractiveProcess) ![]const u8 {
206+
const stderr_file = self.child.stderr orelse return error.MissingStderr;
207+
var stderr_reader = stderr_file.reader(&self.stderr_buffer);
208+
var stderr = &stderr_reader.interface;
209+
const line = try stderr.takeDelimiter('\n') orelse return error.EmptyLine;
210+
// Handle potential CR on Windows if the child outputs CRLF
211+
const trimmed = std.mem.trimEnd(u8, line, "\r");
212+
return trimmed;
213+
}
214+
};
215+
216+
/// Spawns an executable for interactive testing.
217+
///
218+
/// Returns an `InteractiveProcess` object to manage the child process's
219+
/// lifecycle and I/O. The caller is responsible for calling `deinit()`
220+
/// on the returned object to ensure cleanup.
221+
pub fn spawn(options: SpawnOptions) !InteractiveProcess {
222+
var child = Child.init(options.argv, options.allocator);
223+
child.cwd = options.cwd;
224+
child.env_map = options.env_map;
225+
226+
// We need pipes for all streams to interact with them
227+
child.stdin_behavior = .Pipe;
228+
child.stdout_behavior = .Pipe;
229+
child.stderr_behavior = .Pipe;
230+
231+
try child.spawn();
232+
errdefer {
233+
// If anything fails after spawn, ensure we kill the process
234+
_ = child.kill() catch {};
235+
}
236+
237+
return InteractiveProcess{ .child = child };
238+
}

src/test.zig

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,7 @@ test "run: executable not found" {
124124
defer tmp.deinit();
125125
try testing.expect(false);
126126
} else |err| {
127-
// Got an error as expected. Nothing more to assert (error kinds vary by platform).
128-
// Print the error (to reference it) and consider this test successful
129-
// because an error was expected.
130-
std.debug.print("spawn error: {any}\n", .{err});
131-
try testing.expect(true);
127+
try testing.expectEqual(error.FileNotFound, err);
132128
}
133129
}
134130

@@ -173,3 +169,20 @@ test "run: with env_map" {
173169

174170
try testing.expectEqualStrings("hello-env\n", result.stdout);
175171
}
172+
173+
test "spawn: interactive mode" {
174+
const argv = &[_][]const u8{ "cmdtest", "--interactive" };
175+
var proc = try cmdtest.spawn(.{ .argv = argv });
176+
defer proc.deinit();
177+
178+
try proc.writeToStdin("PING\n");
179+
try testing.expectEqualStrings("PONG", try proc.readLineFromStdout());
180+
181+
try proc.writeToStdin("ECHO works\n");
182+
try testing.expectEqualStrings("works", try proc.readLineFromStdout());
183+
184+
try proc.writeToStdin("EXIT\n");
185+
186+
const term = try proc.child.wait();
187+
try testing.expectEqual(@as(u8, 0), term.Exited);
188+
}

src/test_exe.zig

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ pub fn main() !void {
2121
var abort_requested = false;
2222
var print_cwd = false;
2323
var getenv_name: ?[]const u8 = null;
24+
var interactive = false;
2425
while (i < args.len) : (i += 1) {
2526
const s = args[i];
2627
if (std.mem.eql(u8, s, "--stderr")) {
2728
use_stderr = true;
2829
continue;
2930
}
31+
if (std.mem.eql(u8, s, "--interactive")) {
32+
interactive = true;
33+
continue;
34+
}
3035
if (std.mem.eql(u8, s, "--abort")) {
3136
abort_requested = true;
3237
continue;
@@ -69,6 +74,36 @@ pub fn main() !void {
6974
var writer = if (use_stderr) std.fs.File.stderr().writer(&buf) else std.fs.File.stdout().writer(&buf);
7075
const io = &writer.interface;
7176

77+
if (interactive) {
78+
var stdin_buffer: [1024]u8 = undefined;
79+
var stdin_reader = std.fs.File.stdin().reader(&stdin_buffer);
80+
var stdin = &stdin_reader.interface;
81+
82+
while (stdin.takeDelimiterExclusive('\n')) |line| {
83+
const trimmed = std.mem.trimRight(u8, line, "\r");
84+
if (std.mem.eql(u8, trimmed, "PING")) {
85+
try io.writeAll("PONG\n");
86+
} else if (std.mem.startsWith(u8, trimmed, "ECHO ")) {
87+
try io.writeAll(trimmed[5..]);
88+
try io.writeAll("\n");
89+
} else if (std.mem.eql(u8, trimmed, "EXIT")) {
90+
break;
91+
} else {
92+
try io.print("UNKNOWN: {s}\n", .{trimmed});
93+
}
94+
try io.flush();
95+
// NOTE: this is required in order to prevents the program loop forever
96+
_ = try stdin.take(1);
97+
} else |err| switch (err) {
98+
error.EndOfStream => {
99+
std.debug.print("END OF STREAM\n", .{});
100+
},
101+
else => |e| return e,
102+
}
103+
104+
return;
105+
}
106+
72107
if (getenv_name) |name| {
73108
var env_map = std.process.getEnvMap(allocator) catch @panic("fails to get map");
74109
defer env_map.deinit();

0 commit comments

Comments
 (0)