mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
Add a REPL-only `/save [filename.lp]` command that persists the current
interactive session as PandaScript without requiring the user to start the
agent with `-i <script>`. The command records the same replayable actions as
the existing script recorder, but keeps them in memory until the user chooses
to save.
Functional behavior:
- During a REPL session, record replayable browser actions into an in-memory
script buffer.
- Manual slash commands are recorded through the same PandaScript formatting
and filtering rules used by file recording.
- Natural-language turns record their prompt as a `# ...` comment only when
the LLM turn produces at least one successful replayable tool call.
- Failed LLM tool calls are skipped, and repeated successful `/extract` calls
keep only the last successful extract, matching the existing recorder logic.
- `/save filename.lp` writes the current in-memory recording to that file.
- Bare `/save` creates a random `session-<hex>.lp` file on first save.
- If the first save targets an existing file, prompt with the existing numbered
TTY picker and ask whether to replace or append.
- After the first successful save, the REPL session is locked to that filename:
later `/save` or `/save same-file.lp` appends to the same file without
prompting, while `/save other-file.lp` is rejected.
- After each successful save, reset the in-memory recorder so future saves only
append actions entered since the previous save.
- On any `/save` error or cancellation, keep the in-memory recorder intact so
the user can retry without losing captured actions.
- Restrict `/save` filenames to local file names, not paths, to keep behavior
scoped to the current directory.
Code changes:
- Add `Recorder.Memory`, an in-memory recorder that shares the existing
`Command.isRecorded`, `Command.format`, comment formatting, and `LP_*`
reverse-substitution behavior with file recording.
- Add `Recorder.Memory.reset()` so `/save` can clear only successfully saved
deltas.
- Add `/save` to the REPL meta command table and help/completion surface.
- Add `save_buffer` and `save_path` state to `Agent`.
- Feed manual REPL tool calls into `save_buffer` alongside the existing optional
file recorder.
- Extend LLM turn recording with a `capture_for_save` flag so natural-language
REPL turns can be captured without affecting non-REPL script/self-heal paths.
- Implement `/save` handling in `Agent`:
- parse and validate the optional filename,
- choose replace/append for existing first-save targets,
- remember the first successful save path,
- enforce the single-destination rule for the rest of the session,
- append later deltas by default,
- commit remembered path state only after a successful file write.
- Add focused coverage for the memory recorder’s filtering and reset behavior.
498 lines
18 KiB
Zig
498 lines
18 KiB
Zig
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
const log = lp.log;
|
|
const testing = @import("../testing.zig");
|
|
const Command = @import("command.zig").Command;
|
|
|
|
const Recorder = @This();
|
|
|
|
allocator: std.mem.Allocator,
|
|
/// Open append-mode handle while recording is active. Becomes null when a
|
|
/// write fails mid-session and the recorder self-disables; `isActive()`
|
|
/// reflects this.
|
|
file: ?std.fs.File,
|
|
/// Path of the active recording, owned by the Recorder.
|
|
path: []const u8,
|
|
/// Number of lines successfully appended since init. Bumped only on success
|
|
/// so callers see the actual file line count, not the attempt count.
|
|
lines: u32,
|
|
/// Reused between writes so each line doesn't alloc/free.
|
|
buf: std.Io.Writer.Allocating,
|
|
/// Reset per write — backs short-lived scrub allocations so the first
|
|
/// recorded command pays the page setup and the rest reuse the bump.
|
|
arena: std.heap.ArenaAllocator,
|
|
|
|
/// Append-open `sub_path` under `dir`, inserting a leading newline if the
|
|
/// file is non-empty.
|
|
pub fn init(allocator: std.mem.Allocator, dir: std.fs.Dir, sub_path: []const u8) !Recorder {
|
|
const owned_path = try allocator.dupe(u8, sub_path);
|
|
errdefer allocator.free(owned_path);
|
|
const file = try openForAppend(dir, sub_path);
|
|
return .{
|
|
.allocator = allocator,
|
|
.file = file,
|
|
.path = owned_path,
|
|
.lines = 0,
|
|
.buf = .init(allocator),
|
|
.arena = .init(allocator),
|
|
};
|
|
}
|
|
|
|
fn openForAppend(dir: std.fs.Dir, sub_path: []const u8) !std.fs.File {
|
|
const f = try dir.createFile(sub_path, .{ .truncate = false });
|
|
errdefer f.close();
|
|
try f.seekFromEnd(0);
|
|
const pos = try f.getPos();
|
|
if (pos > 0) try f.writeAll("\n");
|
|
return f;
|
|
}
|
|
|
|
pub fn deinit(self: *Recorder) void {
|
|
self.buf.deinit();
|
|
self.arena.deinit();
|
|
if (self.file) |f| f.close();
|
|
self.allocator.free(self.path);
|
|
}
|
|
|
|
pub fn isActive(self: *const Recorder) bool {
|
|
return self.file != null;
|
|
}
|
|
|
|
pub fn record(self: *Recorder, cmd: Command) void {
|
|
if (self.file == null) return;
|
|
if (!cmd.isRecorded()) return;
|
|
self.tryRecord(cmd) catch |err| self.disable(err);
|
|
}
|
|
|
|
fn tryRecord(self: *Recorder, cmd: Command) !void {
|
|
self.buf.clearRetainingCapacity();
|
|
try cmd.format(&self.buf.writer);
|
|
try self.buf.writer.writeByte('\n');
|
|
try self.writeScrubbed();
|
|
}
|
|
|
|
pub fn recordComment(self: *Recorder, comment: []const u8) void {
|
|
if (self.file == null) return;
|
|
self.tryRecordComment(comment) catch |err| self.disable(err);
|
|
}
|
|
|
|
fn tryRecordComment(self: *Recorder, comment: []const u8) !void {
|
|
self.buf.clearRetainingCapacity();
|
|
// Embedded newlines would smuggle an executable line into the script on
|
|
// replay (e.g. `# foo\n/goto https://attacker`). Emit each line of the
|
|
// comment as its own `# ` line; strip lone CRs.
|
|
var it = std.mem.splitScalar(u8, comment, '\n');
|
|
while (it.next()) |line| {
|
|
const trimmed = std.mem.trimRight(u8, line, "\r");
|
|
try self.buf.writer.writeAll("# ");
|
|
try self.buf.writer.writeAll(trimmed);
|
|
try self.buf.writer.writeByte('\n');
|
|
}
|
|
try self.writeScrubbed();
|
|
}
|
|
|
|
fn writeScrubbed(self: *Recorder) !void {
|
|
// Reverse-substitute any LP_* env-var values that snuck in as literals
|
|
// (e.g. an agent that retyped a username it saw via getUrl) so the
|
|
// recording stays portable instead of leaking the resolved secret.
|
|
// Propagate scrub OOM so the recorder disables itself rather than
|
|
// silently writing the unscrubbed buffer.
|
|
_ = self.arena.reset(.retain_capacity);
|
|
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
|
|
|
|
try self.file.?.writeAll(scrubbed);
|
|
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
|
|
}
|
|
|
|
/// Any failure along the record path — buffer-write OOM, scrub OOM, or file
|
|
/// write — flips the recorder to inactive so subsequent calls become silent
|
|
/// no-ops and `isActive()` reflects the stopped state.
|
|
fn disable(self: *Recorder, err: anyerror) void {
|
|
log.warn(.app, "recording disabled", .{ .err = @errorName(err) });
|
|
if (self.file) |f| {
|
|
f.close();
|
|
self.file = null;
|
|
}
|
|
}
|
|
|
|
/// In-memory recorder used by the REPL `/save` command. It intentionally
|
|
/// shares the same command filter/formatter/scrubber as file recording, but
|
|
/// leaves persistence timing to the caller.
|
|
pub const Memory = struct {
|
|
allocator: std.mem.Allocator,
|
|
lines: u32,
|
|
content: std.Io.Writer.Allocating,
|
|
buf: std.Io.Writer.Allocating,
|
|
arena: std.heap.ArenaAllocator,
|
|
|
|
pub fn init(allocator: std.mem.Allocator) Memory {
|
|
return .{
|
|
.allocator = allocator,
|
|
.lines = 0,
|
|
.content = .init(allocator),
|
|
.buf = .init(allocator),
|
|
.arena = .init(allocator),
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Memory) void {
|
|
self.content.deinit();
|
|
self.buf.deinit();
|
|
self.arena.deinit();
|
|
}
|
|
|
|
pub fn bytes(self: *Memory) []const u8 {
|
|
return self.content.written();
|
|
}
|
|
|
|
pub fn reset(self: *Memory) void {
|
|
self.lines = 0;
|
|
self.content.clearRetainingCapacity();
|
|
self.buf.clearRetainingCapacity();
|
|
_ = self.arena.reset(.retain_capacity);
|
|
}
|
|
|
|
pub fn record(self: *Memory, cmd: Command) !void {
|
|
if (!cmd.isRecorded()) return;
|
|
self.buf.clearRetainingCapacity();
|
|
try cmd.format(&self.buf.writer);
|
|
try self.buf.writer.writeByte('\n');
|
|
try self.appendScrubbed();
|
|
}
|
|
|
|
pub fn recordComment(self: *Memory, comment: []const u8) !void {
|
|
self.buf.clearRetainingCapacity();
|
|
var it = std.mem.splitScalar(u8, comment, '\n');
|
|
while (it.next()) |line| {
|
|
const trimmed = std.mem.trimRight(u8, line, "\r");
|
|
try self.buf.writer.writeAll("# ");
|
|
try self.buf.writer.writeAll(trimmed);
|
|
try self.buf.writer.writeByte('\n');
|
|
}
|
|
try self.appendScrubbed();
|
|
}
|
|
|
|
fn appendScrubbed(self: *Memory) !void {
|
|
_ = self.arena.reset(.retain_capacity);
|
|
const scrubbed = try lp.tools.reverseSubstituteEnvVars(self.arena.allocator(), self.buf.written());
|
|
try self.content.writer.writeAll(scrubbed);
|
|
self.lines += @intCast(std.mem.count(u8, scrubbed, "\n"));
|
|
}
|
|
};
|
|
|
|
// --- Tests ---
|
|
|
|
fn parseLine(arena: std.mem.Allocator, line: []const u8) Command {
|
|
return Command.parse(arena, line) catch unreachable;
|
|
}
|
|
|
|
test "record writes state-mutating commands" {
|
|
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "test.lp");
|
|
defer recorder.deinit();
|
|
|
|
recorder.record(parseLine(aa, "/goto https://example.com"));
|
|
recorder.record(parseLine(aa, "/click selector='Login'"));
|
|
recorder.record(parseLine(aa, "/tree"));
|
|
recorder.record(parseLine(aa, "/waitForSelector '.dashboard'"));
|
|
recorder.record(parseLine(aa, "/markdown"));
|
|
recorder.record(parseLine(aa, "/scroll y=200"));
|
|
recorder.record(parseLine(aa, "/hover selector='#menu'"));
|
|
recorder.record(parseLine(aa, "/selectOption selector='#country' value='France'"));
|
|
recorder.record(parseLine(aa, "/setChecked selector='#agree'"));
|
|
recorder.record(parseLine(aa, "/setChecked selector='#newsletter' checked=false"));
|
|
recorder.record(parseLine(aa, "/extract '{\"title\":\".title\"}'"));
|
|
recorder.recordComment("LOGIN");
|
|
|
|
const file = tmp.dir.openFile("test.lp", .{}) catch unreachable;
|
|
defer file.close();
|
|
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 selector='Login'\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/waitForSelector '.dashboard'\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/scroll y=200\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/hover selector='#menu'\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/selectOption selector='#country' value='France'\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/setChecked selector='#agree'\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/setChecked selector='#newsletter' checked=false\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "/extract '{\"title\":\".title\"}'\n") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, content, "\n# LOGIN\n") != null);
|
|
// Read-only tools (tree, markdown) are gated out by isRecorded().
|
|
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 arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "test2.lp");
|
|
defer recorder.deinit();
|
|
|
|
recorder.record(parseLine(aa, ""));
|
|
recorder.record(parseLine(aa, " "));
|
|
recorder.record(parseLine(aa, "# this is a comment"));
|
|
recorder.record(parseLine(aa, "/goto https://example.com"));
|
|
|
|
const file = tmp.dir.openFile("test2.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.expectEqualStrings("/goto 'https://example.com'\n", content);
|
|
}
|
|
|
|
test "lines counter tracks successful appends" {
|
|
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "count.lp");
|
|
defer recorder.deinit();
|
|
|
|
recorder.record(parseLine(aa, "/goto https://example.com")); // +1
|
|
recorder.record(parseLine(aa, "/tree")); // skipped — not isRecorded()
|
|
recorder.record(parseLine(aa, "/click selector='Login'")); // +1
|
|
recorder.recordComment("a note"); // +1
|
|
|
|
try std.testing.expectEqual(@as(u32, 3), recorder.lines);
|
|
}
|
|
|
|
test "init appends to an existing file without truncating" {
|
|
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
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;
|
|
}
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "script.lp");
|
|
defer recorder.deinit();
|
|
recorder.record(parseLine(aa, "/click selector='Login'"));
|
|
|
|
try std.testing.expect(recorder.isActive());
|
|
try std.testing.expectEqualStrings("script.lp", recorder.path);
|
|
|
|
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 selector='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);
|
|
}
|
|
|
|
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
|
|
extern fn unsetenv(name: [*:0]u8) c_int;
|
|
|
|
test "recordComment scrubs literal LP_* values back to placeholders" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
const var_name = "LP_RECORDER_COMMENT_TEST";
|
|
const var_value = "topsecret";
|
|
_ = setenv(@constCast(var_name), @constCast(var_value), 1);
|
|
defer _ = unsetenv(@constCast(var_name));
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "scrub.lp");
|
|
defer recorder.deinit();
|
|
|
|
recorder.recordComment("a user noted that their password is topsecret");
|
|
|
|
const file = tmp.dir.openFile("scrub.lp", .{}) catch unreachable;
|
|
defer file.close();
|
|
var buf: [256]u8 = undefined;
|
|
const n = file.readAll(&buf) catch unreachable;
|
|
try std.testing.expectEqualStrings(
|
|
"# a user noted that their password is $LP_RECORDER_COMMENT_TEST\n",
|
|
buf[0..n],
|
|
);
|
|
}
|
|
|
|
test "recordComment splits embedded newlines into separate comment lines" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "multi.lp");
|
|
defer recorder.deinit();
|
|
|
|
// An attacker-controlled comment trying to smuggle a command must not
|
|
// produce an executable line on replay.
|
|
recorder.recordComment("note\n/goto https://attacker\r\nmore");
|
|
|
|
const file = tmp.dir.openFile("multi.lp", .{}) catch unreachable;
|
|
defer file.close();
|
|
var buf: [256]u8 = undefined;
|
|
const n = file.readAll(&buf) catch unreachable;
|
|
try std.testing.expectEqualStrings(
|
|
"# note\n# /goto https://attacker\n# more\n",
|
|
buf[0..n],
|
|
);
|
|
}
|
|
|
|
test "record disables recorder on write failure" {
|
|
const filter: testing.LogFilter = .init(&.{.app});
|
|
defer filter.deinit();
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
// Open the file read-only so writeAll fails with `error.NotOpenForWriting`.
|
|
// Struct literal (not `init`) because only this test needs to inject a
|
|
// read-only handle to exercise the failure path.
|
|
const file = blk: {
|
|
_ = tmp.dir.createFile("ro.lp", .{}) catch unreachable;
|
|
break :blk tmp.dir.openFile("ro.lp", .{ .mode = .read_only }) catch unreachable;
|
|
};
|
|
|
|
var recorder: Recorder = .{
|
|
.allocator = std.testing.allocator,
|
|
.file = file,
|
|
.path = try std.testing.allocator.dupe(u8, "test.lp"),
|
|
.lines = 0,
|
|
.buf = .init(std.testing.allocator),
|
|
.arena = .init(std.testing.allocator),
|
|
};
|
|
defer recorder.deinit();
|
|
|
|
var test_arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer test_arena.deinit();
|
|
const aa = test_arena.allocator();
|
|
|
|
try std.testing.expect(recorder.isActive());
|
|
recorder.record(parseLine(aa, "/goto https://example.com"));
|
|
try std.testing.expect(!recorder.isActive());
|
|
try std.testing.expectEqual(@as(u32, 0), recorder.lines);
|
|
|
|
// Subsequent calls are silent no-ops, not silent successes.
|
|
recorder.record(parseLine(aa, "/click selector='Login'"));
|
|
recorder.recordComment("note");
|
|
try std.testing.expectEqual(@as(u32, 0), recorder.lines);
|
|
}
|
|
|
|
test "init creates the file if missing" {
|
|
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var recorder: Recorder = try .init(std.testing.allocator, tmp.dir, "fresh.lp");
|
|
defer recorder.deinit();
|
|
recorder.record(parseLine(aa, "/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]);
|
|
}
|
|
|
|
test "record and parse: triple-quote round-trip" {
|
|
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
|
|
var recorder = try Recorder.init(std.testing.allocator, tmp.dir, "triple.lp");
|
|
defer recorder.deinit();
|
|
|
|
const cmd_str = "/extract '{\n \"title\": \"span.title\",\n \"desc\": \"p.description\"\n}'";
|
|
const original_cmd = parseLine(aa, cmd_str);
|
|
recorder.record(original_cmd);
|
|
|
|
const file = tmp.dir.openFile("triple.lp", .{}) catch unreachable;
|
|
defer file.close();
|
|
var buf: [512]u8 = undefined;
|
|
const n = file.readAll(&buf) catch unreachable;
|
|
const content = buf[0..n];
|
|
|
|
var iter: lp.script.Iterator = .init(aa, content);
|
|
const entry = (try iter.next()).?;
|
|
const parsed_cmd = entry.command;
|
|
|
|
try std.testing.expectEqualStrings("extract", parsed_cmd.tool_call.name());
|
|
|
|
const original_val = original_cmd.tool_call.args.?.object.get("schema").?.string;
|
|
const parsed_val = parsed_cmd.tool_call.args.?.object.get("schema").?.string;
|
|
try std.testing.expectEqualStrings(original_val, parsed_val);
|
|
}
|
|
|
|
test "memory recorder mirrors file recorder filtering" {
|
|
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
|
|
defer arena.deinit();
|
|
const aa = arena.allocator();
|
|
|
|
var memory: Memory = .init(std.testing.allocator);
|
|
defer memory.deinit();
|
|
|
|
try memory.record(parseLine(aa, "/goto https://example.com"));
|
|
try memory.record(parseLine(aa, "/tree"));
|
|
try memory.record(parseLine(aa, "/click selector='Login'"));
|
|
try memory.recordComment("search for login");
|
|
|
|
try std.testing.expectEqualStrings(
|
|
"/goto 'https://example.com'\n/click selector='Login'\n# search for login\n",
|
|
memory.bytes(),
|
|
);
|
|
try std.testing.expectEqual(@as(u32, 3), memory.lines);
|
|
|
|
memory.reset();
|
|
try std.testing.expectEqualStrings("", memory.bytes());
|
|
try std.testing.expectEqual(@as(u32, 0), memory.lines);
|
|
|
|
try memory.record(parseLine(aa, "/scroll y=200"));
|
|
try std.testing.expectEqualStrings("/scroll y=200\n", memory.bytes());
|
|
try std.testing.expectEqual(@as(u32, 1), memory.lines);
|
|
}
|