Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-05-25 18:18:55 +02:00
7 changed files with 157 additions and 28 deletions

View File

@@ -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);

View File

@@ -1,12 +1,52 @@
<!DOCTYPE html>
<head id="the_head">
<title>Test Document Title</title>
<title>Test File Web API</title>
<script src="./testing.js"></script>
</head>
<script id=file>
const file = new File();
<script id=file_basic>
// File requires parts and name.
// Verify basic construction.
const f = new File(["test data"], "test.txt");
testing.expectEqual(true, file instanceof File);
testing.expectEqual(true, file instanceof Blob);
testing.expectEqual(true, f instanceof File);
testing.expectEqual(true, f instanceof Blob);
testing.expectEqual("test.txt", f.name);
testing.expectEqual(9, f.size);
testing.expectEqual("", f.type);
testing.expectTrue(typeof f.lastModified === 'number');
// lastModified should be close to now (within 5 seconds)
const now = Date.now();
testing.expectTrue(Math.abs(now - f.lastModified) < 5000);
</script>
<script id=file_options type=module>
const state = await testing.async();
// Constructor with full option properties
const customTime = 1234567890;
const f2 = new File(["foo", "bar"], "data.csv", {
type: "text/csv",
lastModified: customTime
});
testing.expectEqual("data.csv", f2.name);
testing.expectEqual(6, f2.size);
testing.expectEqual("text/csv", f2.type);
testing.expectEqual(customTime, f2.lastModified);
// Verify async reader methods (inherited from Blob)
const text = await f2.text();
state.resolve();
await state.done(() => {
testing.expectEqual("foobar", text);
});
</script>
<script id=file_empty>
const fEmpty = new File([], "");
testing.expectEqual("", fEmpty.name);
testing.expectEqual(0, fEmpty.size);
testing.expectEqual("", fEmpty.type);
</script>

View File

@@ -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);

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -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");

View File

@@ -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, .{});
};

View File

@@ -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

View File

@@ -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")) {