diff --git a/src/browser/Session.zig b/src/browser/Session.zig index c9412e64a..f6f0653ca 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -86,6 +86,10 @@ _nav_cursor: usize = 0, // `commitPendingPage`). _tool_frame_override: ?u32 = null, +// A popup the last tool action opened (target=_blank). Tools act on it, as +// a user whose click opened a tab would, until it goes away. +_followed_popup: ?u32 = null, + // Loader IDs are scoped to the Session: each new BrowserContext gets a // fresh counter. Frame IDs (`frame_id_gen`) live on `Browser` instead so // CDP target IDs stay unique across BrowserContext lifecycle on a single @@ -457,6 +461,12 @@ pub fn currentFrame(self: *Session) ?*Frame { // No pages[0] fallthrough: the override targets one specific page. return self.findFrameByFrameId(frame_id); } + if (self._followed_popup) |frame_id| { + if (self.findFrameByFrameId(frame_id)) |frame| { + return frame; + } + self._followed_popup = null; + } if (self.pages.items.len == 0) { return null; } @@ -472,6 +482,11 @@ pub fn setToolFrameOverride(self: *Session, frame_id: ?u32) void { self._tool_frame_override = frame_id; } +/// See `_followed_popup`. +pub fn followPopup(self: *Session, frame_id: u32) void { + self._followed_popup = frame_id; +} + // Multi-page aware: frame ids are globally unique (monotonic on `Browser`). // First we find the "live" page for a frame, then we search every nested // frame within that page. diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index c074caf44..b3f78287a 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -553,7 +553,15 @@ fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8, break :blk target.ownerFrame(frame); } break :blk frame.resolveTargetFrame(target_name) orelse { - log.warn(.not_implemented, "target", .{ .type = frame._type, .url = frame.url, .target = target_name }); + // _blank: a new top-level context, as window.open creates. The + // opener is withheld unless rel=opener. + const owner = target.ownerFrame(frame); + try element.focus(frame); + _ = try owner.openPopup(.{ + .url = href, + .name = "", + .opener = if (hasRelToken(element, "opener")) owner.window else null, + }); return; }; }; @@ -565,6 +573,17 @@ fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8, }, .{ .anchor = target_frame }); } +fn hasRelToken(element: *Element, token: []const u8) bool { + const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return false; + var it = std.mem.tokenizeAny(u8, rel, &std.ascii.whitespace); + while (it.next()) |t| { + if (std.ascii.eqlIgnoreCase(t, token)) { + return true; + } + } + return false; +} + pub fn triggerKeyboard(frame: *Frame, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); // Dispatch to the effective active element. When nothing is explicitly diff --git a/src/browser/tests/frames/target.html b/src/browser/tests/frames/target.html index a44a47cb3..f15fdb144 100644 --- a/src/browser/tests/frames/target.html +++ b/src/browser/tests/frames/target.html @@ -38,6 +38,13 @@ } + + +
diff --git a/src/browser/tests/mcp_target_blank.html b/src/browser/tests/mcp_target_blank.html new file mode 100644 index 000000000..31689fb53 --- /dev/null +++ b/src/browser/tests/mcp_target_blank.html @@ -0,0 +1,7 @@ + + +opener + + new tab + + diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 55b4444fe..9fe441b7d 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -1778,23 +1778,52 @@ fn formatActionResult( return std.fmt.allocPrint(arena, "{s} ({f}){s}", .{ prefix, target, suffix }) catch ToolError.InternalError; } +/// What `finalizeAction` compares against; take it before the action runs. +const ActionScope = struct { + frame: ?*lp.Frame, + popups: usize, +}; + +fn beginAction(session: *lp.Session) ActionScope { + const frame = session.currentFrame(); + return .{ .frame = frame, .popups = if (frame) |f| f._page.popups.items.len else 0 }; +} + /// Finish a state-changing action: drain any queued navigation triggered by /// the action, then tag `body` with the resulting page URL and title so the /// caller (LLM, MCP client) can see whether the action triggered navigation. -fn finalizeAction(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, body: []const u8) ToolError![]const u8 { - const before = session.currentFrame(); +fn finalizeAction(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, scope: ActionScope, body: []const u8) ToolError![]const u8 { + const before = scope.frame; if (before) |b| { try awaitQueuedNavigation(session, b._frame_id); } - const page = try requireFrame(session); + var page = try requireFrame(session); // A queued navigation that swaps the root frame tears down the previous // Page (`Session.replaceRootImmediate` / `commitPendingPage`), so every // DOMNode pointer in the registry now dangles. Drop the registry so the // next action can't dereference freed memory. if (before != null and before.? != page) registry.reset(); + + var note: []const u8 = ""; + if (page._page.popups.items.len > scope.popups) { + // The action opened a new window (target=_blank or window.open). + // Follow it, as a user whose click opened a tab would. + var runner = session.runner(.{}); + runner.waitForFrame(page._page.frame._frame_id, 10000, .{ .until = .done }) catch |err| + return if (err == error.Cancelled) ToolError.Cancelled else ToolError.NavigationFailed; + page = try requireFrame(session); + const popups = page._page.popups.items; + if (popups.len > scope.popups) { + page = popups[popups.len - 1]; + session.followPopup(page._frame_id); + registry.reset(); + note = " Opened a new window; tools now act on it."; + } + } + const page_title = page.getTitle() catch null; - return std.fmt.allocPrint(arena, "{s}. Page url: {s}, title: {s}", .{ - body, page.url, page_title orelse "(none)", + return std.fmt.allocPrint(arena, "{s}.{s} Page url: {s}, title: {s}", .{ + body, note, page.url, page_title orelse "(none)", }) catch ToolError.InternalError; } @@ -1806,10 +1835,12 @@ fn execClick(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. const args = try parseArgs(Params, arena, arguments); const resolved = try resolveTarget(session, registry, args.selector, args.backendNodeId); + const scope = beginAction(session); + lp.actions.click(resolved.node, resolved.page) catch |err| return mapActionError(err); const body = try formatActionResult(arena, "Clicked element", resolved.target, ""); - return finalizeAction(arena, session, registry, body); + return finalizeAction(arena, session, registry, scope, body); } fn execFill(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -1823,12 +1854,14 @@ fn execFill(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R const text = try substituteEnvVars(arena, raw_text); const resolved = try resolveTarget(session, registry, args.selector, args.backendNodeId); + const scope = beginAction(session); + lp.actions.fill(resolved.node, text, resolved.page) catch |err| return mapActionError(err); // Show the original reference (e.g. $LP_PASSWORD) in the result, not the resolved value const suffix = std.fmt.allocPrint(arena, " with \"{s}\"", .{raw_text}) catch return ToolError.InternalError; const body = try formatActionResult(arena, "Filled element", resolved.target, suffix); - return finalizeAction(arena, session, registry, body); + return finalizeAction(arena, session, registry, scope, body); } fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -1947,10 +1980,12 @@ fn execHover(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. const args = try parseArgs(Params, arena, arguments); const resolved = try resolveTarget(session, registry, args.selector, args.backendNodeId); + const scope = beginAction(session); + lp.actions.hover(resolved.node, resolved.page) catch |err| return mapActionError(err); const body = try formatActionResult(arena, "Hovered element", resolved.target, ""); - return finalizeAction(arena, session, registry, body); + return finalizeAction(arena, session, registry, scope, body); } fn execPress(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -1972,12 +2007,14 @@ fn execPress(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode. target_node = try resolveOptionalNode(registry, args.backendNodeId); } + const scope = beginAction(session); + lp.actions.press(target_node, args.key, page) catch |err| return mapActionError(err); // Pressing Enter on a form input triggers implicit form submission; // `finalizeAction` drains the queued navigation before tagging the body. const body = std.fmt.allocPrint(arena, "Pressed key '{s}'", .{args.key}) catch return ToolError.InternalError; - return finalizeAction(arena, session, registry, body); + return finalizeAction(arena, session, registry, scope, body); } fn execSelectOption(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -1989,11 +2026,13 @@ fn execSelectOption(arena: std.mem.Allocator, session: *lp.Session, registry: *C const args = try parseArgs(Params, arena, arguments); const resolved = try resolveTarget(session, registry, args.selector, args.backendNodeId); + const scope = beginAction(session); + lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| return mapActionError(err); const prefix = std.fmt.allocPrint(arena, "Selected option '{s}'", .{args.value}) catch return ToolError.InternalError; const body = try formatActionResult(arena, prefix, resolved.target, ""); - return finalizeAction(arena, session, registry, body); + return finalizeAction(arena, session, registry, scope, body); } fn execSetChecked(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { @@ -2005,12 +2044,14 @@ fn execSetChecked(arena: std.mem.Allocator, session: *lp.Session, registry: *CDP const args = try parseArgs(Params, arena, arguments); const resolved = try resolveTarget(session, registry, args.selector, args.backendNodeId); + const scope = beginAction(session); + lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| return mapActionError(err); const state_str: []const u8 = if (args.checked) "checked" else "unchecked"; const suffix = std.fmt.allocPrint(arena, " to {s}", .{state_str}) catch return ToolError.InternalError; const body = try formatActionResult(arena, "Set element", resolved.target, suffix); - return finalizeAction(arena, session, registry, body); + return finalizeAction(arena, session, registry, scope, body); } fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 2f6602143..d0b3ba58d 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -493,6 +493,40 @@ test "MCP - evaluate: object return serializes as JSON" { } }, out.written()); } +test "MCP - click on a target=_blank link follows the new window" { + var out: std.Io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_target_blank.html", &out.writer); + defer server.deinit(); + + const click = + \\{ + \\ "jsonrpc": "2.0", + \\ "id": 1, + \\ "method": "tools/call", + \\ "params": { + \\ "name": "click", + \\ "arguments": { "selector": "#nt" } + \\ } + \\} + ; + try router.handleMessage(server, testing.arena_allocator, click); + try testing.expect(std.mem.indexOf(u8, out.written(), "Opened a new window; tools now act on it. Page url: http://localhost:9582/src/browser/tests/mcp_actions.html") != null); + + out.clearRetainingCapacity(); + const get_url = + \\{ + \\ "jsonrpc": "2.0", + \\ "id": 2, + \\ "method": "tools/call", + \\ "params": { "name": "getUrl", "arguments": {} } + \\} + ; + try router.handleMessage(server, testing.arena_allocator, get_url); + try testing.expectJson(.{ .id = 2, .result = .{ + .content = &.{.{ .type = "text", .text = "http://localhost:9582/src/browser/tests/mcp_actions.html" }}, + } }, out.written()); +} + test "MCP - evaluate: localStorage persists across navigations and is origin-scoped" { var out: std.Io.Writer.Allocating = .init(testing.arena_allocator); const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);