From aeba861d69c56912d28740fc0fe7ae5a1b46e07d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 22 May 2026 20:16:09 +0800 Subject: [PATCH 1/6] Add WebDriver getComputedLabel Used by https://github.com/lightpanda-io/wpt/pull/68 Helps many /accname/ WPT tests pass --- src/browser/webapi/WebDriver.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 9b73d819..e21514d6 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -20,6 +20,9 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Frame = @import("../Frame.zig"); + +const Element = @import("Element.zig"); // This type is only included when the binary is built with the -Dwpt_extensions flag const WebDriver = @This(); @@ -30,6 +33,12 @@ pub fn deleteAllCookies(_: *const WebDriver, page: *Page) void { page.session.cookie_jar.clearRetainingCapacity(); } +pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ![]const u8 { + const AXNode = @import("../../cdp/AXNode.zig"); + const axnode = AXNode.fromNode(element.asNode()); + return (try axnode.getName(frame, frame.call_arena)) orelse ""; +} + pub const JsApi = struct { pub const bridge = js.Bridge(WebDriver); @@ -40,4 +49,5 @@ pub const JsApi = struct { pub const empty_with_no_proto = true; }; pub const deleteAllCookies = bridge.function(WebDriver.deleteAllCookies, .{}); + pub const getComputedLabel = bridge.function(WebDriver.getComputedLabel, .{}); }; From 6df1dfe2380208bba33ca845aea8481cc6bbe5e9 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Fri, 22 May 2026 15:41:15 +0200 Subject: [PATCH 2/6] Replace active page on synthetic root navigation (about:blank, blob:) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page.navigate("about:blank") (and blob:) issued against a non-blank tab routed through Session.initiateRootNavigation, which always allocated a pending Page. A pending Page is promoted to active only by frameHeaderDoneCallback when HTTP response headers arrive — but synthetic navigations ZIGFLAGS= make no HTTP request, so the pending Page was never committed and the previous document stayed active (window.location.href, document.URL and Page.getFrameTree all kept reporting the old page). Route synthetic URLs through the existing immediate-swap path (replaceRootImmediate) from initiateRootNavigation, mirroring what processRootQueuedNavigation already does for JS-initiated synthetic navigations. replaceRootImmediate now takes (frame_id, url, opts) so both call sites share it. Fixes #2363 --- src/browser/Session.zig | 29 +++++++++++++++++++---------- src/cdp/domains/page.zig | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index f388b0fb..1b585f96 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -489,7 +489,8 @@ fn processRootQueuedNavigation(self: *Session) !void { const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:"); if (is_synthetic) { - return self.replaceRootImmediate(current_frame._frame_id, qn); + defer self.arena_pool.release(qn.arena); + return self.replaceRootImmediate(current_frame._frame_id, qn.url, qn.opts); } // The qn arena is consumed here regardless of success — frame.navigate @@ -500,18 +501,18 @@ fn processRootQueuedNavigation(self: *Session) !void { return self.initiateRootNavigation(current_frame._frame_id, qn.url, qn.opts); } -// Legacy immediate-swap path: tear down the active page and create a new one -// in its place before issuing the navigation. Used for synthetic navigations -// (about:blank, blob:) where there is no in-flight HTTP and therefore no -// "pending" window to span. -fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !void { - defer self.arena_pool.release(qn.arena); - +// Immediate-swap path for synthetic navigations (about:blank, blob:): there is +// no in-flight HTTP and therefore no "pending" window to span — and no +// frameHeaderDoneCallback to commit a pending Page. Tear down the active page +// and create a new one in its place, then navigate it. Reached from both the +// queued-navigation path (processRootQueuedNavigation) and the CDP entry point +// (initiateRootNavigation); each caller owns any arena tied to `url`/`opts`. +fn replaceRootImmediate(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void { self.tearDownActivePage(); const new_frame = try self.installNewActivePage(frame_id); - new_frame.navigate(qn.url, qn.opts) catch |err| { - log.err(.browser, "queued navigation error", .{ .err = err }); + new_frame.navigate(url, opts) catch |err| { + log.err(.browser, "synthetic navigation error", .{ .err = err, .url = url }); return err; }; } @@ -524,6 +525,14 @@ fn replaceRootImmediate(self: *Session, frame_id: u32, qn: *QueuedNavigation) !v pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, opts: Frame.NavigateOpts) !void { self.discardPendingPage(); + // Synthetic navigations (about:blank, blob:) have no HTTP round-trip and + // therefore no frameHeaderDoneCallback to commit a pending Page. Swap the + // active Page immediately instead of allocating a pending one that would + // never be promoted, leaving the previous document in place (issue #2363). + if (std.mem.eql(u8, "about:blank", url) or std.mem.startsWith(u8, url, "blob:")) { + return self.replaceRootImmediate(frame_id, url, opts); + } + const page = try self.allocatePage(frame_id); errdefer self.destroyPage(page); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index bb0274e1..f84e496f 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -1309,6 +1309,42 @@ test "cdp.frame: navigate inherits original fragment across redirect" { } } +test "cdp.frame: navigate to about:blank replaces a non-blank document" { + // Regression test for #2363. Page.navigate("about:blank") issued against a + // tab that already holds a real document must replace the active document + // with a fresh about:blank page — not leave the previous page in place. + // A synthetic (no-HTTP) navigation has no response-headers callback to + // commit a pending Page, so it must swap the active Page immediately. + var ctx = try testing.context(); + defer ctx.deinit(); + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-AB", .url = "hi.html", .target_id = "TID-AB-0000000".* }); + + // Precondition: the tab is on a non-blank document. + { + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expect(std.mem.endsWith(u8, frame.url, "/hi.html")); + } + + try ctx.processMessage(.{ .id = 70, .method = "Page.navigate", .params = .{ .url = "about:blank" } }); + { + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + } + + // The active frame must now point at the replaced about:blank document. + const frame = bc.session.currentFrame() orelse unreachable; + try testing.expectEqualSlices(u8, "about:blank", frame.url); + + // ...and the active page's JS context must agree — the exact symptom in the + // bug report was window.location.href staying on the previous URL. + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + const v = try ls.local.exec("window.location.href === 'about:blank'", null); + try testing.expect(v.toBool()); +} + test "cdp.frame: anchor click sends Referer matching the originating page" { // HTML Living Standard "navigate" algorithm + Fetch §4.5 "request's referrer": // when a navigation is initiated by a hyperlink click (or form submit, or From 2d48926f1eec1bf29a6443bb029db15e2106f782 Mon Sep 17 00:00:00 2001 From: willmafh Date: Fri, 22 May 2026 22:24:11 +0800 Subject: [PATCH 3/6] bugfix: get scheme correctly when there is any leading whitespace --- src/network/http.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/network/http.zig b/src/network/http.zig index 8c3fba2c..bec25af5 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -204,8 +204,9 @@ pub const AuthChallenge = struct { .scheme = null, }; - const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, value, std.ascii.whitespace[0..]), 0, " ") orelse value.len; - const _scheme = value[0..pos]; + const challenge_value = std.mem.trim(u8, value, std.ascii.whitespace[0..]); + const pos = std.mem.indexOfPos(u8, challenge_value, 0, " ") orelse challenge_value.len; + const _scheme = challenge_value[0..pos]; if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { ac.scheme = .basic; } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { From a7e3bea672eeb2efb68d3af78d2a86310dfd56c8 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Sun, 24 May 2026 13:04:33 +0530 Subject: [PATCH 4/6] feat(webapi): implement W3C File API --- src/browser/tests/file.html | 50 +++++++++++++++++++++++++++---- src/browser/webapi/Blob.zig | 4 +-- src/browser/webapi/File.zig | 60 +++++++++++++++++++++++++++++++++---- 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/browser/tests/file.html b/src/browser/tests/file.html index 3db5fdfe..0b301b79 100644 --- a/src/browser/tests/file.html +++ b/src/browser/tests/file.html @@ -1,12 +1,52 @@ - Test Document Title + Test File Web API - + + + + diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 2bead21d..9cc0b4f6 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -113,7 +113,7 @@ pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: } /// Validates and normalizes MIME type according to spec. -fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 { +pub fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 { if (mime_type.len == 0) { return ""; } @@ -171,7 +171,7 @@ const vector_sizes = blk: { }; /// Writes a single part with optional line ending normalization. -fn writePartWithEndings(part: []const u8, use_native_endings: bool, writer: *Writer) !void { +pub fn writePartWithEndings(part: []const u8, use_native_endings: bool, writer: *Writer) !void { // Transparent - no conversion needed. if (!use_native_endings) { try writer.writeAll(part); diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index a0dce126..a740ed04 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -27,13 +27,61 @@ const Blob = @import("Blob.zig"); const File = @This(); _proto: *Blob, +_name: []const u8, +_last_modified: i64, -// TODO: Implement File API. -pub fn init(page: *Page) !*File { +pub const InitOptions = struct { + type: []const u8 = "", + endings: []const u8 = "transparent", + lastModified: ?i64 = null, +}; + +pub fn init( + parts_: ?[]const js.Value, + name_: []const u8, + opts_: ?InitOptions, + page: *Page, +) !*File { const session = page.session; - const arena = try session.getArena(.tiny, "File"); + const arena = try session.getArena(.large, "File"); errdefer session.releaseArena(arena); - return page.factory.blob(arena, File{ ._proto = undefined }); + + const opts = opts_ orelse InitOptions{}; + const mime = try Blob.validateMimeType(arena, opts.type, false); + + const data = blk: { + if (parts_) |blob_parts| { + const use_native_endings = std.mem.eql(u8, opts.endings, "native"); + var w: std.Io.Writer.Allocating = .init(arena); + for (blob_parts) |js_val| { + const part = try js_val.toStringSmart(); + try Blob.writePartWithEndings(part, use_native_endings, &w.writer); + } + break :blk w.written(); + } + + break :blk ""; + }; + + const last_modified = opts.lastModified orelse std.time.milliTimestamp(); + + const file = try page.factory.blob(arena, File{ + ._proto = undefined, + ._name = try arena.dupe(u8, name_), + ._last_modified = last_modified, + }); + file._proto._slice = data; + file._proto._mime = mime; + + return file; +} + +pub fn getName(self: *const File) []const u8 { + return self._name; +} + +pub fn getLastModified(self: *const File) f64 { + return @floatFromInt(self._last_modified); } pub const JsApi = struct { @@ -46,6 +94,8 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(File.init, .{}); + pub const name = bridge.accessor(File.getName, null, .{}); + pub const lastModified = bridge.accessor(File.getLastModified, null, .{}); }; const testing = @import("../../testing.zig"); From 43a592d2fcff747d24d1b444d14e99ba13d20c10 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 25 May 2026 09:05:37 +0800 Subject: [PATCH 5/6] small dedupe for arena release --- src/browser/Session.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 1b585f96..5f9ede3e 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -488,16 +488,14 @@ fn processRootQueuedNavigation(self: *Session) !void { // immediate-swap path for them. const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:"); - if (is_synthetic) { - defer self.arena_pool.release(qn.arena); - return self.replaceRootImmediate(current_frame._frame_id, qn.url, qn.opts); - } - // The qn arena is consumed here regardless of success — frame.navigate // dupes the URL into the page's own arena, so we can release the qn // arena as soon as navigate returns. defer self.arena_pool.release(qn.arena); + if (is_synthetic) { + return self.replaceRootImmediate(current_frame._frame_id, qn.url, qn.opts); + } return self.initiateRootNavigation(current_frame._frame_id, qn.url, qn.opts); } From d3c5c31a937175f2254e92ff9351e220e2a96689 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 25 May 2026 11:36:17 +0800 Subject: [PATCH 6/6] deduplicate File and Blob init --- src/browser/webapi/Blob.zig | 2 +- src/browser/webapi/File.zig | 39 ++++++++++++------------------------- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 9cc0b4f6..e41c0094 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -113,7 +113,7 @@ pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: } /// Validates and normalizes MIME type according to spec. -pub fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 { +fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 { if (mime_type.len == 0) { return ""; } diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index a740ed04..43a28933 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -38,40 +38,25 @@ pub const InitOptions = struct { pub fn init( parts_: ?[]const js.Value, - name_: []const u8, + name: []const u8, opts_: ?InitOptions, page: *Page, ) !*File { - const session = page.session; - const arena = try session.getArena(.large, "File"); - errdefer session.releaseArena(arena); - const opts = opts_ orelse InitOptions{}; - const mime = try Blob.validateMimeType(arena, opts.type, false); + const blob = try Blob.init(parts_, .{ + .type = opts.type, + .endings = opts.endings, + }, page); - const data = blk: { - if (parts_) |blob_parts| { - const use_native_endings = std.mem.eql(u8, opts.endings, "native"); - var w: std.Io.Writer.Allocating = .init(arena); - for (blob_parts) |js_val| { - const part = try js_val.toStringSmart(); - try Blob.writePartWithEndings(part, use_native_endings, &w.writer); - } - break :blk w.written(); - } + errdefer blob.deinit(page); - break :blk ""; + const file = try blob._arena.create(File); + file.* = .{ + ._proto = blob, + ._name = try blob._arena.dupe(u8, name), + ._last_modified = opts.lastModified orelse std.time.milliTimestamp(), }; - - const last_modified = opts.lastModified orelse std.time.milliTimestamp(); - - const file = try page.factory.blob(arena, File{ - ._proto = undefined, - ._name = try arena.dupe(u8, name_), - ._last_modified = last_modified, - }); - file._proto._slice = data; - file._proto._mime = mime; + blob._type = .{ .file = file }; return file; }