script: unify heal formatting and move TTY helpers

- Merge `formatHealReplacement` and `formatHealReplacementLines` using
  a new `HealBody` union.
- Move `interactiveTty` and `promptNumberedChoice` to `Terminal.zig`.
- Relocate and consolidate tests for `canHeal` and `isRecorded`.
- Make internal `Schema` functions private.
This commit is contained in:
Adrià Arrufat
2026-05-22 16:19:46 +02:00
parent 486d0d53a9
commit 1309407e01
6 changed files with 135 additions and 189 deletions

View File

@@ -285,24 +285,22 @@ test "format: /eval emits triple-quote block for multi-line script" {
try testing.expectString("/eval '''\nconst x = 1;\nreturn x;\n'''", aw.written());
}
test "format: /setChecked omits checked=true (matches default)" {
test "format: /setChecked omits checked=true (default), keeps checked=false" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#agree' checked=true");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectString("/setChecked selector='#agree'", aw.written());
}
const aa = arena.allocator();
test "format: /setChecked keeps checked=false (non-default)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#x' checked=false");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectString("/setChecked selector='#x' checked=false", aw.written());
const cases = [_]struct { input: []const u8, expected: []const u8 }{
.{ .input = "/setChecked selector='#agree' checked=true", .expected = "/setChecked selector='#agree'" },
.{ .input = "/setChecked selector='#x' checked=false", .expected = "/setChecked selector='#x' checked=false" },
};
for (cases) |case| {
const cmd = try Command.parse(aa, case.input);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectString(case.expected, aw.written());
}
}
test "format: /login and /acceptCookies" {
@@ -317,6 +315,26 @@ test "format: /login and /acceptCookies" {
try testing.expectString("/acceptCookies", aw2.written());
}
test "canHeal: only page-local DOM commands are allowed" {
// Table-driven over the live tool flags so adding a new tool can't
// silently drift from the heal allow-list.
const allow = [_]BrowserTool{ .click, .hover, .waitForSelector, .fill, .selectOption, .setChecked, .scroll, .extract, .press };
const deny = [_]BrowserTool{ .goto, .eval, .tree, .markdown, .search, .links };
for (allow) |action| {
const cmd = Command.fromToolCall(action, null);
try testing.expect(cmd.canHeal());
}
for (deny) |action| {
const cmd = Command.fromToolCall(action, null);
try testing.expect(!cmd.canHeal());
}
try testing.expect(!(Command{ .login = {} }).canHeal());
try testing.expect(!(Command{ .acceptCookies = {} }).canHeal());
try testing.expect(!(Command{ .comment = {} }).canHeal());
}
test "isRecorded / canHeal / producesData via tool flags" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
@@ -335,22 +353,22 @@ test "isRecorded / canHeal / producesData via tool flags" {
try testing.expect(!login.canHeal());
}
test "isRecorded: null args on a required-fields tool are not recorded" {
// A provider that hands back `arguments: null` for `/click` would
// otherwise produce a bare `/click` line that can't be replayed.
const click_null = Command.fromToolCall(.click, null);
try testing.expect(click_null.isRecorded()); // click has zero required fields
const goto_null = Command.fromToolCall(.goto, null);
try testing.expect(!goto_null.isRecorded()); // goto requires url
const fill_null = Command.fromToolCall(.fill, null);
try testing.expect(!fill_null.isRecorded()); // fill requires value
}
test "isRecorded and format: backendNodeId stripped, selector preserved" {
test "isRecorded: args shape and locator semantics" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
// Null args: recorded iff the tool has zero required fields. A provider
// that hands back `arguments: null` for `/click` would otherwise produce
// a bare `/click` line that can't be replayed.
try testing.expect(Command.fromToolCall(.click, null).isRecorded());
try testing.expect(!Command.fromToolCall(.goto, null).isRecorded());
try testing.expect(!Command.fromToolCall(.fill, null).isRecorded());
// Non-object args: recorded iff the tool doesn't need a locator.
try testing.expect(Command.fromToolCall(.goto, .{ .string = "https://x" }).isRecorded());
try testing.expect(!Command.fromToolCall(.click, .{ .string = "#submit" }).isRecorded());
// selector + backendNodeId: keep the call, drop the backendNodeId.
{
var obj: std.json.ObjectMap = .init(aa);
@@ -365,7 +383,7 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
try testing.expectString("/click selector='#submit'", aw.written());
}
// backendNodeId only: still skipped — no replayable identifier.
// backendNodeId only: skipped — no replayable identifier.
{
var obj: std.json.ObjectMap = .init(aa);
try obj.put("backendNodeId", .{ .integer = 42 });
@@ -373,24 +391,3 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
try testing.expect(!cmd.isRecorded());
}
}
test "fromToolCall: builds a tool_call Command" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var obj: std.json.ObjectMap = .init(arena.allocator());
try obj.put("url", .{ .string = "https://x" });
const cmd = Command.fromToolCall(.goto, .{ .object = obj });
try testing.expect(cmd == .tool_call);
try testing.expectString("goto", cmd.tool_call.name());
}
test "isRecorded: non-object args check locator presence" {
// goto does not need a locator: isRecorded returns true even if args is not object
const goto_non_obj = Command.fromToolCall(.goto, .{ .string = "https://x" });
try testing.expect(goto_non_obj.isRecorded());
// click needs a locator: isRecorded returns false if args is not object
const click_non_obj = Command.fromToolCall(.click, .{ .string = "#submit" });
try testing.expect(!click_non_obj.isRecorded());
}