tools: declare image support at the call site, not in each consumer

Callers pass CallOpts.inline_image; execScreenshot rejects a path-less
call before navigating or rendering, which removes the per-consumer
guards and covers the model-driven tool path that had none. MCP image
content is a protocol type, the screenshot recording rule joins the
recorder's replayRequires predicate, and the viewport-to-Opts mapping,
node-scope ladder and save-path helpers are shared instead of copied.
This commit is contained in:
Adrià Arrufat authored and GitHub committed 2026-08-27 15:03:19 +02:00
1 parent 56c6a0a7ab
commit 193f15558c
10 files changed
+105 -124

No files matched your search

+2 -5
View File
@@ -1488,7 +1488,7 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool
.tool_call => |t| t,
else => return .{ .text = "internal: command has no tool mapping", .is_error = true },
};
const result = browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args) catch |err| return .{
return browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args, .{}) catch |err| .{
.text = switch (err) {
error.OutOfMemory => "out of memory",
error.FrameNotLoaded => "no page loaded — run /goto <url> first",
@@ -1496,9 +1496,6 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool
},
.is_error = true,
};
// Tool results reach the model as text; an inline image has nowhere to go.
if (result.image != null) return .{ .text = "screenshot needs `path` here; the inline image is MCP-only", .is_error = true };
return result;
}
/// Data output (/extract, /evaluate, /markdown, /tree, …) → plain stdout on
@@ -1925,7 +1922,7 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
self.terminal.spinner.setTool(tool_name, args_str);
defer self.terminal.spinner.setThinking();
const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments)) |result|
const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments, .{})) |result|
.{ .content = capToolOutput(allocator, tool_name, result.text), .is_error = result.is_error }
else |err|
.{ .content = std.fmt.allocPrint(allocator, "Error: {s}", .{browser_tools.errorMessage(err)}) catch "Error: tool execution failed", .is_error = true };
+10
View File
@@ -23,6 +23,7 @@ const Base64Writer = @import("../Base64Writer.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
const Frame = @import("Frame.zig");
const Viewport = @import("Viewport.zig");
const markdown = @import("markdown.zig");
const Node = @import("webapi/Node.zig");
@@ -44,6 +45,15 @@ pub const Opts = struct {
width: f32,
height: f32,
};
/// Height 0 renders the whole content instead of one viewport.
pub fn fromViewport(viewport: Viewport, full_page: bool) Opts {
return .{
.width = viewport.width,
.height = if (full_page) 0 else viewport.height,
.scale = viewport.scale,
};
}
};
pub fn png(arena: Allocator, node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !u32 {
+65 -59
View File
@@ -215,6 +215,14 @@ pub const save_script_rules =
/// `..` segment. Operator-controlled symlinks already inside CWD are out
/// of scope — the threat we close here is "client supplies an arbitrary
/// path string".
pub const unsafe_path_message = "path must be relative and must not contain '..' segments";
/// The cwd is the server's, not one the user picked, so report where a file
/// really went.
pub fn absolutePath(arena: std.mem.Allocator, path: []const u8) []const u8 {
return std.Io.Dir.cwd().realPathFileAlloc(lp.io, path, arena) catch path;
}
pub fn isPathSafe(path: []const u8) bool {
if (path.len == 0) return false;
if (std.fs.path.isAbsolute(path)) return false;
@@ -302,16 +310,23 @@ pub const Tool = enum {
};
}
/// Tool requires a target element (selector or backendNodeId) at
/// runtime even though the JSON schema marks both as optional. Used by
/// the recorder to skip lines that can't be replayed.
pub fn needsLocator(self: Tool) bool {
/// Args a replay needs even though the schema marks them optional; the
/// recorder skips a line missing one. Exhaustive so a new tool must choose.
pub fn replayRequires(self: Tool) []const []const u8 {
return switch (self) {
.click, .fill, .hover, .selectOption, .setChecked => true,
.goto, .search, .markdown, .html, .screenshot, .links, .evaluate, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .waitForScript, .waitForState, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
.click, .fill, .hover, .selectOption, .setChecked => &.{"selector"},
.screenshot => &.{"path"},
.goto, .search, .markdown, .html, .links, .evaluate, .extract, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .scroll, .waitForSelector, .waitForScript, .waitForState, .press, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => &.{},
};
}
pub fn needsLocator(self: Tool) bool {
for (self.replayRequires()) |field| {
if (std.mem.eql(u8, field, "selector")) return true;
}
return false;
}
/// Result is data the caller probably wants on stdout (extracted JSON,
/// markdown, evaluate return value) rather than a status line on stderr.
pub fn producesData(self: Tool) bool {
@@ -401,13 +416,13 @@ pub const Tool = enum {
),
},
.screenshot => .{
.description = "Render the page, or one node, as a PNG: the text layout Lightpanda computes, not a pixel-accurate browser rendering (no images, fonts or CSS colours). With `path`, writes the file and returns its location; without it, returns the image inline (MCP only). Use it to see spatial layout; read content with `markdown`/`tree`.",
.description = "Render the page, or one node, as a PNG: the text layout Lightpanda computes, not a pixel-accurate browser rendering (no images, fonts or CSS colours). With `path`, writes the file and returns its location; without it, returns the image inline where the client can display one. Use it to see spatial layout; read content with `markdown`/`tree`.",
.summary = "Screenshot of the page or a node",
.input_schema = minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "path": { "type": "string", "description": "Optional relative path (no '..' segments) to write the PNG to. Created or overwritten. Required outside MCP." },
\\ "path": { "type": "string", "description": "Optional relative path (no '..' segments) to write the PNG to. Created or overwritten. Without it the image is returned inline, which needs a client that can display images." },
\\ "selector": { "type": "string", "description": "Optional CSS selector. When set, render only that element." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, render only that node. 0 is treated as omitted." },
\\ "fullPage": { "type": "boolean", "description": "Render the whole content height instead of one viewport. Defaults to false." },
@@ -722,6 +737,7 @@ pub const Tool = enum {
};
pub fn minify(comptime json: []const u8) []const u8 {
// Cumulative: tool_defs evaluates every schema in one comptime scope.
@setEvalBranchQuota(100_000);
return comptime blk: {
var buf: [json.len]u8 = undefined;
@@ -809,7 +825,7 @@ pub fn errorMessage(err: ToolError) []const u8 {
pub const ToolResult = struct {
text: []const u8,
is_error: bool = false,
/// A rendered PNG the caller streams out itself (MCP image content).
/// Only set when the caller passed `CallOpts.inline_image`.
image: ?lp.screenshot.Prepared = null,
};
@@ -838,12 +854,18 @@ const ActionTarget = union(enum) {
const NodeAndPage = struct { node: *DOMNode, page: *lp.Frame, target: ActionTarget };
/// What the caller can do with a result beyond its text.
pub const CallOpts = struct {
inline_image: bool = false,
};
pub fn call(
arena: std.mem.Allocator,
session: *lp.Session,
registry: *CDPNode.Registry,
tool_name: []const u8,
arguments: ?std.json.Value,
opts: CallOpts,
) ToolError!ToolResult {
// In-band so an LLM that invented a tool name (e.g. OpenAI's internal
// `multi_tool_use.parallel` wrapper) learns the name is wrong instead of
@@ -862,7 +884,7 @@ pub fn call(
};
const substituted = try substituteStringArgs(arena, tool, normalized);
return dispatch(arena, session, registry, tool, substituted) catch |err| {
return dispatch(arena, session, registry, tool, substituted, opts) catch |err| {
if (err == error.NavigationFailed) {
if (formatNavigationError(arena, session)) |text|
return .{ .text = text, .is_error = true };
@@ -877,13 +899,14 @@ fn dispatch(
registry: *CDPNode.Registry,
tool: Tool,
substituted: ?std.json.Value,
opts: CallOpts,
) ToolError!ToolResult {
return switch (tool) {
.goto => .{ .text = try execGoto(arena, session, registry, substituted) },
.search => execSearch(arena, substituted),
.markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) },
.html => .{ .text = try execHtml(arena, session, registry, substituted) },
.screenshot => try execScreenshot(arena, session, registry, substituted),
.screenshot => try execScreenshot(arena, session, registry, substituted, opts.inline_image),
.links => .{ .text = try execLinks(arena, session, registry, substituted) },
.tree => .{ .text = try execTree(arena, session, registry, substituted) },
.nodeDetails => .{ .text = try execNodeDetails(arena, session, registry, substituted) },
@@ -1289,21 +1312,21 @@ fn execMarkdown(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNo
const args = try parseArgsOrDefault(Params, arena, arguments);
const page = try ensurePage(session, registry, args.url, args.timeout);
const opts: lp.markdown.Opts = .{ .max_bytes = args.maxBytes };
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
var aw: std.Io.Writer.Allocating = .init(arena);
if (args.selector) |sel| {
const resolved = try resolveBySelector(session, sel);
lp.markdown.dump(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
} else if (args.backendNodeId) |nid| {
const resolved = try resolveNodeAndPage(session, registry, nid);
lp.markdown.dump(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
} else {
lp.markdown.dump(page.document.asNode(), opts, &aw.writer, page) catch return ToolError.InternalError;
}
lp.markdown.dump(node, .{ .max_bytes = args.maxBytes }, &aw.writer, page) catch return ToolError.InternalError;
return aw.written();
}
/// The node a read tool works on: the selector match, the registry node, or
/// the whole document. All three live in the current frame.
fn resolveScope(session: *lp.Session, registry: *CDPNode.Registry, page: *lp.Frame, selector: ?[]const u8, node_id: ?CDPNode.Id) ToolError!*DOMNode {
if (selector) |sel| return (try resolveBySelector(session, sel)).node;
if (node_id) |nid| return (try resolveNodeAndPage(session, registry, nid)).node;
return page.document.asNode();
}
const HtmlParams = struct {
selector: ?[]const u8 = null,
backendNodeId: ?CDPNode.Id = null,
@@ -1319,19 +1342,16 @@ fn execHtml(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R
const opts: lp.dump.Opts = .{ .strip = args.strip, .max_bytes = args.maxBytes };
var aw: std.Io.Writer.Allocating = .init(arena);
if (args.selector) |sel| {
const resolved = try resolveBySelector(session, sel);
lp.dump.deep(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
} else if (args.backendNodeId) |nid| {
const resolved = try resolveNodeAndPage(session, registry, nid);
lp.dump.deep(resolved.node, opts, &aw.writer, resolved.page) catch return ToolError.InternalError;
} else {
if (args.selector == null and args.backendNodeId == null) {
lp.dump.root(page.document, opts, &aw.writer, page) catch return ToolError.InternalError;
} else {
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
lp.dump.deep(node, opts, &aw.writer, page) catch return ToolError.InternalError;
}
return aw.written();
}
fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult {
fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value, inline_image: bool) ToolError!ToolResult {
const Params = struct {
path: ?[]const u8 = null,
selector: ?[]const u8 = null,
@@ -1342,41 +1362,27 @@ fn execScreenshot(arena: std.mem.Allocator, session: *lp.Session, registry: *CDP
};
const args = try parseArgsOrDefault(Params, arena, arguments);
if (args.path) |path| {
if (!isPathSafe(path)) return .{ .text = "path must be relative and must not contain '..' segments", .is_error = true };
if (!isPathSafe(path)) return .{ .text = unsafe_path_message, .is_error = true };
} else if (!inline_image) {
return .{ .text = "pass `path`: this client cannot display an inline image", .is_error = true };
}
const page = try ensurePage(session, registry, args.url, args.timeout);
const node = try resolveScope(session, registry, page, args.selector, args.backendNodeId);
const prepared = lp.screenshot.prepare(arena, node, .fromViewport(page._page.getViewport(), args.fullPage), page) catch
return ToolError.InternalError;
const width = prepared.opts.width;
var node = page.document.asNode();
var frame = page;
if (args.selector) |sel| {
const resolved = try resolveBySelector(session, sel);
node = resolved.node;
frame = resolved.page;
} else if (args.backendNodeId) |nid| {
const resolved = try resolveNodeAndPage(session, registry, nid);
node = resolved.node;
frame = resolved.page;
if (args.path) |path| {
const height = writePng(&prepared, path) catch |err| return .{
.text = std.fmt.allocPrint(arena, "could not write {s}: {s}", .{ path, @errorName(err) }) catch return ToolError.OutOfMemory,
.is_error = true,
};
return .{ .text = std.fmt.allocPrint(arena, "Saved {d}x{d} PNG to {s}", .{ width, height, absolutePath(arena, path) }) catch return ToolError.OutOfMemory };
}
const viewport = page._page.getViewport();
const prepared = lp.screenshot.prepare(arena, node, .{
.width = viewport.width,
.height = if (args.fullPage) 0 else viewport.height,
.scale = viewport.scale,
}, frame) catch return ToolError.InternalError;
const path = args.path orelse return .{
.text = std.fmt.allocPrint(arena, "PNG, {d}px wide", .{viewport.width}) catch return ToolError.OutOfMemory,
return .{
.text = std.fmt.allocPrint(arena, "PNG, {d}px wide", .{width}) catch return ToolError.OutOfMemory,
.image = prepared,
};
const height = writePng(&prepared, path) catch |err| return .{
.text = std.fmt.allocPrint(arena, "could not write {s}: {s}", .{ path, @errorName(err) }) catch return ToolError.OutOfMemory,
.is_error = true,
};
// Absolute path: the cwd is the server's, not one the user picked.
const where = std.Io.Dir.cwd().realPathFileAlloc(lp.io, path, arena) catch path;
return .{ .text = std.fmt.allocPrint(arena, "Saved {d}x{d} PNG to {s}", .{ viewport.width, height, where }) catch return ToolError.OutOfMemory };
}
fn writePng(prepared: *const lp.screenshot.Prepared, path: []const u8) !u32 {
@@ -2455,7 +2461,7 @@ test "call: unknown tool name surfaces in-band" {
// Session/registry are never touched on this branch; the name check is
// the first thing `call` does.
const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null);
const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null, .{});
try std.testing.expect(r.is_error);
try std.testing.expectEqualStrings("Unknown tool: multi_tool_use.parallel", r.text);
}
+1 -5
View File
@@ -993,11 +993,7 @@ fn captureScreenshot(cmd: *CDP.Command) !void {
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
const viewport = cmd.cdp.browser.getViewport();
var opts: lp.screenshot.Opts = .{
.scale = viewport.scale,
.width = viewport.width,
.height = if (params.captureBeyondViewport orelse false) 0 else viewport.height,
};
var opts: lp.screenshot.Opts = .fromViewport(viewport, params.captureBeyondViewport orelse false);
if (params.clip) |clip| {
opts.clip = .{
.x = @floatCast(clip.x),
+2 -6
View File
@@ -439,9 +439,7 @@ fn writeResults(app: *App, opts: FetchOpts, pages: []const Session.PageHandle, e
}
fn prepareShot(arena: std.mem.Allocator, frame: *Frame, opts: FetchOpts) !screenshot.Prepared {
return screenshot.prepare(arena, try dumpRoot(frame, opts.selector), .{
.width = frame._page.getViewport().width,
}, frame);
return screenshot.prepare(arena, try dumpRoot(frame, opts.selector), .fromViewport(frame._page.getViewport(), true), frame);
}
fn dumpRoot(frame: *Frame, selector: ?[]const u8) !*Node {
@@ -462,9 +460,7 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, opts: FetchOpts, frame: *Fram
.png => {
var arena: std.heap.ArenaAllocator = .init(app.allocator);
defer arena.deinit();
_ = try screenshot.png(arena.allocator(), root, .{
.width = frame._page.getViewport().width,
}, writer, frame);
_ = try screenshot.png(arena.allocator(), root, .fromViewport(frame._page.getViewport(), true), writer, frame);
},
.semantic_tree, .semantic_tree_text => {
var registry = CDPNode.Registry.init(app.allocator);
+6
View File
@@ -167,6 +167,12 @@ pub const CallParams = struct {
arguments: ?std.json.Value = null,
};
pub const ImageContent = struct {
type: []const u8 = "image",
data: @import("../browser/screenshot.zig").Prepared,
mimeType: []const u8 = "image/png",
};
pub fn TextContent(comptime T: type) type {
return struct {
type: []const u8 = "text",
+8 -38
View File
@@ -156,7 +156,7 @@ fn dispatchBrowserTool(
};
const active = server.active_session;
const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments) catch |err| {
const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments, .{ .inline_image = true }) catch |err| {
// evaluate/extract surface failures in-band so the LLM can self-correct;
// other tools' operational failures are protocol-level.
if (surfacesErrorInBand(tool)) {
@@ -173,41 +173,14 @@ fn dispatchBrowserTool(
};
if (result.image) |image| {
return server.sendResult(id, ImageResult{ .image = image, .text = result.text });
return server.sendResult(id, .{
.content = .{ protocol.ImageContent{ .data = image }, protocol.TextContent([]const u8){ .text = result.text } },
.isError = result.is_error,
});
}
try sendToolResultText(server, id, result.text, result.is_error);
}
/// MCP image content: the PNG streams straight into the message as base64.
const ImageResult = struct {
image: lp.screenshot.Prepared,
text: []const u8,
pub fn jsonStringify(self: @This(), jw: anytype) !void {
try jw.beginObject();
try jw.objectField("content");
try jw.beginArray();
try jw.beginObject();
try jw.objectField("type");
try jw.write("image");
try jw.objectField("data");
try jw.write(self.image);
try jw.objectField("mimeType");
try jw.write("image/png");
try jw.endObject();
try jw.beginObject();
try jw.objectField("type");
try jw.write("text");
try jw.objectField("text");
try jw.write(self.text);
try jw.endObject();
try jw.endArray();
try jw.objectField("isError");
try jw.write(false);
try jw.endObject();
}
};
fn surfacesErrorInBand(tool: BrowserTool) bool {
return tool == .evaluate or tool == .extract;
}
@@ -219,7 +192,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
};
if (!browser_tools.isPathSafe(args.path)) {
return sendErrorContent(server, id, "path must be relative and must not contain '..' segments");
return sendErrorContent(server, id, browser_tools.unsafe_path_message);
}
// The client never sees resolved secrets, but scrub any literal LP_* value
@@ -233,8 +206,7 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
return sendErrorContent(server, id, msg);
};
// Absolute path: the cwd is the client-launched server's, not one the user picked.
const where = std.Io.Dir.cwd().realPathFileAlloc(lp.io, args.path, arena) catch args.path;
const where = browser_tools.absolutePath(arena, args.path);
const lines = std.mem.count(u8, script, "\n") + 1;
const msg = std.fmt.allocPrint(arena, "saved {d} line(s) to {s}", .{ lines, where }) catch
return sendErrorContent(server, id, "out of memory");
@@ -1498,9 +1470,7 @@ test "MCP - screenshot: inline image, file, unsafe path" {
defer std.Io.Dir.cwd().deleteFile(lp.io, path) catch {};
out.clearRetainingCapacity();
const to_file =
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screenshot","arguments":{"path":"mcp-screenshot-test.png","selector":"#hoverTarget"}}}
;
const to_file = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"screenshot\",\"arguments\":{\"path\":\"" ++ path ++ "\",\"selector\":\"#hoverTarget\"}}}";
try router.handleMessage(server, testing.arena_allocator, to_file);
try testing.expect(std.mem.indexOf(u8, out.written(), "Saved 1920x") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "\"type\":\"image\"") == null);
+1 -2
View File
@@ -714,14 +714,13 @@ fn callTool(
self.session.browser.env.isolate.enter();
defer self.session.browser.env.isolate.exit();
const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args) catch |err| switch (err) {
const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args, .{}) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.FrameNotLoaded => return .{ .fail = "no page loaded - run page.goto(url) first" },
else => return .{ .fail = std.fmt.allocPrint(arena, "{s} failed: {s}", .{ @tagName(tool), @errorName(err) }) catch return error.OutOfMemory },
};
if (result.is_error) return .{ .fail = result.text };
if (result.image != null) return .{ .fail = "screenshot needs `path` in a script" };
return .{ .ok = result.text };
}
+9 -8
View File
@@ -104,19 +104,20 @@ pub const Command = union(enum) {
}
/// Skip the line when the recorded form would not round-trip:
/// - no `selector` AND (tool needs one OR only locator is the
/// ephemeral `backendNodeId`);
/// - an arg the replay requires (`Tool.replayRequires`) is missing;
/// - the only locator is the ephemeral `backendNodeId`;
/// - a string field can't be quoted unambiguously.
fn isRecorded(self: ToolCall) bool {
if (!self.tool.isRecorded()) return false;
const s = self.schema();
const args = self.args orelse return s.required.len == 0 and !self.tool.needsLocator() and self.tool != .screenshot;
if (args != .object) return !self.tool.needsLocator();
// An inline screenshot (no `path`) only exists as MCP image content.
if (self.tool == .screenshot and !args.object.contains("path")) return false;
const required = self.tool.replayRequires();
const args = self.args orelse return s.required.len == 0 and required.len == 0;
if (args != .object) return required.len == 0;
const has_selector = args.object.contains("selector");
if (!has_selector and (self.tool.needsLocator() or args.object.contains("backendNodeId"))) return false;
if (!args.object.contains("selector") and args.object.contains("backendNodeId")) return false;
for (required) |field| {
if (!args.object.contains(field)) return false;
}
const positional = s.isBarePositional(args.object);
+1 -1
View File
@@ -198,7 +198,7 @@ fn note(tool: browser_tools.Tool) []const u8 {
.waitForSelector => "`waitFor*` default timeout 5000 ms.",
.waitForScript => "Re-evaluates page JS until truthy.",
.waitForState => "",
.screenshot => "Needs `path`; writes a PNG of the text layout.",
.screenshot => "`path` is required: writes a PNG of the text layout.",
.press => "Selector first! `page.press(\"Enter\")` binds \"Enter\" to `selector` and fails — use `page.press(null, \"Enter\")` or `page.press({ key: \"Enter\" })`.",
.click, .fill, .scroll, .hover, .selectOption, .setChecked => "",
.search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => "",