agent: add unit tests for Command, CommandExecutor, and Recorder

This commit is contained in:
Adrià Arrufat
2026-04-04 08:28:52 +02:00
parent 7aabda9392
commit 1f75ce1778
4 changed files with 343 additions and 0 deletions

View File

@@ -4,3 +4,9 @@ pub const Terminal = @import("agent/Terminal.zig");
pub const Command = @import("agent/Command.zig");
pub const CommandExecutor = @import("agent/CommandExecutor.zig");
pub const Recorder = @import("agent/Recorder.zig");
test {
_ = Command;
_ = CommandExecutor;
_ = Recorder;
}

View File

@@ -218,3 +218,192 @@ fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool {
}
return true;
}
// --- Tests ---
test "parse GOTO" {
const cmd = parse("GOTO https://example.com");
try std.testing.expectEqualStrings("https://example.com", cmd.goto);
}
test "parse GOTO case insensitive" {
const cmd = parse("goto https://example.com");
try std.testing.expectEqualStrings("https://example.com", cmd.goto);
}
test "parse GOTO missing url" {
const cmd = parse("GOTO");
try std.testing.expect(cmd == .natural_language);
}
test "parse CLICK quoted" {
const cmd = parse("CLICK \"Login\"");
try std.testing.expectEqualStrings("Login", cmd.click);
}
test "parse CLICK unquoted" {
const cmd = parse("CLICK .submit-btn");
try std.testing.expectEqualStrings(".submit-btn", cmd.click);
}
test "parse TYPE two quoted args" {
const cmd = parse("TYPE \"#email\" \"user@test.com\"");
try std.testing.expectEqualStrings("#email", cmd.type_cmd.selector);
try std.testing.expectEqualStrings("user@test.com", cmd.type_cmd.value);
}
test "parse TYPE missing second arg" {
const cmd = parse("TYPE \"#email\"");
try std.testing.expect(cmd == .natural_language);
}
test "parse WAIT" {
const cmd = parse("WAIT \".dashboard\"");
try std.testing.expectEqualStrings(".dashboard", cmd.wait);
}
test "parse TREE" {
const cmd = parse("TREE");
try std.testing.expect(cmd == .tree);
}
test "parse MARKDOWN alias MD" {
try std.testing.expect(parse("MARKDOWN") == .markdown);
try std.testing.expect(parse("md") == .markdown);
}
test "parse EXTRACT with file" {
const cmd = parse("EXTRACT \".title\" > titles.json");
try std.testing.expectEqualStrings(".title", cmd.extract.selector);
try std.testing.expectEqualStrings("titles.json", cmd.extract.file.?);
}
test "parse EXTRACT without file" {
const cmd = parse("EXTRACT \".title\"");
try std.testing.expectEqualStrings(".title", cmd.extract.selector);
try std.testing.expect(cmd.extract.file == null);
}
test "parse EVAL single line" {
const cmd = parse("EVAL \"document.title\"");
try std.testing.expectEqualStrings("document.title", cmd.eval_js);
}
test "parse LOGIN" {
try std.testing.expect(parse("LOGIN") == .login);
try std.testing.expect(parse("login") == .login);
}
test "parse ACCEPT_COOKIES" {
try std.testing.expect(parse("ACCEPT_COOKIES") == .accept_cookies);
try std.testing.expect(parse("ACCEPT-COOKIES") == .accept_cookies);
}
test "parse EXIT" {
try std.testing.expect(parse("EXIT") == .exit);
}
test "parse comment" {
try std.testing.expect(parse("# this is a comment") == .comment);
try std.testing.expect(parse("# INTENT: LOGIN") == .comment);
}
test "parse natural language fallback" {
const cmd = parse("what is on this page?");
try std.testing.expectEqualStrings("what is on this page?", cmd.natural_language);
}
test "parse whitespace trimming" {
const cmd = parse(" GOTO https://example.com ");
try std.testing.expectEqualStrings("https://example.com", cmd.goto);
}
test "parse empty input" {
const cmd = parse("");
try std.testing.expect(cmd == .natural_language);
}
test "ScriptIterator basic commands" {
const script =
\\GOTO https://example.com
\\TREE
\\CLICK "Login"
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expectEqualStrings("https://example.com", e1.command.goto);
try std.testing.expectEqual(@as(u32, 1), e1.line_num);
const e2 = iter.next().?;
try std.testing.expect(e2.command == .tree);
const e3 = iter.next().?;
try std.testing.expectEqualStrings("Login", e3.command.click);
try std.testing.expect(iter.next() == null);
}
test "ScriptIterator skips blank lines and comments" {
const script =
\\# Navigate
\\GOTO https://example.com
\\
\\# Extract
\\TREE
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .comment);
const e2 = iter.next().?;
try std.testing.expect(e2.command == .goto);
const e3 = iter.next().?;
try std.testing.expect(e3.command == .comment);
const e4 = iter.next().?;
try std.testing.expect(e4.command == .tree);
try std.testing.expect(iter.next() == null);
}
test "ScriptIterator multi-line EVAL" {
const script =
\\GOTO https://example.com
\\EVAL """
\\ const x = 1;
\\ const y = 2;
\\ return x + y;
\\"""
\\TREE
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .goto);
const e2 = iter.next().?;
try std.testing.expect(e2.command == .eval_js);
try std.testing.expect(std.mem.indexOf(u8, e2.command.eval_js, "const x = 1;") != null);
try std.testing.expect(std.mem.indexOf(u8, e2.command.eval_js, "return x + y;") != null);
defer std.testing.allocator.free(e2.command.eval_js);
const e3 = iter.next().?;
try std.testing.expect(e3.command == .tree);
try std.testing.expect(iter.next() == null);
}
test "ScriptIterator unterminated EVAL" {
const script =
\\EVAL """
\\ const x = 1;
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .natural_language);
try std.testing.expectEqualStrings("unterminated EVAL block", e1.command.natural_language);
}

View File

@@ -224,3 +224,75 @@ fn buildJson(arena: std.mem.Allocator, value: anytype) []const u8 {
std.json.Stringify.value(value, .{}, &aw.writer) catch return "{}";
return aw.written();
}
// --- Tests ---
test "escapeJs no escaping needed" {
const result = escapeJs(std.testing.allocator, "hello world");
try std.testing.expectEqualStrings("hello world", result);
}
test "escapeJs quotes and backslashes" {
const result = escapeJs(std.testing.allocator, "say \"hello\\world\"");
defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("say \\\"hello\\\\world\\\"", result);
}
test "escapeJs newlines and tabs" {
const result = escapeJs(std.testing.allocator, "line1\nline2\ttab");
defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("line1\\nline2\\ttab", result);
}
test "escapeJs injection attempt" {
const result = escapeJs(std.testing.allocator, "\"; alert(1); //");
defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("\\\"; alert(1); //", result);
}
test "sanitizePath allows relative" {
try std.testing.expectEqualStrings("output.json", sanitizePath("output.json").?);
try std.testing.expectEqualStrings("dir/file.json", sanitizePath("dir/file.json").?);
}
test "sanitizePath rejects absolute" {
try std.testing.expect(sanitizePath("/etc/passwd") == null);
}
test "sanitizePath rejects traversal" {
try std.testing.expect(sanitizePath("../../../etc/passwd") == null);
try std.testing.expect(sanitizePath("foo/../../bar") == null);
}
test "substituteEnvVars no vars" {
const result = substituteEnvVars(std.testing.allocator, "hello world");
try std.testing.expectEqualStrings("hello world", result);
}
test "substituteEnvVars with HOME" {
// Use arena since substituteEnvVars makes intermediate allocations (dupeZ)
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const result = substituteEnvVars(a, "dir=$HOME/test");
// Result should not contain $HOME literally (it got substituted)
try std.testing.expect(std.mem.indexOf(u8, result, "$HOME") == null);
try std.testing.expect(std.mem.indexOf(u8, result, "/test") != null);
}
test "substituteEnvVars missing var kept literal" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const result = substituteEnvVars(arena.allocator(), "$UNLIKELY_VAR_12345");
try std.testing.expectEqualStrings("$UNLIKELY_VAR_12345", result);
}
test "substituteEnvVars bare dollar" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const result = substituteEnvVars(arena.allocator(), "price is $ 5");
try std.testing.expectEqualStrings("price is $ 5", result);
}

View File

@@ -68,3 +68,79 @@ fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool {
}
return true;
}
// --- Tests ---
test "isNonRecordedCommand" {
try std.testing.expect(isNonRecordedCommand("WAIT"));
try std.testing.expect(isNonRecordedCommand("wait"));
try std.testing.expect(isNonRecordedCommand("TREE"));
try std.testing.expect(isNonRecordedCommand("MARKDOWN"));
try std.testing.expect(isNonRecordedCommand("MD"));
try std.testing.expect(isNonRecordedCommand("md"));
try std.testing.expect(!isNonRecordedCommand("GOTO"));
try std.testing.expect(!isNonRecordedCommand("CLICK"));
try std.testing.expect(!isNonRecordedCommand("EXTRACT"));
}
test "record writes state-mutating commands" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const file = tmp.dir.createFile("test.panda", .{ .read = true }) catch unreachable;
var recorder = Self{ .file = file };
defer recorder.deinit();
recorder.record("GOTO https://example.com");
recorder.record("CLICK \"Login\"");
recorder.record("TREE"); // should be skipped
recorder.record("WAIT \".dashboard\""); // should be skipped
recorder.record("MARKDOWN"); // should be skipped
recorder.record("EXTRACT \".title\"");
recorder.recordComment("# INTENT: LOGIN");
// Read back and verify
file.seekTo(0) catch unreachable;
var buf: [512]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expect(std.mem.indexOf(u8, content, "GOTO https://example.com\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "CLICK \"Login\"\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "EXTRACT \".title\"\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "# INTENT: LOGIN\n") != null);
// Verify skipped commands are NOT present
try std.testing.expect(std.mem.indexOf(u8, content, "TREE") == null);
try std.testing.expect(std.mem.indexOf(u8, content, "WAIT") == null);
try std.testing.expect(std.mem.indexOf(u8, content, "MARKDOWN") == null);
}
test "record skips empty and comment lines" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const file = tmp.dir.createFile("test2.panda", .{ .read = true }) catch unreachable;
var recorder = Self{ .file = file };
defer recorder.deinit();
recorder.record("");
recorder.record(" ");
recorder.record("# this is a comment");
recorder.record("GOTO https://example.com");
file.seekTo(0) catch unreachable;
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expectEqualStrings("GOTO https://example.com\n", content);
}
test "recorder with null file is no-op" {
var recorder = Self{ .file = null };
recorder.record("GOTO https://example.com");
recorder.recordComment("# test");
recorder.deinit();
}