mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-22 20:45:27 -04:00
browser: open target=_blank links as popups and let tools follow them
Clicking a target=_blank link was logged and dropped. It now opens a popup Frame, the same top-level context window.open creates; the opener is withheld unless rel=opener, per spec. A popup is invisible to the tool layer, which acts on the root frame, so finalizeAction snapshots the popup count before the action and, when one appears, waits for it to load and makes it the session's current frame. The action result says so. Once the popup is gone, tools fall back to the root.
This commit is contained in:
1 parent
fa1b9ab03d
commit
96e9ef969a
6 files changed
+135
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<a id=lblank target=_blank href=support/page.html></a>
|
||||
<script id=blank>
|
||||
// Opens a new top-level context; the opener stays put.
|
||||
$('#lblank').click();
|
||||
testing.expectEqual(true, location.pathname.endsWith('target.html'));
|
||||
</script>
|
||||
|
||||
<iframe name=frame3 id=f3></iframe>
|
||||
<form target="_top" action="support/page.html">
|
||||
<input type=submit id=submit1 formtarget="frame3">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>opener</title></head>
|
||||
<body>
|
||||
<a id="nt" target="_blank" href="mcp_actions.html">new tab</a>
|
||||
</body>
|
||||
</html>
|
||||
+52
-11
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user