Merge branch 'main' into semantic-tree

This commit is contained in:
Adrià Arrufat
2026-03-10 17:26:34 +09:00
16 changed files with 812 additions and 15 deletions

View File

@@ -377,6 +377,10 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr);
}
pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr);
}
pub fn setCallbacks(
self: *const Connection,
comptime header_cb: libcurl.CurlHeaderFunction,

526
src/browser/interactive.zig Normal file
View File

@@ -0,0 +1,526 @@
// 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 Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Allocator = std.mem.Allocator;
pub const InteractivityType = enum {
native,
aria,
contenteditable,
listener,
focusable,
};
pub const InteractiveElement = struct {
node: *Node,
tag_name: []const u8,
role: ?[]const u8,
name: ?[]const u8,
interactivity_type: InteractivityType,
listener_types: []const []const u8,
disabled: bool,
tab_index: i32,
id: ?[]const u8,
class: ?[]const u8,
href: ?[]const u8,
input_type: ?[]const u8,
value: ?[]const u8,
element_name: ?[]const u8,
placeholder: ?[]const u8,
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("tagName");
try jw.write(self.tag_name);
try jw.objectField("role");
try jw.write(self.role);
try jw.objectField("name");
try jw.write(self.name);
try jw.objectField("type");
try jw.write(@tagName(self.interactivity_type));
if (self.listener_types.len > 0) {
try jw.objectField("listeners");
try jw.beginArray();
for (self.listener_types) |lt| {
try jw.write(lt);
}
try jw.endArray();
}
if (self.disabled) {
try jw.objectField("disabled");
try jw.write(true);
}
try jw.objectField("tabIndex");
try jw.write(self.tab_index);
if (self.id) |v| {
try jw.objectField("id");
try jw.write(v);
}
if (self.class) |v| {
try jw.objectField("class");
try jw.write(v);
}
if (self.href) |v| {
try jw.objectField("href");
try jw.write(v);
}
if (self.input_type) |v| {
try jw.objectField("inputType");
try jw.write(v);
}
if (self.value) |v| {
try jw.objectField("value");
try jw.write(v);
}
if (self.element_name) |v| {
try jw.objectField("elementName");
try jw.write(v);
}
if (self.placeholder) |v| {
try jw.objectField("placeholder");
try jw.write(v);
}
try jw.endObject();
}
};
/// Collect all interactive elements under `root`.
pub fn collectInteractiveElements(
root: *Node,
arena: Allocator,
page: *Page,
) ![]InteractiveElement {
// Pre-build a map of event_target pointer → event type names,
// so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena);
var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
const html_el = el.is(Element.Html) orelse continue;
// Skip non-visual elements that are never user-interactive.
switch (el.getTag()) {
.script, .style, .link, .meta, .head, .noscript, .template => continue,
else => {},
}
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
const listener_types = getListenerTypes(
el.asEventTarget(),
listener_targets,
);
try results.append(arena, .{
.node = node,
.tag_name = el.getTagNameLower(),
.role = getRole(el),
.name = getAccessibleName(el),
.interactivity_type = itype,
.listener_types = listener_types,
.disabled = isDisabled(el),
.tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")),
.href = if (el.getAttributeSafe(comptime .wrap("href"))) |href|
URL.resolve(arena, page.base(), href, .{ .encode = true }) catch href
else
null,
.input_type = getInputType(el),
.value = getInputValue(el),
.element_name = el.getAttributeSafe(comptime .wrap("name")),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
});
}
return results.items;
}
const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8));
/// Pre-build a map from event_target pointer → list of event type names.
/// This lets both classifyInteractivity (O(1) "has any?") and
/// getListenerTypes (O(1) "which ones?") avoid re-iterating per element.
fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap {
var map = ListenerTargetMap{};
// addEventListener registrations
var it = page._event_manager.lookup.iterator();
while (it.next()) |entry| {
const list = entry.value_ptr.*;
if (list.first != null) {
const gop = try map.getOrPut(arena, entry.key_ptr.event_target);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(arena, entry.key_ptr.type_string.str());
}
}
// Inline handlers (onclick, onmousedown, etc.)
var attr_it = page._event_target_attr_listeners.iterator();
while (attr_it.next()) |entry| {
const gop = try map.getOrPut(arena, @intFromPtr(entry.key_ptr.target));
if (!gop.found_existing) gop.value_ptr.* = .empty;
// Strip "on" prefix to get the event type name.
try gop.value_ptr.append(arena, @tagName(entry.key_ptr.handler)[2..]);
}
return map;
}
fn classifyInteractivity(
el: *Element,
html_el: *Element.Html,
listener_targets: ListenerTargetMap,
) ?InteractivityType {
// 1. Native interactive by tag
switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native,
.anchor, .area => {
if (el.getAttributeSafe(comptime .wrap("href")) != null) return .native;
},
.input => {
if (el.is(Element.Html.Input)) |input| {
if (input._input_type != .hidden) return .native;
}
},
else => {},
}
// 2. ARIA interactive role
if (el.getAttributeSafe(comptime .wrap("role"))) |role| {
if (isInteractiveRole(role)) return .aria;
}
// 3. contenteditable (15 bytes, exceeds SSO limit for comptime)
if (el.getAttributeSafe(.wrap("contenteditable"))) |ce| {
if (ce.len == 0 or std.ascii.eqlIgnoreCase(ce, "true")) return .contenteditable;
}
// 4. Event listeners (addEventListener or inline handlers)
const et_ptr = @intFromPtr(html_el.asEventTarget());
if (listener_targets.get(et_ptr) != null) return .listener;
// 5. Explicitly focusable via tabindex.
// Only count elements with an EXPLICIT tabindex attribute,
// since getTabIndex() returns 0 for all interactive tags by default
// (including anchors without href and hidden inputs).
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |_| {
if (html_el.getTabIndex() >= 0) return .focusable;
}
return null;
}
fn isInteractiveRole(role: []const u8) bool {
const interactive_roles = [_][]const u8{
"button", "link", "tab", "menuitem",
"menuitemcheckbox", "menuitemradio", "switch", "checkbox",
"radio", "slider", "spinbutton", "searchbox",
"combobox", "option", "treeitem",
};
for (interactive_roles) |r| {
if (std.ascii.eqlIgnoreCase(role, r)) return true;
}
return false;
}
fn getRole(el: *Element) ?[]const u8 {
// Explicit role attribute takes precedence
if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role;
// Implicit role from tag
return switch (el.getTag()) {
.button, .summary => "button",
.anchor, .area => if (el.getAttributeSafe(comptime .wrap("href")) != null) "link" else null,
.input => blk: {
if (el.is(Element.Html.Input)) |input| {
break :blk switch (input._input_type) {
.text, .tel, .url, .email => "textbox",
.checkbox => "checkbox",
.radio => "radio",
.button, .submit, .reset, .image => "button",
.range => "slider",
.number => "spinbutton",
.search => "searchbox",
else => null,
};
}
break :blk null;
},
.select => "combobox",
.textarea => "textbox",
.details => "group",
else => null,
};
}
fn getAccessibleName(el: *Element) ?[]const u8 {
// aria-label
if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| {
if (v.len > 0) return v;
}
// alt (for img, input[type=image])
if (el.getAttributeSafe(comptime .wrap("alt"))) |v| {
if (v.len > 0) return v;
}
// title
if (el.getAttributeSafe(comptime .wrap("title"))) |v| {
if (v.len > 0) return v;
}
// placeholder
if (el.getAttributeSafe(comptime .wrap("placeholder"))) |v| {
if (v.len > 0) return v;
}
// value (for buttons)
if (el.getTag() == .input) {
if (el.getAttributeSafe(comptime .wrap("value"))) |v| {
if (v.len > 0) return v;
}
}
// Text content (first non-empty text node, trimmed)
return getTextContent(el.asNode());
}
fn getTextContent(node: *Node) ?[]const u8 {
var tw = TreeWalker.FullExcludeSelf.init(node, .{});
while (tw.next()) |child| {
// Skip text inside script/style elements.
if (child.is(Element)) |el| {
switch (el.getTag()) {
.script, .style => {
tw.skipChildren();
continue;
},
else => {},
}
}
if (child.is(Node.CData)) |cdata| {
if (cdata.is(Node.CData.Text)) |text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
if (content.len > 0) return content;
}
}
}
return null;
}
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input._input_type.toString();
}
return null;
}
fn getInputValue(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input.getValue();
}
return null;
}
/// Get all event listener types registered on this target.
fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) []const []const u8 {
if (listener_targets.get(@intFromPtr(target))) |types| return types.items;
return &.{};
}
const testing = @import("../testing.zig");
fn testInteractive(html: []const u8) ![]InteractiveElement {
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectInteractiveElements(div.asNode(), page.call_arena, page);
}
test "browser.interactive: button" {
const elements = try testInteractive("<button>Click me</button>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("button", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual("Click me", elements[0].name.?);
try testing.expectEqual(InteractivityType.native, elements[0].interactivity_type);
}
test "browser.interactive: anchor with href" {
const elements = try testInteractive("<a href=\"/page\">Link</a>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("a", elements[0].tag_name);
try testing.expectEqual("link", elements[0].role.?);
try testing.expectEqual("Link", elements[0].name.?);
}
test "browser.interactive: anchor without href" {
const elements = try testInteractive("<a>Not a link</a>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: input types" {
const elements = try testInteractive(
\\<input type="text" placeholder="Search">
\\<input type="hidden" name="csrf">
);
try testing.expectEqual(1, elements.len);
try testing.expectEqual("input", elements[0].tag_name);
try testing.expectEqual("text", elements[0].input_type.?);
try testing.expectEqual("Search", elements[0].placeholder.?);
}
test "browser.interactive: select and textarea" {
const elements = try testInteractive(
\\<select name="color"><option>Red</option></select>
\\<textarea name="msg"></textarea>
);
try testing.expectEqual(2, elements.len);
try testing.expectEqual("select", elements[0].tag_name);
try testing.expectEqual("textarea", elements[1].tag_name);
}
test "browser.interactive: aria role" {
const elements = try testInteractive("<div role=\"button\">Custom</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("div", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type);
}
test "browser.interactive: contenteditable" {
const elements = try testInteractive("<div contenteditable=\"true\">Edit me</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.contenteditable, elements[0].interactivity_type);
}
test "browser.interactive: tabindex" {
const elements = try testInteractive("<div tabindex=\"0\">Focusable</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.focusable, elements[0].interactivity_type);
try testing.expectEqual(@as(i32, 0), elements[0].tab_index);
}
test "browser.interactive: disabled" {
const elements = try testInteractive("<button disabled>Off</button>");
try testing.expectEqual(1, elements.len);
try testing.expect(elements[0].disabled);
}
test "browser.interactive: disabled by fieldset" {
const elements = try testInteractive(
\\<fieldset disabled>
\\ <button>Disabled</button>
\\ <legend><button>In legend</button></legend>
\\</fieldset>
);
try testing.expectEqual(2, elements.len);
// Button outside legend is disabled by fieldset
try testing.expect(elements[0].disabled);
// Button inside first legend is NOT disabled
try testing.expect(!elements[1].disabled);
}
test "browser.interactive: non-interactive div" {
const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: details and summary" {
const elements = try testInteractive("<details><summary>More</summary><p>Content</p></details>");
try testing.expectEqual(2, elements.len);
try testing.expectEqual("details", elements[0].tag_name);
try testing.expectEqual("summary", elements[1].tag_name);
}
test "browser.interactive: mixed elements" {
const elements = try testInteractive(
\\<div>
\\ <a href="/home">Home</a>
\\ <p>Some text</p>
\\ <button id="btn1">Submit</button>
\\ <input type="email" placeholder="Email">
\\ <div>Not interactive</div>
\\ <div role="tab">Tab</div>
\\</div>
);
try testing.expectEqual(4, elements.len);
}

View File

@@ -4,9 +4,17 @@
<script id=basic>
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
}
{
// Empty XML is a parse error (no root element)
const parser = new DOMParser();
testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
}
}
</script>

View File

@@ -6,6 +6,7 @@
</html>
<script src="../testing.js"></script>
<applet></applet>
<script id=document>
testing.expectEqual('HTMLDocument', document.__proto__.constructor.name);
@@ -23,7 +24,7 @@
testing.expectEqual(2, document.scripts.length);
testing.expectEqual(0, document.forms.length);
testing.expectEqual(1, document.links.length);
testing.expectEqual(0, document.applets.length);
testing.expectEqual(0, document.applets.length); // deprecated, always returns 0
testing.expectEqual(0, document.anchors.length);
testing.expectEqual(7, document.all.length);
testing.expectEqual('document', document.currentScript.id);

View File

@@ -90,15 +90,16 @@ pub fn parseFromString(
return pe.err;
}
// If first node is a `ProcessingInstruction`, skip it.
const first_child = doc_node.firstChild() orelse {
// Parsing should fail if there aren't any nodes.
unreachable;
// Empty XML or no root element - this is a parse error.
// TODO: Return a document with a <parsererror> element per spec.
return error.JsException;
};
// If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) {
// We're sure that firstChild exist, this cannot fail.
_ = doc_node.removeChild(first_child, page) catch unreachable;
_ = try doc_node.removeChild(first_child, page);
}
return doc.asDocument();

View File

@@ -167,9 +167,8 @@ pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
return collections.NodeLive(.tag).init(self.asNode(), .embed, page);
}
const applet_string = String.init(undefined, "applet", .{}) catch unreachable;
pub fn getApplets(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag_name) {
return collections.NodeLive(.tag_name).init(self.asNode(), applet_string, page);
pub fn getApplets(_: *const HTMLDocument) collections.HTMLCollection {
return .{ ._data = .empty };
}
pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script {

View File

@@ -20,14 +20,15 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive;
pub const ChildNodes = @import("collections/ChildNodes.zig");
pub const DOMTokenList = @import("collections/DOMTokenList.zig");
pub const RadioNodeList = @import("collections/RadioNodeList.zig");
pub const HTMLCollection = @import("collections/HTMLCollection.zig");
pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig");
pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig");
pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig");
pub fn registerTypes() []const type {
return &.{
@import("collections/HTMLCollection.zig"),
@import("collections/HTMLCollection.zig").Iterator,
HTMLCollection,
HTMLCollection.Iterator,
@import("collections/NodeList.zig"),
@import("collections/NodeList.zig").KeyIterator,
@import("collections/NodeList.zig").ValueIterator,

View File

@@ -36,6 +36,7 @@ const Mode = enum {
links,
anchors,
form,
empty,
};
const HTMLCollection = @This();
@@ -52,22 +53,26 @@ _data: union(Mode) {
links: NodeLive(.links),
anchors: NodeLive(.anchors),
form: NodeLive(.form),
empty: void,
},
pub fn length(self: *HTMLCollection, page: *const Page) u32 {
return switch (self._data) {
.empty => 0,
inline else => |*impl| impl.length(page),
};
}
pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element {
return switch (self._data) {
.empty => null,
inline else => |*impl| impl.getAtIndex(index, page),
};
}
pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element {
return switch (self._data) {
.empty => null,
inline else => |*impl| impl.getByName(name, page),
};
}
@@ -87,6 +92,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
.links => |*impl| .{ .links = impl._tw.clone() },
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
.form => |*impl| .{ .form = impl._tw.clone() },
.empty => .empty,
},
}, page);
}
@@ -106,6 +112,7 @@ pub const Iterator = GenericIterator(struct {
links: TreeWalker.FullExcludeSelf,
anchors: TreeWalker.FullExcludeSelf,
form: TreeWalker.FullExcludeSelf,
empty: void,
},
pub fn next(self: *@This(), _: *Page) ?*Element {
@@ -121,6 +128,7 @@ pub const Iterator = GenericIterator(struct {
.links => |*impl| impl.nextTw(&self.tw.links),
.anchors => |*impl| impl.nextTw(&self.tw.anchors),
.form => |*impl| impl.nextTw(&self.tw.form),
.empty => return null,
};
}
}, null);

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const log = @import("../../log.zig");
const markdown = lp.markdown;
const SemanticTree = lp.SemanticTree;
const interactive = lp.interactive;
const Node = @import("../Node.zig");
const DOMNode = @import("../../browser/webapi/Node.zig");
@@ -28,11 +29,13 @@ pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
getMarkdown,
getSemanticTree,
getInteractiveElements,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.getMarkdown => return getMarkdown(cmd),
.getSemanticTree => return getSemanticTree(cmd),
.getInteractiveElements => return getInteractiveElements(cmd),
}
}
@@ -96,6 +99,35 @@ fn getMarkdown(cmd: anytype) !void {
}, .{});
}
fn getInteractiveElements(cmd: anytype) !void {
const Params = struct {
nodeId: ?Node.Id = null,
};
const params = (try cmd.params(Params)) orelse Params{};
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const root = if (params.nodeId) |nodeId|
(bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom
else
page.document.asNode();
const elements = try interactive.collectInteractiveElements(root, cmd.arena, page);
// Register nodes so nodeIds are valid for subsequent CDP calls.
var node_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, elements.len);
for (elements) |el| {
const registered = try bc.node_registry.register(el.node);
node_ids.appendAssumeCapacity(registered.id);
}
return cmd.sendResult(.{
.elements = elements,
.nodeIds = node_ids.items,
}, .{});
}
const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" {
var ctx = testing.context();
@@ -112,3 +144,20 @@ test "cdp.lp: getMarkdown" {
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
try testing.expect(result.get("markdown") != null);
}
test "cdp.lp: getInteractiveElements" {
var ctx = testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
_ = try bc.session.createPage();
try ctx.processMessage(.{
.id = 1,
.method = "LP.getInteractiveElements",
});
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
try testing.expect(result.get("elements") != null);
try testing.expect(result.get("nodeIds") != null);
}

View File

@@ -19,6 +19,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const screenshot_png = @embedFile("screenshot.png");
const id = @import("../id.zig");
const log = @import("../../log.zig");
const js = @import("../../browser/js/js.zig");
@@ -39,6 +41,8 @@ pub fn processMessage(cmd: anytype) !void {
navigate,
stopLoading,
close,
captureScreenshot,
getLayoutMetrics,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -50,6 +54,8 @@ pub fn processMessage(cmd: anytype) !void {
.navigate => return navigate(cmd),
.stopLoading => return cmd.sendResult(null, .{}),
.close => return close(cmd),
.captureScreenshot => return captureScreenshot(cmd),
.getLayoutMetrics => return getLayoutMetrics(cmd),
}
}
@@ -514,6 +520,109 @@ const LifecycleEvent = struct {
timestamp: u64,
};
const Viewport = struct {
x: f64,
y: f64,
width: f64,
height: f64,
scale: f64,
};
fn base64Encode(comptime input: []const u8) [std.base64.standard.Encoder.calcSize(input.len)]u8 {
const encoder = std.base64.standard.Encoder;
var buf: [encoder.calcSize(input.len)]u8 = undefined;
_ = encoder.encode(&buf, input);
return buf;
}
fn captureScreenshot(cmd: anytype) !void {
const Params = struct {
format: ?[]const u8 = "png",
quality: ?u8 = null,
clip: ?Viewport = null,
fromSurface: ?bool = false,
captureBeyondViewport: ?bool = false,
optimizeForSpeed: ?bool = false,
};
const params = try cmd.params(Params) orelse Params{};
const format = params.format orelse "png";
if (!std.mem.eql(u8, format, "png")) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{ .format = format });
return cmd.sendError(-32000, "unsupported screenshot format.", .{});
}
if (params.quality != null) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{ .quality = params.quality });
}
if (params.clip != null) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{ .clip = params.clip });
}
if (params.fromSurface orelse false or params.captureBeyondViewport orelse false or params.optimizeForSpeed orelse false) {
log.warn(.not_implemented, "Page.captureScreenshot params", .{
.fromSurface = params.fromSurface,
.captureBeyondViewport = params.captureBeyondViewport,
.optimizeForSpeed = params.optimizeForSpeed,
});
}
return cmd.sendResult(.{
.data = base64Encode(screenshot_png),
}, .{});
}
fn getLayoutMetrics(cmd: anytype) !void {
const width = 1920;
const height = 1080;
return cmd.sendResult(.{
.layoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.visualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.contentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
.cssLayoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.cssVisualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.cssContentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
}, .{});
}
const testing = @import("../testing.zig");
test "cdp.page: getFrameTree" {
var ctx = testing.context();
@@ -547,3 +656,77 @@ test "cdp.page: getFrameTree" {
}, .{ .id = 11 });
}
}
test "cdp.page: captureScreenshot" {
var ctx = testing.context();
defer ctx.deinit();
{
try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } });
try ctx.expectSentError(-32000, "unsupported screenshot format.", .{ .id = 10 });
}
{
try ctx.processMessage(.{ .id = 11, .method = "Page.captureScreenshot" });
try ctx.expectSentResult(.{
.data = base64Encode(screenshot_png),
}, .{ .id = 11 });
}
}
test "cdp.page: getLayoutMetrics" {
var ctx = testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
const width = 1920;
const height = 1080;
try ctx.processMessage(.{ .id = 12, .method = "Page.getLayoutMetrics" });
try ctx.expectSentResult(.{
.layoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.visualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.contentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
.cssLayoutViewport = .{
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
},
.cssVisualViewport = .{
.offsetX = 0,
.offsetY = 0,
.pageX = 0,
.pageY = 0,
.clientWidth = width,
.clientHeight = height,
.scale = 1,
.zoom = 1,
},
.cssContentSize = .{
.x = 0,
.y = 0,
.width = width,
.height = height,
},
}, .{ .id = 12 });
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -718,7 +718,11 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
// add credentials
if (req.credentials) |creds| {
try conn.setProxyCredentials(creds);
if (transfer._auth_challenge != null and transfer._auth_challenge.?.source == .proxy) {
try conn.setProxyCredentials(creds);
} else {
try conn.setCredentials(creds);
}
}
}

View File

@@ -33,6 +33,7 @@ pub const dump = @import("browser/dump.zig");
pub const markdown = @import("browser/markdown.zig");
pub const SemanticTree = @import("SemanticTree.zig");
pub const CDPNode = @import("cdp/Node.zig");
pub const interactive = @import("browser/interactive.zig");
pub const mcp = @import("mcp.zig");
pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");

View File

@@ -148,6 +148,7 @@ pub const CurlOption = enum(c.CURLoption) {
cookie = c.CURLOPT_COOKIE,
private = c.CURLOPT_PRIVATE,
proxy_user_pwd = c.CURLOPT_PROXYUSERPWD,
user_pwd = c.CURLOPT_USERPWD,
header_data = c.CURLOPT_HEADERDATA,
header_function = c.CURLOPT_HEADERFUNCTION,
write_data = c.CURLOPT_WRITEDATA,
@@ -512,6 +513,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.accept_encoding,
.custom_request,
.cookie,
.user_pwd,
.proxy_user_pwd,
.copy_post_fields,
=> blk: {