diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 096f00d2..939d5f1f 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -569,30 +569,29 @@ 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) { - return self.replaceRootImmediate(current_frame._frame_id, qn); - } - // 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); } -// 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; }; } @@ -605,6 +604,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/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..e41c0094 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -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..43a28933 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,46 @@ 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 { - const session = page.session; - const arena = try session.getArena(.tiny, "File"); - errdefer session.releaseArena(arena); - return page.factory.blob(arena, File{ ._proto = undefined }); +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 opts = opts_ orelse InitOptions{}; + const blob = try Blob.init(parts_, .{ + .type = opts.type, + .endings = opts.endings, + }, page); + + errdefer blob.deinit(page); + + 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(), + }; + blob._type = .{ .file = file }; + + 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 +79,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"); 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, .{}); }; 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 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")) {