mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
Merge branch 'main' into semantic-tree
This commit is contained in:
@@ -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
526
src/browser/interactive.zig
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
BIN
src/cdp/domains/screenshot.png
Normal file
BIN
src/cdp/domains/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user