Add scripted terminal thin client
This commit is contained in:
@@ -3,6 +3,7 @@ const protocol = @import("protocol.zig");
|
||||
const replay = @import("replay.zig");
|
||||
const session = @import("session.zig");
|
||||
const socket = @import("socket.zig");
|
||||
const tui = @import("tui.zig");
|
||||
|
||||
pub const version = "0.1.0-dev";
|
||||
|
||||
@@ -119,6 +120,7 @@ test {
|
||||
_ = replay;
|
||||
_ = session;
|
||||
_ = socket;
|
||||
_ = tui;
|
||||
}
|
||||
|
||||
test "regular: help text names the binary and smoke boundary" {
|
||||
|
||||
+296
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user