mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-04-04 08:30:31 +00:00
agent: add unit tests for Command, CommandExecutor, and Recorder
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user