Add scripted terminal thin client

This commit is contained in:
slhx agent
2026-06-21 02:02:53 +02:00
parent 86bac27a8a
commit 16d2a023a3
2 changed files with 298 additions and 0 deletions
+2
View File
@@ -3,6 +3,7 @@ const protocol = @import("protocol.zig");
const replay = @import("replay.zig"); const replay = @import("replay.zig");
const session = @import("session.zig"); const session = @import("session.zig");
const socket = @import("socket.zig"); const socket = @import("socket.zig");
const tui = @import("tui.zig");
pub const version = "0.1.0-dev"; pub const version = "0.1.0-dev";
@@ -119,6 +120,7 @@ test {
_ = replay; _ = replay;
_ = session; _ = session;
_ = socket; _ = socket;
_ = tui;
} }
test "regular: help text names the binary and smoke boundary" { test "regular: help text names the binary and smoke boundary" {
+296
View File
@@ -0,0 +1,296 @@
const std = @import("std");
const protocol = @import("protocol.zig");
const replay = @import("replay.zig");
const session_mod = @import("session.zig");
// First terminal thin client surface, scriptable for E2E-style tests.
// req: session/001, session/003, ui/001, coding/001, testing/001, testing/002
test {
_ = Client;
}
pub const Error = error{
ViewportTooSmall,
InvalidResize,
UnknownTraceEvent,
ProtocolRejected,
ClientQuit,
NothingSaved,
};
pub const Viewport = struct {
width: usize,
height: usize,
pub fn validate(self: Viewport) !void {
if (self.width == 0 or self.height < 2) return Error.ViewportTooSmall;
}
};
pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult {
var client = try Client.init(allocator, viewport);
defer client.deinit();
var lines = std.mem.splitScalar(u8, trace, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try client.handleTraceLine(line);
}
const frame = try client.render(allocator);
errdefer allocator.free(frame);
const saved_bytes = if (client.saved_bytes) |bytes| try allocator.dupe(u8, bytes) else null;
errdefer if (saved_bytes) |bytes| allocator.free(bytes);
return .{ .frame = frame, .saved_bytes = saved_bytes, .quit = client.quit };
}
pub const TraceResult = struct {
frame: []u8,
saved_bytes: ?[]u8,
quit: bool,
pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void {
allocator.free(self.frame);
if (self.saved_bytes) |bytes| allocator.free(bytes);
}
};
pub const Client = struct {
allocator: std.mem.Allocator,
session: session_mod.Session,
viewport: Viewport,
saved_bytes: ?[]u8 = null,
quit: bool = false,
pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client {
try viewport.validate();
return .{
.allocator = allocator,
.session = session_mod.Session.init(allocator),
.viewport = viewport,
};
}
pub fn deinit(self: *Client) void {
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
self.session.deinit();
self.* = undefined;
}
pub fn handleTraceLine(self: *Client, line: []const u8) !void {
if (std.mem.eql(u8, line, "quit")) {
self.quit = true;
return;
}
if (self.quit) return Error.ClientQuit;
if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line);
if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line);
if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left");
if (std.mem.eql(u8, line, "right")) return self.applyProtocol("command move_right");
if (std.mem.eql(u8, line, "backspace")) return self.applyProtocol("command delete_backward");
if (std.mem.eql(u8, line, "save")) return self.save();
if (std.mem.startsWith(u8, line, "resize ")) return self.resize(line[7..]);
if (std.mem.eql(u8, line, "render")) return;
return Error.UnknownTraceEvent;
}
pub fn render(self: *const Client, allocator: std.mem.Allocator) ![]u8 {
try self.viewport.validate();
const snap = try self.session.snapshot();
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
const max_body_lines = self.viewport.height - 1;
const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte);
const cursor_col = columnAt(snap.bytes, snap.cursor_byte);
var visible_line_index: usize = 0;
var body_lines_used: usize = 0;
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
while (line_iter.next()) |line| : (visible_line_index += 1) {
if (body_lines_used >= max_body_lines) break;
try appendVisibleCells(allocator, &out, line, self.viewport.width);
try out.append(allocator, '\n');
body_lines_used += 1;
if (visible_line_index == cursor_line and body_lines_used < max_body_lines) {
try appendCursorLine(allocator, &out, @min(cursor_col, self.viewport.width - 1), self.viewport.width);
body_lines_used += 1;
}
}
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
try out.append(allocator, '~');
try out.append(allocator, '\n');
}
const status = try std.fmt.allocPrint(
allocator,
"mim row={d} col={d} bytes={d}{s}",
.{ cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
);
defer allocator.free(status);
try appendVisibleCells(allocator, &out, status, self.viewport.width);
return out.toOwnedSlice(allocator);
}
pub fn saved(self: *const Client) ![]const u8 {
return self.saved_bytes orelse Error.NothingSaved;
}
fn applyProtocolCommand(self: *Client, line: []const u8) !void {
const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line});
defer self.allocator.free(command);
try self.applyProtocol(command);
}
fn applyProtocol(self: *Client, line: []const u8) !void {
const response = try protocol.handleLine(self.allocator, &self.session, line);
defer self.allocator.free(response);
if (std.mem.startsWith(u8, response, "err ")) return Error.ProtocolRejected;
}
fn resize(self: *Client, payload: []const u8) !void {
const x = std.mem.indexOfScalar(u8, payload, 'x') orelse return Error.InvalidResize;
const width = std.fmt.parseUnsigned(usize, payload[0..x], 10) catch return Error.InvalidResize;
const height = std.fmt.parseUnsigned(usize, payload[x + 1 ..], 10) catch return Error.InvalidResize;
const viewport = Viewport{ .width = width, .height = height };
try viewport.validate();
self.viewport = viewport;
}
fn save(self: *Client) !void {
const snap = try self.session.snapshot();
const copy = try self.allocator.dupe(u8, snap.bytes);
if (self.saved_bytes) |old| self.allocator.free(old);
self.saved_bytes = copy;
}
};
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
var i: usize = 0;
while (i < bytes.len) {
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
const end = @min(bytes.len, i + len);
const next = bytes[0..end];
if (session_mod.cellWidth(next) > max_cells) break;
try out.appendSlice(allocator, bytes[i..end]);
i = end;
}
}
fn appendCursorLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), cursor_col: usize, max_cells: usize) !void {
var i: usize = 0;
while (i < cursor_col and i + 1 < max_cells) : (i += 1) try out.append(allocator, ' ');
try out.append(allocator, '^');
try out.append(allocator, '\n');
}
fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize {
var line: usize = 0;
for (bytes[0..@min(cursor_byte, bytes.len)]) |byte| {
if (byte == '\n') line += 1;
}
return line;
}
fn columnAt(bytes: []const u8, cursor_byte: usize) usize {
const prefix = bytes[0..@min(cursor_byte, bytes.len)];
const line_start = if (std.mem.lastIndexOfScalar(u8, prefix, '\n')) |idx| idx + 1 else 0;
return session_mod.cellWidth(prefix[line_start..]);
}
fn assertLinesFit(frame: []const u8, width: usize) !void {
var lines = std.mem.splitScalar(u8, frame, '\n');
while (lines.next()) |line| {
try std.testing.expect(session_mod.cellWidth(line) <= width);
}
}
test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" {
const trace =
\\open abc
\\right
\\insert é
\\save
\\quit
\\
;
const result = try runTrace(std.testing.allocator, .{ .width = 12, .height = 4 }, trace);
defer result.deinit(std.testing.allocator);
try std.testing.expect(result.quit);
try std.testing.expect(std.mem.indexOf(u8, result.frame, "aébc") != null);
try assertLinesFit(result.frame, 12);
try std.testing.expectEqualStrings("aébc", result.saved_bytes.?);
const recording =
\\protocol open abc
\\protocol command move_right
\\input insert é
\\save aébc
\\
;
const replay_result = try replay.replayText(std.testing.allocator, recording);
defer replay_result.deinit(std.testing.allocator);
switch (replay_result) {
.ok => {},
.diverged => return error.ExpectedReplayOk,
}
}
test "regular: backspace deletes whole UTF-8 codepoint through terminal client" {
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
defer client.deinit();
try client.handleTraceLine("open aéb");
try client.handleTraceLine("right");
try client.handleTraceLine("right");
try client.handleTraceLine("backspace");
try client.handleTraceLine("save");
try std.testing.expectEqualStrings("ab", try client.saved());
}
test "regular: resize changes render width without changing buffer bytes" {
var client = try Client.init(std.testing.allocator, .{ .width = 8, .height = 3 });
defer client.deinit();
try client.handleTraceLine("open abcdefghijk");
try client.handleTraceLine("resize 5x3");
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try assertLinesFit(frame, 5);
try client.handleTraceLine("save");
try std.testing.expectEqualStrings("abcdefghijk", try client.saved());
}
test "adversarial: tiny or malformed resize is rejected" {
var client = try Client.init(std.testing.allocator, .{ .width = 4, .height = 3 });
defer client.deinit();
try client.handleTraceLine("open abc");
try std.testing.expectError(Error.ViewportTooSmall, client.handleTraceLine("resize 0x3"));
try std.testing.expectError(Error.InvalidResize, client.handleTraceLine("resize phone"));
}
test "adversarial: invalid UTF-8 insertion is rejected and previous bytes can still save" {
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
defer client.deinit();
try client.handleTraceLine("open safe");
const bad = [_]u8{ 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 };
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(&bad));
try client.handleTraceLine("save");
try std.testing.expectEqualStrings("safe", try client.saved());
}
test "adversarial: quit prevents further edits" {
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
defer client.deinit();
try client.handleTraceLine("open abc");
try client.handleTraceLine("quit");
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
}