diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 34405913..bf09540a 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -192,13 +192,15 @@ jobs: - run: chmod a+x ./lightpanda + # force a wakup of the auth server before requesting it w/ the test itself + - run: curl https://${{ vars.WBA_DOMAIN }} + - name: run wba test shell: bash run: | - node webbotauth/validator.js & VALIDATOR_PID=$! - sleep 2 + sleep 5 exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index cb8497f9..029228cd 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -10,7 +10,7 @@ env: on: schedule: - - cron: "23 2 * * *" + - cron: "21 2 * * *" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -19,8 +19,12 @@ jobs: wpt-build-release: name: zig build release - runs-on: ubuntu-latest - timeout-minutes: 15 + env: + ARCH: aarch64 + OS: linux + + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 steps: - uses: actions/checkout@v6 @@ -28,9 +32,15 @@ jobs: fetch-depth: 0 - uses: ./.github/actions/install + with: + os: ${{env.OS}} + arch: ${{env.ARCH}} + + - name: v8 snapshot + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build release - run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic - name: upload artifact uses: actions/upload-artifact@v7 @@ -43,7 +53,7 @@ jobs: wpt-build-runner: name: build wpt runner - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm timeout-minutes: 15 steps: @@ -71,8 +81,8 @@ jobs: - wpt-build-runner # use a self host runner. - runs-on: lpd-bench-hetzner - timeout-minutes: 180 + runs-on: lpd-wpt-aws + timeout-minutes: 600 steps: - uses: actions/checkout@v6 @@ -105,8 +115,8 @@ jobs: - name: run test with json output run: | ./wpt serve 2> /dev/null & echo $! > WPT.pid - sleep 10s - ./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json + sleep 20s + ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json kill `cat WPT.pid` - name: write commit diff --git a/Makefile b/Makefile index 462761b1..922c77a7 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end +.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end ## Build v8 snapshot build-v8-snapshot: @@ -77,11 +77,6 @@ run-debug: build-dev @printf "\033[36mRunning...\033[0m\n" @./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;) -## Run a JS shell in debug mode -shell: - @printf "\033[36mBuilding shell...\033[0m\n" - @$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) - ## Test - `grep` is used to filter out the huge compile command on build ifeq ($(OS), macos) test: @@ -106,4 +101,3 @@ install: build data: cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig - diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 166acf4a..af8720e9 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -38,6 +38,7 @@ page: *Page, arena: std.mem.Allocator, prune: bool = true, interactive_only: bool = false, +max_depth: u32 = std.math.maxInt(u32) - 1, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; @@ -46,7 +47,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| { + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -59,7 +60,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| { + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -72,7 +73,7 @@ const OptionData = struct { }; const NodeData = struct { - id: u32, + id: CDPNode.Id, axn: AXNode, role: []const u8, name: ?[]const u8, @@ -83,7 +84,9 @@ const NodeData = struct { node_name: []const u8, }; -fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap) !void { +fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void { + if (current_depth > self.max_depth) return; + // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -230,7 +233,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam } gop.value_ptr.* += 1; - try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets); + try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1); } } @@ -474,3 +477,56 @@ const TextVisitor = struct { } } }; + +const testing = @import("testing.zig"); + +test "SemanticTree backendDOMNodeId" { + var registry: CDPNode.Registry = .init(testing.allocator); + defer registry.deinit(); + + var page = try testing.pageTest("cdp/registry1.html"); + defer testing.reset(); + defer page._session.removePage(); + + const st: Self = .{ + .dom_node = page.window._document.asNode(), + .registry = ®istry, + .page = page, + .arena = testing.arena_allocator, + .prune = false, + .interactive_only = false, + .max_depth = std.math.maxInt(u32) - 1, + }; + + const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{}); + defer testing.allocator.free(json_str); + + try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null); +} + +test "SemanticTree max_depth" { + var registry: CDPNode.Registry = .init(testing.allocator); + defer registry.deinit(); + + var page = try testing.pageTest("cdp/registry1.html"); + defer testing.reset(); + defer page._session.removePage(); + + const st: Self = .{ + .dom_node = page.window._document.asNode(), + .registry = ®istry, + .page = page, + .arena = testing.arena_allocator, + .prune = false, + .interactive_only = false, + .max_depth = 1, + }; + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + + try st.textStringify(&aw.writer); + const text_str = aw.written(); + + try testing.expect(std.mem.indexOf(u8, text_str, "other") == null); +} diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index a45b35df..f913dafd 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -1212,6 +1212,12 @@ pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise { return resolver.promise(); } +pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise { + var resolver = js.PromiseResolver.init(self); + resolver.rejectError("Local.rejectPromise", value); + return resolver.promise(); +} + pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise { var resolver = js.PromiseResolver.init(self); resolver.resolve("Local.resolvePromise", value); diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig index 67f04311..6386569a 100644 --- a/src/browser/js/PromiseResolver.zig +++ b/src/browser/js/PromiseResolver.zig @@ -18,7 +18,9 @@ const js = @import("js.zig"); const v8 = js.v8; + const log = @import("../../log.zig"); +const DOMException = @import("../webapi/DOMException.zig"); const PromiseResolver = @This(); @@ -63,14 +65,19 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype }; } -const RejectError = union(enum) { +pub const RejectError = union(enum) { generic: []const u8, type_error: []const u8, + dom_exception: anyerror, }; pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void { const handle = switch (err) { .type_error => |str| self.local.isolate.createTypeError(str), .generic => |str| self.local.isolate.createError(str), + .dom_exception => |exception| { + self.reject(source, DOMException.fromError(exception)); + return; + }, }; self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| { log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false }); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 4fc96b7e..2f5d0bb9 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -725,6 +725,8 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/collections.zig"), @import("../webapi/Console.zig"), @import("../webapi/Crypto.zig"), + @import("../webapi/Permissions.zig"), + @import("../webapi/StorageManager.zig"), @import("../webapi/CSS.zig"), @import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRuleList.zig"), diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 8a4984a4..f4a0f874 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -124,352 +124,362 @@ fn hasVisibleContent(root: *Node) bool { return false; } -fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - state.last_char_was_newline = true; +const Context = struct { + state: State, + writer: *std.Io.Writer, + page: *Page, + + fn ensureNewline(self: *Context) !void { + if (!self.state.last_char_was_newline) { + try self.writer.writeByte('\n'); + self.state.last_char_was_newline = true; + } } -} + + fn render(self: *Context, node: *Node) error{WriteFailed}!void { + switch (node._type) { + .document, .document_fragment => { + try self.renderChildren(node); + }, + .element => |el| { + try self.renderElement(el); + }, + .cdata => |cd| { + if (node.is(Node.CData.Text)) |_| { + var text = cd.getData().str(); + if (self.state.pre_node) |pre| { + if (node.parentNode() == pre and node.nextSibling() == null) { + text = std.mem.trimRight(u8, text, " \t\r\n"); + } + } + try self.renderText(text); + } + }, + else => {}, + } + } + + fn renderChildren(self: *Context, parent: *Node) !void { + var it = parent.childrenIterator(); + while (it.next()) |child| { + try self.render(child); + } + } + + fn renderElement(self: *Context, el: *Element) !void { + const tag = el.getTag(); + + if (!isVisibleElement(el)) return; + + // --- Opening Tag Logic --- + + // Ensure block elements start on a new line (double newline for paragraphs etc) + if (tag.isBlock() and !self.state.in_table) { + try self.ensureNewline(); + if (shouldAddSpacing(tag)) { + try self.writer.writeByte('\n'); + } + } else if (tag == .li or tag == .tr) { + try self.ensureNewline(); + } + + // Prefixes + switch (tag) { + .h1 => try self.writer.writeAll("# "), + .h2 => try self.writer.writeAll("## "), + .h3 => try self.writer.writeAll("### "), + .h4 => try self.writer.writeAll("#### "), + .h5 => try self.writer.writeAll("##### "), + .h6 => try self.writer.writeAll("###### "), + .ul => { + if (self.state.list_depth < self.state.list_stack.len) { + self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 }; + self.state.list_depth += 1; + } + }, + .ol => { + if (self.state.list_depth < self.state.list_stack.len) { + self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 }; + self.state.list_depth += 1; + } + }, + .li => { + const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0; + for (0..indent) |_| try self.writer.writeAll(" "); + + if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) { + const current_list = &self.state.list_stack[self.state.list_depth - 1]; + try self.writer.print("{d}. ", .{current_list.index}); + current_list.index += 1; + } else { + try self.writer.writeAll("- "); + } + self.state.last_char_was_newline = false; + }, + .table => { + self.state.in_table = true; + self.state.table_row_index = 0; + self.state.table_col_count = 0; + }, + .tr => { + self.state.table_col_count = 0; + try self.writer.writeByte('|'); + }, + .td, .th => { + // Note: leading pipe handled by previous cell closing or tr opening + self.state.last_char_was_newline = false; + try self.writer.writeByte(' '); + }, + .blockquote => { + try self.writer.writeAll("> "); + self.state.last_char_was_newline = false; + }, + .pre => { + try self.writer.writeAll("```\n"); + self.state.pre_node = el.asNode(); + self.state.last_char_was_newline = true; + }, + .code => { + if (self.state.pre_node == null) { + try self.writer.writeByte('`'); + self.state.in_code = true; + self.state.last_char_was_newline = false; + } + }, + .b, .strong => { + try self.writer.writeAll("**"); + self.state.last_char_was_newline = false; + }, + .i, .em => { + try self.writer.writeAll("*"); + self.state.last_char_was_newline = false; + }, + .s, .del => { + try self.writer.writeAll("~~"); + self.state.last_char_was_newline = false; + }, + .hr => { + try self.writer.writeAll("---\n"); + self.state.last_char_was_newline = true; + return; + }, + .br => { + if (self.state.in_table) { + try self.writer.writeByte(' '); + } else { + try self.writer.writeByte('\n'); + self.state.last_char_was_newline = true; + } + return; + }, + .img => { + try self.writer.writeAll("!["); + if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| { + try self.escape(alt); + } + try self.writer.writeAll("]("); + if (el.getAttributeSafe(comptime .wrap("src"))) |src| { + const absolute_src = URL.resolve(self.page.call_arena, self.page.base(), src, .{ .encode = true }) catch src; + try self.writer.writeAll(absolute_src); + } + try self.writer.writeAll(")"); + self.state.last_char_was_newline = false; + return; + }, + .anchor => { + const has_content = hasVisibleContent(el.asNode()); + const label = getAnchorLabel(el); + const href_raw = el.getAttributeSafe(comptime .wrap("href")); + + if (!has_content and label == null and href_raw == null) return; + + const has_block = hasBlockDescendant(el.asNode()); + const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null; + + if (has_block) { + try self.renderChildren(el.asNode()); + if (href) |h| { + if (!self.state.last_char_was_newline) try self.writer.writeByte('\n'); + try self.writer.writeAll("([]("); + try self.writer.writeAll(h); + try self.writer.writeAll("))\n"); + self.state.last_char_was_newline = true; + } + return; + } + + if (isStandaloneAnchor(el)) { + if (!self.state.last_char_was_newline) try self.writer.writeByte('\n'); + try self.writer.writeByte('['); + if (has_content) { + try self.renderChildren(el.asNode()); + } else { + try self.writer.writeAll(label orelse ""); + } + try self.writer.writeAll("]("); + if (href) |h| { + try self.writer.writeAll(h); + } + try self.writer.writeAll(")\n"); + self.state.last_char_was_newline = true; + return; + } + + try self.writer.writeByte('['); + if (has_content) { + try self.renderChildren(el.asNode()); + } else { + try self.writer.writeAll(label orelse ""); + } + try self.writer.writeAll("]("); + if (href) |h| { + try self.writer.writeAll(h); + } + try self.writer.writeByte(')'); + self.state.last_char_was_newline = false; + return; + }, + .input => { + const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return; + if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) { + const checked = el.getAttributeSafe(comptime .wrap("checked")) != null; + try self.writer.writeAll(if (checked) "[x] " else "[ ] "); + self.state.last_char_was_newline = false; + } + return; + }, + else => {}, + } + + // --- Render Children --- + try self.renderChildren(el.asNode()); + + // --- Closing Tag Logic --- + + // Suffixes + switch (tag) { + .pre => { + if (!self.state.last_char_was_newline) { + try self.writer.writeByte('\n'); + } + try self.writer.writeAll("```\n"); + self.state.pre_node = null; + self.state.last_char_was_newline = true; + }, + .code => { + if (self.state.pre_node == null) { + try self.writer.writeByte('`'); + self.state.in_code = false; + self.state.last_char_was_newline = false; + } + }, + .b, .strong => { + try self.writer.writeAll("**"); + self.state.last_char_was_newline = false; + }, + .i, .em => { + try self.writer.writeAll("*"); + self.state.last_char_was_newline = false; + }, + .s, .del => { + try self.writer.writeAll("~~"); + self.state.last_char_was_newline = false; + }, + .blockquote => {}, + .ul, .ol => { + if (self.state.list_depth > 0) self.state.list_depth -= 1; + }, + .table => { + self.state.in_table = false; + }, + .tr => { + try self.writer.writeByte('\n'); + if (self.state.table_row_index == 0) { + try self.writer.writeByte('|'); + for (0..self.state.table_col_count) |_| { + try self.writer.writeAll("---|"); + } + try self.writer.writeByte('\n'); + } + self.state.table_row_index += 1; + self.state.last_char_was_newline = true; + }, + .td, .th => { + try self.writer.writeAll(" |"); + self.state.table_col_count += 1; + self.state.last_char_was_newline = false; + }, + else => {}, + } + + // Post-block newlines + if (tag.isBlock() and !self.state.in_table) { + try self.ensureNewline(); + } + } + + fn renderText(self: *Context, text: []const u8) !void { + if (text.len == 0) return; + + if (self.state.pre_node) |_| { + try self.writer.writeAll(text); + self.state.last_char_was_newline = text[text.len - 1] == '\n'; + return; + } + + // Check for pure whitespace + if (isAllWhitespace(text)) { + if (!self.state.last_char_was_newline) { + try self.writer.writeByte(' '); + } + return; + } + + // Collapse whitespace + var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); + var first = true; + while (it.next()) |word| { + if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) { + try self.writer.writeByte(' '); + } + + try self.escape(word); + self.state.last_char_was_newline = false; + first = false; + } + + // Handle trailing whitespace from the original text + if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) { + try self.writer.writeByte(' '); + } + } + + fn escape(self: *Context, text: []const u8) !void { + for (text) |c| { + switch (c) { + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => { + try self.writer.writeByte('\\'); + try self.writer.writeByte(c); + }, + else => try self.writer.writeByte(c), + } + } + } +}; pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { _ = opts; - var state = State{}; - try render(node, &state, writer, page); - if (!state.last_char_was_newline) { + var ctx: Context = .{ + .state = .{}, + .writer = writer, + .page = page, + }; + try ctx.render(node); + if (!ctx.state.last_char_was_newline) { try writer.writeByte('\n'); } } -fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void { - switch (node._type) { - .document, .document_fragment => { - try renderChildren(node, state, writer, page); - }, - .element => |el| { - try renderElement(el, state, writer, page); - }, - .cdata => |cd| { - if (node.is(Node.CData.Text)) |_| { - var text = cd.getData().str(); - if (state.pre_node) |pre| { - if (node.parentNode() == pre and node.nextSibling() == null) { - text = std.mem.trimRight(u8, text, " \t\r\n"); - } - } - try renderText(text, state, writer); - } - }, - else => {}, - } -} - -fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void { - var it = parent.childrenIterator(); - while (it.next()) |child| { - try render(child, state, writer, page); - } -} - -fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void { - const tag = el.getTag(); - - if (!isVisibleElement(el)) return; - - // --- Opening Tag Logic --- - - // Ensure block elements start on a new line (double newline for paragraphs etc) - if (tag.isBlock() and !state.in_table) { - try ensureNewline(state, writer); - if (shouldAddSpacing(tag)) { - try writer.writeByte('\n'); - } - } else if (tag == .li or tag == .tr) { - try ensureNewline(state, writer); - } - - // Prefixes - switch (tag) { - .h1 => try writer.writeAll("# "), - .h2 => try writer.writeAll("## "), - .h3 => try writer.writeAll("### "), - .h4 => try writer.writeAll("#### "), - .h5 => try writer.writeAll("##### "), - .h6 => try writer.writeAll("###### "), - .ul => { - if (state.list_depth < state.list_stack.len) { - state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 }; - state.list_depth += 1; - } - }, - .ol => { - if (state.list_depth < state.list_stack.len) { - state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 }; - state.list_depth += 1; - } - }, - .li => { - const indent = if (state.list_depth > 0) state.list_depth - 1 else 0; - for (0..indent) |_| try writer.writeAll(" "); - - if (state.list_depth > 0 and state.list_stack[state.list_depth - 1].type == .ordered) { - const current_list = &state.list_stack[state.list_depth - 1]; - try writer.print("{d}. ", .{current_list.index}); - current_list.index += 1; - } else { - try writer.writeAll("- "); - } - state.last_char_was_newline = false; - }, - .table => { - state.in_table = true; - state.table_row_index = 0; - state.table_col_count = 0; - }, - .tr => { - state.table_col_count = 0; - try writer.writeByte('|'); - }, - .td, .th => { - // Note: leading pipe handled by previous cell closing or tr opening - state.last_char_was_newline = false; - try writer.writeByte(' '); - }, - .blockquote => { - try writer.writeAll("> "); - state.last_char_was_newline = false; - }, - .pre => { - try writer.writeAll("```\n"); - state.pre_node = el.asNode(); - state.last_char_was_newline = true; - }, - .code => { - if (state.pre_node == null) { - try writer.writeByte('`'); - state.in_code = true; - state.last_char_was_newline = false; - } - }, - .b, .strong => { - try writer.writeAll("**"); - state.last_char_was_newline = false; - }, - .i, .em => { - try writer.writeAll("*"); - state.last_char_was_newline = false; - }, - .s, .del => { - try writer.writeAll("~~"); - state.last_char_was_newline = false; - }, - .hr => { - try writer.writeAll("---\n"); - state.last_char_was_newline = true; - return; - }, - .br => { - if (state.in_table) { - try writer.writeByte(' '); - } else { - try writer.writeByte('\n'); - state.last_char_was_newline = true; - } - return; - }, - .img => { - try writer.writeAll("!["); - if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| { - try escapeMarkdown(writer, alt); - } - try writer.writeAll("]("); - if (el.getAttributeSafe(comptime .wrap("src"))) |src| { - const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src; - try writer.writeAll(absolute_src); - } - try writer.writeAll(")"); - state.last_char_was_newline = false; - return; - }, - .anchor => { - const has_content = hasVisibleContent(el.asNode()); - const label = getAnchorLabel(el); - const href_raw = el.getAttributeSafe(comptime .wrap("href")); - - if (!has_content and label == null and href_raw == null) return; - - const has_block = hasBlockDescendant(el.asNode()); - const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null; - - if (has_block) { - try renderChildren(el.asNode(), state, writer, page); - if (href) |h| { - if (!state.last_char_was_newline) try writer.writeByte('\n'); - try writer.writeAll("([]("); - try writer.writeAll(h); - try writer.writeAll("))\n"); - state.last_char_was_newline = true; - } - return; - } - - if (isStandaloneAnchor(el)) { - if (!state.last_char_was_newline) try writer.writeByte('\n'); - try writer.writeByte('['); - if (has_content) { - try renderChildren(el.asNode(), state, writer, page); - } else { - try writer.writeAll(label orelse ""); - } - try writer.writeAll("]("); - if (href) |h| { - try writer.writeAll(h); - } - try writer.writeAll(")\n"); - state.last_char_was_newline = true; - return; - } - - try writer.writeByte('['); - if (has_content) { - try renderChildren(el.asNode(), state, writer, page); - } else { - try writer.writeAll(label orelse ""); - } - try writer.writeAll("]("); - if (href) |h| { - try writer.writeAll(h); - } - try writer.writeByte(')'); - state.last_char_was_newline = false; - return; - }, - .input => { - const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return; - if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) { - const checked = el.getAttributeSafe(comptime .wrap("checked")) != null; - try writer.writeAll(if (checked) "[x] " else "[ ] "); - state.last_char_was_newline = false; - } - return; - }, - else => {}, - } - - // --- Render Children --- - try renderChildren(el.asNode(), state, writer, page); - - // --- Closing Tag Logic --- - - // Suffixes - switch (tag) { - .pre => { - if (!state.last_char_was_newline) { - try writer.writeByte('\n'); - } - try writer.writeAll("```\n"); - state.pre_node = null; - state.last_char_was_newline = true; - }, - .code => { - if (state.pre_node == null) { - try writer.writeByte('`'); - state.in_code = false; - state.last_char_was_newline = false; - } - }, - .b, .strong => { - try writer.writeAll("**"); - state.last_char_was_newline = false; - }, - .i, .em => { - try writer.writeAll("*"); - state.last_char_was_newline = false; - }, - .s, .del => { - try writer.writeAll("~~"); - state.last_char_was_newline = false; - }, - .blockquote => {}, - .ul, .ol => { - if (state.list_depth > 0) state.list_depth -= 1; - }, - .table => { - state.in_table = false; - }, - .tr => { - try writer.writeByte('\n'); - if (state.table_row_index == 0) { - try writer.writeByte('|'); - for (0..state.table_col_count) |_| { - try writer.writeAll("---|"); - } - try writer.writeByte('\n'); - } - state.table_row_index += 1; - state.last_char_was_newline = true; - }, - .td, .th => { - try writer.writeAll(" |"); - state.table_col_count += 1; - state.last_char_was_newline = false; - }, - else => {}, - } - - // Post-block newlines - if (tag.isBlock() and !state.in_table) { - try ensureNewline(state, writer); - } -} - -fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void { - if (text.len == 0) return; - - if (state.pre_node) |_| { - try writer.writeAll(text); - state.last_char_was_newline = text[text.len - 1] == '\n'; - return; - } - - // Check for pure whitespace - if (isAllWhitespace(text)) { - if (!state.last_char_was_newline) { - try writer.writeByte(' '); - } - return; - } - - // Collapse whitespace - var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); - var first = true; - while (it.next()) |word| { - if (!first or (!state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) { - try writer.writeByte(' '); - } - - try escapeMarkdown(writer, word); - state.last_char_was_newline = false; - first = false; - } - - // Handle trailing whitespace from the original text - if (!first and !state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) { - try writer.writeByte(' '); - } -} - -fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void { - for (text) |c| { - switch (c) { - '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => { - try writer.writeByte('\\'); - try writer.writeByte(c); - }, - else => try writer.writeByte(c), - } - } -} - fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { const testing = @import("../testing.zig"); const page = try testing.test_session.createPage(); diff --git a/src/browser/tests/window/navigator.html b/src/browser/tests/navigator/navigator.html similarity index 56% rename from src/browser/tests/window/navigator.html rename to src/browser/tests/navigator/navigator.html index df9f436a..b82c6e38 100644 --- a/src/browser/tests/window/navigator.html +++ b/src/browser/tests/navigator/navigator.html @@ -27,3 +27,44 @@ testing.expectEqual(false, navigator.javaEnabled()); testing.expectEqual(false, navigator.webdriver); + + + + + + + + + diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index 10ce6677..229d8469 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -225,3 +225,17 @@ URL.revokeObjectURL(blobUrl); }); + + diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 5e69db07..218110c9 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -18,13 +18,21 @@ const std = @import("std"); const builtin = @import("builtin"); + +const log = @import("../../log.zig"); + const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); + const PluginArray = @import("PluginArray.zig"); +const Permissions = @import("Permissions.zig"); +const StorageManager = @import("StorageManager.zig"); const Navigator = @This(); _pad: bool = false, _plugins: PluginArray = .{}, +_permissions: Permissions = .{}, +_storage: StorageManager = .{}, pub const init: Navigator = .{}; @@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray { return &self._plugins; } +pub fn getPermissions(self: *Navigator) *Permissions { + return &self._permissions; +} + +pub fn getStorage(self: *Navigator) *StorageManager { + return &self._storage; +} + +pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise { + log.info(.not_implemented, "navigator.getBattery", .{}); + return page.js.local.?.rejectErrorPromise(.{ .dom_exception = error.NotSupported }); +} + pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void { try validateProtocolHandlerScheme(scheme); try validateProtocolHandlerURL(url, page); @@ -144,6 +165,7 @@ pub const JsApi = struct { pub const onLine = bridge.property(true, .{ .template = false }); pub const cookieEnabled = bridge.property(true, .{ .template = false }); pub const hardwareConcurrency = bridge.property(4, .{ .template = false }); + pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false }); pub const maxTouchPoints = bridge.property(0, .{ .template = false }); pub const vendor = bridge.property("", .{ .template = false }); pub const product = bridge.property("Gecko", .{ .template = false }); @@ -156,4 +178,12 @@ pub const JsApi = struct { // Methods pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); + pub const getBattery = bridge.function(Navigator.getBattery, .{}); + pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{}); + pub const storage = bridge.accessor(Navigator.getStorage, null, .{}); }; + +const testing = @import("../../testing.zig"); +test "WebApi: Navigator" { + try testing.htmlRunner("navigator", .{}); +} diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig new file mode 100644 index 00000000..ee197d3f --- /dev/null +++ b/src/browser/webapi/Permissions.zig @@ -0,0 +1,94 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Session = @import("../Session.zig"); + +const Allocator = std.mem.Allocator; + +pub fn registerTypes() []const type { + return &.{ Permissions, PermissionStatus }; +} + +const Permissions = @This(); + +// Padding to avoid zero-size struct pointer collisions +_pad: bool = false, + +const QueryDescriptor = struct { + name: []const u8, +}; +// We always report 'prompt' (the default safe value — neither granted nor denied). +pub fn query(_: *const Permissions, qd: QueryDescriptor, page: *Page) !js.Promise { + const arena = try page.getArena(.{ .debug = "PermissionStatus" }); + errdefer page.releaseArena(arena); + + const status = try arena.create(PermissionStatus); + status.* = .{ + ._arena = arena, + ._state = "prompt", + ._name = try arena.dupe(u8, qd.name), + }; + return page.js.local.?.resolvePromise(status); +} + +const PermissionStatus = struct { + _arena: Allocator, + _name: []const u8, + _state: []const u8, + + pub fn deinit(self: *PermissionStatus, _: bool, session: *Session) void { + session.releaseArena(self._arena); + } + + fn getName(self: *const PermissionStatus) []const u8 { + return self._name; + } + + fn getState(self: *const PermissionStatus) []const u8 { + return self._state; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(PermissionStatus); + pub const Meta = struct { + pub const name = "PermissionStatus"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(PermissionStatus.deinit); + }; + pub const name = bridge.accessor(getName, null, .{}); + pub const state = bridge.accessor(getState, null, .{}); + }; +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(Permissions); + + pub const Meta = struct { + pub const name = "Permissions"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const query = bridge.function(Permissions.query, .{ .dom_exception = true }); +}; diff --git a/src/browser/webapi/StorageManager.zig b/src/browser/webapi/StorageManager.zig new file mode 100644 index 00000000..e7b95cc4 --- /dev/null +++ b/src/browser/webapi/StorageManager.zig @@ -0,0 +1,71 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); + +pub fn registerTypes() []const type { + return &.{ StorageManager, StorageEstimate }; +} + +const StorageManager = @This(); + +_pad: bool = false, + +pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise { + const est = try page._factory.create(StorageEstimate{ + ._usage = 0, + ._quota = 1024 * 1024 * 1024, // 1 GiB + }); + return page.js.local.?.resolvePromise(est); +} + +const StorageEstimate = struct { + _quota: u64, + _usage: u64, + + fn getUsage(self: *const StorageEstimate) u64 { + return self._usage; + } + + fn getQuota(self: *const StorageEstimate) u64 { + return self._quota; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(StorageEstimate); + pub const Meta = struct { + pub const name = "StorageEstimate"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const quota = bridge.accessor(getQuota, null, .{}); + pub const usage = bridge.accessor(getUsage, null, .{}); + }; +}; + +pub const JsApi = struct { + pub const bridge = js.Bridge(StorageManager); + pub const Meta = struct { + pub const name = "StorageManager"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + pub const estimate = bridge.function(StorageManager.estimate, .{}); +}; diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 7ab7b8ec..0a44aae2 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -28,6 +28,8 @@ const URL = @import("../../URL.zig"); const Blob = @import("../Blob.zig"); const Request = @import("Request.zig"); const Response = @import("Response.zig"); +const AbortSignal = @import("../AbortSignal.zig"); +const DOMException = @import("../DOMException.zig"); const IS_DEBUG = @import("builtin").mode == .Debug; @@ -39,6 +41,7 @@ _buf: std.ArrayList(u8), _response: *Response, _resolver: js.PromiseResolver.Global, _owns_response: bool, +_signal: ?*AbortSignal, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; @@ -47,6 +50,13 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { const request = try Request.init(input, options, page); const resolver = page.js.local.?.createPromiseResolver(); + if (request._signal) |signal| { + if (signal._aborted) { + resolver.reject("fetch aborted", DOMException.init("The operation was aborted.", "AbortError")); + return resolver.promise(); + } + } + if (std.mem.startsWith(u8, request._url, "blob:")) { return handleBlobUrl(request._url, resolver, page); } @@ -62,6 +72,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { ._resolver = try resolver.persist(), ._response = response, ._owns_response = true, + ._signal = request._signal, }; const http_client = page._session.browser.http_client; @@ -126,6 +137,12 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void { fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); + if (self._signal) |signal| { + if (signal._aborted) { + return false; + } + } + const arena = self._response._arena; if (transfer.getContentLength()) |cl| { try self._buf.ensureTotalCapacity(arena, cl); @@ -175,6 +192,14 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); + + // Check if aborted + if (self._signal) |signal| { + if (signal._aborted) { + return error.Abort; + } + } + try self._buf.appendSlice(self._response._arena, data); } diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 3d3ca825..6ba8f224 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -25,6 +25,7 @@ const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); const Headers = @import("Headers.zig"); const Blob = @import("../Blob.zig"); +const AbortSignal = @import("../AbortSignal.zig"); const Allocator = std.mem.Allocator; const Request = @This(); @@ -36,6 +37,7 @@ _body: ?[]const u8, _arena: Allocator, _cache: Cache, _credentials: Credentials, +_signal: ?*AbortSignal, pub const Input = union(enum) { request: *Request, @@ -48,6 +50,7 @@ pub const InitOpts = struct { body: ?[]const u8 = null, cache: Cache = .default, credentials: Credentials = .@"same-origin", + signal: ?*AbortSignal = null, }; const Credentials = enum { @@ -97,6 +100,13 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { .request => |r| r._body, }; + const signal = if (opts.signal) |s| + s + else switch (input) { + .url => null, + .request => |r| r._signal, + }; + return page._factory.create(Request{ ._url = url, ._arena = arena, @@ -105,6 +115,7 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request { ._cache = opts.cache, ._credentials = opts.credentials, ._body = body, + ._signal = signal, }); } @@ -144,6 +155,10 @@ pub fn getCredentials(self: *const Request) []const u8 { return @tagName(self._credentials); } +pub fn getSignal(self: *const Request) ?*AbortSignal { + return self._signal; +} + pub fn getHeaders(self: *Request, page: *Page) !*Headers { if (self._headers) |headers| { return headers; @@ -200,6 +215,7 @@ pub fn clone(self: *const Request, page: *Page) !*Request { ._cache = self._cache, ._credentials = self._credentials, ._body = self._body, + ._signal = self._signal, }); } @@ -218,6 +234,7 @@ pub const JsApi = struct { pub const headers = bridge.accessor(Request.getHeaders, null, .{}); pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); + pub const signal = bridge.accessor(Request.getSignal, null, .{}); pub const blob = bridge.function(Request.blob, .{}); pub const text = bridge.function(Request.text, .{}); pub const json = bridge.function(Request.json, .{}); diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index efbf9ec7..51b3249d 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -53,12 +53,18 @@ fn getSemanticTree(cmd: anytype) !void { format: ?enum { text } = null, prune: ?bool = null, interactiveOnly: ?bool = null, + backendNodeId: ?Node.Id = null, + maxDepth: ?u32 = 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 dom_node = page.document.asNode(); + + const dom_node = if (params.backendNodeId) |nodeId| + (bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom + else + page.document.asNode(); var st = SemanticTree{ .dom_node = dom_node, @@ -67,6 +73,7 @@ fn getSemanticTree(cmd: anytype) !void { .arena = cmd.arena, .prune = params.prune orelse true, .interactive_only = params.interactiveOnly orelse false, + .max_depth = params.maxDepth orelse std.math.maxInt(u32) - 1, }; if (params.format) |format| { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index d8fd4ead..aefcca83 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -70,7 +70,9 @@ pub const tool_list = [_]protocol.Tool{ \\{ \\ "type": "object", \\ "properties": { - \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." } + \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." }, + \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." }, + \\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." } \\ } \\} ), @@ -161,6 +163,8 @@ const ToolStreamingText = struct { action: enum { markdown, links, semantic_tree }, registry: ?*CDPNode.Registry = null, arena: ?std.mem.Allocator = null, + backendNodeId: ?u32 = null, + maxDepth: ?u32 = null, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { try jw.beginWriteRaw(); @@ -196,12 +200,24 @@ const ToolStreamingText = struct { } }, .semantic_tree => { + var root_node = self.page.document.asNode(); + if (self.backendNodeId) |node_id| { + if (self.registry) |registry| { + if (registry.lookup_by_id.get(node_id)) |n| { + root_node = n.dom; + } else { + log.warn(.mcp, "semantic_tree id missing", .{ .id = node_id }); + } + } + } + const st = lp.SemanticTree{ - .dom_node = self.page.document.asNode(), + .dom_node = root_node, .registry = self.registry.?, .page = self.page, .arena = self.arena.?, .prune = true, + .max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1, }; st.textStringify(w) catch |err| { @@ -328,9 +344,13 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const TreeParams = struct { url: ?[:0]const u8 = null, + backendNodeId: ?u32 = null, + maxDepth: ?u32 = null, }; + var tree_args: TreeParams = .{}; if (arguments) |args_raw| { if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + tree_args = args; if (args.url) |u| { try performGoto(server, u, id); } @@ -341,7 +361,7 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va }; const content = [_]protocol.TextContent(ToolStreamingText){.{ - .text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena }, + .text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); }