Files
browser/src/agent/Recorder.zig
Adrià Arrufat c0491bd69e agent: clean up and optimize code
- Fix typo in REPL info message.
- Optimize Recorder to avoid unnecessary allocations.
- Simplify field type detection in SlashCommand using stringToEnum.
- Remove unused yellow ANSI constant in Terminal.
- Shorten log message in McpServer.
2026-05-04 10:51:41 +02:00

182 lines
7.1 KiB
Zig

const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const Command = @import("Command.zig");
const Self = @This();
allocator: std.mem.Allocator,
file: ?std.fs.File,
needs_separator: bool,
/// Append-open `path`, inserting a leading newline if the file is non-empty.
/// A null path disables recording.
pub fn init(allocator: std.mem.Allocator, path: ?[]const u8) Self {
const file: ?std.fs.File = if (path) |p| blk: {
const f = std.fs.cwd().createFile(p, .{ .truncate = false }) catch |err| {
log.warn(.app, "could not open recording file", .{ .err = @errorName(err) });
break :blk null;
};
f.seekFromEnd(0) catch |err| {
log.warn(.app, "could not seek recording file", .{ .err = @errorName(err) });
f.close();
break :blk null;
};
const pos = f.getPos() catch 0;
if (pos > 0) _ = f.write("\n") catch {};
break :blk f;
} else null;
return .{ .allocator = allocator, .file = file, .needs_separator = false };
}
pub fn deinit(self: *Self) void {
if (self.file) |f| f.close();
}
pub fn record(self: *Self, cmd: Command.Command) void {
const f = self.file orelse return;
if (!cmd.isRecorded()) return;
var aw: std.Io.Writer.Allocating = .init(self.allocator);
defer aw.deinit();
cmd.format(&aw.writer) catch return;
aw.writer.writeByte('\n') catch return;
_ = f.write(aw.written()) catch return;
self.needs_separator = true;
}
pub fn recordComment(self: *Self, comment: []const u8) void {
const f = self.file orelse return;
const prefix: []const u8 = if (self.needs_separator) "\n# " else "# ";
f.writeAll(prefix) catch return;
f.writeAll(comment) catch return;
f.writeAll("\n") catch return;
self.needs_separator = true;
}
test "record writes state-mutating commands" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const file = tmp.dir.createFile("test.lp", .{ .read = true }) catch unreachable;
var recorder: Self = .{ .allocator = std.testing.allocator, .file = file, .needs_separator = false };
defer recorder.deinit();
recorder.record(Command.parse("GOTO https://example.com"));
recorder.record(Command.parse("CLICK \"Login\""));
recorder.record(Command.parse("TREE"));
recorder.record(Command.parse("WAIT \".dashboard\""));
recorder.record(Command.parse("MARKDOWN"));
recorder.record(Command.parse("SCROLL 0 200"));
recorder.record(Command.parse("HOVER '#menu'"));
recorder.record(Command.parse("SELECT '#country' 'France'"));
recorder.record(Command.parse("CHECK '#agree'"));
recorder.record(Command.parse("CHECK '#newsletter' false"));
recorder.record(Command.parse("EXTRACT \".title\""));
recorder.recordComment("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, "WAIT '.dashboard'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "SCROLL 0 200\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "HOVER '#menu'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "SELECT '#country' 'France'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "CHECK '#agree'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "CHECK '#newsletter' false\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "EXTRACT '.title'\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "\n# LOGIN\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "TREE") == 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.lp", .{ .read = true }) catch unreachable;
var recorder: Self = .{ .allocator = std.testing.allocator, .file = file, .needs_separator = false };
defer recorder.deinit();
recorder.record(Command.parse(""));
recorder.record(Command.parse(" "));
recorder.record(Command.parse("# this is a comment"));
recorder.record(Command.parse("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 = .{ .allocator = std.testing.allocator, .file = null, .needs_separator = false };
recorder.record(Command.parse("GOTO https://example.com"));
recorder.recordComment("# test");
recorder.deinit();
}
test "init appends to an existing file without truncating" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Seed a file with a prior line.
{
const seed = tmp.dir.createFile("script.lp", .{}) catch unreachable;
defer seed.close();
_ = seed.writeAll("GOTO https://example.com\n") catch unreachable;
}
// Resolve absolute path for Recorder.init (which uses std.fs.cwd()).
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const abs_path = tmp.dir.realpath("script.lp", &path_buf) catch unreachable;
var recorder: Self = .init(std.testing.allocator, abs_path);
defer recorder.deinit();
recorder.record(Command.parse("CLICK 'Login'"));
// Read back.
const file = tmp.dir.openFile("script.lp", .{}) catch unreachable;
defer file.close();
var buf: [256]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);
// The prior line must precede the appended line.
const prior = std.mem.indexOf(u8, content, "GOTO").?;
const appended = std.mem.indexOf(u8, content, "CLICK").?;
try std.testing.expect(prior < appended);
}
test "init creates the file if missing" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = tmp.dir.realpath(".", &path_buf) catch unreachable;
var full_buf: [std.fs.max_path_bytes]u8 = undefined;
const abs_path = std.fmt.bufPrint(&full_buf, "{s}/fresh.lp", .{dir_path}) catch unreachable;
var recorder: Self = .init(std.testing.allocator, abs_path);
defer recorder.deinit();
recorder.record(Command.parse("GOTO https://example.com"));
const file = tmp.dir.openFile("fresh.lp", .{}) catch unreachable;
defer file.close();
var buf: [128]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
try std.testing.expectEqualStrings("GOTO https://example.com\n", buf[0..n]);
}